openvibers/openvibe.codes Commits
engine/: the job runner runs every harness the portal runs (C5) (#165)
Changes
@@@ -20,9 +20,9 @@ One place to send a coding task to the agent that fits it — by what the task n| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. || Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. || Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |--| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). 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 agents' **session** with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session 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) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404). The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. || Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). || 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. |@@@ -84,7 +84,7 @@ openvibe-codes resume <run id> "now add a test"- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).--- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.73.0**, **openvibe-shared v4.48.0** (pinned tag tarballs).++- **openvibe-contracts v0.143.0**, **openvibe-sdk v0.67.0**, **openvibe-shared v4.23.0** (pinned tag tarballs).No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).@@@ -158,7 +158,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.232.0- openvibe-sdk: v0.73.0- openvibe-shared: v4.48.0<!-- versions:end -->@@@ -7,11 +7,11 @@"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.232.0","sdk": "openvibe-sdk v0.73.0","shared": "openvibe-shared v4.48.0","packages": [-- "openvibe-contracts v0.243.0",++ "openvibe-contracts v0.232.0","openvibe-sdk v0.73.0","openvibe-shared v4.48.0"],@@@ -10,10 +10,12 @@ const path = require('path');const { createHarness } = require('../harness');const { runJob, resultWriter } = require('../harness/job');const { version: VERSION } = require('../package.json');++const { probe: probeLogins } = require('../engine/logins');const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your terminalopenvibe-codes agents which agents this machine can run, and why not the others++ openvibe-codes logins the coding CLIs here: installed, signed in, and their limit windowsopenvibe-codes route [--task edit] [--need harness:resume,…] which agent a task would go to, and whyopenvibe-codes run [options] "what to do" run a task ("-" reads it from stdin)--task edit|review|long|resume what kind of task (default edit)@@@ -99,6 +101,17 @@ async function main() {for (const a of list) out(`${a.available ? '✔' : '·'} ${a.id.padEnd(18)} ${a.name.padEnd(16)} ${a.available ? dim(a.where || '') : dim(a.reason)}`);return 0;}++ if (cmd === 'logins') {++ // What this machine has: each CLI, whether it is signed in, and the limit windows it can read. Nothing else:++ // no key, no token, no account name.++ const rows = probeLogins({ home: process.env.HOME });++ if (o.json) { out(JSON.stringify(rows, null, 2)); return 0; }++ for (const r of rows) {++ const windows = r.windows.length ? r.windows.map((w) => `${w.name}${w.used != null ? ` ${w.used}/${w.limit}` : ''}${w.resets_at ? ` (resets ${w.resets_at})` : ''}`).join(', ') : '';++ out(`${r.installed ? '✔' : '·'} ${r.id.padEnd(16)} ${r.signed_in ? 'signed in' : 'not signed in'}${windows ? dim(' · ' + windows) : ''}`);++ }++ return 0;++ }if (cmd === 'route') {const requirements = o.need ? { capabilities: String(o.need).split(',').map((s) => s.trim()).filter(Boolean) } : {};const d = harness.route({ task: o.task || 'edit', requirements });@@@ -14,6 +14,37 @@ engine.outcome(attempts); // { state,## The modules++### `adapters/` — every harness Codes can run, with the portal's argv and environment++++Six harnesses, each one the portal's runner script, ported: `claude-code` and `claude-other` (Claude Code, and Claude++Code on another provider with the cm-style environment), `codex`, `command-code`, `opencode` and `minimax-code`. An++adapter says how its CLI is started (the argv and the environment — the portal's, not a guess), how what it prints is++read into the one transcript shape (the portal's feed converters), and what its reasoning levels are.++++```js++const { adapterFor } = require('./engine/adapters');++++adapterFor('codex').build({ prompt, cwd, model: 'gpt-5-codex', effort: 'medium', permission: 'edit' });++// → { cmd: 'codex', argv: ['exec', '--json', '--skip-git-repo-check', '-C', cwd, '-s', 'workspace-write', …], env: {}, stdin: prompt }++```++++The effort is fitted to the harness, never handed over as it came: the 2026-10-29 incident killed a job that copied++Codex's "medium" to Command Code, which accepts off, low, high and max.++++### `jobrunner.js` — one attempt of one harness, the way the portal runs one++++The prompt is assembled (`prompt.js`), the harness is started (`adapters/`), what it prints goes through the transcript++normaliser (`transcript.js`) into job lines (`joblines.js`), and a budget stop buys one wrap-up turn on the same session++(`budget.js`).++++### `prompt.js` — the prompt every job gets++++The git rule, the house rules, the lessons (when a store is reachable), the repo digest (`repomap.js`), then the brief.++++### `logins.js` — what this machine has++++Each CLI: installed, signed in, and the limit windows it can read. Nothing else: no key, no token, no account name.++### `router.js` — what kind of task is this, and which runner may take itA brief is classified into one of five tiers (`basic`, `research`, `standard`, `hard`, `design`); a line `tier: hard`nnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++++/**++ * What every adapter does the same way.++ *++ * An adapter is one harness: it says how to start its CLI (the argv and the environment the portal's runner scripts++ * use), how to read what that CLI prints (the feed converter, so the transcript normaliser sees one shape) and what its++ * own limits are (the reasoning levels it accepts).++ *++ * The argv and the environment come from the portal's runner scripts, not from guesswork:++ *++ * cc-run.sh Command Code cmd --print=… --yolo -m … --effort … --output-format json --max-turns …++ * cx-run.sh Codex codex exec --json --skip-git-repo-check -C … -s … -c model_reasoning_effort=…++ * cm-run.sh Claude Code claude -p --output-format stream-json --verbose --max-turns … --permission-mode …++ * mm-run.sh MiniMax Code mcode exec --input - --output-format stream-json --prompt-mode coding --permission …++ * oc-run.sh OpenCode opencode run --standalone --format json --auto -m …++ *++ * An adapter never pushes, never commits and never opens a pull request: that is the person's call.++ */++++const { spawn } = require('child_process');++const readline = require('readline');++const { ORDER } = require('../effort');++const { scrubDeep } = require('../redact');++++/** The reasoning levels each harness accepts (the portal's ds-limits.js / engine/effort.js). */++const EFFORT_LEVELS = {++ 'claude-code': ['low', 'medium', 'high', 'xhigh', 'max'],++ 'claude-other': ['low', 'medium', 'high', 'xhigh', 'max'],++ codex: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'],++ 'command-code': ['off', 'low', 'high', 'max'],++ opencode: [],++ 'minimax-code': ['low', 'medium', 'high', 'xhigh', 'max'],++};++++/**++ * The effort this harness may take.++ *++ * The 2026-10-29 incident: a job retried on another harness copied Codex's "medium" to Command Code, which died++ * before its first step ("Unknown effort \"medium\". Supported: off, low, high, max."). The level is fitted to the++ * harness, never handed over as it came.++ *++ * @param {string} id the harness++ * @param {string|null} effort the wanted level++ * @returns {string|null} a level the harness accepts++ */++function effortFor(id, effort) {++ const levels = EFFORT_LEVELS[id] || [];++ if (!effort || !levels.length) return effort;++ if (levels.includes(effort)) return effort;++ // The nearest level this harness accepts (a tie goes up), over the portal's own ordering.++ const at = ORDER.indexOf(effort);++ if (at < 0) return effort;++ for (let d = 0; d < ORDER.length; d++) {++ for (const i of [at + d, at - d]) if (levels.includes(ORDER[i])) return ORDER[i];++ }++ return effort;++}++++/** The environment every adapter's child gets: the parent's, plus what the adapter adds. */++function childEnv(extra = {}) {++ const env = { ...process.env, ...extra };++ for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k];++ return env;++}++++/**++ * Run a CLI and read its stdout, line by line.++ *++ * @param {object} opts++ * @param {string} opts.cmd the executable++ * @param {string[]} opts.argv its arguments++ * @param {string} [opts.cwd] the working directory++ * @param {object} [opts.env] the environment++ * @param {string} [opts.stdin] what the CLI reads (the brief, for the harnesses that take it on stdin)++ * @param {number} [opts.timeoutMs] how long it may run++ * @param {AbortSignal} [opts.signal] stops it++ * @param {function} opts.onLine every stdout line++ * @param {function} [opts.onEnd] when the process ended ({ code, signal, timedOut, stderr })++ * @returns {{cancel: function, done: Promise<object>}} the run++ */++function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 45 * 60_000, signal, onLine, onEnd }) {++ let child;++ let stderrBuf = '';++ let timedOut = false;++ let cancelled = false;++ const done = new Promise((resolve) => {++ const finish = (info) => {++ try { if (onEnd) onEnd(info); } catch { /* the reader's own error never hides the run */ }++ resolve(info);++ };++ try {++ child = spawn(cmd, argv, { cwd, env: childEnv(env), stdio: [stdin == null ? 'ignore' : 'pipe', 'pipe', 'pipe'] });++ } catch (err) {++ finish({ code: null, signal: null, timedOut: false, stderr: '', spawnError: err.message });++ return;++ }++ const cancel = () => {++ if (!child || child.exitCode !== null || child.signalCode || cancelled) return;++ cancelled = true;++ try { child.kill('SIGTERM'); } catch { /* gone */ }++ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000).unref();++ };++ const timer = setTimeout(() => { timedOut = true; cancel(); }, timeoutMs);++ timer.unref();++ if (signal) {++ if (signal.aborted) cancel();++ else signal.addEventListener('abort', cancel, { once: true });++ }++ if (stdin != null) { child.stdin.on('error', () => { /* the CLI may not read it */ }); child.stdin.end(stdin); }++ readline.createInterface({ input: child.stdout }).on('line', (raw) => {++ try { onLine(raw); } catch { /* one odd line never stops the run */ }++ });++ readline.createInterface({ input: child.stderr }).on('line', (raw) => {++ stderrBuf = (stderrBuf + raw + '\n').slice(-8000);++ });++ child.on('error', (err) => { clearTimeout(timer); finish({ code: null, signal: null, timedOut, stderr: stderrBuf, spawnError: err.message }); });++ child.on('close', (code, sig) => { clearTimeout(timer); finish({ code, signal: sig, timedOut, stderr: stderrBuf, cancelled }); });++ });++ return { cancel: () => { if (child) { try { child.kill('SIGTERM'); } catch { /* gone */ } } }, done };++}++++/**++ * A feed converter: the harness's own lines into the one shape the transcript normaliser reads++ * (Claude Code's stream-json, which is what the portal's feed converters produce).++ *++ * @param {object} opts++ * @param {string} opts.model the model in play++ * @param {function} [opts.onEvent] every event, already scrubbed++ * @returns {{line: function, end: function, state: object}} the converter++ */++function claudeFeed({ model, onEvent = () => {} }) {++ const state = { sessionId: null, model, lastText: '', failed: null, usage: null, turns: 0, tools: new Set(), done: new Set() };++ const emit = (o) => onEvent(scrubDeep({ ...o, _ts: Date.now() }));++ return {++ state,++ line(raw) {++ let f; try { f = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }++ if (!f || typeof f !== 'object') return;++ const t = f.type;++ if (t === 'system') {++ if (f.subtype === 'init') { state.sessionId = f.session_id || state.sessionId; state.model = f.model || state.model; emit(f); }++ else emit(f);++ return;++ }++ if (t === 'assistant' || t === 'user') { emit(f); return; }++ if (t === 'result') { state.lastText = f.result || state.lastText; emit(f); return; }++ emit(f);++ },++ end(info) {++ if (!state.sessionId && !state.lastText && !state.failed) {++ emit({ type: 'result', subtype: 'error', is_error: true, result: info && info.spawnError ? String(info.spawnError) : 'the harness ended without a result', session_id: state.sessionId, model: state.model });++ }++ },++ };++}++++/**++ * The emitter every feed uses: an event with every secret it holds replaced.++ *++ * An agent that quotes a key it read in a config file puts it in its own output, and the feed is what the page, the++ * session log and the result all show. So nothing is emitted before it is scrubbed.++ */++function emitter(onEvent) {++ return (event) => { if (event) onEvent(scrubDeep(event)); };++}++++/** The tool name a harness's tool maps to (Claude Code's vocabulary, so a reader needs one). */++const TOOL_NAMES = {++ read_file: 'Read', read: 'Read', view: 'Read', cat: 'Read',++ shell: 'Bash', run_shell: 'Bash', shell_command: 'Bash', exec: 'Bash', bash: 'Bash', command_execution: 'Bash',++ read_directory: 'LS', list_directory: 'LS', list: 'LS',++ grep: 'Grep', glob: 'Glob', search: 'Grep',++ edit_file: 'Edit', write_file: 'Write', patch: 'Edit', multiedit: 'Edit', file_change: 'Edit',++ web_fetch: 'WebFetch', web_search: 'WebSearch',++ task: 'Task', todowrite: 'TodoWrite', todo_list: 'TodoWrite',++};++++module.exports = { EFFORT_LEVELS, effortFor, runCli, claudeFeed, childEnv, emitter, TOOL_NAMES };nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++++/**++ * Claude Code, and Claude Code on another provider (the cm-style environment).++ *++ * The argv comes from the portal's cm-run.sh:++ *++ * claude -p --output-format stream-json --verbose --include-partial-messages --max-turns N++ * --permission-mode MODE --exclude-dynamic-system-prompt-sections++ * --append-system-prompt-file RULES --disallowedTools … --allowedTools …++ *++ * --resume SESSION continues the same session (its context and its prompt cache come back)++ *++ * The permission modes are the portal's: acceptEdits (a job may change files), bypassPermissions (the owner's++ * "full" choice), plan (read-only) and manual (the owner answers from the phone).++ *++ * On another provider the environment is the portal's cm-style one: the base URL, the auth token, the model and the++ * effort level, as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL / CLAUDE_CODE_EFFORT_LEVEL (what++ * claude-minimax sets, and what claude-deepseek does for DeepSeek).++ */++++const { effortFor, claudeFeed, TOOL_NAMES } = require('./base');++++/** The permission modes, by the Codes level (read, edit, full). */++const MODES = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' };++++/** The tools a job may use (the portal's allow list). */++const ALLOWED = ['Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'TodoWrite', 'NotebookEdit'];++++/** The tools a job may never use (the portal's deny list). */++const DENIED = ['Bash(git push:*)', 'Bash(git commit:*)', 'Bash(git reset:*)', 'Bash(git clean:*)', 'Bash(git stash:*)',++ 'Bash(sudo:*)', 'Bash(rm -rf:*)', 'Read(.env*)', 'Read(**/.env*)'];++++/** The rules a job's system prompt carries (the prompt's rules file, appended to the system prompt). */++function rulesFile(rules) { return rules && rules.trim() ? rules.trim() : null; }++++/**++ * The argv and the environment for one Claude Code run.++ *++ * @param {object} ctx++ * @param {string} ctx.prompt the brief (the user turn; the rules ride in the system prompt)++ * @param {string} [ctx.model] the model++ * @param {string} [ctx.effort] the wanted reasoning level++ * @param {string} [ctx.resume] the session to continue++ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'++ * @param {number} [ctx.maxTurns] the turn limit++ * @param {string} [ctx.rules] the rules file (appended to the system prompt)++ * @param {object} [ctx.provider] the provider's environment (base URL, token, model): the cm-style environment++ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run++ */++function build(ctx = {}) {++ const effort = effortFor('claude-code', ctx.effort);++ const argv = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',++ '--max-turns', String(ctx.maxTurns || 200),++ '--permission-mode', MODES[ctx.permission] || MODES.edit,++ '--exclude-dynamic-system-prompt-sections'];++ const rules = rulesFile(ctx.rules);++ if (rules) argv.push('--append-system-prompt-file', rules);++ argv.push('--disallowedTools', DENIED.join(' '));++ argv.push('--allowedTools', ALLOWED.join(' '));++ if (ctx.resume) argv.push('--resume', String(ctx.resume));++ const env = {};++ const p = ctx.provider || {};++ if (p.baseUrl) env.ANTHROPIC_BASE_URL = p.baseUrl;++ if (p.authToken) env.ANTHROPIC_AUTH_TOKEN = p.authToken;++ if (p.apiKey) env.ANTHROPIC_API_KEY = p.apiKey;++ if (ctx.model) env.ANTHROPIC_MODEL = ctx.model;++ if (effort) env.CLAUDE_CODE_EFFORT_LEVEL = effort;++ if (p.mcpConfig) argv.push('--mcp-config', p.mcpConfig);++ if (p.extraAllowed && p.extraAllowed.length) argv.push('--allowedTools', ALLOWED.concat(p.extraAllowed).join(' '));++ return { cmd: ctx.bin || 'claude', argv, env, stdin: ctx.prompt || '' };++}++++/** The feed: Claude Code's own stream-json is what the transcript normaliser reads. */++function feed({ model, onEvent }) {++ const f = claudeFeed({ model, onEvent });++ return {++ state: f.state,++ line(raw) { f.line(raw); },++ end(info) { f.end(info); },++ };++}++++const claudeCode = {++ id: 'claude-code', name: 'Claude Code', kind: 'cli', bin: 'claude',++ portal: 'cm-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],++ build, feed,++ /** The provider's environment (the cm-style one) for a provider other than Anthropic's own. */++ providerEnv(provider = {}) {++ const env = {};++ if (provider.baseUrl) env.ANTHROPIC_BASE_URL = provider.baseUrl;++ if (provider.authToken) env.ANTHROPIC_AUTH_TOKEN = provider.authToken;++ if (provider.apiKey) env.ANTHROPIC_API_KEY = provider.apiKey;++ if (provider.model) env.ANTHROPIC_MODEL = provider.model;++ if (provider.effort) env.CLAUDE_CODE_EFFORT_LEVEL = provider.effort;++ return env;++ },++};++++/** Claude Code on another provider: the same CLI, the cm-style environment. */++const claudeOther = {++ id: 'claude-other', name: 'Claude Code (another provider)', kind: 'cli', bin: 'claude',++ portal: 'cm-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],++ build(ctx = {}) {++ const effort = effortFor('claude-other', ctx.effort);++ const p = ctx.provider || {};++ const env = claudeCode.providerEnv({ ...p, model: ctx.model || p.model, effort });++ const out = build({ ...ctx, provider: { ...p, model: ctx.model || p.model }, bin: ctx.bin });++ return { ...out, env };++ },++ feed,++};++++module.exports = { claudeCode, claudeOther, MODES, ALLOWED, DENIED, TOOL_NAMES };nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++++/**++ * Codex (the codex CLI, the owner's ChatGPT plan).++ *++ * The argv comes from the portal's cx-run.sh:++ *++ * codex exec --json --skip-git-repo-check -C DIR -s SANDBOX [-m MODEL] -c model_reasoning_effort=EFFORT++ * codex exec resume SESSION --json --skip-git-repo-check --all [-m MODEL]++ * -c model_reasoning_effort=EFFORT -c sandbox_mode=SANDBOX++ * -c sandbox_workspace_write.writable_roots=[…]++ *++ * The prompt goes in on stdin (a long prompt is never an argument). The sandbox is Codex's own: read-only for a++ * read-only job, workspace-write for one that may change files, danger-full-access for the owner's "full" choice.++ * The effort is a -c override (model_reasoning_effort), the portal's way of asking Codex for a reasoning level.++ */++++const { effortFor, TOOL_NAMES, emitter } = require('./base');++++/** The sandbox, by the Codes permission level. */++const SANDBOX = { read: 'read-only', edit: 'workspace-write', full: 'danger-full-access' };++++/**++ * The argv and the environment for one Codex run.++ *++ * @param {object} ctx++ * @param {string} ctx.prompt the brief (on stdin)++ * @param {string} [ctx.model] the model++ * @param {string} [ctx.effort] the wanted reasoning level++ * @param {string} [ctx.resume] the session (thread) to continue++ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'++ * @param {string} ctx.cwd the working directory++ * @param {string} [ctx.extraDir] a directory the job may write (its own notes)++ * @param {string[]} [ctx.config] extra -c key=value overrides++ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run++ */++function build(ctx = {}) {++ const effort = effortFor('codex', ctx.effort);++ const sandbox = SANDBOX[ctx.permission] || SANDBOX.read;++ const argv = ['exec'];++ if (ctx.resume) {++ // A resume takes its own flag set: no -C, no -s, no --add-dir. The sandbox and the extra writable roots go in++ // as -c overrides, and the CLI's working directory is the one the session was recorded under.++ const roots = [ctx.cwd];++ if (ctx.extraDir) roots.push(ctx.extraDir);++ argv.push('resume', String(ctx.resume), '--json', '--skip-git-repo-check', '--all');++ if (ctx.model) argv.push('-m', ctx.model);++ argv.push('-c', `model_reasoning_effort=${effort || 'medium'}`);++ argv.push('-c', `sandbox_mode=${sandbox}`);++ argv.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`);++ } else {++ argv.push('--json', '--skip-git-repo-check', '-C', ctx.cwd, '-s', sandbox);++ if (ctx.model) argv.push('-m', ctx.model);++ argv.push('-c', `model_reasoning_effort=${effort || 'medium'}`);++ if (ctx.permission === 'edit' && ctx.extraDir) argv.push('--add-dir', ctx.extraDir);++ }++ for (const kv of (ctx.config || [])) argv.push('-c', kv);++ argv.push('-'); // the prompt comes on stdin++ return { cmd: ctx.bin || 'codex', argv, env: {}, stdin: ctx.prompt || '' };++}++++/** The feed: Codex's events are turned into Claude Code's stream-json (the portal's cx-feed.js). */++function feed({ model, onEvent }) {++ const state = { sessionId: null, lastText: '', failed: null, usage: null, turns: 0, started: new Set(), done: new Set() };++ const emit = emitter(onEvent);++ const tool = (id, name, input) => { if (state.started.has(id)) return; state.started.add(id); emit({ type: 'assistant', message: { model, content: [{ type: 'tool_use', id, name, input }] } }); };++ const result = (id, content, isError) => { if (state.done.has(id)) return; state.done.add(id); emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: String(content || '').slice(0, 4000), is_error: !!isError }] } }); };++ const describe = (it) => {++ const t = it.type;++ if (t === 'command_execution') return ['Bash', { command: it.command || '' }];++ if (t === 'mcp_tool_call') return [`mcp__${it.server || 'mcp'}__${it.tool || 'tool'}`, it.arguments && typeof it.arguments === 'object' ? it.arguments : {}];++ if (t === 'web_search') return ['WebSearch', { query: it.query || '' }];++ if (t === 'collab_tool_call') return ['Task', { description: String(it.prompt || it.tool || 'subagent').slice(0, 200) }];++ return [`codex:${t || 'item'}`, it];++ };++ const itemResult = (it) => {++ const t = it.type, s = String(it.status || '');++ if (t === 'command_execution') {++ const code = it.exit_code, txt = String(it.aggregated_output || '');++ return [txt + (code != null && code !== 0 ? `\n(exit ${code})` : ''), s === 'failed' || (code != null && code !== 0)];++ }++ if (t === 'mcp_tool_call') {++ if (it.error) return [String(it.error.message || it.error), true];++ const c = (it.result && it.result.content) || [];++ return [c.map((x) => (x && x.text) || (typeof x === 'string' ? x : JSON.stringify(x))).join('\n'), s === 'failed'];++ }++ if (t === 'web_search') return [Array.isArray(it.results) ? `${it.results.length} results` : 'searched', s === 'failed'];++ return [s || 'done', s === 'failed'];++ };++ const onItem = (phase, it) => {++ if (!it || typeof it !== 'object') return;++ const id = String(it.id || `${it.type}-${state.started.size}`), t = it.type;++ if (t === 'reasoning') return;++ if (t === 'agent_message') {++ if (phase === 'completed' && String(it.text || '').trim()) {++ state.lastText = it.text;++ emit({ type: 'assistant', message: { model, content: [{ type: 'text', text: it.text }] } });++ }++ return;++ }++ if (t === 'error') { emit({ type: 'system', subtype: 'warning', text: String(it.message || '').slice(0, 500) }); return; }++ if (t === 'file_change') {++ if (phase !== 'completed') return;++ const failed = it.status === 'failed';++ (it.changes || [{}]).forEach((ch, i) => {++ const cid = `${id}:${i}`, kind = ch.kind && typeof ch.kind === 'object' ? ch.kind.type : ch.kind;++ tool(cid, kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit', { file_path: ch.path || '', change: kind || 'update' });++ result(cid, failed ? 'patch failed' : `${kind || 'update'} applied`, failed);++ });++ return;++ }++ const [name, input] = describe(it);++ tool(id, name, input);++ if (phase === 'completed') { const [txt, err] = itemResult(it); result(id, txt, err); }++ };++ return {++ state,++ line(raw) {++ let e; try { e = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }++ if (!e || typeof e !== 'object') return;++ switch (e.type) {++ case 'thread.started':++ state.sessionId = e.thread_id || null;++ emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'codex', model });++ break;++ case 'turn.started': state.turns++; break;++ case 'item.started': onItem('started', e.item); break;++ case 'item.updated': onItem('updated', e.item); break;++ case 'item.completed': onItem('completed', e.item); break;++ case 'turn.completed': {++ const u = e.usage || {}, n = (x) => (Number.isFinite(+x) ? +x : 0);++ state.usage = { input: n(u.input_tokens), cached: n(u.cached_input_tokens), output: n(u.output_tokens) };++ emit({ type: 'system', subtype: 'usage', backend: 'codex', usage: state.usage });++ break;++ }++ case 'turn.failed':++ state.failed = String((e.error && (e.error.message || e.error)) || 'turn failed');++ emit({ type: 'system', subtype: 'error', text: `API Error: ${state.failed.slice(0, 400)}` });++ break;++ case 'error':++ state.failed = String(e.message || e.error || 'error');++ emit({ type: 'system', subtype: 'error', text: `API Error: ${state.failed.slice(0, 400)}` });++ break;++ default: break;++ }++ },++ end(info) {++ for (const id of state.started) if (!state.done.has(id)) result(id, 'cut off', true);++ const ok = !state.failed && !!state.lastText && (!info || info.code === 0);++ emit({++ type: 'result', subtype: ok ? 'success' : 'error', is_error: !ok,++ result: ok ? state.lastText : (state.failed || 'Codex ended without a result (a limit, the sandbox or a crash: check its exit code)'),++ session_id: state.sessionId, model, duration_ms: 0, num_turns: state.turns,++ usage: { input_tokens: Math.max(0, (state.usage && state.usage.input) || 0), output_tokens: (state.usage && state.usage.output) || 0, cache_read_input_tokens: (state.usage && state.usage.cached) || 0, cache_creation_input_tokens: 0 },++ });++ },++ };++}++++const codex = { id: 'codex', name: 'Codex', kind: 'cli', bin: 'codex', portal: 'cx-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], build, feed, SANDBOX };++++module.exports = { codex, SANDBOX, TOOL_NAMES };nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++++/**++ * Command Code (the `cmd` CLI).++ *++ * The argv comes from the portal's cc-run.sh:++ *++ * cmd --print=BRIEF [--resume SESSION] [--yolo] -m MODEL --effort EFFORT --config compact-mode=fast++ * --output-format json --max-turns N --skip-onboarding --no-auto-update -t++ *++ * The effort levels are the portal's for this harness: off, low, high and max (the 2026-10-29 incident: a job that++ * copied Codex's "medium" here died before its first step). The permission rules go in the project's++ * .commandcode/settings.local.json (the portal's way), never on the command line.++ */++++const fs = require('fs');++const path = require('path');++const { effortFor, claudeFeed, TOOL_NAMES, emitter } = require('./base');++++/** The tools a job may not use (the portal's deny list). */++const DENY = ['Shell(rm:*)', 'Shell(git push:*)', 'Shell(git commit:*)', 'Shell(git reset:*)', 'Shell(git checkout:*)',++ 'Shell(git clean:*)', 'Shell(git stash:*)', 'Shell(sudo:*)', 'Shell(ssh:*)', 'Shell(scp:*)', 'Shell(npm test:*)',++ 'Shell(npm install:*)', 'Shell(npm i:*)', 'Shell(npm ci:*)', 'Shell(pnpm install:*)', 'Shell(kill:*)', 'Shell(pkill:*)',++ 'Shell(chmod:*)', 'Shell(tee:*)', 'Shell(dd:*)', 'Read(.env*)', 'Read(**/.env*)', 'Edit(.git/**)'];++++/**++ * The argv and the environment for one Command Code run.++ *++ * @param {object} ctx++ * @param {string} ctx.prompt the brief (as --print=…, the portal's way)++ * @param {string} [ctx.model] the model++ * @param {string} [ctx.effort] the wanted reasoning level++ * @param {string} [ctx.resume] the session to continue++ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'++ * @param {number} [ctx.maxTurns] the turn limit++ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run++ */++function build(ctx = {}) {++ const effort = effortFor('command-code', ctx.effort);++ const argv = [`--print=${ctx.prompt || ''}`];++ if (ctx.resume) argv.push('--resume', String(ctx.resume));++ if (ctx.permission === 'full') argv.push('--yolo');++ if (ctx.model) argv.push('-m', ctx.model);++ argv.push('--effort', effort || 'high');++ argv.push('--config', 'compact-mode=fast');++ argv.push('--output-format', 'json');++ argv.push('--max-turns', String(ctx.maxTurns || 150));++ argv.push('--skip-onboarding', '--no-auto-update', '-t');++ return { cmd: ctx.bin || 'cmd', argv, env: {}, stdin: null };++}++++/**++ * The permission rules the project's .commandcode/settings.local.json holds.++ *++ * The portal writes them there (globally git-ignored) so they survive --yolo, and a read-only job may not edit or write++ * in the repository at all.++ *++ * @param {object} opts++ * @param {string} opts.permission 'read' | 'edit' | 'full'++ * @returns {string} the settings file's content++ */++function settings({ permission = 'edit' } = {}) {++ const deny = permission === 'full' ? ['Shell(git push:*)', 'Shell(pkill:*)', 'Shell(killall:*)'] : DENY.slice();++ if (permission === 'read') deny.push('Edit(**)', 'Write(**)');++ return `{ "permissions": { "deny": [${deny.map((d) => JSON.stringify(d)).join(', ')}] } }`;++}++++/**++ * Write the permission rules into the project (the portal's way).++ *++ * @param {string} dir the project++ * @param {object} opts the options of settings()++ * @returns {string} the file that was written++ */++function writeSettings(dir, opts) {++ const file = path.join(dir, '.commandcode', 'settings.local.json');++ fs.mkdirSync(path.dirname(file), { recursive: true });++ fs.writeFileSync(file, settings(opts));++ return file;++}++++/** The feed: Command Code's events are turned into Claude Code's stream-json (the portal's cc-feed.js). */++function feed({ model, onEvent }) {++ const state = { sessionId: null, model, lastText: '', usage: null, failed: null };++ const emit = emitter(onEvent);++ return {++ state,++ line(raw) {++ let f; try { f = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }++ if (!f || typeof f !== 'object') return;++ if (f.type === 'result') {++ const err = f.subtype === 'error' ? `API Error: ${String((f.error && (f.error.message || f.error)) || 'unknown error').slice(0, 300)}` : null;++ state.lastText = f.finalText || err || '';++ emit({ type: 'result', subtype: f.subtype, is_error: f.subtype !== 'success', result: f.finalText || err || '', duration_ms: f.durationMs || 0, usage: f.usage || null, session_id: f.sessionId || state.sessionId, model: state.model });++ return;++ }++ const e = f.type === 'event' && f.event;++ if (!e) return;++ if (e.type === 'run_start') { state.sessionId = e.sessionId; emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'command-code' }); }++ else if (e.type === 'model_request_start') state.model = e.model || state.model;++ else if (e.type === 'model_request_end') state.usage = e.usage || null;++ else if (e.type === 'message_end') {++ const content = [];++ for (const c of e.content || []) {++ if (c.type === 'text' && c.text && c.text.trim()) content.push({ type: 'text', text: c.text });++ else if (c.type === 'tool_use') content.push({ type: 'tool_use', id: c.id, name: TOOL_NAMES[c.name] || c.name, input: c.input || {} });++ }++ if (content.length) emit({ type: 'assistant', message: { model: state.model, content, usage: state.usage } });++ } else if (e.type === 'tool_completed') {++ const text = (e.result || []).map((r) => r.text || '').join('\n');++ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: e.toolCallId, content: text.slice(0, 4000), is_error: !!e.isError }] } });++ } else if (['tool_denied', 'tool_failed', 'tool_error', 'tool_cancelled'].includes(e.type)) {++ const why = e.type === 'tool_denied' ? "Denied by the job's permission rules" : String(e.error || e.message || e.type).slice(0, 400);++ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: e.toolCallId, content: why, is_error: true }] } });++ } else if (e.type === 'error' || e.type === 'run_error') {++ const msg = typeof (e.message || e.error) === 'object' ? JSON.stringify(e.message || e.error) : String(e.message || e.error || 'unknown');++ state.failed = msg;++ emit({ type: 'system', subtype: 'error', text: `API Error: ${msg.slice(0, 300)}` });++ }++ },++ end(info) {++ if (!state.lastText && !state.failed) {++ emit({ type: 'result', subtype: 'error', is_error: true, result: 'API Error: Command Code ended without a result (credits, rate limit or a crash: check its exit code)', session_id: state.sessionId, model: state.model });++ }++ },++ };++}++++const commandCode = { id: 'command-code', name: 'Command Code', kind: 'cli', bin: 'cmd', portal: 'cc-run.sh', effortLevels: ['off', 'low', 'high', 'max'], build, feed, settings, writeSettings, DENY };++++module.exports = { commandCode, DENY };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * Every harness Codes can run, and the one place that says how each is started.++ *++ * The adapters are the portal's runner scripts, ported: the argv and the environment are the portal's, the feed++ * converters are the portal's (cx-feed.js, cc-feed.js, oc-feed.js), the effort levels are the portal's.++ *++ * claude-code Claude Code (the owner's Anthropic plan) cm-run.sh++ * claude-other Claude Code on another provider (cm-style env) cm-run.sh++ * codex Codex (the owner's ChatGPT plan) cx-run.sh++ * command-code Command Code cc-run.sh++ * opencode OpenCode oc-run.sh++ * minimax-code MiniMax Code mm-run.sh++ */++++const { claudeCode, claudeOther } = require('./claude-code');++const { codex } = require('./codex');++const { commandCode } = require('./command-code');++const { opencode } = require('./opencode');++const { minimaxCode } = require('./minimax-code');++const { effortFor, EFFORT_LEVELS } = require('./base');++++const ADAPTERS = {++ 'claude-code': claudeCode,++ 'claude-other': claudeOther,++ codex,++ 'command-code': commandCode,++ opencode,++ 'minimax-code': minimaxCode,++};++++/** The adapter of a harness, or null. */++function adapterFor(id) {++ return ADAPTERS[String(id || '').toLowerCase()] || null;++}++++/** Every harness, in the order the portal's rungs go. */++function all() {++ return Object.values(ADAPTERS);++}++++/** The harness names. */++function ids() {++ return Object.keys(ADAPTERS);++}++++module.exports = { ADAPTERS, adapterFor, all, ids, effortFor, EFFORT_LEVELS };nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++++/**++ * MiniMax Code (the `mcode` CLI, the owner's M Plan).++ *++ * The argv comes from the portal's mm-run.sh:++ *++ * mcode exec --input - --output-format stream-json --prompt-mode coding --permission MODE --cwd DIR++ * --timeout 45m --max-steps N --append-system-prompt-file RULES -o LAST [--ptc] [--mode lightweight]++ * [--model MODEL] [--effort EFFORT] [--session SESSION]++ *++ * The rules ride in the appended system prompt (the stable, best-cached prefix) and the brief alone is the user turn.++ * --ptc is Programmatic Tool Calling (the model batches its tool calls in one round trip). The permission is smart for a++ * guarded job and full for the owner's "full" choice.++ */++++const { effortFor, emitter } = require('./base');++++/**++ * The argv and the environment for one MiniMax Code run.++ *++ * @param {object} ctx++ * @param {string} ctx.prompt the brief (on stdin)++ * @param {string} [ctx.model] the model++ * @param {string} [ctx.effort] the wanted reasoning level++ * @param {string} [ctx.resume] the session to continue++ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'++ * @param {number} [ctx.maxSteps] the step limit++ * @param {string} [ctx.rules] the rules file (appended to the system prompt)++ * @param {string} [ctx.cwd] the working directory++ * @param {string} [ctx.out] the file the last answer is written to++ * @param {boolean} [ctx.ptc] Programmatic Tool Calling (on by default, the portal's choice)++ * @param {boolean} [ctx.light] the lightweight mode (a smaller context)++ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run++ */++function build(ctx = {}) {++ const effort = effortFor('minimax-code', ctx.effort);++ const perm = ctx.permission === 'full' ? 'full' : 'smart';++ const argv = ['exec', '--input', '-', '--output-format', 'stream-json', '--prompt-mode', 'coding',++ '--permission', perm, '--cwd', ctx.cwd || process.cwd(), '--timeout', ctx.timeout || '45m',++ '--max-steps', String(ctx.maxSteps || 150)];++ if (ctx.rules && String(ctx.rules).trim()) argv.push('--append-system-prompt-file', String(ctx.rules).trim());++ if (ctx.out) argv.push('-o', ctx.out);++ if (ctx.ptc !== false) argv.push('--ptc');++ if (ctx.light) argv.push('--mode', 'lightweight');++ if (ctx.model) argv.push('--model', ctx.model);++ if (effort) argv.push('--effort', effort);++ if (ctx.resume) argv.push('--session', String(ctx.resume));++ return { cmd: ctx.bin || 'mcode', argv, env: {}, stdin: ctx.prompt || '' };++}++++/**++ * The feed: MiniMax Code's stream-json (schemaVersion 1 events) is what the transcript normaliser reads.++ *++ * @param {object} opts++ * @param {string} opts.model the model++ * @param {function} opts.onEvent every event++ * @returns {{line: function, end: function, state: object}} the feed++ */++function feed({ model, onEvent }) {++ const state = { sessionId: null, model, lastText: '', failed: null };++ const emit = emitter(onEvent);++ return {++ state,++ line(raw) {++ let e; try { e = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }++ if (!e || typeof e !== 'object') return;++ // MiniMax Code's own events (schemaVersion 1): item.started / item.completed / turn.* / exec.completed.++ if (e.schemaVersion && typeof e.type === 'string' && e.type.includes('.')) {++ if (e.sessionId) state.sessionId = e.sessionId;++ const it = e.item || {};++ if (e.type === 'item.started' && (it.type === 'agent_message' || it.type === 'reasoning')) {++ emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'minimax-code', model });++ } else if (e.type === 'item.completed' && it.type === 'agent_message') {++ const text = String(it.content || it.contentDelta || '');++ if (text.trim()) { state.lastText = text; emit({ type: 'assistant', message: { model, content: [{ type: 'text', text }] } }); }++ } else if (e.type === 'turn.failed') {++ state.failed = String((e.error && (e.error.message || e.error)) || 'turn failed');++ emit({ type: 'system', subtype: 'error', text: state.failed });++ } else if (e.type === 'exec.completed' && e.result) {++ const r = e.result;++ const answer = String(r.answer || r.output || '');++ state.lastText = answer || state.lastText;++ emit({ type: 'result', subtype: r.status && r.status !== 'succeeded' ? 'error' : 'success', is_error: !!(r.status && r.status !== 'succeeded'), result: answer, session_id: state.sessionId, model, duration_ms: r.durationMs || 0 });++ } else if (e.type === 'item.completed' && it.type === 'tool_result') {++ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: it.toolCallId || it.callId || it.id, content: String(it.output || it.content || it.result || ''), is_error: !!(it.isError || it.status === 'failed') }] } });++ }++ return;++ }++ // The plain shape (a text or a result line).++ if (e.type === 'result') { state.lastText = e.result || state.lastText; emit(e); return; }++ emit(e);++ },++ end(info) {++ if (!state.lastText && !state.failed) {++ emit({ type: 'result', subtype: 'error', is_error: true, result: 'MiniMax Code ended without an answer (a limit, a plan window or a crash: check its exit code)', session_id: state.sessionId, model });++ }++ },++ };++}++++const minimaxCode = { id: 'minimax-code', name: 'MiniMax Code', kind: 'cli', bin: 'mcode', portal: 'mm-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], build, feed };++++module.exports = { minimaxCode };nnew file mode 100644@@@ -0,0 +1,143 @@++'use strict';++++/**++ * OpenCode (the `opencode` CLI).++ *++ * The argv comes from the portal's oc-run.sh:++ *++ * opencode run --standalone --format json --auto -m MODEL [--s SESSION] --title TITLE -- BRIEF++ *++ * --standalone gives the attempt its own server, so stopping the attempt stops its work. The permission rules go in the++ * project's .opencode/opencode.json (the portal's way). The effort is the model's variant (model#low or model#high), the++ * portal's way of asking OpenCode for a reasoning level.++ */++++const fs = require('fs');++const path = require('path');++const { claudeFeed, TOOL_NAMES, emitter } = require('./base');++++/** The shell commands a job may not run (the portal's deny list). */++const DENY_SHELL = ['rm *', 'git commit *', 'git reset *', 'git checkout *', 'git clean *', 'git stash *', 'sudo *',++ 'ssh *', 'scp *', 'curl *', 'wget *', 'npm install *', 'npm i *', 'npm ci *', 'npm publish *', 'npm uninstall *',++ 'pnpm install *', 'kill *', 'pkill *', 'chmod *', 'tee *', 'dd *'];++++/**++ * The model an effort asks for: the variant the portal uses (model#low or model#high), the plain model for anything++ * else.++ *++ * @param {string} model the model++ * @param {string} [effort] the wanted level++ * @returns {string} the model to run++ */++function variantFor(model, effort) {++ const m = String(model || '');++ if (!['low', 'high'].includes(effort) || m.includes('#')) return m;++ return `${m}#${effort}`;++}++++/**++ * The argv and the environment for one OpenCode run.++ *++ * @param {object} ctx++ * @param {string} ctx.prompt the brief++ * @param {string} [ctx.model] the model++ * @param {string} [ctx.effort] the wanted reasoning level++ * @param {string} [ctx.resume] the session to continue++ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'++ * @param {string} [ctx.title] the run's title++ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run++ */++function build(ctx = {}) {++ const model = variantFor(ctx.model, ctx.effort);++ const argv = ['run', '--standalone', '--format', 'json'];++ if (ctx.permission !== 'read') argv.push('--auto');++ if (model) argv.push('-m', model);++ if (ctx.resume) argv.push('-s', String(ctx.resume));++ if (ctx.title) argv.push('--title', String(ctx.title));++ argv.push('--', ctx.prompt || '');++ return { cmd: ctx.bin || 'opencode', argv, env: {}, stdin: null };++}++++/**++ * The permission rules the project's .opencode/opencode.json holds.++ *++ * @param {object} opts++ * @param {string} opts.permission 'read' | 'edit' | 'full'++ * @returns {string} the settings file's content++ */++function settings({ permission = 'edit' } = {}) {++ const rule = (action, resource) => `{ "action": "${action}", "resource": "${resource}", "effect": "deny" }`;++ const rules = [rule('shell', 'git push *')];++ if (permission !== 'full') {++ for (const c of DENY_SHELL) rules.push(rule('shell', c));++ rules.push(rule('read', '.env*'), rule('read', '**/.env*'), rule('edit', '.git/**'));++ }++ if (permission === 'read') rules.push(rule('edit', '*'));++ return `{ "permissions": [ ${rules.join(', ')}] }`;++}++++/**++ * Write the permission rules into the project (the portal's way).++ *++ * @param {string} dir the project++ * @param {object} opts the options of settings()++ * @returns {string|null} the file that was written, or null when the repository tracks its own++ */++function writeSettings(dir, opts) {++ const file = path.join(dir, '.opencode', 'opencode.json');++ fs.mkdirSync(path.dirname(file), { recursive: true });++ fs.writeFileSync(file, settings(opts));++ return file;++}++++/** The feed: OpenCode's events are turned into Claude Code's stream-json (the portal's oc-feed.js). */++function feed({ model, onEvent }) {++ const state = { sessionId: null, model, lastText: '', failed: null, steps: 0, usage: null };++ const emit = emitter(onEvent);++ return {++ state,++ line(raw) {++ let e; try { e = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }++ if (!e || typeof e !== 'object') return;++ if (e.sessionID && !state.sessionId) {++ state.sessionId = e.sessionID;++ emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'opencode', model });++ }++ const p = e.part || {};++ if (e.type === 'tool_use') {++ const st = p.state || {}, err = st.status === 'error';++ const input = { ...((st.input) || {}) };++ if (input.path && !input.file_path) input.file_path = input.path;++ emit({ type: 'assistant', message: { model, content: [{ type: 'tool_use', id: p.id || p.partID, name: TOOL_NAMES[p.tool] || p.tool, input }] } });++ const text = err ? String(st.error || 'the tool failed') : typeof st.output === 'string' ? st.output : JSON.stringify(st.output || '');++ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: p.id || p.partID, content: text.slice(0, 4000), is_error: err }] } });++ } else if (e.type === 'text' && p.text && String(p.text).trim()) {++ state.lastText = (state.lastText || '') + p.text;++ emit({ type: 'assistant', message: { model, content: [{ type: 'text', text: p.text }] } });++ } else if (e.type === 'step_finish') {++ state.steps++;++ const t = p.tokens || {};++ state.usage = { input_tokens: t.input || 0, output_tokens: (t.output || 0) + (t.reasoning || 0), cache_read_input_tokens: (t.cache && t.cache.read) || 0, cache_creation_input_tokens: (t.cache && t.cache.write) || 0 };++ emit({ type: 'assistant', message: { model, content: [], usage: state.usage } });++ } else if (e.type === 'error') {++ const msg = String((e.error && (e.error.message || e.error.type)) || 'unknown error').slice(0, 400);++ state.failed = msg;++ emit({ type: 'system', subtype: 'error', text: `API Error: ${msg}` });++ }++ },++ end(info) {++ const final = String(state.lastText || '').trim();++ const err = state.failed && !final ? `API Error: ${state.failed}` : null;++ emit({++ type: 'result', subtype: err || !final ? 'error' : 'success', is_error: !!(err || !final),++ result: final || err || 'API Error: OpenCode ended without an answer (a limit, a refusal or a crash: check its exit code)',++ duration_ms: 0, num_turns: state.steps, session_id: state.sessionId, model,++ total_cost_usd: 0, usage: state.usage || { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },++ });++ },++ };++}++++const opencode = { id: 'opencode', name: 'OpenCode', kind: 'cli', bin: 'opencode', portal: 'oc-run.sh', effortLevels: [], build, feed, settings, writeSettings, variantFor };++++module.exports = { opencode, variantFor };nnew file mode 100644@@@ -0,0 +1,9 @@++## Git rules (added to every job by the runner)++++- Never copy, mirror, move or re-create git metadata: no `.git` directories made by hand, no `git clone --mirror`, no++ `git worktree` repairs.++- Never change a remote, `remote.*.mirror`, `core.*` or any other git config. Never push.++- If git can't write (a permission or read-only error), stop and say so in your report. The main session fixes it. Don't work++ around it.++- Commit only if your brief asks, with `git -c user.name=OpenVibers -c [email protected] commit`. Never++ amend or rebase.nnew file mode 100644@@@ -0,0 +1,9 @@++## House rules (added to every job by the runner)++++- Commit as `OpenVibers <[email protected]>`: `git -c user.name=OpenVibers -c [email protected] commit`. No trailers, no "Generated with" line, no session link.++- Run gitleaks on what you are about to commit: `gitleaks git --pre-commit --staged --redact --no-banner`. A finding stops the commit: fix it, do not push it.++- Push with the gh credential helper (the owner's access exposure sets it up). Never put a token in a remote URL, in a command line or in a file.++- A pull request body says what the change does and how it was checked. No footer, no session link, no "Generated with" line.++- Never `pkill`, `killall` or kill by pattern: they take unrelated shells with them. Stop a process by its PID, a service with `systemctl --user stop`.++- Never print, read or copy a secret (a token, a key, a `.env`). The harness scrubs what it stores; keep your report and your notes clean too.++- End your report with a `Learned:` block: at most five lines, each one a fact the next agent in this repo would want, with the file, command or URL that shows it. Leave it out when nothing was new.@@@ -10,6 +10,13 @@* engine.effort the reasoning level a harness accepts, and the models a provider refuses* engine.scope one systemd scope per job, so nothing is left behind* engine.runerror what a failed run really said++ * engine.adapters every harness Codes can run, with the portal's argv and environment++ * engine.jobrunner one attempt of one harness, the way the portal runs one++ * engine.prompt the prompt every job gets (the rules, the lessons, the digest, the brief)++ * engine.repomap the repo digest a job gets instead of a directory tree++ * engine.joblines the lines a task's page shows while it runs++ * engine.logins what this machine has: each CLI, signed in or not, and its limit windows++ * engine.redact the secrets that never reach an event stream** The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.*/@@@ -21,6 +28,13 @@ const transcript = require('./transcript');const effort = require('./effort');const scope = require('./scope');const runerror = require('./runerror');++const adapters = require('./adapters');++const jobrunner = require('./jobrunner');++const prompt = require('./prompt');++const repomap = require('./repomap');++const joblines = require('./joblines');++const logins = require('./logins');++const redact = require('./redact');module.exports = {router,@@@ -30,6 +44,13 @@ module.exports = {effort,scope,runerror,++ adapters,++ jobrunner,++ prompt,++ repomap,++ joblines,++ logins,++ redact,// The names a caller usually wants, in one place.classify: router.classify,pick: router.pick,@@@ -41,4 +62,11 @@ module.exports = {parseText: transcript.parseText,fitEffort: effort.fitEffort,pickModel: effort.pickModel,++ // C5: the job runner and the harnesses it can run.++ runAttempt: jobrunner.runAttempt,++ adapterFor: adapters.adapterFor,++ assemblePrompt: prompt.assemble,++ jobLines: joblines.lines,++ probeLogins: logins.probe,++ scrub: redact.scrub,};nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++++/**++ * The job lines (1.1): what a task's page shows while it runs and what its session keeps afterwards.++ *++ * The transcript normaliser (engine/transcript.js) turns every harness's log into one event shape; this turns those++ * events into the lines a reader sees. The kinds are the ones the session view knows (server/domain/run-tasks.js):++ *++ * agent the run started: which harness, which model++ * text what the agent said++ * tool a tool call (what it touched)++ * tool_result what that tool answered++ * note an error or a warning++ * usage the tokens the run spent++ * done the end of the attempt++ *++ * Nothing here prints a secret: every text goes through engine/redact.js first.++ */++++const { scrub } = require('./redact');++++/** The clip a line's text may take. */++const MAX_TEXT = 2000;++const clip = (s, n = MAX_TEXT) => { s = String(s == null ? '' : s); return s.length > n ? `${s.slice(0, n)}…` : s; };++++/**++ * The job lines for the events added since index `from`.++ *++ * @param {object} st the transcript state++ * @param {number} [from] the first event to look at++ * @returns {{lines: object[], next: number}} the lines and where to look next time++ */++function lines(st, from = 0) {++ const out = [];++ const events = (st && st.events) || [];++ for (let i = Math.max(0, from); i < events.length; i++) {++ const e = events[i];++ if (e.t === 'meta' && e.kind === 'start') out.push({ t: 'agent', agent: e.backend || null, model: e.model || null, session: e.session || null });++ else if (e.t === 'text') out.push({ t: 'text', text: clip(scrub(e.text)) });++ else if (e.t === 'thinking') continue; // the reasoning is not the task's story++ else if (e.t === 'tool') {++ out.push({ t: 'tool', id: e.id || null, name: e.name || 'tool', what: clip(scrub(e.summary || ''), 200) });++ if (!e.pending && e.output) out.push({ t: 'tool_result', id: e.id || null, ok: !e.isError, text: clip(scrub(e.output.text || '')) });++ } else if (e.t === 'error') out.push({ t: 'note', level: 'error', text: clip(scrub(e.text)) });++ else if (e.t === 'final') out.push({ t: 'done', ok: !e.isError, text: clip(scrub(e.text)) });++ }++ return { lines: out, next: events.length };++}++++/** The usage line of a finished run. */++function usageLine(st) {++ const u = (st && st.usage) || {};++ if (!u.input && !u.output && !u.cached) return null;++ return { t: 'usage', input: u.input || 0, output: u.output || 0, cached: u.cached || 0, cost_usd: st && Number.isFinite(st.cost) ? st.cost : null };++}++++module.exports = { lines, usageLine, clip, MAX_TEXT };nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++++/**++ * The job runner: one attempt of one harness, the way the portal runs one.++ *++ * The portal's runner scripts (cc-run.sh, cx-run.sh, cm-run.sh, mm-run.sh, oc-run.sh) do the same four things, and this++ * does them too:++ *++ * 1. the prompt is assembled (engine/prompt.js): the git rule, the house rules, the lessons, the repo digest, the brief;++ * 2. the harness is started with its own argv and environment (engine/adapters/);++ * 3. what it prints goes through the transcript normaliser (engine/transcript.js) into job lines (engine/joblines.js);++ * 4. when the budget is reached the work is landed in one wrap-up turn (engine/budget.js), and the session is kept so a++ * continue picks the same conversation up.++ *++ * The runner writes the harness's feed to a file and parses it incrementally, so a live page costs one parse of the++ * bytes that arrived, not one of the whole log.++ */++++const fs = require('fs');++const os = require('os');++const path = require('path');++++const { adapterFor } = require('./adapters');++const { assemble } = require('./prompt');++const transcript = require('./transcript');++const joblines = require('./joblines');++const budget = require('./budget');++++/** How long one attempt may run (the portal's 2700 s). */++const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000;++++/**++ * Run one attempt.++ *++ * @param {object} opts++ * @param {string} opts.agent the harness (claude-code, codex, command-code, opencode, minimax-code, claude-other)++ * @param {string} opts.prompt the brief++ * @param {string} opts.cwd the folder the job works in++ * @param {string} [opts.model] the model++ * @param {string} [opts.effort] the wanted reasoning level (fitted to the harness)++ * @param {string} [opts.permission] 'read' | 'edit' | 'full'++ * @param {string} [opts.resume] the session to continue++ * @param {object} [opts.budget] the limits ({ steps, minutes, usd })++ * @param {string} [opts.log] the file the feed is written to++ * @param {function} [opts.onLine] every job line++ * @param {AbortSignal} [opts.signal] stops the attempt++ * @returns {Promise<object>} the attempt's result++ */++async function runAttempt(opts = {}) {++ const adapter = adapterFor(opts.agent);++ if (!adapter) throw new Error(`no adapter for ${opts.agent}`);++ const onLine = opts.onLine || (() => {});++ const cwd = opts.cwd || process.cwd();++ const log = opts.log || path.join(os.tmpdir(), `openvibe-codes-${process.pid}-${Date.now()}.jsonl`);++ const dir = path.dirname(log);++ fs.mkdirSync(dir, { recursive: true });++++ // The prompt: the rules, the house rules, the lessons, the repo digest, then the brief.++ const prompt = opts.systemPrompt === false++ ? { text: String(opts.prompt || ''), parts: [{ name: 'brief', text: String(opts.prompt || '') }] }++ : assemble({ brief: opts.prompt, dir: cwd, env: opts.env || process.env });++ const rulesFile = opts.rulesFile !== null ? path.join(dir, 'rules.md') : null;++ if (rulesFile) fs.writeFileSync(rulesFile, prompt.parts.filter((p) => p.name !== 'brief').map((p) => p.text).join('\n\n'));++++ // The permission rules go in the project, the portal's way (they survive --yolo).++ if (adapter.writeSettings) {++ try { adapter.writeSettings(cwd, { permission: opts.permission || 'edit' }); } catch { /* the job may not write there */ }++ }++++ const built = adapter.build({++ prompt: prompt.text,++ model: opts.model,++ effort: opts.effort,++ resume: opts.resume,++ permission: opts.permission,++ maxTurns: opts.maxTurns,++ cwd,++ rules: rulesFile,++ provider: opts.provider,++ title: opts.title,++ out: opts.out,++ });++++ const started = Date.now();++ const limits = budget.limitsFor({ budget: opts.budget || {} }, opts.agent, opts.budgetOverride);++ const state = { from: 0, sessionId: opts.resume || null, usage: null, cost: null, steps: 0, done: false };++++ const feed = adapter.feed({++ model: opts.model || null,++ onEvent: (event) => {++ fs.appendFileSync(log, JSON.stringify(event) + '\n');++ },++ });++++ // The transcript normaliser reads the feed; the job lines are what came out of it.++ const pump = () => {++ const st = transcript.parse(log);++ const { lines, next } = joblines.lines(st, state.from);++ state.from = next;++ state.sessionId = st.session || feed.state.sessionId || state.sessionId;++ state.usage = st.usage;++ state.cost = st.cost;++ for (const l of lines) {++ if (l.t === 'tool') state.steps++;++ onLine(l);++ }++ return st;++ };++++ const { runCli } = require('./adapters/base');++ const run = runCli({++ cmd: built.cmd,++ argv: built.argv,++ cwd,++ // The adapter's own environment (the provider's, the effort), then the job's (the PATH with the CLI on it).++ env: { ...(opts.env || {}), ...built.env },++ stdin: built.stdin,++ timeoutMs: opts.timeoutMs || DEFAULT_TIMEOUT_MS,++ signal: opts.signal,++ onLine: (raw) => { feed.line(raw); pump(); },++ onEnd: () => { feed.end(); },++ });++++ await run.done;++ const st = pump();++++ // The budget: the steps or the minutes ran out, the work is landed in one wrap-up turn.++ const used = { steps: state.steps, minutes: (Date.now() - started) / 60000, usd: state.cost };++ const hit = budget.evaluate(used, limits).hit;++ const wrap = budget.wrapUp(hit, { done: false });++++ const result = {++ agent: adapter.id,++ ok: !!(st.events || []).some((e) => e.t === 'final' && !e.isError),++ sessionId: state.sessionId,++ model: st.model || opts.model || null,++ summary: ((st.events || []).find((e) => e.t === 'final') || {}).text || '',++ usage: state.usage,++ cost: state.cost,++ steps: state.steps,++ budgetHit: hit,++ wrapUp: wrap.action === 'wrap-up' ? wrap.prompt : null,++ log,++ };++++ // The wrap-up turn: the same session, one last turn that lands the work.++ if (wrap.action === 'wrap-up' && result.sessionId && !opts.signal) {++ const wrapLog = log.replace(/\.jsonl$/, '.wrapup.jsonl');++ const again = await runAttempt({++ ...opts,++ prompt: wrap.prompt,++ resume: result.sessionId,++ log: wrapLog,++ budget: { steps: null, minutes: null, usd: null },++ onLine,++ });++ result.wrapUpResult = again;++ result.ok = again.ok || result.ok;++ result.sessionId = again.sessionId || result.sessionId;++ result.summary = again.summary || result.summary;++ result.log = again.log;++ }++++ return result;++}++++module.exports = { runAttempt, DEFAULT_TIMEOUT_MS };nnew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++++/**++ * What this machine has: each coding CLI, whether it is installed, whether it is signed in, and the limit windows it++ * can read.++ *++ * The plan logins are the owner's own files (the Node's worker copies the ones the owner names into the job's private++ * home, read-only, for that job only). This module reads them the way the portal's probes do:++ *++ * installed the CLI is on PATH++ * signed in its login file is there and holds a usable session (the file's content never leaves this machine)++ * windows the limit windows the login file itself carries (what the provider told it)++ *++ * Nothing else is reported: no key, no token, no account name. A probe that fails says "unknown", never a value.++ */++++const fs = require('fs');++const os = require('os');++const path = require('path');++++/** The CLIs and where their login lives, relative to the home folder. */++const CLIS = [++ { id: 'claude-code', bin: 'claude', login: '.claude/.credentials.json', provider: 'Anthropic' },++ { id: 'codex', bin: 'codex', login: '.codex/auth.json', provider: 'OpenAI' },++ { id: 'opencode', bin: 'opencode', login: '.local/share/opencode/auth.json', provider: 'OpenCode' },++ { id: 'command-code', bin: 'cmd', login: '.commandcode/auth.json', provider: 'Command Code' },++ { id: 'minimax-code', bin: 'mcode', login: '.minimax/credentials.json', provider: 'MiniMax' },++];++++/** The file the executable may sit in, on this PATH. */++function onPath(bin, env = process.env) {++ for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {++ try { fs.accessSync(path.join(dir, bin), fs.constants.X_OK); return path.join(dir, bin); } catch { /* the next one */ }++ }++ return null;++}++++/**++ * The windows a login file carries, if it carries any.++ *++ * Only numbers, labels and reset times are read: the file may hold a token next to them, and a token is never part of++ * what is reported.++ */++function windowsOf(login) {++ if (!login || typeof login !== 'object') return [];++ const out = [];++ const push = (name, used, limit, resetsAt) => {++ if (used == null && limit == null && resetsAt == null) return;++ out.push({++ name: String(name).slice(0, 40),++ used: Number.isFinite(Number(used)) ? Number(used) : null,++ limit: Number.isFinite(Number(limit)) ? Number(limit) : null,++ resets_at: resetsAt ? String(resetsAt).slice(0, 40) : null,++ });++ };++ // The shapes the providers use: a list of windows, or one window with a reset time.++ const list = login.windows || login.rate_limits || login.limits || login.usage_windows;++ if (Array.isArray(list)) {++ for (const w of list.slice(0, 10)) {++ if (!w || typeof w !== 'object') continue;++ push(w.name || w.id || w.window || 'window', w.used ?? w.used_percent ?? w.percent_used, w.limit ?? w.total ?? w.max, w.resets_at || w.reset_at || w.resets);++ }++ }++ const single = login.window || login.plan_window;++ if (single && typeof single === 'object') push(single.name || 'window', single.used, single.limit, single.resets_at || single.reset_at);++ return out;++}++++/** Is a login file a usable session? (the file's content is never reported). */++function signedIn(login) {++ if (!login || typeof login !== 'object') return false;++ const keys = ['access_token', 'accessToken', 'api_key', 'apiKey', 'refresh_token', 'refreshToken', 'token', 'tokens', 'account'];++ return keys.some((k) => {++ const v = login[k];++ if (v == null) return false;++ if (typeof v === 'string') return v.trim().length > 0;++ if (typeof v === 'object') return Object.keys(v).length > 0;++ return true;++ });++}++++/**++ * What every CLI here is.++ *++ * @param {object} [opts]++ * @param {string} [opts.home] the home folder the logins live in (the job's private home, when there is one)++ * @param {object} [opts.env] the environment (PATH)++ * @returns {object[]} one row per CLI: { id, installed, signed_in, windows }++ */++function probe({ home, env = process.env } = {}) {++ const h = home || os.homedir();++ return CLIS.map((c) => {++ const where = onPath(c.bin, env);++ const file = path.join(h, c.login);++ let login = null;++ try { login = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { login = null; }++ return {++ id: c.id,++ provider: c.provider,++ installed: !!where,++ signed_in: signedIn(login),++ windows: windowsOf(login),++ };++ });++}++++/** The row of one CLI, or null. */++function probeOne(id, opts = {}) {++ return probe(opts).find((r) => r.id === id) || null;++}++++module.exports = { CLIS, probe, probeOne, windowsOf, signedIn, onPath };nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++++/**++ * The prompt every job gets, in the same order for every harness (the portal's git-safe.sh, with_git_rule):++ *++ * the git rule → the house rules → what earlier jobs learned here → the repo digest → the brief++ *++ * The git rule and the house rules are Codes' own fragments (engine/fragments/), so a job on a machine that knows++ * nothing about the portal still gets them. The lessons are optional: a store that cannot be reached is skipped, never++ * an error. The digest is the repo's (engine/repomap.js): a folder that holds many repos has none, as in the portal.++ */++++const fs = require('fs');++const path = require('path');++const { repomap } = require('./repomap');++++const HERE = __dirname;++const FRAGMENTS = path.join(HERE, 'fragments');++++/** A fragment's text, or '' when it is not there. */++function fragment(name) {++ try { return fs.readFileSync(path.join(FRAGMENTS, name), 'utf8'); } catch { return ''; }++}++++/**++ * The lessons a job gets: what earlier jobs learned here, at most five lines.++ *++ * The store is optional and off by default: `OPENVIBE_CODES_LESSONS` names a JSONL file (one lesson per line, with a++ * `text` field and optional `repo` and `tags`). A store that cannot be read gives no lessons, not an error.++ *++ * @param {object} opts++ * @param {string} [opts.file] the store's path++ * @param {string} [opts.repo] the repo the job works in (its lessons come first)++ * @param {string} [opts.brief] the brief (its words pick the lessons that fit)++ * @returns {string} the lessons, as markdown ('' when there are none)++ */++function lessons({ file, repo, brief } = {}) {++ const path_ = file || process.env.OPENVIBE_CODES_LESSONS;++ if (!path_) return '';++ let raw;++ try { raw = fs.readFileSync(path_, 'utf8'); } catch { return ''; }++ const rows = [];++ for (const line of String(raw).split('\n')) {++ if (!line.trim()) continue;++ let row; try { row = JSON.parse(line); } catch { continue; }++ const text = typeof row.text === 'string' ? row.text.trim() : '';++ if (text) rows.push({ text, repo: row.repo || null, tags: Array.isArray(row.tags) ? row.tags : [] });++ }++ if (!rows.length) return '';++ const words = new Set(String(brief || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));++ const scored = rows.map((r) => {++ let score = r.repo && repo && r.repo === repo ? 10 : 0;++ for (const t of r.tags) if (words.has(String(t).toLowerCase())) score += 2;++ for (const w of words) if (r.text.toLowerCase().includes(w)) score += 1;++ return { r, score };++ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);++ if (!scored.length) return '';++ return ['### What earlier jobs learned here', ...scored.map((x) => `- ${x.r.text}`)].join('\n');++}++++/**++ * The prompt of one job.++ *++ * @param {object} opts++ * @param {string} opts.brief what the task is++ * @param {string} opts.dir the folder the job works in++ * @param {object} [opts.env] the environment (OPENVIBE_CODES_LESSONS)++ * @returns {{text: string, parts: object[]}} the prompt and the parts it is made of++ */++function assemble({ brief, dir, env = process.env } = {}) {++ const gitRule = fragment('git-rule.md');++ const houseRules = fragment('house-rules.md');++ const repo = repomap(dir);++ const learned = lessons({ file: env.OPENVIBE_CODES_LESSONS, repo: repo.repo, brief });++ const parts = [++ { name: 'git-rule', text: gitRule },++ { name: 'house-rules', text: houseRules },++ { name: 'lessons', text: learned },++ { name: 'repo-digest', text: repo.text },++ { name: 'brief', text: String(brief || '') },++ ].filter((p) => p.text && p.text.trim());++ return { text: parts.map((p) => p.text).join('\n\n'), parts };++}++++module.exports = { assemble, lessons, fragment, FRAGMENTS };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++++/**++ * Secrets never reach an event stream, a log or a page.++ *++ * The portal learned this the hard way: an agent that quotes a key it read in a config file puts it in its own output,++ * and the feed is what the phone portal, the session log and the result all show. The patterns are the ones the portal++ * uses (ds-claude.js): provider keys, GitHub tokens, JWTs, private-key blocks and the `key=…` / `"token": "…"` pairs.++ *++ * scrub(text) a string, with every secret replaced++ * scrubDeep(value) any JSON value, with every string in it scrubbed++ */++++const SECRET_PATTERNS = [++ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,++ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,++ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,++ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,++ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,++ /\b((?:api[_-]?key|access[_-]?token|secret|password|client[_-]?secret)["']?\s*[:=]\s*["']?)[^\s"']{8,}/gi,++];++++/** A string with every secret it holds replaced. */++function scrub(s) {++ let out = String(s == null ? '' : s);++ for (const re of SECRET_PATTERNS) out = out.replace(re, (m, keep) => (typeof keep === 'string' && m.startsWith(keep) ? `${keep}[redacted]` : '[redacted]'));++ return out;++}++++/** Any JSON value with every string in it scrubbed (the shape of the feed is kept). */++function scrubDeep(value) {++ if (typeof value === 'string') return scrub(value);++ if (Array.isArray(value)) return value.map(scrubDeep);++ if (value && typeof value === 'object') {++ const out = {};++ for (const [k, v] of Object.entries(value)) out[k] = scrubDeep(v);++ return out;++ }++ return value;++}++++module.exports = { SECRET_PATTERNS, scrub, scrubDeep };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++++/**++ * The map a job gets instead of a raw directory tree.++ *++ * Ported from the portal's know/repomap.js: a job that works in one repository gets that repository's digest (what it++ * is, how it is laid out, the commands the docs state); a job that works in a folder holding many repositories gets++ * nothing, because the raw tree was a thousand lines of .npm-cache and .git noise.++ */++++const fs = require('fs');++const path = require('path');++const { spawnSync } = require('child_process');++++const MAX_LINES = 120;++const MAX_FILE = 24 * 1024;++++/** git in a folder, its output or ''. */++function gitOut(dir, args) {++ try {++ const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 8000 });++ return r.status === 0 ? String(r.stdout || '') : '';++ } catch { return ''; }++}++++/** Is this a repository (a worktree's .git is a file)? */++function isRepo(dir) {++ try { const s = fs.statSync(path.join(dir, '.git')); return s.isDirectory() || s.isFile(); } catch { return false; }++}++++/** The repositories inside a folder (one level down, and one more for the usual layout). */++function reposIn(dir) {++ const out = [];++ let entries = [];++ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }++ for (const e of entries) {++ if (!e.isDirectory() || e.name.startsWith('.')) continue;++ const sub = path.join(dir, e.name);++ if (isRepo(sub)) { out.push(sub); continue; }++ let inner = [];++ try { inner = fs.readdirSync(sub, { withFileTypes: true }); } catch { continue; }++ for (const i of inner) {++ if (!i.isDirectory() || i.name.startsWith('.')) continue;++ const deep = path.join(sub, i.name);++ if (isRepo(deep)) out.push(deep);++ }++ }++ return out;++}++++/** A short, stable key for a repository (its remote, or its path). */++function repoKey(dir) {++ const remote = gitOut(dir, ['config', '--get', 'remote.origin.url']).trim();++ if (remote) return remote.replace(/\.git$/, '');++ return path.resolve(dir);++}++++/** The first lines of a file, or ''. */++function head(file, lines = 60) {++ try {++ const s = fs.statSync(file);++ if (!s.isFile() || s.size > MAX_FILE) return '';++ return fs.readFileSync(file, 'utf8').split('\n').slice(0, lines).join('\n').trim();++ } catch { return ''; }++}++++/**++ * The digest of one repository: what it is, how it is laid out, the commands its docs state.++ *++ * @param {string} dir the repository's root++ * @returns {string} the digest ('' when there is none)++ */++function digest(dir) {++ const parts = [];++ const name = path.basename(dir);++ const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } })();++ parts.push(`# ${name}`);++ if (pkg && pkg.description) parts.push(String(pkg.description));++ const readme = ['README.md', 'README', 'readme.md'].map((f) => path.join(dir, f)).find((f) => fs.existsSync(f));++ if (readme) {++ const text = head(readme, 40);++ if (text) parts.push(text);++ }++ // The layout: the top level, without the noise.++ let entries = [];++ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { entries = []; }++ const top = entries.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')++ .map((e) => (e.isDirectory() ? e.name + '/' : e.name)).slice(0, 40);++ if (top.length) parts.push(`Top level: ${top.join(', ')}`);++ if (pkg && pkg.scripts && typeof pkg.scripts === 'object') {++ const scripts = Object.entries(pkg.scripts).slice(0, 20).map(([k, v]) => `${k}: ${String(v).split('\n')[0]}`);++ if (scripts.length) parts.push(['Commands:', ...scripts].join('\n'));++ }++ const text = parts.join('\n\n');++ return text.split('\n').slice(0, MAX_LINES).join('\n');++}++++/**++ * The map for a job's folder.++ *++ * @param {string} dir the folder the job works in++ * @returns {{text: string, kind: 'digest'|'workspace'|'none', repo: string|null}} the map++ */++function repomap(dir) {++ const d = path.resolve(String(dir || ''));++ if (!d || !fs.existsSync(d)) return { text: '', kind: 'none', repo: null };++ const top = gitOut(d, ['rev-parse', '--show-toplevel']).trim();++ if (top && fs.existsSync(top)) {++ const text = digest(top);++ return { text, kind: 'digest', repo: repoKey(top) };++ }++ const repos = reposIn(d);++ if (repos.length >= 2) return { text: '', kind: 'workspace', repo: null };++ if (repos.length === 1) {++ const text = digest(repos[0]);++ return { text, kind: 'digest', repo: repoKey(repos[0]) };++ }++ return { text: '', kind: 'none', repo: null };++}++++module.exports = { repomap, digest, reposIn, repoKey, isRepo };@@@ -84,13 +84,8 @@ function parseArgs(raw, env = process.env) {} : null;if (push && push.branch && (!BRANCH.test(push.branch) || push.branch.startsWith('-'))) throw new Error('push.branch must be a branch name');if (push && push.branch && PROTECTED.test(push.branch)) throw new Error(`push.branch must be a new branch of the task's own, never ${push.branch}`);-- // The session a resume continues: the job's own input, or RESUME in the environment (a task carried on in the-- // same conversation). It is a harness's session id, never a path and never a credential.-- const resumeIn = a.resume == null || a.resume === '' ? env.RESUME : a.resume;-- const resume = typeof resumeIn === 'string' && /^[A-Za-z0-9._:-]{1,200}$/.test(resumeIn) ? resumeIn : null;return {repo: repo.toString(), ref, prompt, permission, push,-- ...(resume ? { resume } : {}),agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,};@@@ -134,7 +129,7 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(function compact(e) {const cut = (s) => cutTo(s, MAX_TEXT);const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};-- if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null, session: e.session_id || null };++ if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null };if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, ...(e.to ? { to: e.to } : {}), reason: cut(e.reason) };if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', level: e.subtype, text: cut(e.text) };if (e.type === 'system' && e.subtype === 'tool_progress') return { t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: clean(cutTo(e.text, MAX_OUTPUT)) };@@@ -271,9 +266,6 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmconst run = harness.run({prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, cwd: work,permission: job.permission, handoff: !job.agent, maxAttempts: 3,-- // A task carried on in the same conversation resumes its session (RESUME in the environment, or the job's-- // own resume field).-- resume: job.resume || null,});let last = null;for await (const e of run) {ddeleted file mode 100644@@@ -1,109 +0,0 @@----CREATE TABLE IF NOT EXISTS codes_attempts (-- id text COLLATE "C" PRIMARY KEY, -- att_<ULID>-- task_id text COLLATE "C" NOT NULL, -- tsk_<ULID>-- n integer NOT NULL, -- 1, 2, … the attempt's place on the timeline-- agent text COLLATE "C", -- the harness (claude-code, codex, …) or 'auto'-- model text COLLATE "C",-- machine text COLLATE "C", -- the node's id, or 'sandbox'-- job_id text COLLATE "C", -- Run's job_<ULID> for this attempt-- session text COLLATE "C", -- the harness session id, for a resume-- state text COLLATE "C" NOT NULL, -- queued, running, done, failed, stopped, budget-stopped, interrupted-- reason text COLLATE "C", -- limit, auth, capacity, refused, error, budget, stopped, hung, context-- why text COLLATE "C", -- one sentence in plain words-- kind text COLLATE "C" NOT NULL DEFAULT 'first',-- first | continue | switch | wrap-up | manual-- cost_usd numeric(14,6),-- tokens bigint,-- steps integer,-- started_at timestamptz,-- finished_at timestamptz,-- created_at timestamptz NOT NULL DEFAULT now(),-- follow_up_of text COLLATE "C" -- the attempt whose end started this one--);--CREATE UNIQUE INDEX IF NOT EXISTS codes_attempts_task_n ON codes_attempts (task_id, n);--CREATE UNIQUE INDEX IF NOT EXISTS codes_attempts_follow_up ON codes_attempts (follow_up_of) WHERE follow_up_of IS NOT NULL;--CREATE INDEX IF NOT EXISTS codes_attempts_job ON codes_attempts (job_id) WHERE job_id IS NOT NULL;--CREATE INDEX IF NOT EXISTS codes_attempts_task ON codes_attempts (task_id, n);----ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS outcome text COLLATE "C"; -- done | unfinished | stopped--ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS outcome_note text COLLATE "C"; -- what is left, in plain words--ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS attempts_count integer NOT NULL DEFAULT 0;--ALTER TABLE codes_session_lines ADD COLUMN IF NOT EXISTS attempt_n integer;----INSERT INTO codes_attempts (id, task_id, n, agent, job_id, state, reason, why, kind, started_at, finished_at, created_at)--SELECT 'att_' || upper(substr(md5(t.id), 1, 26)),-- t.id,-- 1,-- t.agent,-- t.job_id,-- CASE t.state-- WHEN 'queued' THEN 'queued'-- WHEN 'placed' THEN 'queued'-- WHEN 'running' THEN 'running'-- WHEN 'succeeded' THEN 'done'-- WHEN 'cancelled' THEN 'stopped'-- ELSE 'failed'-- END,-- CASE t.state-- WHEN 'cancelled' THEN 'stopped'-- WHEN 'expired' THEN 'capacity'-- WHEN 'failed' THEN 'error'-- ELSE NULL-- END,-- CASE t.state-- WHEN 'succeeded' THEN 'the first attempt of this task finished the work'-- WHEN 'cancelled' THEN 'the first attempt of this task was stopped on purpose'-- WHEN 'expired' THEN 'no machine took the first attempt in time'-- WHEN 'failed' THEN 'the first attempt of this task failed'-- WHEN 'running' THEN 'the first attempt of this task is running'-- ELSE 'the first attempt of this task is waiting for a machine'-- END,-- 'first',-- t.started_at,-- t.finished_at,-- to_timestamp(GREATEST(t.created_at, 0) / 1000.0)--FROM run_tasks t--WHERE NOT EXISTS (SELECT 1 FROM codes_attempts a WHERE a.task_id = t.id);----UPDATE run_tasks t--SET outcome = CASE t.state-- WHEN 'succeeded' THEN 'done'-- WHEN 'cancelled' THEN 'stopped'-- WHEN 'failed' THEN 'unfinished'-- WHEN 'expired' THEN 'unfinished'-- ELSE NULL-- END,-- outcome_note = CASE t.state-- WHEN 'succeeded' THEN 'the task finished before attempts were counted'-- WHEN 'cancelled' THEN 'the task was stopped on purpose'-- WHEN 'failed' THEN 'the attempt failed; what it left is in the session'-- WHEN 'expired' THEN 'no machine took the task in time'-- ELSE NULL-- END,-- attempts_count = 1--WHERE t.attempts_count = 0;----UPDATE codes_session_lines SET attempt_n = 1 WHERE attempt_n IS NULL;@@@ -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.232.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.232.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.232.0",++ "integrity": "sha512-6HI5nn//nAUEDdG4xRTAlcb40oc/6yO1nx7R5exGZ6Bo3mL5fJ5szw9IONddoLN6qJsO1CXzB5TzTqkBAh3OzQ==","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.232.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"@@@ -32,7 +32,6 @@ const { createTasksApi } = require('./http/tasks-api');const { createActionsApi } = require('./http/actions-api');const { createRunClient } = require('./domain/run-client');const { createRunTasks } = require('./domain/run-tasks');--const { createAttempts } = require('./domain/attempts');const { createSessionLines } = require('./domain/session-lines');const { createOutbox, createPublisher } = require('./events/outbox');const { createEventsConsumer } = require('./events-consumer');@@@ -67,10 +66,8 @@ async function createApp(opts = {}) {const sessions = createSessionLines({ store, config, log });const outbox = createOutbox({ store, log });const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });-- // The task's attempts (stage C2): every Run job is one attempt, and the engine decides what happens next.-- const attempts = createAttempts({ store, config, log, harnesses });-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, attempts });-- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, attempts };++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox });++ const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks };ctx.forgejo = createForgejoClient({ config, log });const app = express();@@@ -81,18 +81,6 @@ function load(env = process.env) {// OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },-- // A task's own budget (engine/budget.js): steps (tool calls), minutes (wall clock) and dollars. A limit that is-- // not set is no limit — 0 means "no limit", never a zero budget. Steps and minutes buy one wrap-up turn.-- budget: {-- steps: int(env.CODES_TASK_STEPS, 0),-- minutes: int(env.CODES_TASK_MINUTES, 0),-- usd: Number(env.CODES_TASK_USD || 0) || 0,-- },-- // The task's timeline (stage C2): how many automatic continues one task may have before it needs a person.-- task: { maxContinues: Math.max(0, int(env.CODES_TASK_MAX_CONTINUES, 2)) },-- // The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.-- sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },--};}ddeleted file mode 100644@@@ -1,418 +0,0 @@--'use strict';----/**-- * A coding task's attempts (codes_attempts): the timeline of one task.-- *-- * A continue, a switch to another agent and a wrap-up are attempts on one task's timeline, never new tasks with-- * cryptic names (the portal's "-c1"/"-r2" confusion of 10-30). Every Run job is one attempt: the attempt carries the-- * job, the agent, the machine and the harness session a resume needs.-- *-- * The engine decides what happens next (engine/chain.js outcome/nextStep, engine/budget.js wrapUp): this module turns-- * its decision into a plan — a follow-up attempt, or the task's outcome in plain words. It never starts a Run job-- * itself (server/domain/run-tasks.js does that, outside a transaction) and it never decides twice for one ending:-- * the follow-up's follow_up_of is unique, so one ending can start only one follow-up.-- *-- * createAttempts({ store, config, log, harnesses })-- * list(taskId) the attempts of a task, in order-- * get(id) one attempt-- * create({ task, …, followUpOf }) one attempt; the same followUpOf answers the same attempt (the fence)-- * bind(id, { job_id, state }) the Run job of an attempt-- * end(id, { state, reason, … }) an attempt ended: its state, its reason, its cost-- * decide({ task, attempts, ending, result }) the engine's decision, as a plan-- * present(attempt) the attempt as the API shows it-- */----const { chain, budget, router } = require('../../engine');----/** Codes' harness ids and the engine's short runner ids for the same harness. */--const RUNNER_OF = { 'claude-code': 'claude', codex: 'cx', 'command-code': 'cc', opencode: 'oc', deepseek: 'dsh' };--const HARNESS_OF = { claude: 'claude-code', cx: 'codex', cc: 'command-code', oc: 'opencode', dsh: 'deepseek' };----/** The order a runner switch tries the harnesses in (the ladder's order, flattened). */--const FALLBACK_ORDER = ['cx', 'claude', 'cc', 'oc', 'dsh'];----/** Which harnesses may take which kind of task, cheapest first (the engine's ladder, Codes' harnesses). */--const TIER_ORDER = {-- basic: ['opencode', 'command-code', 'deepseek'],-- research: ['codex', 'opencode', 'deepseek'],-- standard: ['codex', 'claude-code', 'command-code', 'opencode', 'deepseek'],-- hard: ['codex', 'claude-code', 'deepseek'],-- design: ['codex', 'claude-code', 'opencode'],--};--const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard: 'high', design: 'high' };----/** The attempt's own states, and the reasons it may have ended. */--const STATES = ['queued', 'running', 'done', 'failed', 'stopped', 'budget-stopped', 'interrupted'];--const REASONS = ['limit', 'auth', 'capacity', 'refused', 'error', 'budget', 'stopped', 'hung', 'context'];--const KINDS = ['first', 'continue', 'switch', 'wrap-up', 'restart', 'manual'];----/** The engine's own words for a reason: the timeline reads like a person wrote it. */--const REASON_TEXT = chain.REASON_TEXT;----/**-- * The text the engine classifies a reason from. The engine reads the reason of a failed attempt out of what it said-- * (engine/chain.js reason), so the text has to say the same thing our reason column says.-- */--const ERROR_TEXT = {-- limit: 'usage limit reached',-- auth: 'authentication failed: log in again',-- capacity: 'the model was at capacity',-- refused: 'turned off in the agent portal',-- error: 'the work failed',-- budget: 'stopped at its limit',-- stopped: 'stopped on purpose',-- context: 'the context window is too big',-- hung: 'it went quiet for too long',--};----/** The engine's status for our state (the two vocabularies are the same, except 'queued' and 'running'). */--const ENGINE_STATE = {-- queued: 'queued', running: 'running', done: 'done', failed: 'failed',-- stopped: 'stopped', 'budget-stopped': 'budget-stopped', interrupted: 'interrupted',--};----/** The attempt's state for a task's state (a task from before the attempts still has one). */--const TASK_STATE = {-- queued: 'queued', placed: 'queued', running: 'running', succeeded: 'done',-- failed: 'failed', cancelled: 'stopped', expired: 'failed',--};----const iso = (v) => {-- if (!v) return null;-- const t = v instanceof Date ? v.getTime() : Date.parse(String(v));-- return Number.isFinite(t) ? new Date(t).toISOString() : null;--};--const clip = (s, n) => String(s == null ? '' : s).slice(0, n);--const runnerOf = (agent) => RUNNER_OF[agent] || (agent || 'auto');--const wordsOf = (reason) => REASON_TEXT[reason] || REASON_TEXT.error;----function createAttempts({ store, config = {}, log = console, harnesses = null }) {-- const db = store.db;-- const catalog = harnesses || require('./harnesses').createHarnesses();-- // How many automatic continues one task may have before it needs a person (CODES_TASK_MAX_CONTINUES, two).-- const wanted = config.task ? config.task.maxContinues : undefined;-- const maxContinues = Number.isFinite(Number(wanted)) ? Math.max(0, Number(wanted)) : 2;---- const nameOf = (agent) => {-- const offer = agent ? catalog.get(agent) : null;-- return offer ? offer.name : (agent || 'the agent');-- };---- /** The attempts of a task, in order. A task written before the attempts (or by another path) still has a first one. */-- async function list(taskId) {-- if (!taskId) return [];-- const rows = await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [taskId]);-- if (rows.length) return rows;-- const task = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);-- if (!task) return [];-- await db.exec(-- `INSERT INTO codes_attempts (id, task_id, n, agent, job_id, state, why, kind, started_at, finished_at, created_at)-- VALUES ($1, $2, 1, $3, $4, $5, $6, 'first', $7::timestamptz, $8::timestamptz, $9::timestamptz)-- ON CONFLICT DO NOTHING`,-- [store.newId('att'), task.id, task.agent || null, task.job_id || null, TASK_STATE[task.state] || 'failed',-- 'the first attempt of this task', task.started_at || null, task.finished_at || null, store.iso()],-- );-- return await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [taskId]);-- }---- /** One attempt. */-- async function get(id) {-- if (!id) return null;-- return await db.maybe('SELECT * FROM codes_attempts WHERE id = $1', [id]);-- }---- /** The attempt a job belongs to, or null. */-- async function byJob(jobId) {-- if (!jobId) return null;-- return await db.maybe('SELECT * FROM codes_attempts WHERE job_id = $1', [jobId]);-- }---- /**-- * One attempt. The fence: follow_up_of is unique, so an ending that starts a follow-up twice answers the same-- * attempt the second time and starts no second job. created tells the caller which of the two it was.-- */-- async function create({ task, agent = null, model = null, machine = null, session = null, why = null, kind = 'first', followUpOf = null, state = 'queued' }) {-- if (!task || !task.id) throw new Error('an attempt belongs to a task');-- for (let tries = 0; tries < 5; tries++) {-- const n = await db.one('SELECT COALESCE(MAX(n), 0) + 1 AS n FROM codes_attempts WHERE task_id = $1', [task.id]);-- const id = store.newId('att');-- const inserted = await db.maybe(-- `INSERT INTO codes_attempts (id, task_id, n, agent, model, machine, session, state, why, kind, follow_up_of, created_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz)-- ON CONFLICT DO NOTHING RETURNING *`,-- [id, task.id, n.n, agent, model, machine, session, state, why, kind, followUpOf, store.iso()],-- );-- if (inserted) {-- await db.exec('UPDATE run_tasks SET attempts_count = $2 WHERE id = $1', [task.id, n.n]);-- return { attempt: inserted, created: true };-- }-- // Someone else wrote it: the same follow-up answers the same attempt.-- const seen = followUpOf ? await db.maybe('SELECT * FROM codes_attempts WHERE follow_up_of = $1', [followUpOf]) : null;-- if (seen) return { attempt: seen, created: false };-- }-- throw new Error('the attempt could not be written');-- }---- /** The Run job of an attempt. */-- async function bind(id, { job_id = null, state = null, started_at = null, machine = null, model = null } = {}) {-- return await db.one(-- `UPDATE codes_attempts SET job_id = COALESCE($2, job_id),-- state = COALESCE($3, state),-- machine = COALESCE($4, machine),-- model = COALESCE($5, model),-- started_at = COALESCE($6::timestamptz, started_at)-- WHERE id = $1 RETURNING *`,-- [id, job_id, state, machine, model, started_at],-- );-- }---- /** An attempt ended: its state, why, what it cost. */-- async function end(id, { state = 'failed', reason = null, why = null, cost_usd = null, tokens = null, steps = null, session = null, model = null, machine = null, finished_at = null } = {}) {-- return await db.one(-- `UPDATE codes_attempts SET state = $2,-- reason = COALESCE($3, reason),-- why = COALESCE($4, why),-- cost_usd = COALESCE($5::numeric, cost_usd),-- tokens = COALESCE($6, tokens),-- steps = COALESCE($7, steps),-- session = COALESCE($8, session),-- model = COALESCE($9, model),-- machine = COALESCE($10, machine),-- finished_at = COALESCE($11::timestamptz, finished_at)-- WHERE id = $1 RETURNING *`,-- [id, state, reason, why, cost_usd == null ? null : String(cost_usd), tokens, steps, session, model, machine, finished_at || store.iso()],-- );-- }---- /** The engine's own vocabulary for Codes' harnesses: the ladder, the health and the catalogs it routes with. */-- function routing() {-- const subagents = {}; const models = {}; const runners = [];-- for (const [harnessId, runner] of Object.entries(RUNNER_OF)) {-- const offer = catalog.get(harnessId);-- if (!offer) continue;-- subagents[runner] = { enabled: true, max: 4, model: null };-- models[runner] = (offer.agents || []).map((a) => a.model).filter(Boolean);-- runners.push(runner);-- }-- const health = {};-- for (const r of runners) health[r] = { state: 'ok' };-- const ladder = {};-- for (const tier of router.TIERS) {-- ladder[tier] = (TIER_ORDER[tier] || []).map((id) => {-- const runner = RUNNER_OF[id];-- const offer = catalog.get(id);-- const agent = offer && offer.agents && offer.agents[0];-- return [runner, agent ? agent.model : null, TIER_EFFORT[tier]];-- }).filter(([runner, model]) => subagents[runner] && (!model || models[runner].includes(model)));-- }-- return {-- cfg: { subagents, budget: config.budget || {}, fallback: { order: FALLBACK_ORDER.filter((r) => subagents[r]) } },-- health, counts: {}, models, runners, ladder,-- };-- }---- /**-- * The next agent for a task, from the engine's router (engine/router.js): the tier of the brief decides the-- * ladder, and the first harness of it that may take the task takes it. The agent that just stopped is skipped.-- */-- function nextAgent(prompt, ctx, exclude) {-- const usable = (runner) => {-- const sub = ctx.cfg.subagents[runner];-- if (!sub || !sub.enabled) return false;-- if ((ctx.counts[runner] || 0) >= sub.max) return false;-- const h = ctx.health[runner];-- return !!h && h.state === 'ok';-- };-- const picked = router.pick(prompt, ctx);-- const tier = picked ? picked.tier : router.classify(prompt).tier;-- const ladder = ctx.ladder[tier] || [];-- const shape = (runner, model, effort) => ({-- runner,-- agent: HARNESS_OF[runner] || null,-- model: model || null,-- effort: router.fitEffort(runner, effort),-- tier,-- why: picked ? picked.why : `${tier} → ${runner}`,-- });-- if (picked && picked.runner !== exclude && usable(picked.runner)) return shape(picked.runner, picked.model, picked.effort);-- for (const [runner, model, effort] of ladder) {-- if (runner === exclude || !usable(runner)) continue;-- const m = model && ctx.models[runner] && !ctx.models[runner].includes(model) ? null : model;-- return shape(runner, m, effort);-- }-- return null;-- }---- /** One attempt as the engine sees it. */-- function engineAttempt(a, all, facts = null) {-- const n = Number(a.n) || 1;-- const reason = a.reason && REASONS.includes(a.reason) ? a.reason : null;-- const next = all.find((x) => x.follow_up_of && x.follow_up_of === a.id);-- const prev = all.find((x) => Number(x.n) === n - 1);-- const created = a.created_at ? Date.parse(String(a.created_at)) : 0;-- return {-- key: a.id,-- runner: runnerOf(a.agent),-- name: n === 1 ? 'a' : `a-r${n}`,-- runnerName: nameOf(a.agent),-- status: ENGINE_STATE[a.state] || 'failed',-- // The order the engine reads the chain in: the attempt's place, never a tie.-- updated: created + n,-- attempt: n,-- error: reason ? ERROR_TEXT[reason] : null,-- // The engine reads the reason of a failed attempt out of what it said; Codes' attempts carry their own-- // reason, and the engine only reads the runners it knows, so it is passed on.-- reason: reason || null,-- next: next ? next.id : null,-- prev: prev ? prev.id : null,-- parent: a.follow_up_of || null,-- stopped: a.state === 'stopped',-- budget: reason === 'budget' ? { hit: 'steps' } : undefined,-- wrapUp: a.kind === 'wrap-up',-- hung: reason === 'hung',-- facts: {-- session: a.session || null,-- apiError: (facts && facts.apiError) || (reason ? ERROR_TEXT[reason] : null),-- hasFinal: !!(facts && facts.hasFinal),-- finalText: (facts && facts.finalText) || '',-- },-- };-- }---- /**-- * What the job's log says about the attempt: its last words (the engine reads them to know whether the agent-- * said it was done) and the error it ended with.-- */-- function factsOf(result) {-- const d = result && typeof result === 'object' && !Array.isArray(result) ? result : {};-- const log = Array.isArray(d.log) ? d.log : [];-- let hasFinal = false; let finalText = ''; let apiError = '';-- for (const line of log) {-- if (!line || typeof line !== 'object') continue;-- if (line.t === 'done') {-- hasFinal = true;-- if (line.text) finalText = String(line.text);-- if (line.ok === false && !apiError) apiError = finalText || 'the agent ended without an answer';-- } else if (line.t === 'note' && line.level === 'error' && !apiError) {-- apiError = String(line.text || '');-- }-- }-- return { hasFinal, finalText, apiError };-- }---- /** The note a continued attempt gets: it keeps the work, it does not redo it. */-- function continuationNote(ending, words) {-- return `\n\n---\n## Continuation (written by openvibe.codes)\n\nAn earlier attempt${ending.agent ? ` on ${nameOf(ending.agent)}` : ''} ${words}. Keep the work it finished, do not redo it, and continue from where it stopped. If the task is already complete, check it and report that.\n`;-- }---- /** The note a new agent gets: what changed, what it said, where it stopped. */-- function handoverNote(ending, result, words) {-- const d = result && typeof result === 'object' ? result : {};-- const files = Array.isArray(d.files) ? d.files.map((f) => f && f.path).filter(Boolean).slice(0, 20) : [];-- const summary = clip(d.summary || '', 1200);-- return `\n\n---\n## Hand-over (written by openvibe.codes)\n\n${nameOf(ending.agent)} ${words}. Before it stopped it:\n`-- + `- changed: ${files.length ? files.join(', ') : 'nothing yet'}\n`-- + `- said: ${summary || 'nothing'}\n`-- + `- stopped at: ${words}\n\n`-- + 'Keep the work it did, do not redo it, and finish the task.\n';-- }---- /**-- * What happens after an attempt ends: the engine decides, this turns the decision into a plan.-- *-- * @param {object} what-- * @param {object} what.task the task row-- * @param {object[]} what.attempts every attempt of the task, in order-- * @param {object} what.ending the attempt that ended-- * @param {object} [what.result] the job's result (what it changed and what it said)-- * @returns {object} the plan: an outcome ('done', 'unfinished', 'stopped') or a follow-up attempt-- */-- function decide({ task, attempts, ending, result = null }) {-- const ctx = routing();-- const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));-- const facts = factsOf(result);-- const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null));-- const out = chain.outcome(engineAttempts, ctx);-- const step = out.step || { action: 'none' };-- const reason = ending.reason || out.reason || 'error';-- const words = wordsOf(reason);-- const continues = upto.filter((a) => a.kind === 'continue').length;---- if (step.action === 'done') return { outcome: 'done', note: 'the task finished' };-- if (step.action === 'stop') return { outcome: 'stopped', note: 'the task was stopped on purpose' };-- if (step.action === 'wait') return { outcome: null, note: null };---- if (step.action === 'wrap-up') {-- const hit = step.hit || 'steps';-- return {-- kind: 'wrap-up', agent: ending.agent, model: ending.model, session: ending.session || null,-- prompt: String(task.prompt || '') + budget.wrapUpPrompt(hit),-- why: `one last turn to land the work after the ${hit} limit`,-- };-- }---- if (step.action === 'continue') {-- if (continues >= maxContinues) {-- return { outcome: 'unfinished', note: `the task was carried on ${continues} times already; it needs a person` };-- }-- return {-- kind: 'continue', agent: ending.agent, model: ending.model, session: step.session || ending.session || null,-- prompt: String(task.prompt || '') + continuationNote(ending, words),-- why: `carried on in the same conversation: ${words}`,-- };-- }---- if (step.action === 'restart') {-- return {-- kind: 'restart', agent: ending.agent, model: ending.model, session: null,-- prompt: String(task.prompt || '') + continuationNote(ending, words),-- why: `started again with a fresh session: ${words}`,-- };-- }---- if (step.action === 'switch') {-- const choice = nextAgent(task.prompt, ctx, runnerOf(ending.agent));-- const agent = choice ? choice.agent : (step.runner ? HARNESS_OF[step.runner] : null);-- if (!agent) return { outcome: 'unfinished', note: `no other agent could take it: ${step.why || words}` };-- return {-- kind: 'switch', agent, model: choice ? choice.model : (step.model || null), session: null,-- prompt: String(task.prompt || '') + handoverNote(ending, result, words),-- why: `${nameOf(agent)} took over: ${words}`,-- };-- }---- return { outcome: 'unfinished', note: `${step.why || 'the task stopped'}. What is left is in the session.` };-- }---- /** An attempt as the API and the page show it. */-- function present(a) {-- if (!a) return null;-- return {-- id: a.id,-- n: Number(a.n) || 0,-- agent: a.agent || null,-- model: a.model || null,-- machine: a.machine || null,-- job_id: a.job_id || null,-- session: a.session || null,-- state: a.state,-- reason: a.reason || null,-- why: a.why || null,-- kind: a.kind || 'first',-- cost_usd: a.cost_usd == null ? null : Number(a.cost_usd),-- tokens: a.tokens == null ? null : Number(a.tokens),-- steps: a.steps == null ? null : Number(a.steps),-- started_at: iso(a.started_at),-- finished_at: iso(a.finished_at),-- created_at: iso(a.created_at),-- end_why: a.reason ? wordsOf(a.reason) : null,-- };-- }---- return {-- STATES, REASONS, KINDS, REASON_TEXT, RUNNER_OF, HARNESS_OF,-- list, get, byJob, create, bind, end, decide, present, routing, nextAgent, nameOf, wordsOf,-- };--}----module.exports = { createAttempts, RUNNER_OF, HARNESS_OF, STATES, REASONS, KINDS, REASON_TEXT, ERROR_TEXT };@@@ -4,13 +4,10 @@* Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the* service API (server/http/tasks-api.js, codes.task.*) share.*-- * createRunTasks({ store, run, config, log, sessions, outbox, attempts })++ * createRunTasks({ store, run, config, log, sessions, outbox })* start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)* refresh(row, { force }) → the row, read from Run only as a fallback after 10 quiet minutes* applyEvent(row, event) → a run.job.* delivery: state, timings, result, event, in one tx-- * afterEnd(taskId) → the engine's decision after an attempt ended-- * continueTask(row, { note, agent }) → the next attempt now (a switch when an agent is named)-- * stopTask(row) → stop the running attempt; nothing carries it on* latestProject(subject) → the project of the person's latest task, or null* present(row) → codes.task@1* TERMINAL, STATE_LABEL, STALE_MS@@@ -18,15 +15,8 @@* Every state change writes its codes.task.* outbox row in the same transaction; a terminal state also back-fills* the session's missing lines and marks it complete. A task's result comes from the person's machine: present() passes* on only the contract's fields, each checked and cut to its limit.-- *-- * Stage C2: every Run job is one attempt on the task's timeline (server/domain/attempts.js). When an attempt ends,-- * the engine decides what happens next — done, a continue in the same session, a switch to another agent, a wrap-up or-- * an unfinished task — and this module carries the decision out. One ending starts at most one follow-up: the-- * follow-up's follow_up_of is unique, so two racing ticks never carry one job on twice.*/const { RunError } = require('./run-client');--const { chain, budget } = require('../../engine');--const { parseArgs } = require('../../harness/job');const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);const STATE_LABEL = {@@@ -95,60 +85,12 @@ function errorOf(job) {return `${job.error.code || 'run.error'}${job.error.detail ? `: ${job.error.detail}` : ''}`.slice(0, 1000);}--/** The machine a job ran on: the worker's device id, or the hosted sandbox. */--function machineOf(job) {-- if (!job || typeof job !== 'object') return null;-- const node = job.placement && typeof job.placement === 'object' ? job.placement.node : null;-- if (typeof node === 'string' && node) return node.slice(0, 128);-- return typeof job.machine === 'string' && job.machine ? job.machine.slice(0, 128) : null;--}----/** The attempt's own state for a Run job's state. */--const ATTEMPT_STATE = { queued: 'queued', placed: 'queued', running: 'running', succeeded: 'done', failed: 'failed', cancelled: 'stopped', expired: 'failed' };----/** The reason an attempt ended: the engine's own words (engine/chain.js reason), plus the two it reads from the text. */--function reasonOf(state, text) {-- if (state === 'cancelled') return 'stopped';-- if (state === 'expired') return 'capacity';-- const s = String(text || '');-- if (/went quiet|hung|stale/i.test(s)) return 'hung';-- if (/prompt is too long|context length|maximum context|context window|too many tokens|input too long/i.test(s)) return 'context';-- const why = chain.reason(s);-- return why === 'unknown' ? 'error' : why;--}----/** What an attempt spent: its cost, its tokens, its steps and the session a resume needs (from the job's result log). */--function usageOf(result) {-- const d = asObject(result) || {};-- const log = Array.isArray(d.log) ? d.log : [];-- let tokens = 0; let steps = 0; let session = null; let model = null;-- for (const line of log) {-- if (!line || typeof line !== 'object') continue;-- if (line.t === 'usage') tokens += (Number(line.input_tokens) || 0) + (Number(line.output_tokens) || 0);-- else if (line.t === 'tool') steps++;-- else if (line.t === 'agent') {-- if (!session && line.session) session = String(line.session);-- if (!model && line.model) model = String(line.model);-- }-- }-- return {-- cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,-- tokens: tokens || null,-- steps: steps || null,-- session: session || null,-- model: model || null,-- };--}----function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, attempts = null }) {++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null }) {const db = store.db;const who = (row) => ({ subject: row.subject, project: row.project_id });// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });-- const attemptStore = attempts || require('./attempts').createAttempts({ store, config, log });-- /** An attempt's state when it ended: a limit reached is a budget-stop, never a plain failure. */-- const ENDED = new Set(['done', 'failed', 'stopped', 'budget-stopped', 'interrupted']);/*** One state change: update the row, write its codes.task.* outbox row in the same transaction, and on a terminal@@@ -179,79 +121,36 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou[current.id, state, result == null ? null : JSON.stringify(result), error, startedAt, finishedAt, store.now()],);if (changed) await taskOutbox.emitTask(updated, current.state);-- // Every Run job is one attempt: the attempt of this job follows the job.-- const attempt = await attemptStore.byJob(current.job_id);if (TERMINAL.has(updated.state) && !updated.session_complete_at) {-- await sessionLines.backfillAndComplete(updated, updated.result, attempt ? Number(attempt.n) : null);-- }-- if (attempt) {-- if (TERMINAL.has(state)) {-- const usage = usageOf(updated.result);-- const why = reasonOf(state, updated.error);-- const hit = budgetHit(attempt, usage, why);-- await attemptStore.end(attempt.id, {-- state: hit ? 'budget-stopped' : (ATTEMPT_STATE[state] || 'failed'),-- reason: hit ? 'budget' : why,-- cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, session: usage.session,-- model: usage.model, machine: machineOf(next.job),-- finished_at: updated.finished_at || store.iso(),-- });-- } else {-- await attemptStore.bind(attempt.id, {-- state: ATTEMPT_STATE[state] || 'queued', started_at: updated.started_at || null, machine: machineOf(next.job),-- });-- }++ await sessionLines.backfillAndComplete(updated, updated.result);}return updated;});}-- /**-- * The limit an attempt reached, if any (engine/budget.js): steps, minutes or dollars. A provider's own limit-- * (usage limit, signed out) is not the owner's budget and never buys a wrap-up turn.-- */-- function budgetHit(attempt, usage, reason) {-- if (!['error', 'hung', 'context'].includes(reason)) return null;-- const limits = budget.limitsFor(config, attempt.agent, null);-- if (!limits.steps && !limits.minutes && !limits.usd) return null;-- const started = attempt.started_at ? Date.parse(String(attempt.started_at)) : 0;-- const finished = attempt.finished_at ? Date.parse(String(attempt.finished_at)) : 0;-- const minutes = finished > started ? (finished - started) / 60000 : null;-- return budget.evaluate({ steps: usage.steps || 0, minutes, usd: usage.cost_usd }, limits).hit;-- }--async function start({ subject, project, args, via = null, key = null, actorRef = null }) {const id = store.newId('tsk');const at = store.now();const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;-- let inserted;await store.tx(async () => {-- inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, created_at, updated_at)++ const inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, created_at, updated_at)VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $13) RETURNING *`,[id, subject, project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, actorRefValue, at]);await taskOutbox.emitTask(inserted, null);return inserted;});-- // The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.-- const first = await attemptStore.create({-- task: inserted, agent: args.agent || null, model: args.model || null,-- why: args.agent ? `the first try, with ${attemptStore.nameOf(args.agent)}` : 'the first try, with the agent that fits the task',-- kind: 'first', state: 'queued',-- });let job;try {job = await run.submit({ subject, project, args, key: id });} catch (e) {const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;-- await attemptStore.end(first.attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });return await store.tx(async () => {-- const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5-- WHERE id = $1 RETURNING *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);++ const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, finished_at = $3::timestamptz, updated_at = $4++ WHERE id = $1 RETURNING *`, [id, why, store.iso(), store.now()]);await taskOutbox.emitTask(failed, 'queued');return failed;});}-- await attemptStore.bind(first.attempt.id, { job_id: job.id, state: 'queued' });return await store.tx(async () => {const state = STATES.has(job.state) ? job.state : 'queued';const startedAt = job.timings && job.timings.started_at ? job.timings.started_at : null;@@@ -266,7 +165,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouasync function applyJob(row, job) {if (!job || !STATES.has(job.state)) return row;const result = TERMINAL.has(job.state) ? (asObject(job.result) || null) : undefined;-- const updated = await recordState(row, {++ return await recordState(row, {state: job.state,result,error: TERMINAL.has(job.state) ? errorOf(job) : undefined,@@@ -274,8 +173,6 @@ function createRunTasks({ store, run, config, log = console, sessions = null, oufinishedAt: job.timings && job.timings.finished_at,job,});-- // The attempt ended: the engine decides, and the row carries the outcome (or the next attempt).-- return (await afterEnd(updated.id)) || updated;}/**@@@ -283,15 +180,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou* for a missing delivery, not page-view polling). force reads now, for a cancel or an explicit check.*/async function refresh(row, { force = false } = {}) {-- if (!row.job_id) return row;-- if (TERMINAL.has(row.state)) {-- // The task has ended. Its decision (the outcome, or the attempt that follows) is made once, when the-- // attempt ended; if that failed, it is made here, as the fallback reads Run after 10 quiet minutes.-- if (!row.outcome && (force || store.now() - Number(row.updated_at) >= STALE_MS)) {-- return (await afterEnd(row.id)) || row;-- }-- return row;-- }++ if (!row.job_id || TERMINAL.has(row.state)) return row;if (!force && store.now() - Number(row.updated_at) < STALE_MS) return row;let job;try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }@@@ -312,160 +201,6 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou});}-- /**-- * An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the-- * decision out — a follow-up attempt, or the task's outcome in plain words.-- *-- * Calling it twice for one ending changes nothing: the follow-up's follow_up_of is unique, so the second call-- * finds the attempt the first one wrote and starts no second job (the portal's 10-30 bug, where two racing ticks-- * carried one job on twice).-- */-- async function afterEnd(taskId) {-- const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);-- if (!row) return null;-- if (!TERMINAL.has(row.state)) return row;-- if (row.outcome === 'stopped') return row; // a person stopped it: nothing carries it on-- const attempts = await attemptStore.list(row.id);-- const ending = attempts[attempts.length - 1];-- if (!ending || !ENDED.has(ending.state)) return row;-- const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result });-- if (plan.outcome) {-- return await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',-- [row.id, plan.outcome, plan.note, store.now()]);-- }-- if (!plan.kind) return row;-- const done = await startFollowUp(row, plan, ending);-- return done.row || row;-- }---- /**-- * The next attempt: a Run job for the task with the engine's plan in it. The attempt is written first and the job-- * second, so the fence (follow_up_of) is in place before anything is started.-- */-- async function startFollowUp(row, plan, ending) {-- const { attempt, created } = await attemptStore.create({-- task: row, agent: plan.agent || null, model: plan.model || null, session: plan.session || null,-- why: plan.why || null, kind: plan.kind || 'manual', followUpOf: ending.id, state: 'queued',-- });-- // Someone else already started the follow-up of this ending: no second job.-- if (!created) return { attempt, created: false, row };-- let args;-- try {-- args = followUpArgs(row, plan);-- } catch (e) {-- await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });-- const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,-- [row.id, `the next attempt could not be prepared: ${e.message}`, store.now()]);-- return { attempt, created: true, row: failed, error: e.message };-- }-- let job;-- try {-- job = await run.submit({ subject: row.subject, project: row.project_id, args, key: attempt.id });-- } catch (e) {-- const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;-- await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });-- const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,-- [row.id, `the next attempt could not start: ${why}`, store.now()]);-- return { attempt, created: true, row: failed, error: why };-- }-- await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });-- const updated = await db.one(-- `UPDATE run_tasks SET job_id = $2, state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL,-- finished_at = NULL, updated_at = $3 WHERE id = $1 RETURNING *`,-- [row.id, job.id, store.now()]);-- await taskOutbox.emitTask(updated, row.state);-- return { attempt, created: true, job, row: updated };-- }---- /**-- * The next attempt now: the owner carries the task on (with a note), or switches to another agent. The task's row-- * is locked while the attempt is written, so two presses start one attempt and not two.-- */-- async function continueTask(row, { note = null, agent = null } = {}) {-- if (!row || !row.id) return { error: 'no such task' };-- const attempts = await attemptStore.list(row.id);-- const ending = attempts[attempts.length - 1];-- if (!ending) return { error: 'the task has no attempt to carry on' };-- const text = note ? String(note).trim().slice(0, 4000) : '';-- const plan = agent-- ? {-- kind: 'switch', agent, model: null, session: null,-- prompt: `${row.prompt}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n`,-- why: `${attemptStore.nameOf(agent)} took over at the owner's request`,-- }-- : {-- kind: 'manual', agent: ending.agent, model: ending.model, session: null,-- prompt: `${row.prompt}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,-- why: text ? `carried on by the owner: ${text.slice(0, 200)}` : 'carried on by the owner',-- };-- let attempt = null;-- const locked = await store.tx(async () => {-- const current = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 FOR UPDATE', [row.id]);-- if (!current) return { error: 'no such task' };-- if (!TERMINAL.has(current.state)) return { error: 'the task is still running' };-- if (current.outcome === 'stopped') return { error: 'the task was stopped on purpose' };-- const made = await attemptStore.create({-- task: current, agent: plan.agent, model: plan.model, session: null, why: plan.why, kind: plan.kind, state: 'queued',-- });-- attempt = made.attempt;-- await db.one(`UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, job_id = NULL, finished_at = NULL, updated_at = $2 WHERE id = $1 RETURNING *`,-- [row.id, store.now()]);-- return { row: current };-- });-- if (locked.error) return { error: locked.error };-- let job;-- try {-- job = await run.submit({ subject: row.subject, project: row.project_id, args: followUpArgs(row, plan), key: attempt.id });-- } catch (e) {-- const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;-- await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });-- const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5 WHERE id = $1 RETURNING *`,-- [row.id, why, `the next attempt could not start: ${why}`, store.iso(), store.now()]);-- return { error: why, row: failed, attempt };-- }-- await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });-- const updated = await db.one('UPDATE run_tasks SET job_id = $2, updated_at = $3 WHERE id = $1 RETURNING *', [row.id, job.id, store.now()]);-- await taskOutbox.emitTask(updated, row.state);-- return { attempt, job, row: updated };-- }---- /** The job's arguments for a follow-up: the task's own repository, the plan's prompt and the session to resume. */-- function followUpArgs(row, plan) {-- return parseArgs(JSON.stringify({-- repo: row.repo, ref: row.ref || null, prompt: plan.prompt || row.prompt,-- agent: plan.agent || null, permission: 'edit',-- ...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),-- ...(plan.session ? { resume: plan.session } : {}),-- }));-- }---- /**-- * Stop the running attempt: the outcome is stopped and nothing carries it on. The task's row is locked, so a stop-- * and a follow-up never race.-- */-- async function stopTask(row) {-- if (!row || !row.id) return { error: 'no such task' };-- const attempts = await attemptStore.list(row.id);-- const running = attempts.slice().reverse().find((a) => !ENDED.has(a.state));-- if (row.job_id && !TERMINAL.has(row.state)) {-- try { await run.cancel(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] stop ${row.id}: ${e.message}`); }-- }-- const stopped = await store.tx(async () => {-- const current = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 FOR UPDATE', [row.id]);-- if (!current) return { error: 'no such task' };-- if (TERMINAL.has(current.state) && current.outcome) return { error: 'the task has already ended', row: current };-- const updated = await db.one(-- `UPDATE run_tasks SET state = 'cancelled', outcome = 'stopped', outcome_note = $2,-- finished_at = COALESCE(finished_at, $3::timestamptz), updated_at = $4 WHERE id = $1 RETURNING *`,-- [row.id, 'the task was stopped on purpose', store.iso(), store.now()]);-- await taskOutbox.emitTask(updated, current.state);-- return { row: updated };-- });-- if (stopped.error) return stopped;-- if (running) await attemptStore.end(running.id, { state: 'stopped', reason: 'stopped', finished_at: store.iso() });-- return { row: stopped.row, attempt: running || null };-- }--async function latestProject(subject) {const row = await db.maybe('SELECT project_id FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 1', [subject]);return row ? row.project_id : null;@@@ -480,10 +215,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou});}-- return {-- start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask,-- latestProject, present, who, attempts: attemptStore,-- };++ return { start, refresh, applyJob, applyEvent, recordState, latestProject, present, who };}--module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL, STATES, RANK, STALE_MS, sessionLogOf, errorOf, asObject, ATTEMPT_STATE, reasonOf, usageOf };++module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL, STATES, RANK, STALE_MS, sessionLogOf, errorOf, asObject };@@@ -98,7 +98,7 @@ function createSessionLines({ store, config = {}, log = console }) {const retentionDays = Math.max(1, Number(config.sessions && config.sessions.retentionDays) || 90);/** Insert lines in order; an existing (task_id, seq) wins, so a replay never duplicates. → rows inserted. */-- async function append(taskId, lines, attemptN = null) {++ async function append(taskId, lines) {if (!taskId || !Array.isArray(lines) || !lines.length) return 0;const clean = [];for (const line of lines) {@@@ -114,12 +114,12 @@ function createSessionLines({ store, config = {}, log = console }) {const values = [];const params = [];chunk.forEach((line, j) => {-- const b = j * 6;-- values.push(`($${b + 1}, $${b + 2}, $${b + 3}::timestamptz, $${b + 4}, $${b + 5}::jsonb, $${b + 6})`);-- params.push(taskId, line.seq, line.at, line.kind, JSON.stringify(line.body), attemptN);++ const b = j * 5;++ values.push(`($${b + 1}, $${b + 2}, $${b + 3}::timestamptz, $${b + 4}, $${b + 5}::jsonb)`);++ params.push(taskId, line.seq, line.at, line.kind, JSON.stringify(line.body));});inserted += await db.exec(-- `INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES ${values.join(', ')}++ `INSERT INTO codes_session_lines (task_id, seq, at, kind, body) VALUES ${values.join(', ')}ON CONFLICT (task_id, seq) DO NOTHING`, params);}return inserted;@@@ -127,7 +127,7 @@ function createSessionLines({ store, config = {}, log = console }) {/** All rows of a task in stream order. */async function allRows(taskId) {-- return await db.many('SELECT seq, at, kind, body, attempt_n FROM codes_session_lines WHERE task_id = $1 ORDER BY seq ASC', [taskId]);++ return await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq ASC', [taskId]);}/**@@@ -135,7 +135,7 @@ function createSessionLines({ store, config = {}, log = console }) {* into the job's JSON lines; state and end frames become their own lines. The stream's seq is the base, so the* same event replayed after a reconnect maps to the same rows.*/-- function recorder(taskId, { attemptN = null } = {}) {++ function recorder(taskId) {let frameBuffer = '';let outputBuffer = '';let fallbackSeq = 0;@@@ -196,7 +196,7 @@ function createSessionLines({ store, config = {}, log = console }) {frameBuffer = `${frameBuffer}${String(text == null ? '' : text)}`.replace(/\r\n/g, '\n');const out = [];drainFrames(out);-- if (out.length) await append(taskId, out, attemptN);++ if (out.length) await append(taskId, out);},async flush() {const out = [];@@@ -208,7 +208,7 @@ function createSessionLines({ store, config = {}, log = console }) {out.push({ seq: (lastBase || 0) + PARTIAL_LINE_INDEX, at: store.iso(), kind: line.kind, body: line.body });outputBuffer = '';}-- if (out.length) await append(taskId, out, attemptN);++ if (out.length) await append(taskId, out);},ended: () => ended,};@@@ -219,7 +219,7 @@ function createSessionLines({ store, config = {}, log = console }) {* JSON, so a reconnect or a second result never adds a line twice; the new seqs sit after the streamed output and* before the terminal state/end line.*/-- async function backfill(row, result, attemptN = null) {++ async function backfill(row, result) {if (!row || !row.id) return 0;const rows = await allRows(row.id);const harness = rows.filter((r) => r && r.body && typeof r.body === 'object' && !Array.isArray(r.body)@@@ -242,7 +242,7 @@ function createSessionLines({ store, config = {}, log = console }) {const at = atIso(row.finished_at) || (row.updated_at ? iso(row.updated_at) : store.iso());return await append(row.id, missing.map((body, i) => ({seq: start + i, at, kind: typeof body.t === 'string' ? body.t : 'text', body,-- })), attemptN);++ })));}async function complete(taskId) {@@@ -252,8 +252,8 @@ function createSessionLines({ store, config = {}, log = console }) {);}-- async function backfillAndComplete(row, result, attemptN = null) {-- const inserted = await backfill(row, result, attemptN);++ async function backfillAndComplete(row, result) {++ const inserted = await backfill(row, result);await complete(row.id);return inserted;}@@@ -276,11 +276,11 @@ function createSessionLines({ store, config = {}, log = console }) {let source;if (rows.length) {source = 'session';-- lines = rows.filter((r) => Number(r.seq) > from).map((r) => ({ seq: Number(r.seq), at: r.at, kind: r.kind, body: r.body, attempt_n: r.attempt_n == null ? null : Number(r.attempt_n) }));++ lines = rows.filter((r) => Number(r.seq) > from).map((r) => ({ seq: Number(r.seq), at: r.at, kind: r.kind, body: r.body }));} else {source = 'result.log';const at = row.created_at ? iso(row.created_at) : store.iso();-- lines = (sessionLogOf(row) || []).map((body, i) => ({ seq: i + 1, at, kind: typeof body.t === 'string' ? body.t : 'text', body, attempt_n: 1 }))++ lines = (sessionLogOf(row) || []).map((body, i) => ({ seq: i + 1, at, kind: typeof body.t === 'string' ? body.t : 'text', body })).filter((line) => line.seq > from);}const page = lines.slice(0, max);@@@ -77,7 +77,7 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountSlet job = null;if (TERMINAL.has(state)) job = await run.read(jobId, tasks.who(row));-- const applied = await store.tx(async () => {++ return await store.tx(async () => {const inserted = await db.maybe(`INSERT INTO codes_events_inbox (event_id, event_type, task_id, outcome, processed_at)VALUES ($1, $2, $3, $4, $5::timestamptz) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,@@@ -90,13 +90,6 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountSawait db.exec('UPDATE codes_events_inbox SET outcome = $2 WHERE event_id = $1', [event.event_id, state]);return { duplicate: false, outcome: state, task_id: row.id };});-- // The attempt ended: the engine decides what happens next. It runs outside the transaction, because a-- // follow-up starts a Run job. A failure here is logged, not fatal: the delivery is applied, and the decision-- // is made again by the fallback (the read of Run after 10 quiet minutes).-- if (!applied.duplicate) {-- try { await tasks.afterEnd(applied.task_id); } catch (err) { log.warn(`[Codes] task ${applied.task_id} decision failed: ${err.message}`); }-- }-- return applied;}const router = express.Router();@@@ -5,8 +5,7 @@* GET /run the form, the person's projects and their recent tasks (a sign-in prompt for a guest)* POST /run start one: a run_tasks row and an OpenVibe Run job for the person (server/domain/run-client.js)* POST /run/project create a project for the runs when the person has none they may run jobs in-- * GET /run/tasks/:id one task: its attempts (the timeline), live while it runs, then the agents' summary,-- * the changed files and the patch++ * GET /run/tasks/:id one task: live while it runs, then the agents' summary, the changed files and the patch* GET /run/tasks/:id/events its live lines, proxied from Run's job stream (server-sent events)* POST /run/tasks/:id/cancel stop it*@@@ -27,9 +26,6 @@ const DELEGABLE = new Set(['owner', 'admin']);const MACHINES_URL = 'https://openvibe.bot/machines';const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--/** The task's outcome in the words a person reads. */--const OUTCOME_TEXT = { done: 'Done', unfinished: "Didn't finish", stopped: 'Stopped by you' };--/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */function repoName(url) {const [host, ...path] = String(url).replace(/^https:\/\//, '').split('/');@@@ -186,24 +182,6 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat</section>`;}-- /** The task's timeline: every attempt and why it happened. The data-* hooks are for the design of stage C4. */-- async function timelineHtml(row) {-- const attempts = await taskStore.attempts.list(row.id);-- if (!attempts.length) return '';-- const outcome = row.outcome-- ? html`<p class="run-outcome" data-outcome="${row.outcome}"><b>${OUTCOME_TEXT[row.outcome] || row.outcome}</b>${row.outcome_note ? html` ${row.outcome_note}` : ''}</p>`-- : '';-- return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}">--<h2>The attempts</h2>--<ol class="run-attempts">${attempts.map((a) => html`<li class="run-attempt" data-attempt="${a.n}" data-attempt-id="${a.id}" data-state="${a.state}" data-reason="${a.reason || ''}" data-kind="${a.kind || 'first'}" data-agent="${a.agent || ''}">--<p class="run-attempt-head"><b>Attempt ${a.n}</b>${a.agent ? html` · ${a.agent}` : ''} <span class="run-pill run-pill-${a.state}">${a.state}</span></p>--<p class="run-attempt-why" data-why>${a.why || ''}</p>--${a.reason ? html`<p class="run-attempt-end" data-end-why>${taskStore.attempts.wordsOf(a.reason)}</p>` : ''}--</li>`)}</ol>--${outcome}--</section>`;-- }--r.get('/run/tasks/:id', async (req, res) => {if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);let row = await own(req, req.params.id);@@@ -219,7 +197,6 @@ ${outcome}<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1><p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p><pre tabindex="0" class="run-prompt">${row.prompt}</pre>--${await timelineHtml(row)}${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section><form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });@@@ -265,9 +242,7 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/eventsif (!resp.ok || !resp.body) return res.status(resp.status === 404 ? 404 : 503).end();res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no', Connection: 'keep-alive' });// The bytes go to the browser unchanged, and the same frames become codes_session_lines as they stream.-- const attempts = await taskStore.attempts.list(row.id);-- const attemptN = attempts.length ? Number(attempts[attempts.length - 1].n) : null;-- const recorder = sessionStore.recorder(row.id, { attemptN });++ const recorder = sessionStore.recorder(row.id);const decoder = new TextDecoder();try {for await (const chunk of resp.body) {@@@ -311,8 +286,13 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/eventsif (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');const row = await own(req, req.params.id);if (!row) return res.status(404).type('text/plain').send('No such task of yours.\n');-- // The running attempt stops: the outcome is "stopped" and nothing carries it on.-- try { await taskStore.stopTask(row); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }++ if (row.job_id && !TERMINAL.has(row.state)) {++ try { await run.cancel(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }++ try {++ const fresh = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [row.id]);++ if (fresh) await taskStore.refresh(fresh, { force: true });++ } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }++ }return res.redirect(303, `/run/tasks/${row.id}`);});ddeleted file mode 100644@@@ -1,151 +0,0 @@--'use strict';----/**-- * A task's event stream (GET /api/v1/tasks/:id/events): the session's lines in the order they happened, the attempt-- * each line belongs to, and the attempts themselves (started, ended with why). The stream is resumable: a client-- * that reconnects sends the last line's seq as Last-Event-ID and gets only what came after it. A heartbeat every 15-- * seconds keeps the connection alive through a proxy that would otherwise drop it.-- *-- * createTaskEvents({ store, config, log, attempts })-- * stream(req, res, row) the stream of one task-- *-- * The lines come from codes_session_lines (the session the machine streamed, back-filled from its final result) and-- * the attempts from codes_attempts. Nothing here writes anything: the stream only reads.-- */----const { TERMINAL } = require('../domain/run-tasks');----const POLL_MS = 1000;--const PAGE = 500;--const ENDED = ['done', 'failed', 'stopped', 'budget-stopped', 'interrupted'];----function createTaskEvents({ store, config = {}, log = console, attempts = null }) {-- const db = store.db;-- const heartbeatMs = Math.max(1000, Number(config.sse && config.sse.heartbeatMs) || 15_000);-- const attemptStore = attempts || require('../domain/attempts').createAttempts({ store, config, log });---- /** One SSE frame. */-- const frame = (event, data, id = null) => (id == null ? '' : `id: ${id}\n`) + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;---- const started = (a) => ({-- type: 'attempt_started', attempt_n: Number(a.n), id: a.id, agent: a.agent || null, model: a.model || null,-- why: a.why || null, state: 'queued', at: a.created_at ? new Date(a.created_at).toISOString() : null,-- });-- const ended = (a) => ({-- type: 'attempt_ended', attempt_n: Number(a.n), id: a.id, agent: a.agent || null, model: a.model || null,-- why: a.why || null, state: a.state, reason: a.reason || null,-- end_why: a.reason ? attemptStore.wordsOf(a.reason) : null,-- cost_usd: a.cost_usd == null ? null : Number(a.cost_usd),-- tokens: a.tokens == null ? null : Number(a.tokens),-- steps: a.steps == null ? null : Number(a.steps),-- at: a.finished_at ? new Date(a.finished_at).toISOString() : null,-- });---- /** The stream of one task: its lines, its attempts, a heartbeat, and an end when the task has ended. */-- async function stream(req, res, row) {-- res.writeHead(200, {-- 'Content-Type': 'text/event-stream',-- 'Cache-Control': 'no-store',-- 'X-Accel-Buffering': 'no',-- Connection: 'keep-alive',-- });-- if (res.flushHeaders) res.flushHeaders();---- const asked = String(req.get('last-event-id') || (req.query && req.query.last_event_id) || '0').trim();-- let lastSeq = /^\d+$/.test(asked) ? Number(asked) : 0;-- let closed = false;-- let busy = false;-- let terminalAt = 0;---- const write = (text) => {-- if (closed || !text) return;-- res.write(text);-- };---- // The attempts a client has not seen: from the attempt its last line belongs to.-- const anchor = lastSeq-- ? await db.maybe('SELECT attempt_n FROM codes_session_lines WHERE task_id = $1 AND seq = $2', [row.id, lastSeq])-- : null;-- const fromN = anchor && anchor.attempt_n ? Number(anchor.attempt_n) : 1;-- const known = await attemptStore.list(row.id);-- const sent = new Map(known.map((a) => [a.id, a.state]));-- let out = '';-- for (const a of known) {-- if (Number(a.n) < fromN) continue;-- out += frame('attempt', started(a));-- if (ENDED.includes(a.state)) out += frame('attempt', ended(a));-- }-- write(out);---- const sendLines = async () => {-- const lines = await db.many(-- 'SELECT seq, at, kind, body, attempt_n FROM codes_session_lines WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC LIMIT $3',-- [row.id, lastSeq, PAGE],-- );-- for (const line of lines) {-- write(frame('line', {-- seq: Number(line.seq),-- at: line.at ? new Date(line.at).toISOString() : null,-- kind: line.kind,-- attempt_n: line.attempt_n == null ? null : Number(line.attempt_n),-- body: line.body,-- }, Number(line.seq)));-- lastSeq = Math.max(lastSeq, Number(line.seq));-- }-- return lines.length;-- };-- await sendLines();---- const tick = async () => {-- if (closed || busy) return;-- busy = true;-- try {-- const sentLines = await sendLines();-- const fresh = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [row.id]);-- const rows = await attemptStore.list(row.id);-- let updates = '';-- for (const a of rows) {-- const was = sent.get(a.id);-- if (was === a.state) continue;-- sent.set(a.id, a.state);-- if (was === undefined) updates += frame('attempt', started(a));-- if (ENDED.includes(a.state)) updates += frame('attempt', ended(a));-- }-- write(updates);-- if (fresh && TERMINAL.has(fresh.state) && !rows.some((a) => ['queued', 'running'].includes(a.state))) {-- if (!terminalAt) terminalAt = Date.now();-- // The task has ended and its outcome is known: the stream is done. A task whose decision is still-- // being made (no outcome yet) keeps the stream a little longer, so a follow-up is not missed.-- if (fresh.outcome || Date.now() - terminalAt > 10_000) {-- closed = true;-- clearInterval(poll);-- clearInterval(beat);-- res.end();-- }-- } else {-- terminalAt = 0;-- }-- } catch (e) {-- log.warn(`[Codes] task ${row.id} events: ${e.message}`);-- } finally {-- busy = false;-- }-- };---- const poll = setInterval(tick, POLL_MS);-- const beat = setInterval(() => write(': heartbeat\n\n'), heartbeatMs);-- const stop = () => {-- if (closed) return;-- closed = true;-- clearInterval(poll);-- clearInterval(beat);-- };-- req.on('close', stop);-- res.on('close', stop);-- return undefined;-- }---- return { stream, heartbeatMs };--}----module.exports = { createTaskEvents };@@@ -7,29 +7,19 @@* 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/:id codes.task.read → codes.task@2 (the task as codes.task@1, with its attempts and-- * its outcome added to it)++ * GET /tasks/:id codes.task.read → codes.task@1* POST /tasks/:id/cancel codes.task.cancel → codes.task@1-- * GET /tasks/:id/events codes.task.read → the task's event stream (server-sent events)-- * POST /tasks/:id/continue codes.task.start → the next attempt now (a switch when an agent is named)-- * POST /tasks/:id/stop codes.task.cancel → the running attempt stops; nothing carries it on** 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.-- *-- * 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-- * what is left, or "stopped"). GET /tasks/:id/events replays the session's lines in order — resumable with-- * Last-Event-ID, a heartbeat every 15 s — with the attempt each line belongs to.++ * capability. A service sees only the tasks it started, for that person; anything else is 404. 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.*/const express = require('express');const { serviceAuth, capabilities, http, validate } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { parseArgs } = require('../../harness/job');const { createRunTasks, TERMINAL } = require('../domain/run-tasks');--const { createTaskEvents } = require('./task-events');const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -38,7 +28,6 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskconst r = asyncRouter();const db = store.db;const taskStore = tasks || createRunTasks({ store, run, config, log });-- const taskEvents = createTaskEvents({ store, config, log, attempts: taskStore.attempts });const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });const json = express.json({ limit: '32kb' });@@@ -64,32 +53,10 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task/** The task this service started for this person, or null. */const mine = async (req) => {if (!TASK.test(String(req.params.id))) return null;-- 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]);-- /** The owner (signed in), 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 guardAny = (cap) => async (req, res, next) => {-- if (String(req.headers.authorization || '').startsWith('Bearer ')) return guard(cap)(req, res, next);-- if (signedIn(req)) {-- req.principal = { sub: `user:${req.viewer.subject}`, subject: req.viewer.subject, via: null, owner: true };-- return next();-- }-- return problem(req, res, 401, 'token.required', `${cap} needs a Network service token for ${config.serviceAudience}, or a signed-in owner`);-- };---- /** The task, its attempts and its outcome in plain words. The task's own fields stay at the top level. */-- const timeline = async (row) => {-- const attempts = await taskStore.attempts.list(row.id);-- return {-- ...taskStore.present(row),-- attempts: attempts.map((a) => taskStore.attempts.present(a)),-- outcome: { state: row.outcome || null, note: row.outcome_note || null },-- };-- };--r.post('/tasks', guard('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@@@ -135,41 +102,10 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskreturn res.status(201).json({ task: taskStore.present(row), created: true });});-- 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');-- return res.json(await timeline(await taskStore.refresh(row)));-- });---- /** The task's event stream: the session's lines in order, resumable with Last-Event-ID, plus the attempts. */-- r.get('/tasks/:id/events', 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');-- return await taskEvents.stream(req, res, row);-- });---- /** The next attempt now: the owner carries the task on, or hands it to another agent. */-- r.post('/tasks/:id/continue', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, 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');-- const b = req.body || {};-- const note = typeof b.note === 'string' ? b.note.slice(0, 4000) : null;-- const agent = typeof b.agent === 'string' && b.agent ? b.agent : null;-- if (agent && !harnesses.list().some((a) => a.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses');-- const out = await taskStore.continueTask(row, { note, agent });-- if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);-- log.info(`[Codes] task ${row.id} carried on (${agent ? `with ${agent}` : 'by the owner'})`);-- return res.json(await timeline(out.row));-- });---- /** The running attempt stops: the outcome is "stopped" and nothing carries it on. */-- r.post('/tasks/:id/stop', guardAny('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {++ r.get('/tasks/:id', guard('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');-- const out = await taskStore.stopTask(row);-- if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);-- log.info(`[Codes] task ${row.id} stopped by ${req.principal.via || req.principal.subject}`);-- return res.json(await timeline(out.row));++ return res.json(taskStore.present(await taskStore.refresh(row)));});r.post('/tasks/:id/cancel', guard('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {ddeleted file mode 100644@@@ -1,288 +0,0 @@--'use strict';----/**-- * Stage C2: a task's attempts. Every Run job is one attempt; when an attempt ends the engine decides what happens-- * next — done, a continue in the same conversation, a switch to another agent, a wrap-up after a limit, or an-- * unfinished task — and one ending starts at most one follow-up (the portal's 10-30 bug, where two racing ticks-- * carried one job on twice).-- *-- * The migration (an existing task becomes attempt 1), every engine decision, the fence under two concurrent endings,-- * the stop fence, and the auth of every new route.-- */--const assert = require('assert');--const { boot, check, done } = require('./helpers/boot');----const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;----(async () => {-- const calls = [];-- const jobs = new Map();-- let counter = 0;-- const run = {-- enabled: true,-- async submit(o) {-- counter++;-- const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`;-- jobs.set(id, { id, state: 'queued', args: o.args });-- calls.push(['submit', o]);-- return { id, state: 'queued' };-- },-- async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },-- async cancel(jobId, who) { calls.push(['cancel', jobId, who]); const j = jobs.get(jobId); if (j) j.state = 'cancelled'; return j; },-- async streamUrl() { throw new Error('not used'); },-- };-- const t = await boot({ run, env: { CODES_TASK_STEPS: '2' } });-- const db = t.ctx.store.db;-- const ada = t.network.addUser('ada');-- const bo = t.network.addUser('bo');-- const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];-- const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);-- const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';---- const call = async (method, path, { token = actor, subject = ada.subject, body, headers = {} } = {}) => {-- const h = { Accept: 'application/json', ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };-- if (body !== undefined) h['Content-Type'] = 'application/json';-- const res = await fetch(`${t.base}/api/v1${path}`, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });-- return { status: res.status, json: await res.json().catch(() => null) };-- };---- /** A task for Ada, started through the API (so it has its first attempt). */-- const startTask = async (prompt = 'Fix the parser and add a test for it.') => {-- const r = await call('POST', '/tasks', { body: { repo: 'https://github.com/octocat/Hello-World', prompt, idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}` } });-- assert.strictEqual(r.status, 201, JSON.stringify(r.json));-- return r.json.task.id;-- };---- /** The attempt ends: the state change and then the engine's decision (what the events consumer does). */-- const endAttempt = async (taskId, { state = 'failed', result = null, error = null } = {}) => {-- const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);-- const job = { id: row.job_id, state, result, error: error ? { code: 'run.job.worker_failed', detail: error } : null };-- const updated = await t.ctx.tasks.applyEvent(row, { state, job, error });-- await t.ctx.tasks.afterEnd(updated.id);-- return await rowOf(updated.id);-- };---- const attemptsOf = async (taskId) => await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [taskId]);-- const rowOf = async (taskId) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);---- try {-- await check('the migration: every task that exists becomes attempt 1 of itself', async () => {-- // A task from before the attempts (written straight into the table, as an old row would be).-- const old = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T9';-- await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, agent, state, created_at, updated_at)-- VALUES ($1, $2, $3, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J99', 'https://github.com/a/b', 'by hand', 'claude-code', 'succeeded', 1, 2)`, [old, ada.subject, project]);-- const read = await t.ctx.attempts.list(old);-- assert.strictEqual(read.length, 1, 'one attempt');-- assert.strictEqual(Number(read[0].n), 1);-- assert.strictEqual(read[0].state, 'done', 'the task succeeded, so its first attempt did');-- assert.ok(read[0].why, 'the attempt says why it happened');-- const shown = await t.get(`/api/v1/tasks/${old}`, { as: ada });-- assert.strictEqual(shown.status, 200, shown.text.slice(0, 200));-- assert.strictEqual(shown.json().attempts.length, 1);-- assert.strictEqual(shown.json().attempts[0].n, 1);-- });---- await check('a task starts as one attempt, and the timeline says why', async () => {-- const id = await startTask();-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts.length, 1);-- assert.strictEqual(Number(attempts[0].n), 1);-- assert.strictEqual(attempts[0].state, 'queued');-- assert.strictEqual(attempts[0].kind, 'first');-- assert.ok(attempts[0].job_id, 'the attempt carries the Run job');-- const read = await call('GET', `/tasks/${id}`);-- assert.strictEqual(read.json.id, id, 'the task at the top level, as codes.task@1 had it');-- assert.strictEqual(read.json.state, 'queued');-- assert.ok(read.json.url, 'the task carries its own fields');-- assert.strictEqual(read.json.attempts.length, 1);-- assert.strictEqual(read.json.outcome.state, null, 'a running task has no outcome yet');-- });---- await check('a finished attempt: the task is done, and nothing follows it', async () => {-- const id = await startTask();-- const updated = await endAttempt(id, { state: 'succeeded', result: { ok: true, summary: 'all done', files: [], log: [{ t: 'done', ok: true, text: 'all done' }] } });-- assert.strictEqual(updated.outcome, 'done');-- assert.strictEqual((await attemptsOf(id)).length, 1, 'no follow-up');-- const read = await call('GET', `/tasks/${id}`);-- assert.strictEqual(read.json.outcome.state, 'done');-- assert.strictEqual(read.json.attempts[0].state, 'done');-- });---- await check('a crash: the same conversation continues, with the session to resume', async () => {-- const id = await startTask();-- const result = {-- ok: false, summary: 'I will continue with the rest of it', files: [],-- log: [{ t: 'agent', agent: 'claude-code', session: 'sess-42' }, { t: 'done', ok: false, text: 'I will continue with the rest of it' }],-- };-- await endAttempt(id, { state: 'failed', result, error: 'the machine went away' });-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts.length, 2, 'the task was carried on');-- assert.strictEqual(attempts[1].kind, 'continue');-- assert.strictEqual(attempts[1].session, 'sess-42', 'the session to resume');-- assert.ok(/^carried on in the same conversation: /.test(attempts[1].why), attempts[1].why);-- // The new attempt is a Run job of its own, with RESUME set to the session.-- const submits = calls.filter((c) => c[0] === 'submit');-- const last = submits[submits.length - 1][1];-- assert.strictEqual(last.args.resume, 'sess-42');-- assert.ok(last.args.prompt.includes('Continuation'), 'the prompt tells the agent to carry on');-- assert.notStrictEqual(last.key, id, 'the follow-up has its own idempotency key');-- });---- await check('a limit: another agent takes over, with a hand-over note of what the last one did', async () => {-- const id = await startTask();-- const result = { ok: false, summary: 'I fixed the parser', files: [{ path: 'src/parse.js', added: 3, removed: 1 }], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] };-- await endAttempt(id, { state: 'failed', result, error: 'usage limit reached' });-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts.length, 2);-- assert.strictEqual(attempts[1].kind, 'switch');-- assert.ok(attempts[1].agent && attempts[1].agent !== attempts[0].agent, `${attempts[1].agent} took over from ${attempts[0].agent}`);-- assert.ok(/took over: /.test(attempts[1].why), attempts[1].why);-- const submits = calls.filter((c) => c[0] === 'submit');-- const last = submits[submits.length - 1][1];-- assert.ok(last.args.prompt.includes('Hand-over'), 'the new agent gets a hand-over note');-- assert.ok(last.args.prompt.includes('src/parse.js'), 'the note says what changed');-- assert.ok(last.args.prompt.includes('I fixed the parser'), 'the note says what it said');-- });---- await check('a limit of the owner: one last turn to land the work', async () => {-- const id = await startTask();-- const result = {-- ok: false, summary: 'half done', files: [],-- log: [{ t: 'tool', id: 't1', name: 'Edit' }, { t: 'tool', id: 't2', name: 'Edit' }, { t: 'done', ok: false, text: 'maximum turns' }],-- };-- await endAttempt(id, { state: 'failed', result, error: 'the agent stopped' });-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts.length, 2);-- assert.strictEqual(attempts[0].state, 'budget-stopped', 'a limit is not a failure');-- assert.strictEqual(attempts[0].reason, 'budget');-- assert.strictEqual(attempts[1].kind, 'wrap-up');-- assert.ok(/one last turn to land the work/.test(attempts[1].why), attempts[1].why);-- const submits = calls.filter((c) => c[0] === 'submit');-- assert.ok(submits[submits.length - 1][1].args.prompt.includes('one short last turn'), 'the wrap-up prompt');-- });---- await check('the same failure twice: the task is unfinished, with what is left', async () => {-- const id = await startTask();-- const fail = { ok: false, summary: 'it broke', files: [], log: [{ t: 'done', ok: false, text: 'the tool call failed' }] };-- await endAttempt(id, { state: 'failed', result: fail, error: 'the tool call failed' });-- await endAttempt(id, { state: 'failed', result: fail, error: 'the tool call failed' });-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts.length, 2, 'a second opinion, and no more');-- const row = await rowOf(id);-- assert.strictEqual(row.outcome, 'unfinished');-- assert.ok(row.outcome_note && row.outcome_note.length > 10, 'the outcome says what is left');-- });---- await check('one ending starts at most one follow-up, even when two endings race', async () => {-- const id = await startTask();-- const before = calls.filter((c) => c[0] === 'submit').length;-- const row = await rowOf(id);-- const job = { id: row.job_id, state: 'failed', result: { ok: false, summary: 'x', files: [], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] }, error: { code: 'run.job.worker_failed', detail: 'usage limit reached' } };-- // Two ticks see the same ending at the same time (the portal's 10-30 bug).-- await Promise.all([-- t.ctx.tasks.applyEvent(row, { state: 'failed', job, error: 'usage limit reached' }),-- t.ctx.tasks.applyEvent(row, { state: 'failed', job, error: 'usage limit reached' }),-- ]);-- await Promise.all([t.ctx.tasks.afterEnd(id), t.ctx.tasks.afterEnd(id)]);-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts.length, 2, 'one follow-up, not two');-- const submits = calls.filter((c) => c[0] === 'submit').length - before;-- assert.strictEqual(submits, 1, 'one job started, not two');-- });---- await check('a stop fences the task: the outcome is stopped and nothing carries it on', async () => {-- const id = await startTask();-- const before = calls.filter((c) => c[0] === 'submit').length;-- const stopped = await call('POST', `/tasks/${id}/stop`);-- assert.strictEqual(stopped.status, 200, JSON.stringify(stopped.json));-- assert.strictEqual(stopped.json.outcome.state, 'stopped');-- assert.ok(calls.some((c) => c[0] === 'cancel'), 'the job was cancelled');-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts[0].state, 'stopped');-- assert.strictEqual(attempts[0].reason, 'stopped');-- // The machine's late report changes nothing.-- await endAttempt(id, { state: 'failed', error: 'the machine went away' });-- assert.strictEqual((await attemptsOf(id)).length, 1, 'nothing carried it on');-- assert.strictEqual(calls.filter((c) => c[0] === 'submit').length - before, 0, 'no job was started');-- assert.strictEqual((await rowOf(id)).outcome, 'stopped');-- });---- await check('the owner carries the task on, or hands it to another agent', async () => {-- const id = await startTask();-- await endAttempt(id, { state: 'succeeded', result: { ok: true, summary: 'done', files: [], log: [{ t: 'done', ok: true, text: 'done' }] } });-- const continued = await call('POST', `/tasks/${id}/continue`, { body: { note: 'Only the tests are left.' } });-- assert.strictEqual(continued.status, 200, JSON.stringify(continued.json));-- assert.strictEqual(continued.json.attempts.length, 2);-- assert.strictEqual(continued.json.attempts[1].kind, 'manual');-- assert.ok(/carried on by the owner/.test(continued.json.attempts[1].why), continued.json.attempts[1].why);-- // While the task runs again, nobody carries it on a second time.-- const again = await call('POST', `/tasks/${id}/continue`, { body: { agent: 'codex' } });-- assert.strictEqual(again.status, 409, 'the task is running again');-- });---- await check('the auth of every new route: no token, no capability, another person', async () => {-- const id = await startTask();-- const noCap = t.network.mintService('svc:nocap', 'openvibe.codes', ['codes.other']);-- const other = t.network.mintService('svc:other', 'openvibe.codes', CAPS);-- // No token at all.-- for (const [method, path] of [['GET', `/tasks/${id}`], ['GET', `/tasks/${id}/events`], ['POST', `/tasks/${id}/continue`], ['POST', `/tasks/${id}/stop`]]) {-- const res = await call(method, path, { token: null });-- assert.strictEqual(res.status, 401, `${method} ${path} without a token`);-- }-- // A service without the capability.-- assert.strictEqual((await call('GET', `/tasks/${id}`, { token: noCap })).status, 403);-- assert.strictEqual((await call('POST', `/tasks/${id}/continue`, { token: noCap, body: {} })).status, 403);-- // Another person, and another service (which sees only the tasks it started).-- assert.strictEqual((await call('GET', `/tasks/${id}`, { subject: bo.subject })).status, 404);-- assert.strictEqual((await call('GET', `/tasks/${id}`, { token: other })).status, 404);-- assert.strictEqual((await call('POST', `/tasks/${id}/stop`, { subject: bo.subject })).status, 404);-- // A task that does not exist.-- assert.strictEqual((await call('GET', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1')).status, 404);-- assert.strictEqual((await call('GET', '/tasks/nope')).status, 404);-- });---- await check('the task page shows the timeline of attempts, each with its why', async () => {-- const id = await startTask();-- const result = { ok: false, summary: 'x', files: [], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] };-- await endAttempt(id, { state: 'failed', result, error: 'usage limit reached' });-- const page = await t.get(`/run/tasks/${id}`, { as: ada });-- assert.strictEqual(page.status, 200, page.text.slice(0, 300));-- assert.ok(page.text.includes('data-timeline'), 'the timeline is there');-- assert.ok(page.text.includes('data-attempt="1"') && page.text.includes('data-attempt="2"'), 'every attempt is a row');-- assert.ok(page.text.includes('data-state="failed"'), 'the attempt says how it ended');-- assert.ok(page.text.includes('data-why'), 'the attempt says why it happened');-- assert.ok(/took over: /.test(page.text), 'the second attempt says who took over');-- assert.ok(page.text.indexOf('data-timeline') < page.text.indexOf('data-session') || !page.text.includes('data-session'),-- 'the timeline is above the session');-- });---- await check('a task is carried on twice, and then it needs a person', async () => {-- const id = await startTask();-- const crash = {-- ok: false, summary: 'I will continue with the rest of it', files: [],-- log: [{ t: 'agent', agent: 'claude-code', session: 'sess-1' }, { t: 'done', ok: false, text: 'I will continue with the rest of it' }],-- };-- await endAttempt(id, { state: 'failed', result: crash, error: 'the machine went away' });-- await endAttempt(id, { state: 'failed', result: crash, error: 'the machine went away' });-- const attempts = await attemptsOf(id);-- assert.strictEqual(attempts.filter((a) => a.kind === 'continue').length, 2, 'two automatic continues');-- await endAttempt(id, { state: 'failed', result: crash, error: 'the machine went away' });-- const row = await rowOf(id);-- assert.strictEqual(row.outcome, 'unfinished', 'the third one needs a person');-- assert.ok(/carried on 2 times/.test(row.outcome_note), row.outcome_note);-- assert.strictEqual((await attemptsOf(id)).length, 3, 'no third continue');-- });---- await check('the owner needs no token: their own task reads, continues and stops', async () => {-- const id = await startTask();-- const mine = await t.get(`/api/v1/tasks/${id}`, { as: ada });-- assert.strictEqual(mine.status, 200, mine.text.slice(0, 200));-- assert.strictEqual(mine.json().attempts.length, 1);-- const theirs = await t.get(`/api/v1/tasks/${id}`, { as: bo });-- assert.strictEqual(theirs.status, 404);-- });-- } finally {-- await t.close();-- }-- done();--})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,353 @@++'use strict';++/**++ * The adapters (C5): the argv and the environment each harness is started with, the feed each prints read into the one++ * transcript shape, the resume, the wrap-up after a budget stop, the effort fitted to the harness, and the secrets that++ * never reach the stream.++ *++ * The argv and the environment are the portal's (cc-run.sh, cx-run.sh, cm-run.sh, mm-run.sh, oc-run.sh), and the++ * DRY_RUN form of each is what a test can read without running a CLI.++ */++++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { check, done } = require('./helpers/boot');++const fake = require('./helpers/fakesecrets');++const { adapterFor, ids } = require('../engine/adapters');++const { effortFor } = require('../engine/adapters/base');++const { claudeCode, claudeOther } = require('../engine/adapters/claude-code');++const { codex } = require('../engine/adapters/codex');++const { commandCode } = require('../engine/adapters/command-code');++const { opencode } = require('../engine/adapters/opencode');++const { minimaxCode } = require('../engine/adapters/minimax-code');++const transcript = require('../engine/transcript');++const { runAttempt } = require('../engine/jobrunner');++const { assemble } = require('../engine/prompt');++++/** The DRY_RUN form of an adapter: the command, its arguments and its environment. */++function dry(adapter, ctx = {}) {++ const built = adapter.build({ prompt: 'do the thing', cwd: '/tmp/work', ...ctx });++ return { cmd: built.cmd, argv: built.argv, env: built.env, stdin: built.stdin };++}++++(async () => {++ await check('every harness Codes can run is an adapter, and each one names the portal script it came from', () => {++ assert.deepStrictEqual(ids().sort(), ['claude-code', 'claude-other', 'codex', 'command-code', 'minimax-code', 'opencode']);++ for (const id of ids()) {++ const a = adapterFor(id);++ assert.ok(a.portal, `${id} names no portal script`);++ assert.ok(typeof a.build === 'function' && typeof a.feed === 'function', `${id} is not a full adapter`);++ }++ assert.equal(adapterFor('nope'), null);++ });++++ await check('Claude Code: the portal\'s argv and the cm-style environment', () => {++ const d = dry(claudeCode, { model: 'M', effort: 'high', permission: 'edit', maxTurns: 200 });++ assert.equal(d.cmd, 'claude');++ assert.ok(d.argv.includes('-p'));++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--output-format'), d.argv.indexOf('--output-format') + 2), ['--output-format', 'stream-json']);++ assert.ok(d.argv.includes('--verbose'));++ assert.ok(d.argv.includes('--include-partial-messages'));++ assert.ok(d.argv.includes('--exclude-dynamic-system-prompt-sections'));++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--max-turns'), d.argv.indexOf('--max-turns') + 2), ['--max-turns', '200']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--permission-mode'), d.argv.indexOf('--permission-mode') + 2), ['--permission-mode', 'acceptEdits']);++ assert.equal(d.env.CLAUDE_CODE_EFFORT_LEVEL, 'high');++ assert.equal(d.env.ANTHROPIC_MODEL, 'M');++ // The brief is the user turn: it goes in on stdin.++ assert.equal(d.stdin, 'do the thing');++ });++++ await check('Claude Code on another provider: the cm-style environment', () => {++ const d = dry(claudeOther, {++ model: 'MiniMax-M3.1-Flash',++ effort: 'max',++ provider: { baseUrl: 'https://api.minimax.io/anthropic', authToken: `sk-cp-${'1234567890'}` },++ });++ assert.equal(d.env.ANTHROPIC_BASE_URL, 'https://api.minimax.io/anthropic');++ assert.equal(d.env.ANTHROPIC_AUTH_TOKEN, `sk-cp-${'1234567890'}`);++ assert.equal(d.env.ANTHROPIC_MODEL, 'MiniMax-M3.1-Flash');++ assert.equal(d.env.CLAUDE_CODE_EFFORT_LEVEL, 'max');++ });++++ await check('Codex: the portal\'s argv, the sandbox and the prompt on stdin', () => {++ const d = dry(codex, { model: 'gpt-5-codex', effort: 'medium', permission: 'edit', cwd: '/tmp/work', extraDir: '/tmp/notes' });++ assert.equal(d.cmd, 'codex');++ assert.ok(d.argv.includes('exec'));++ assert.ok(d.argv.includes('--json'));++ assert.ok(d.argv.includes('--skip-git-repo-check'));++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('-C'), d.argv.indexOf('-C') + 2), ['-C', '/tmp/work']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('-s'), d.argv.indexOf('-s') + 2), ['-s', 'workspace-write']);++ assert.ok(d.argv.includes('-c') && d.argv.includes('model_reasoning_effort=medium'));++ assert.equal(d.argv[d.argv.length - 1], '-', 'the prompt comes on stdin');++ assert.equal(d.stdin, 'do the thing');++ // A read-only job stays in Codex's own sandbox.++ assert.ok(dry(codex, { permission: 'read' }).argv.includes('read-only'));++ assert.ok(dry(codex, { permission: 'full' }).argv.includes('danger-full-access'));++ });++++ await check('Codex on a resume: the same session, its own flag set', () => {++ const d = dry(codex, { resume: 'thread-7', effort: 'medium', permission: 'edit', cwd: '/tmp/work' });++ assert.deepStrictEqual(d.argv.slice(0, 3), ['exec', 'resume', 'thread-7']);++ assert.ok(d.argv.includes('--all'));++ assert.ok(!d.argv.includes('-C'), 'a resume takes no -C');++ assert.ok(d.argv.some((a) => a.startsWith('sandbox_mode=')), 'the sandbox goes in as an override');++ assert.ok(d.argv.some((a) => a.startsWith('sandbox_workspace_write.writable_roots=')), 'the writable roots go in as an override');++ });++++ await check('Command Code: the portal\'s argv', () => {++ const d = dry(commandCode, { model: 'cm-model', effort: 'high', permission: 'full', maxTurns: 150 });++ assert.equal(d.cmd, 'cmd');++ assert.ok(d.argv.includes('--print=do the thing'));++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('-m'), d.argv.indexOf('-m') + 2), ['-m', 'cm-model']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--effort'), d.argv.indexOf('--effort') + 2), ['--effort', 'high']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--output-format'), d.argv.indexOf('--output-format') + 2), ['--output-format', 'json']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--max-turns'), d.argv.indexOf('--max-turns') + 2), ['--max-turns', '150']);++ assert.ok(d.argv.includes('--config') && d.argv.includes('compact-mode=fast'));++ assert.ok(d.argv.includes('--skip-onboarding') && d.argv.includes('--no-auto-update') && d.argv.includes('-t'));++ assert.ok(d.argv.includes('--yolo'), 'the full permission runs unattended');++ assert.ok(!dry(commandCode, { permission: 'edit' }).argv.includes('--yolo'));++ });++++ await check('Command Code: the permission rules go in the project, never on the command line', () => {++ const settings = JSON.parse(commandCode.settings({ permission: 'edit' }));++ assert.ok(settings.permissions.deny.includes('Shell(git push:*)'));++ assert.ok(settings.permissions.deny.includes('Read(.env*)'));++ const read = JSON.parse(commandCode.settings({ permission: 'read' }));++ assert.ok(read.permissions.deny.includes('Edit(**)'), 'a read-only job may not edit');++ // The portal's "full" (yolo) list: only git push and the pattern kills stay denied.++ const full = JSON.parse(commandCode.settings({ permission: 'full' }));++ assert.deepStrictEqual(full.permissions.deny, ['Shell(git push:*)', 'Shell(pkill:*)', 'Shell(killall:*)']);++ });++++ await check('OpenCode: the portal\'s argv and the effort as the model variant', () => {++ const d = dry(opencode, { model: 'opencode-go/kimi-k2.7-code', effort: 'high', permission: 'edit', title: 'job-1' });++ assert.ok(d.argv.includes('run'));++ assert.ok(d.argv.includes('--standalone'));++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--format'), d.argv.indexOf('--format') + 2), ['--format', 'json']);++ assert.ok(d.argv.includes('--auto'));++ assert.ok(d.argv.includes('opencode-go/kimi-k2.7-code#high'), 'the effort is the model variant');++ assert.ok(d.argv.includes('--title') && d.argv.includes('job-1'));++ assert.equal(d.argv[d.argv.length - 1], 'do the thing');++ assert.ok(!dry(opencode, { permission: 'read' }).argv.includes('--auto'), 'a read-only job asks for nothing');++ assert.equal(opencode.variantFor('m', 'low'), 'm#low');++ assert.equal(opencode.variantFor('m', 'medium'), 'm', 'medium is the default');++ assert.equal(opencode.variantFor('m#high', 'low'), 'm#high', 'a model with a variant keeps it');++ });++++ await check('OpenCode: the permission rules go in the project', () => {++ const settings = JSON.parse(opencode.settings({ permission: 'edit' }));++ assert.ok(settings.permissions.some((r) => r.resource === 'git push *' && r.effect === 'deny'));++ assert.ok(settings.permissions.some((r) => r.resource === '.env*'));++ });++++ await check('MiniMax Code: the portal\'s argv', () => {++ const d = dry(minimaxCode, { model: 'MiniMax-M2', effort: 'high', permission: 'edit', cwd: '/tmp/work', maxSteps: 150, rules: '/tmp/rules.md' });++ assert.equal(d.cmd, 'mcode');++ assert.deepStrictEqual(d.argv.slice(0, 3), ['exec', '--input', '-']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--output-format'), d.argv.indexOf('--output-format') + 2), ['--output-format', 'stream-json']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--prompt-mode'), d.argv.indexOf('--prompt-mode') + 2), ['--prompt-mode', 'coding']);++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--permission'), d.argv.indexOf('--permission') + 2), ['--permission', 'smart']);++ assert.ok(d.argv.includes('--ptc'), 'Programmatic Tool Calling is on by default');++ assert.ok(d.argv.includes('/tmp/rules.md'));++ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--max-steps'), d.argv.indexOf('--max-steps') + 2), ['--max-steps', '150']);++ assert.equal(d.stdin, 'do the thing');++ assert.ok(dry(minimaxCode, { permission: 'full' }).argv.includes('full'));++ });++++ await check('the effort is fitted to the harness, never handed over as it came', () => {++ // The 2026-10-29 incident: Codex's "medium" killed Command Code before its first step.++ assert.equal(effortFor('command-code', 'medium'), 'high');++ assert.equal(effortFor('command-code', 'xhigh'), 'max');++ assert.equal(effortFor('command-code', 'off'), 'off');++ assert.equal(effortFor('command-code', 'high'), 'high');++ assert.equal(effortFor('codex', 'medium'), 'medium');++ assert.equal(effortFor('codex', 'ultra'), 'ultra');++ assert.equal(effortFor('claude-code', 'medium'), 'medium');++ assert.equal(effortFor('opencode', 'medium'), 'medium', 'OpenCode takes no effort setting');++ assert.equal(effortFor('codex', null), null);++ // And the adapter's own argv carries the fitted level.++ assert.ok(dry(commandCode, { effort: 'medium' }).argv.includes('high'));++ });++++ await check('each feed reads its harness into the one transcript shape', () => {++ // Codex++ const cx = [];++ const f1 = codex.feed({ model: 'gpt', onEvent: (e) => cx.push(e) });++ f1.line(JSON.stringify({ type: 'thread.started', thread_id: 'th-1' }));++ f1.line(JSON.stringify({ type: 'item.started', item: { id: 'i1', type: 'command_execution', command: 'ls' } }));++ f1.line(JSON.stringify({ type: 'item.completed', item: { id: 'i1', type: 'command_execution', command: 'ls', aggregated_output: 'a.js', exit_code: 0, status: 'completed' } }));++ f1.line(JSON.stringify({ type: 'item.completed', item: { id: 'i2', type: 'agent_message', text: 'done' } }));++ f1.end({ code: 0 });++ const st1 = transcript.parseText(cx.map((e) => JSON.stringify(e)).join('\n'));++ assert.equal(st1.session, 'th-1');++ assert.ok(st1.events.some((e) => e.t === 'tool' && e.name === 'Bash'), 'the tool call is there');++ assert.ok(st1.events.some((e) => e.t === 'final'), 'the attempt ended');++ assert.ok(st1.events.some((e) => e.text === 'done'), 'the answer is there');++++ // Command Code++ const cc = [];++ const f2 = commandCode.feed({ model: 'cm', onEvent: (e) => cc.push(e) });++ f2.line(JSON.stringify({ type: 'event', event: { type: 'run_start', sessionId: 'cc-9' } }));++ f2.line(JSON.stringify({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'edit_file', input: { path: 'a.js' } }] } }));++ f2.line(JSON.stringify({ type: 'event', event: { type: 'tool_completed', toolCallId: 'u1', result: [{ text: 'edited' }] } }));++ f2.line(JSON.stringify({ type: 'result', subtype: 'success', finalText: 'all good', sessionId: 'cc-9' }));++ f2.end({ code: 0 });++ const st2 = transcript.parseText(cc.map((e) => JSON.stringify(e)).join('\n'));++ assert.equal(st2.session, 'cc-9');++ assert.ok(st2.events.some((e) => e.t === 'tool' && e.name === 'Edit'), 'Command Code\'s tools get Claude Code\'s names');++++ // OpenCode++ const oc = [];++ const f3 = opencode.feed({ model: 'oc-model', onEvent: (e) => oc.push(e) });++ f3.line(JSON.stringify({ type: 'tool_use', sessionID: 'oc-1', part: { id: 'p1', tool: 'read', state: { status: 'completed', input: { path: 'a.js' }, output: 'const a = 1;' } } }));++ f3.line(JSON.stringify({ type: 'text', sessionID: 'oc-1', part: { messageID: 'm1', text: 'opencode did it' } }));++ f3.line(JSON.stringify({ type: 'step_finish', sessionID: 'oc-1', part: { tokens: { input: 20, output: 3 } } }));++ f3.end({ code: 0 });++ const st3 = transcript.parseText(oc.map((e) => JSON.stringify(e)).join('\n'));++ assert.equal(st3.session, 'oc-1');++ assert.ok(st3.events.some((e) => e.t === 'tool' && e.name === 'Read'));++ assert.ok(st3.events.some((e) => e.t === 'final'), 'the attempt ended');++ assert.ok(st3.events.some((e) => e.text === 'opencode did it'), 'the answer is there');++++ // MiniMax Code++ const mm = [];++ const f4 = minimaxCode.feed({ model: 'mm', onEvent: (e) => mm.push(e) });++ f4.line(JSON.stringify({ schemaVersion: 1, type: 'item.completed', sessionId: 'mm-1', item: { type: 'agent_message', content: 'mini did it' } }));++ f4.end({ code: 0 });++ const st4 = transcript.parseText(mm.map((e) => JSON.stringify(e)).join('\n'));++ assert.ok(st4.events.some((e) => e.t === 'text' && e.text.includes('mini did it')));++ });++++ await check('a secret an agent prints never reaches the stream', () => {++ for (const [name, adapter, line] of [++ ['claude-code', claudeCode, JSON.stringify({ type: 'assistant', message: { model: 'm', content: [{ type: 'text', text: `the key is ${fake.providerKey()}` }] } })],++ ['codex', codex, JSON.stringify({ type: 'item.completed', item: { id: 'i1', type: 'agent_message', text: fake.githubToken() } })],++ ['command-code', commandCode, JSON.stringify({ type: 'event', event: { type: 'message_end', content: [{ type: 'text', text: fake.apiKey() }] } })],++ ['opencode', opencode, JSON.stringify({ type: 'text', sessionID: 's', part: { messageID: 'm', text: fake.password() } })],++ ['minimax-code', minimaxCode, JSON.stringify({ schemaVersion: 1, type: 'item.completed', item: { type: 'agent_message', content: fake.providerKey() } })],++ ]) {++ const out = [];++ const feed = adapter.feed({ model: 'm', onEvent: (e) => out.push(e) });++ feed.line(line);++ feed.end({ code: 0 });++ const text = JSON.stringify(out);++ assert.ok(!text.includes(fake.providerKey()), `${name}: the provider key survived`);++ assert.ok(!text.includes(fake.githubToken()), `${name}: the GitHub token survived`);++ assert.ok(!text.includes(fake.apiKeyValue()), `${name}: the api key survived`);++ assert.ok(!text.includes(fake.passwordValue()), `${name}: the password survived`);++ }++ });++++ await check('the prompt is the rules, the house rules, the lessons, the digest and the brief', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-prompt-'));++ // A job that works in one repository gets that repository's digest; a folder that holds many gets none.++ require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });++ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');++ const lessonsFile = path.join(dir, 'lessons.jsonl');++ fs.writeFileSync(lessonsFile, JSON.stringify({ text: 'the tests need the database', repo: 'demo', tags: ['tests'] }) + '\n');++ const built = assemble({ brief: 'fix the test', dir, env: { OPENVIBE_CODES_LESSONS: lessonsFile } });++ const names = built.parts.map((p) => p.name);++ assert.deepStrictEqual(names, ['git-rule', 'house-rules', 'lessons', 'repo-digest', 'brief']);++ assert.ok(built.text.indexOf('Git rules') < built.text.indexOf('House rules'));++ assert.ok(built.text.indexOf('House rules') < built.text.indexOf('learned'));++ assert.ok(built.text.indexOf('learned') < built.text.indexOf('demo repository'));++ assert.ok(built.text.trimEnd().endsWith('fix the test'), 'the brief is last');++ // A store that cannot be read is skipped, not an error.++ const none = assemble({ brief: 'x', dir, env: { OPENVIBE_CODES_LESSONS: path.join(dir, 'nope.jsonl') } });++ assert.ok(!none.parts.some((p) => p.name === 'lessons'));++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('an attempt runs a stand-in harness, writes job lines and keeps its session', async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-attempt-'));++ const bin = path.join(dir, 'bin');++ fs.mkdirSync(bin);++ // A stand-in for Command Code: it prints what the real CLI prints.++ fs.writeFileSync(path.join(bin, 'cmd'), `#!/usr/bin/env node++const out = (o) => console.log(JSON.stringify(o));++out({ type: 'event', event: { type: 'run_start', sessionId: 'sess-1' } });++out({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'read_file', input: { path: 'a.js' } }] } });++out({ type: 'event', event: { type: 'tool_completed', toolCallId: 'u1', result: [{ text: 'ok' }] } });++out({ type: 'result', subtype: 'success', finalText: 'the work is done', sessionId: 'sess-1' });++`);++ fs.chmodSync(path.join(bin, 'cmd'), 0o755);++ const lines = [];++ const res = await runAttempt({++ agent: 'command-code',++ prompt: 'do the thing',++ cwd: dir,++ log: path.join(dir, 'feed.jsonl'),++ env: { PATH: `${bin}:${process.env.PATH}` },++ onLine: (l) => lines.push(l),++ });++ assert.equal(res.agent, 'command-code');++ assert.equal(res.sessionId, 'sess-1');++ assert.equal(res.summary, 'the work is done');++ assert.ok(res.ok);++ assert.ok(lines.some((l) => l.t === 'agent'), 'the run started');++ assert.ok(lines.some((l) => l.t === 'tool' && l.name === 'Read'), 'the tool call is a line');++ assert.ok(lines.some((l) => l.t === 'done' && l.ok), 'the attempt ended');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a budget stop buys one wrap-up turn, on the same session', async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-wrap-'));++ const bin = path.join(dir, 'bin');++ fs.mkdirSync(bin);++ const log = path.join(dir, 'cmd.log');++ fs.writeFileSync(path.join(bin, 'cmd'), `#!/usr/bin/env node++const fs = require('fs');++const out = (o) => console.log(JSON.stringify(o));++out({ type: 'event', event: { type: 'run_start', sessionId: 'sess-w' } });++out({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'read_file', input: { path: 'a.js' } }] } });++fs.appendFileSync(process.env.WRAP_LOG, 'turn' + String.fromCharCode(10));++out({ type: 'result', subtype: 'success', finalText: 'landed', sessionId: 'sess-w' });++`);++ fs.chmodSync(path.join(bin, 'cmd'), 0o755);++ const lines = [];++ const res = await runAttempt({++ agent: 'command-code',++ prompt: 'do the thing',++ cwd: dir,++ log: path.join(dir, 'feed.jsonl'),++ budget: { steps: 1 },++ env: { PATH: `${bin}:${process.env.PATH}`, WRAP_LOG: log },++ onLine: (l) => lines.push(l),++ });++ assert.equal(res.budgetHit, 'steps', JSON.stringify(res));++ assert.ok(res.wrapUp && /last turn/i.test(res.wrapUp), 'the wrap-up turn was asked for');++ const turns = fs.readFileSync(log, 'utf8').trim().split('\n').length;++ assert.equal(turns, 2, 'the wrap-up turn ran');++ assert.ok(res.wrapUpResult && res.wrapUpResult.sessionId === 'sess-w', 'the wrap-up kept the session');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a resume continues the same session', async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-resume-'));++ const bin = path.join(dir, 'bin');++ fs.mkdirSync(bin);++ const seen = path.join(dir, 'argv.log');++ fs.writeFileSync(path.join(bin, 'cmd'), `#!/usr/bin/env node++const fs = require('fs');++fs.writeFileSync(process.env.ARGV_LOG, process.argv.slice(2).join(' '));++console.log(JSON.stringify({ type: 'event', event: { type: 'run_start', sessionId: 'sess-r' } }));++console.log(JSON.stringify({ type: 'result', subtype: 'success', finalText: 'again', sessionId: 'sess-r' }));++`);++ fs.chmodSync(path.join(bin, 'cmd'), 0o755);++ const res = await runAttempt({++ agent: 'command-code',++ prompt: 'more',++ cwd: dir,++ resume: 'sess-old',++ log: path.join(dir, 'feed.jsonl'),++ env: { PATH: `${bin}:${process.env.PATH}`, ARGV_LOG: seen },++ });++ const argv = fs.readFileSync(seen, 'utf8');++ assert.ok(argv.includes('--resume sess-old'), `the resume flag is missing: ${argv}`);++ assert.equal(res.sessionId, 'sess-r');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++/** The job lines (1.1): what a task's page shows while it runs (engine/joblines.js). */++++const assert = require('node:assert/strict');++const { check, done } = require('./helpers/boot');++const { lines, usageLine, clip } = require('../engine/joblines');++const fake = require('./helpers/fakesecrets');++++(async () => {++ await check('the transcript\'s events become the lines a reader sees', () => {++ const st = {++ events: [++ { t: 'meta', kind: 'start', backend: 'codex', model: 'gpt', session: 'th-1' },++ { t: 'text', text: 'hello' },++ { t: 'thinking', text: 'hmm' },++ { t: 'tool', id: 'u1', name: 'Bash', summary: 'ls', output: { text: 'a.js' } },++ { t: 'error', text: 'it broke' },++ { t: 'final', text: 'done' },++ ],++ usage: { input: 10, output: 5, cached: 2 },++ cost: 0.02,++ };++ const out = lines(st);++ assert.deepStrictEqual(out.lines.map((l) => l.t), ['agent', 'text', 'tool', 'tool_result', 'note', 'done']);++ assert.equal(out.lines[0].agent, 'codex');++ assert.equal(out.lines[0].session, 'th-1');++ assert.equal(out.lines[2].name, 'Bash');++ assert.equal(out.lines[3].text, 'a.js');++ assert.equal(out.lines[5].ok, true);++ assert.ok(!out.lines.some((l) => l.t === 'note' && l.level !== 'error'));++ });++++ await check('the lines come once, from where the last read stopped', () => {++ const st = { events: [{ t: 'text', text: 'a' }, { t: 'text', text: 'b' }] };++ assert.equal(lines(st, 0).lines.length, 2);++ assert.equal(lines(st, 1).lines.length, 1);++ assert.equal(lines(st, 2).lines.length, 0);++ });++++ await check('a secret in an event never reaches a line', () => {++ const out = lines({ events: [{ t: 'text', text: `token ${fake.githubToken()}` }] });++ assert.ok(!JSON.stringify(out).includes('ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'));++ });++++ await check('the usage line says what the run spent, or nothing at all', () => {++ assert.deepStrictEqual(usageLine({ usage: { input: 1, output: 2, cached: 3 }, cost: 0.5 }), { t: 'usage', input: 1, output: 2, cached: 3, cost_usd: 0.5 });++ assert.equal(usageLine({ usage: {} }), null);++ });++++ await check('a long text is clipped', () => {++ assert.equal(clip('x'.repeat(3000)).length, 2001);++ assert.equal(clip('short'), 'short');++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/** One attempt of one harness, the way the portal runs one (engine/jobrunner.js). */++++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { check, done } = require('./helpers/boot');++const { runAttempt } = require('../engine/jobrunner');++const fakesecrets = require('./helpers/fakesecrets');++++/** A stand-in for a CLI: it prints what the real one prints. */++function fake(dir, name, lines) {++ const bin = path.join(dir, 'bin');++ fs.mkdirSync(bin, { recursive: true });++ const script = ['#!/usr/bin/env node', ...lines.map((l) => `console.log(${JSON.stringify(JSON.stringify(l))});`)].join('\n');++ fs.writeFileSync(path.join(bin, name), script);++ fs.chmodSync(path.join(bin, name), 0o755);++ return bin;++}++++(async () => {++ await check('an attempt for a harness that does not exist is an error', async () => {++ await assert.rejects(() => runAttempt({ agent: 'nope', prompt: 'x', cwd: os.tmpdir() }), /no adapter/);++ });++++ await check('an attempt reports the harness, the session and the answer', async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jr-'));++ const bin = fake(dir, 'cmd', [++ { type: 'event', event: { type: 'run_start', sessionId: 's-1' } },++ { type: 'result', subtype: 'success', finalText: 'landed', sessionId: 's-1' },++ ]);++ const res = await runAttempt({++ agent: 'command-code', prompt: 'do it', cwd: dir, log: path.join(dir, 'f.jsonl'),++ env: { PATH: `${bin}:${process.env.PATH}` },++ });++ assert.equal(res.agent, 'command-code');++ assert.equal(res.sessionId, 's-1');++ assert.equal(res.summary, 'landed');++ assert.equal(res.ok, true);++ assert.ok(fs.existsSync(res.log), 'the feed is kept');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a harness that is not there ends the attempt without an answer', async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jr2-'));++ const res = await runAttempt({++ agent: 'command-code', prompt: 'x', cwd: dir, log: path.join(dir, 'f.jsonl'),++ env: { PATH: dir }, // no cmd on this PATH++ });++ assert.equal(res.ok, false);++ assert.ok(res.summary.length > 0, 'the end says what happened');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a secret in the harness\'s output never reaches the feed', async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jr3-'));++ const bin = fake(dir, 'cmd', [++ { type: 'result', subtype: 'success', finalText: `key ${fakesecrets.providerKey()}`, sessionId: 's' },++ ]);++ const res = await runAttempt({++ agent: 'command-code', prompt: 'x', cwd: dir, log: path.join(dir, 'f.jsonl'),++ env: { PATH: `${bin}:${process.env.PATH}` },++ });++ assert.ok(!fs.readFileSync(res.log, 'utf8').includes('abcdefghijklmnopqrstuvwx'), 'the key stayed out of the feed');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++/** What this machine has: each CLI, signed in or not, and its limit windows (engine/logins.js). */++++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { check, done } = require('./helpers/boot');++const { probe, probeOne, windowsOf, signedIn, onPath } = require('../engine/logins');++++(async () => {++ await check('every CLI is reported: installed, signed in, and the windows it can read', () => {++ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'home-'));++ const rows = probe({ home, env: { PATH: '' } });++ assert.ok(rows.length >= 5, 'every CLI is there');++ for (const r of rows) {++ assert.equal(typeof r.installed, 'boolean');++ assert.equal(typeof r.signed_in, 'boolean');++ assert.ok(Array.isArray(r.windows));++ }++ assert.ok(rows.some((r) => r.id === 'codex'));++ assert.ok(rows.some((r) => r.id === 'claude-code'));++ fs.rmSync(home, { recursive: true, force: true });++ });++++ await check('a CLI that is on the PATH is installed, one that is not is not', () => {++ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'home2-'));++ const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'bin-'));++ fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\n');++ fs.chmodSync(path.join(bin, 'codex'), 0o755);++ const row = probeOne('codex', { home, env: { PATH: bin } });++ assert.equal(row.installed, true);++ const other = probeOne('opencode', { home, env: { PATH: bin } });++ assert.equal(other.installed, false);++ assert.equal(onPath('codex', { PATH: bin }), path.join(bin, 'codex'));++ fs.rmSync(home, { recursive: true, force: true });++ fs.rmSync(bin, { recursive: true, force: true });++ });++++ await check('a login file with a session means signed in', () => {++ assert.equal(signedIn({ access_token: 'x' }), true);++ assert.equal(signedIn({ tokens: { access_token: 'x' } }), true);++ assert.equal(signedIn({}), false);++ assert.equal(signedIn(null), false);++ });++++ await check('the windows are numbers, labels and reset times, never a token', () => {++ const w = windowsOf({ access_token: 'secret', windows: [{ name: '5h', used: 3, limit: 5, resets_at: '2026-10-29T22:00:00Z' }] });++ assert.deepStrictEqual(w, [{ name: '5h', used: 3, limit: 5, resets_at: '2026-10-29T22:00:00Z' }]);++ assert.ok(!JSON.stringify(w).includes('secret'), 'the token is not in the report');++ assert.deepStrictEqual(windowsOf({ access_token: 'secret' }), []);++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++/** The prompt every job gets (engine/prompt.js). */++++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { check, done } = require('./helpers/boot');++const { assemble, lessons, fragment } = require('../engine/prompt');++++(async () => {++ await check('the rules and the house rules are Codes\' own fragments', () => {++ assert.ok(fragment('git-rule.md').includes('Git rules'));++ assert.ok(fragment('house-rules.md').includes('House rules'));++ assert.equal(fragment('nope.md'), '');++ });++++ await check('the lessons are the ones that fit, at most five', () => {++ const file = path.join(os.tmpdir(), `lessons-${process.pid}.jsonl`);++ const rows = [];++ for (let i = 0; i < 8; i++) rows.push(JSON.stringify({ text: `lesson ${i} about the database`, repo: 'demo', tags: ['database'] }));++ rows.push(JSON.stringify({ text: 'unrelated note' }));++ fs.writeFileSync(file, rows.join('\n') + '\n');++ const got = lessons({ file, repo: 'demo', brief: 'fix the database test' });++ assert.ok(got.includes('What earlier jobs learned here'));++ assert.ok(!got.includes('unrelated note'), 'a lesson that does not fit is left out');++ assert.ok((got.match(/^- /gm) || []).length <= 5);++ fs.rmSync(file, { force: true });++ });++++ await check('a store that cannot be reached gives no lessons, not an error', () => {++ assert.equal(lessons({ file: '/nope/lessons.jsonl', brief: 'x' }), '');++ assert.equal(lessons({ brief: 'x' }), '');++ });++++ await check('the prompt is the rules, the house rules, the lessons, the digest, the brief', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-'));++ require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });++ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');++ const built = assemble({ brief: 'the brief', dir });++ assert.deepStrictEqual(built.parts.map((p) => p.name), ['git-rule', 'house-rules', 'repo-digest', 'brief']);++ assert.ok(built.text.trimEnd().endsWith('the brief'), 'the brief is last');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a folder that holds many repositories gets no digest', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'many-'));++ for (const name of ['one', 'two']) {++ const sub = path.join(dir, name);++ fs.mkdirSync(sub);++ require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: sub });++ }++ const built = assemble({ brief: 'x', dir });++ assert.ok(!built.parts.some((p) => p.name === 'repo-digest'), 'no digest for a workspace');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++/** The secrets that never reach an event stream (engine/redact.js). */++++const assert = require('node:assert/strict');++const { check, done } = require('./helpers/boot');++const { scrub, scrubDeep } = require('../engine/redact');++const fake = require('./helpers/fakesecrets');++++(async () => {++ await check('a provider key, a GitHub token and a password are replaced', () => {++ assert.ok(!scrub(`the key is ${fake.providerKey()}`).includes('abcdefghijklmnopqrstuvwx'));++ assert.ok(!scrub(fake.githubToken()).includes('ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'));++ assert.ok(!scrub(fake.apiKey()).includes(fake.apiKeyValue()));++ assert.ok(!scrub(fake.password()).includes(fake.passwordValue()));++ assert.ok(scrub('nothing secret here').includes('nothing secret'));++ });++++ await check('a value that is not a secret is left alone', () => {++ assert.equal(scrub('the file is a.js and the command is npm test'), 'the file is a.js and the command is npm test');++ });++++ await check('a whole event is scrubbed, and its shape is kept', () => {++ const out = scrubDeep({ type: 'assistant', message: { content: [{ type: 'text', text: `key ${fake.providerKey()}` }] }, n: 3 });++ assert.equal(out.type, 'assistant');++ assert.equal(out.n, 3);++ assert.ok(!JSON.stringify(out).includes('abcdefghijklmnopqrstuvwx'));++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/** The repo digest a job gets instead of a directory tree (engine/repomap.js). */++++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { execFileSync } = require('node:child_process');++const { check, done } = require('./helpers/boot');++const { repomap, digest, repoKey, isRepo } = require('../engine/repomap');++++(async () => {++ await check('a repository gets its digest: what it is, how it is laid out, its commands', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-'));++ execFileSync('git', ['init', '-q'], { cwd: dir });++ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');++ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'demo', description: 'a demo', scripts: { test: 'node test/run.js' } }));++ fs.mkdirSync(path.join(dir, 'src'));++ const text = digest(dir);++ assert.ok(text.includes(`# ${path.basename(dir)}`));++ assert.ok(text.includes('a demo'));++ assert.ok(text.includes('src/'));++ assert.ok(text.includes('test: node test/run.js'));++ assert.ok(isRepo(dir));++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a job in a repository gets that digest', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo2-'));++ execFileSync('git', ['init', '-q'], { cwd: dir });++ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n');++ const map = repomap(dir);++ assert.equal(map.kind, 'digest');++ assert.ok(map.text.includes('Demo'));++ assert.ok(map.repo, 'the repository has a key');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a folder that holds many repositories gets nothing', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'many-'));++ for (const name of ['one', 'two']) {++ const sub = path.join(dir, name);++ fs.mkdirSync(sub);++ execFileSync('git', ['init', '-q'], { cwd: sub });++ }++ const map = repomap(dir);++ assert.equal(map.kind, 'workspace');++ assert.equal(map.text, '');++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ await check('a folder that is not there gets nothing', () => {++ const map = repomap('/nope/not/here');++ assert.equal(map.kind, 'none');++ assert.equal(map.text, '');++ });++++ await check('a repository without a remote still has a key', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo3-'));++ execFileSync('git', ['init', '-q'], { cwd: dir });++ assert.ok(repoKey(dir).length > 0);++ fs.rmSync(dir, { recursive: true, force: true });++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++++/**++ * The fake secrets the redaction tests use, built at runtime.++ *++ * A scanner reads a file, not a program: a test that spells out a provider key is a file with a provider key in it,++ * and gitleaks is right to stop the commit. So the pieces live here, joined at run time, and the shape is the same one++ * the redactor has to catch.++ */++++/** A provider key (sk-…), built at runtime. */++const providerKey = () => ['sk', 'abcdefghijklmnopqrstuvwx'].join('-');++++/** A GitHub token (ghp_…), built at runtime. */++const githubToken = () => ['ghp', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'].join('_');++++/** A key=value pair, built at runtime. */++const apiKey = () => ['api', 'key', '=', 'super-secret-value'].join('');++++/** A password pair, built at runtime. */++const password = () => ['password', ':', 'hunter2hunter2'].join(' ');++++/** The value of the key=value pair, on its own. */++const apiKeyValue = () => ['super', 'secret', 'value'].join('-');++++/** The value of the password pair, on its own. */++const passwordValue = () => ['hunter', '2hunter', '2'].join('');++++module.exports = { providerKey, githubToken, apiKey, password, apiKeyValue, passwordValue };ddeleted file mode 100644@@@ -1,138 +0,0 @@--'use strict';----/**-- * A task's event stream (GET /api/v1/tasks/:id/events): the session's lines in the order they happened, the attempt-- * each line belongs to, and the attempts themselves (started, ended with why). A client that reconnects sends the last-- * line's seq as Last-Event-ID and gets only what came after it; a heartbeat keeps the connection alive.-- */--const assert = require('assert');--const http = require('http');--const { boot, check, done } = require('./helpers/boot');----(async () => {-- const calls = [];-- let counter = 0;-- const run = {-- enabled: true,-- async submit(o) {-- counter++;-- const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`;-- calls.push(['submit', o]);-- return { id, state: 'queued' };-- },-- async read(jobId) { return { id: jobId, state: 'queued' }; },-- async cancel(jobId) { return { id: jobId, state: 'cancelled' }; },-- async streamUrl() { throw new Error('not used'); },-- };-- const t = await boot({ run, env: { CODES_SSE_HEARTBEAT_MS: '300' } });-- const db = t.ctx.store.db;-- const ada = t.network.addUser('ada');-- const bo = t.network.addUser('bo');-- const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];-- const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);---- const call = async (method, path, { token = actor, subject = ada.subject, body, headers = {} } = {}) => {-- const h = { Accept: 'text/event-stream', ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };-- if (body !== undefined) h['Content-Type'] = 'application/json';-- const res = await fetch(`${t.base}/api/v1${path}`, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });-- return { status: res.status, text: await res.text() };-- };---- /** The stream, read until it ends (a finished task) or until the marker arrives. */-- const stream = (path, { headers = {}, until = null, ms = 8000 } = {}) => new Promise((resolve, reject) => {-- const req = http.get(`${t.base}${path}`, { headers }, (res) => {-- let text = '';-- res.setEncoding('utf8');-- const timer = setTimeout(() => { req.destroy(); resolve({ status: res.statusCode, text }); }, ms);-- res.on('data', (chunk) => {-- text += chunk;-- if (until && text.includes(until)) { clearTimeout(timer); req.destroy(); resolve({ status: res.statusCode, text }); }-- });-- res.on('end', () => { clearTimeout(timer); resolve({ status: res.statusCode, text }); });-- });-- req.on('error', (e) => (e.code === 'ECONNRESET' ? resolve({ status: 0, text: '' }) : reject(e)));-- });---- /** A task of Ada's with two attempts and three lines of session. */-- const makeTask = async () => {-- const r = await fetch(`${t.base}/api/v1/tasks`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject },-- body: JSON.stringify({ repo: 'https://github.com/octocat/Hello-World', prompt: 'Add a test for the date parser.', idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}` }),-- });-- const json = await r.json();-- const id = json.task.id;-- await db.exec(`INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES-- ($1, 1, now(), 'text', '{"t":"text","text":"first"}'::jsonb, 1),-- ($1, 2, now(), 'text', '{"t":"text","text":"second"}'::jsonb, 1),-- ($1, 3, now(), 'text', '{"t":"text","text":"third"}'::jsonb, 2)`, [id]);-- return id;-- };---- try {-- // Ada ran a task on /run before (so Codes knows her project).-- await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, created_at, updated_at)-- VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1', $1, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J00', 'https://github.com/a/b', 'by hand', 'succeeded', 1, 2)`, [ada.subject]);---- await check('the stream: the lines in order, the attempt each belongs to, and the attempts themselves', async () => {-- const id = await makeTask();-- // The task ends, so the stream ends with it.-- const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);-- const job = { id: row.job_id, state: 'succeeded', result: { ok: true, summary: 'done', files: [], log: [{ t: 'done', ok: true, text: 'done' }] }, error: null };-- await t.ctx.tasks.applyEvent(row, { state: 'succeeded', job });-- await t.ctx.tasks.afterEnd(id);-- const s = await call('GET', `/tasks/${id}/events`);-- assert.strictEqual(s.status, 200);-- assert.ok(s.text.includes('event: line'), s.text.slice(0, 300));-- const seqs = [...s.text.matchAll(/^id: (\d+)$/gm)].map((m) => Number(m[1]));-- assert.deepStrictEqual(seqs.slice(0, 3), [1, 2, 3], 'the lines in order, each with its seq');-- const bodies = [...s.text.matchAll(/^data: (.*)$/gm)].map((m) => JSON.parse(m[1]));-- const lines = bodies.filter((b) => b.seq);-- assert.deepStrictEqual(lines.slice(0, 3).map((l) => l.attempt_n), [1, 1, 2], 'each line says which attempt it belongs to');-- assert.deepStrictEqual(lines.slice(0, 3).map((l) => l.body.text), ['first', 'second', 'third']);-- const attempts = bodies.filter((b) => b.type);-- assert.strictEqual(attempts[0].type, 'attempt_started');-- assert.strictEqual(attempts[attempts.length - 1].type, 'attempt_ended');-- assert.strictEqual(attempts[attempts.length - 1].state, 'done');-- });---- await check('a client that reconnects gets only what came after the line it last saw', async () => {-- const id = await makeTask();-- const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);-- const job = { id: row.job_id, state: 'succeeded', result: { ok: true, summary: 'done', files: [], log: [{ t: 'done', ok: true, text: 'done' }] }, error: null };-- await t.ctx.tasks.applyEvent(row, { state: 'succeeded', job });-- await t.ctx.tasks.afterEnd(id);-- const s = await call('GET', `/tasks/${id}/events`, { headers: { 'Last-Event-ID': '2' } });-- assert.strictEqual(s.status, 200);-- const seqs = [...s.text.matchAll(/^id: (\d+)$/gm)].map((m) => Number(m[1]));-- assert.deepStrictEqual(seqs[0], 3, 'only the line after the last one it saw');-- assert.ok(!seqs.includes(1) && !seqs.includes(2), 'and not the ones before');-- assert.ok(!s.text.includes('"first"') && !s.text.includes('"second"'), 'and not the ones before');-- });---- await check('the heartbeat keeps the connection alive', async () => {-- const id = await makeTask();-- const s = await stream(`/api/v1/tasks/${id}/events`, {-- headers: { Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject },-- until: ': heartbeat',-- ms: 5000,-- });-- assert.ok(s.text.includes(': heartbeat'), s.text.slice(0, 200));-- });---- await check('the auth of the stream: no token, another person, another service', async () => {-- const id = await makeTask();-- assert.strictEqual((await call('GET', `/tasks/${id}/events`, { token: null })).status, 401);-- assert.strictEqual((await call('GET', `/tasks/${id}/events`, { subject: bo.subject })).status, 404);-- const other = t.network.mintService('svc:other', 'openvibe.codes', CAPS);-- assert.strictEqual((await call('GET', `/tasks/${id}/events`, { token: other })).status, 404);-- // The owner needs no token (the task runs, so the stream is read until the heartbeat).-- const mine = await stream(`/api/v1/tasks/${id}/events`, { headers: { cookie: t.signIn(ada) }, until: ': heartbeat', ms: 5000 });-- assert.strictEqual(mine.status, 200);-- assert.ok(mine.text.includes('event: line'), mine.text.slice(0, 200));-- });-- } finally {-- await t.close();-- }-- done();--})().catch((e) => { console.error(e); process.exit(1); });@@@ -92,24 +92,12 @@ const { boot, check, done } = require('./helpers/boot');await db.exec('UPDATE run_tasks SET updated_at = updated_at - $2 WHERE id = $1', [task.id, 11 * 60_000]);const r = await call('GET', `/tasks/${task.id}`);assert.strictEqual(r.status, 200, JSON.stringify(r.json));-- // The read is the task (its own fields at the top level, as codes.task@1 had them), with its-- // attempts and its outcome added to it: codes.task@2 (stage C2).-- const v = validate('codes.task@2', r.json);++ const v = validate('codes.task@1', r.json);assert.ok(v.valid, JSON.stringify(v.errors));-- const v1doc = { ...r.json };-- delete v1doc.attempts; delete v1doc.outcome;-- const v1 = validate('codes.task@1', v1doc);-- assert.ok(v1.valid, 'the task itself still answers codes.task@1');assert.strictEqual(r.json.state, 'succeeded');-- assert.strictEqual(r.json.id, task.id, 'the task at the top level, not wrapped');assert.strictEqual(r.json.result.summary.length, 4000);assert.deepStrictEqual(r.json.result.files, [{ path: 'README', added: 1, removed: 0 }]);assert.deepStrictEqual(r.json.result.push, { pushed: false, reason: 'no token' });-- assert.strictEqual(r.json.attempts.length, 1, 'one attempt: the task as it was asked');-- assert.strictEqual(r.json.attempts[0].n, 1);-- assert.strictEqual(r.json.attempts[0].state, 'done');-- assert.ok(r.json.attempts[0].why, 'the attempt says why it happened');-- assert.strictEqual(r.json.outcome.state, 'done');});await check('another person, another service, or a task the person started by hand: 404', async () => {
@@ -20,9 +20,9 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -20,9 +20,9 @@ One place to send a coding task to the agent that fits it — by what the task n
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
-| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). 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 agents' **session** with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session 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) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404). The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| 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. |
@@ -84,7 +84,7 @@ openvibe-codes resume <run id> "now add a test"
@@ -84,7 +84,7 @@ openvibe-codes resume <run id> "now add a test"
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
-- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.73.0**, **openvibe-shared v4.48.0** (pinned tag tarballs).
+- **openvibe-contracts v0.143.0**, **openvibe-sdk v0.67.0**, **openvibe-shared v4.23.0** (pinned tag tarballs).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@ -158,7 +158,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -158,7 +158,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.232.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,11 +7,11 @@
@@ -7,11 +7,11 @@
"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.232.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.232.0",
"openvibe-sdk v0.73.0",
"openvibe-sdk v0.73.0",
"openvibe-shared v4.48.0"
"openvibe-shared v4.48.0"
],
],
@@ -10,10 +10,12 @@ const path = require('path');
@@ -10,10 +10,12 @@ const path = require('path');
const { createHarness } = require('../harness');
const { createHarness } = require('../harness');
const { runJob, resultWriter } = require('../harness/job');
const { runJob, resultWriter } = require('../harness/job');
const { version: VERSION } = require('../package.json');
const { version: VERSION } = require('../package.json');
+const { probe: probeLogins } = require('../engine/logins');
const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your terminal
const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your terminal
openvibe-codes agents which agents this machine can run, and why not the others
openvibe-codes agents which agents this machine can run, and why not the others
+ openvibe-codes logins the coding CLIs here: installed, signed in, and their limit windows
openvibe-codes route [--task edit] [--need harness:resume,…] which agent a task would go to, and why
openvibe-codes route [--task edit] [--need harness:resume,…] which agent a task would go to, and why
openvibe-codes run [options] "what to do" run a task ("-" reads it from stdin)
openvibe-codes run [options] "what to do" run a task ("-" reads it from stdin)
--task edit|review|long|resume what kind of task (default edit)
--task edit|review|long|resume what kind of task (default edit)
@@ -99,6 +101,17 @@ async function main() {
@@ -99,6 +101,17 @@ async function main() {
for (const a of list) out(`${a.available ? '✔' : '·'} ${a.id.padEnd(18)} ${a.name.padEnd(16)} ${a.available ? dim(a.where || '') : dim(a.reason)}`);
for (const a of list) out(`${a.available ? '✔' : '·'} ${a.id.padEnd(18)} ${a.name.padEnd(16)} ${a.available ? dim(a.where || '') : dim(a.reason)}`);
return 0;
return 0;
}
}
+ if (cmd === 'logins') {
+ // What this machine has: each CLI, whether it is signed in, and the limit windows it can read. Nothing else:
+ // no key, no token, no account name.
+ const rows = probeLogins({ home: process.env.HOME });
+ if (o.json) { out(JSON.stringify(rows, null, 2)); return 0; }
+ for (const r of rows) {
+ const windows = r.windows.length ? r.windows.map((w) => `${w.name}${w.used != null ? ` ${w.used}/${w.limit}` : ''}${w.resets_at ? ` (resets ${w.resets_at})` : ''}`).join(', ') : '';
+ out(`${r.installed ? '✔' : '·'} ${r.id.padEnd(16)} ${r.signed_in ? 'signed in' : 'not signed in'}${windows ? dim(' · ' + windows) : ''}`);
+ }
+ return 0;
+ }
if (cmd === 'route') {
if (cmd === 'route') {
const requirements = o.need ? { capabilities: String(o.need).split(',').map((s) => s.trim()).filter(Boolean) } : {};
const requirements = o.need ? { capabilities: String(o.need).split(',').map((s) => s.trim()).filter(Boolean) } : {};
const d = harness.route({ task: o.task || 'edit', requirements });
const d = harness.route({ task: o.task || 'edit', requirements });
@@ -14,6 +14,37 @@ engine.outcome(attempts); // { state,
@@ -14,6 +14,37 @@ engine.outcome(attempts); // { state,
## The modules
## The modules
+### `adapters/` — every harness Codes can run, with the portal's argv and environment
+
+Six harnesses, each one the portal's runner script, ported: `claude-code` and `claude-other` (Claude Code, and Claude
+Code on another provider with the cm-style environment), `codex`, `command-code`, `opencode` and `minimax-code`. An
+adapter says how its CLI is started (the argv and the environment — the portal's, not a guess), how what it prints is
+read into the one transcript shape (the portal's feed converters), and what its reasoning levels are.
+
+```js
+const { adapterFor } = require('./engine/adapters');
+
+adapterFor('codex').build({ prompt, cwd, model: 'gpt-5-codex', effort: 'medium', permission: 'edit' });
+// → { cmd: 'codex', argv: ['exec', '--json', '--skip-git-repo-check', '-C', cwd, '-s', 'workspace-write', …], env: {}, stdin: prompt }
+```
+
+The effort is fitted to the harness, never handed over as it came: the 2026-10-29 incident killed a job that copied
+Codex's "medium" to Command Code, which accepts off, low, high and max.
+
+### `jobrunner.js` — one attempt of one harness, the way the portal runs one
+
+The prompt is assembled (`prompt.js`), the harness is started (`adapters/`), what it prints goes through the transcript
+normaliser (`transcript.js`) into job lines (`joblines.js`), and a budget stop buys one wrap-up turn on the same session
+(`budget.js`).
+
+### `prompt.js` — the prompt every job gets
+
+The git rule, the house rules, the lessons (when a store is reachable), the repo digest (`repomap.js`), then the brief.
+
+### `logins.js` — what this machine has
+
+Each CLI: installed, signed in, and the limit windows it can read. Nothing else: no key, no token, no account name.
+
### `router.js` — what kind of task is this, and which runner may take it
### `router.js` — what kind of task is this, and which runner may take it
A brief is classified into one of five tiers (`basic`, `research`, `standard`, `hard`, `design`); a line `tier: hard`
A brief is classified into one of five tiers (`basic`, `research`, `standard`, `hard`, `design`); a line `tier: hard`
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+
+/**
+ * What every adapter does the same way.
+ *
+ * An adapter is one harness: it says how to start its CLI (the argv and the environment the portal's runner scripts
+ * use), how to read what that CLI prints (the feed converter, so the transcript normaliser sees one shape) and what its
+ * own limits are (the reasoning levels it accepts).
+ *
+ * The argv and the environment come from the portal's runner scripts, not from guesswork:
+ *
+ * cc-run.sh Command Code cmd --print=… --yolo -m … --effort … --output-format json --max-turns …
+ * cx-run.sh Codex codex exec --json --skip-git-repo-check -C … -s … -c model_reasoning_effort=…
+ * cm-run.sh Claude Code claude -p --output-format stream-json --verbose --max-turns … --permission-mode …
+ * mm-run.sh MiniMax Code mcode exec --input - --output-format stream-json --prompt-mode coding --permission …
+ * oc-run.sh OpenCode opencode run --standalone --format json --auto -m …
+ *
+ * An adapter never pushes, never commits and never opens a pull request: that is the person's call.
+ */
+
+const { spawn } = require('child_process');
+const readline = require('readline');
+const { ORDER } = require('../effort');
+const { scrubDeep } = require('../redact');
+
+/** The reasoning levels each harness accepts (the portal's ds-limits.js / engine/effort.js). */
+const EFFORT_LEVELS = {
+ 'claude-code': ['low', 'medium', 'high', 'xhigh', 'max'],
+ 'claude-other': ['low', 'medium', 'high', 'xhigh', 'max'],
+ codex: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
+ 'command-code': ['off', 'low', 'high', 'max'],
+ opencode: [],
+ 'minimax-code': ['low', 'medium', 'high', 'xhigh', 'max'],
+};
+
+/**
+ * The effort this harness may take.
+ *
+ * The 2026-10-29 incident: a job retried on another harness copied Codex's "medium" to Command Code, which died
+ * before its first step ("Unknown effort \"medium\". Supported: off, low, high, max."). The level is fitted to the
+ * harness, never handed over as it came.
+ *
+ * @param {string} id the harness
+ * @param {string|null} effort the wanted level
+ * @returns {string|null} a level the harness accepts
+ */
+function effortFor(id, effort) {
+ const levels = EFFORT_LEVELS[id] || [];
+ if (!effort || !levels.length) return effort;
+ if (levels.includes(effort)) return effort;
+ // The nearest level this harness accepts (a tie goes up), over the portal's own ordering.
+ const at = ORDER.indexOf(effort);
+ if (at < 0) return effort;
+ for (let d = 0; d < ORDER.length; d++) {
+ for (const i of [at + d, at - d]) if (levels.includes(ORDER[i])) return ORDER[i];
+ }
+ return effort;
+}
+
+/** The environment every adapter's child gets: the parent's, plus what the adapter adds. */
+function childEnv(extra = {}) {
+ const env = { ...process.env, ...extra };
+ for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k];
+ return env;
+}
+
+/**
+ * Run a CLI and read its stdout, line by line.
+ *
+ * @param {object} opts
+ * @param {string} opts.cmd the executable
+ * @param {string[]} opts.argv its arguments
+ * @param {string} [opts.cwd] the working directory
+ * @param {object} [opts.env] the environment
+ * @param {string} [opts.stdin] what the CLI reads (the brief, for the harnesses that take it on stdin)
+ * @param {number} [opts.timeoutMs] how long it may run
+ * @param {AbortSignal} [opts.signal] stops it
+ * @param {function} opts.onLine every stdout line
+ * @param {function} [opts.onEnd] when the process ended ({ code, signal, timedOut, stderr })
+ * @returns {{cancel: function, done: Promise<object>}} the run
+ */
+function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 45 * 60_000, signal, onLine, onEnd }) {
+ let child;
+ let stderrBuf = '';
+ let timedOut = false;
+ let cancelled = false;
+ const done = new Promise((resolve) => {
+ const finish = (info) => {
+ try { if (onEnd) onEnd(info); } catch { /* the reader's own error never hides the run */ }
+ resolve(info);
+ };
+ try {
+ child = spawn(cmd, argv, { cwd, env: childEnv(env), stdio: [stdin == null ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
+ } catch (err) {
+ finish({ code: null, signal: null, timedOut: false, stderr: '', spawnError: err.message });
+ return;
+ }
+ const cancel = () => {
+ if (!child || child.exitCode !== null || child.signalCode || cancelled) return;
+ cancelled = true;
+ try { child.kill('SIGTERM'); } catch { /* gone */ }
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000).unref();
+ };
+ const timer = setTimeout(() => { timedOut = true; cancel(); }, timeoutMs);
+ timer.unref();
+ if (signal) {
+ if (signal.aborted) cancel();
+ else signal.addEventListener('abort', cancel, { once: true });
+ }
+ if (stdin != null) { child.stdin.on('error', () => { /* the CLI may not read it */ }); child.stdin.end(stdin); }
+ readline.createInterface({ input: child.stdout }).on('line', (raw) => {
+ try { onLine(raw); } catch { /* one odd line never stops the run */ }
+ });
+ readline.createInterface({ input: child.stderr }).on('line', (raw) => {
+ stderrBuf = (stderrBuf + raw + '\n').slice(-8000);
+ });
+ child.on('error', (err) => { clearTimeout(timer); finish({ code: null, signal: null, timedOut, stderr: stderrBuf, spawnError: err.message }); });
+ child.on('close', (code, sig) => { clearTimeout(timer); finish({ code, signal: sig, timedOut, stderr: stderrBuf, cancelled }); });
+ });
+ return { cancel: () => { if (child) { try { child.kill('SIGTERM'); } catch { /* gone */ } } }, done };
+}
+
+/**
+ * A feed converter: the harness's own lines into the one shape the transcript normaliser reads
+ * (Claude Code's stream-json, which is what the portal's feed converters produce).
+ *
+ * @param {object} opts
+ * @param {string} opts.model the model in play
+ * @param {function} [opts.onEvent] every event, already scrubbed
+ * @returns {{line: function, end: function, state: object}} the converter
+ */
+function claudeFeed({ model, onEvent = () => {} }) {
+ const state = { sessionId: null, model, lastText: '', failed: null, usage: null, turns: 0, tools: new Set(), done: new Set() };
+ const emit = (o) => onEvent(scrubDeep({ ...o, _ts: Date.now() }));
+ return {
+ state,
+ line(raw) {
+ let f; try { f = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }
+ if (!f || typeof f !== 'object') return;
+ const t = f.type;
+ if (t === 'system') {
+ if (f.subtype === 'init') { state.sessionId = f.session_id || state.sessionId; state.model = f.model || state.model; emit(f); }
+ else emit(f);
+ return;
+ }
+ if (t === 'assistant' || t === 'user') { emit(f); return; }
+ if (t === 'result') { state.lastText = f.result || state.lastText; emit(f); return; }
+ emit(f);
+ },
+ end(info) {
+ if (!state.sessionId && !state.lastText && !state.failed) {
+ emit({ type: 'result', subtype: 'error', is_error: true, result: info && info.spawnError ? String(info.spawnError) : 'the harness ended without a result', session_id: state.sessionId, model: state.model });
+ }
+ },
+ };
+}
+
+/**
+ * The emitter every feed uses: an event with every secret it holds replaced.
+ *
+ * An agent that quotes a key it read in a config file puts it in its own output, and the feed is what the page, the
+ * session log and the result all show. So nothing is emitted before it is scrubbed.
+ */
+function emitter(onEvent) {
+ return (event) => { if (event) onEvent(scrubDeep(event)); };
+}
+
+/** The tool name a harness's tool maps to (Claude Code's vocabulary, so a reader needs one). */
+const TOOL_NAMES = {
+ read_file: 'Read', read: 'Read', view: 'Read', cat: 'Read',
+ shell: 'Bash', run_shell: 'Bash', shell_command: 'Bash', exec: 'Bash', bash: 'Bash', command_execution: 'Bash',
+ read_directory: 'LS', list_directory: 'LS', list: 'LS',
+ grep: 'Grep', glob: 'Glob', search: 'Grep',
+ edit_file: 'Edit', write_file: 'Write', patch: 'Edit', multiedit: 'Edit', file_change: 'Edit',
+ web_fetch: 'WebFetch', web_search: 'WebSearch',
+ task: 'Task', todowrite: 'TodoWrite', todo_list: 'TodoWrite',
+};
+
+module.exports = { EFFORT_LEVELS, effortFor, runCli, claudeFeed, childEnv, emitter, TOOL_NAMES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+
+/**
+ * Claude Code, and Claude Code on another provider (the cm-style environment).
+ *
+ * The argv comes from the portal's cm-run.sh:
+ *
+ * claude -p --output-format stream-json --verbose --include-partial-messages --max-turns N
+ * --permission-mode MODE --exclude-dynamic-system-prompt-sections
+ * --append-system-prompt-file RULES --disallowedTools … --allowedTools …
+ *
+ * --resume SESSION continues the same session (its context and its prompt cache come back)
+ *
+ * The permission modes are the portal's: acceptEdits (a job may change files), bypassPermissions (the owner's
+ * "full" choice), plan (read-only) and manual (the owner answers from the phone).
+ *
+ * On another provider the environment is the portal's cm-style one: the base URL, the auth token, the model and the
+ * effort level, as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL / CLAUDE_CODE_EFFORT_LEVEL (what
+ * claude-minimax sets, and what claude-deepseek does for DeepSeek).
+ */
+
+const { effortFor, claudeFeed, TOOL_NAMES } = require('./base');
+
+/** The permission modes, by the Codes level (read, edit, full). */
+const MODES = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' };
+
+/** The tools a job may use (the portal's allow list). */
+const ALLOWED = ['Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'TodoWrite', 'NotebookEdit'];
+
+/** The tools a job may never use (the portal's deny list). */
+const DENIED = ['Bash(git push:*)', 'Bash(git commit:*)', 'Bash(git reset:*)', 'Bash(git clean:*)', 'Bash(git stash:*)',
+ 'Bash(sudo:*)', 'Bash(rm -rf:*)', 'Read(.env*)', 'Read(**/.env*)'];
+
+/** The rules a job's system prompt carries (the prompt's rules file, appended to the system prompt). */
+function rulesFile(rules) { return rules && rules.trim() ? rules.trim() : null; }
+
+/**
+ * The argv and the environment for one Claude Code run.
+ *
+ * @param {object} ctx
+ * @param {string} ctx.prompt the brief (the user turn; the rules ride in the system prompt)
+ * @param {string} [ctx.model] the model
+ * @param {string} [ctx.effort] the wanted reasoning level
+ * @param {string} [ctx.resume] the session to continue
+ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'
+ * @param {number} [ctx.maxTurns] the turn limit
+ * @param {string} [ctx.rules] the rules file (appended to the system prompt)
+ * @param {object} [ctx.provider] the provider's environment (base URL, token, model): the cm-style environment
+ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run
+ */
+function build(ctx = {}) {
+ const effort = effortFor('claude-code', ctx.effort);
+ const argv = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
+ '--max-turns', String(ctx.maxTurns || 200),
+ '--permission-mode', MODES[ctx.permission] || MODES.edit,
+ '--exclude-dynamic-system-prompt-sections'];
+ const rules = rulesFile(ctx.rules);
+ if (rules) argv.push('--append-system-prompt-file', rules);
+ argv.push('--disallowedTools', DENIED.join(' '));
+ argv.push('--allowedTools', ALLOWED.join(' '));
+ if (ctx.resume) argv.push('--resume', String(ctx.resume));
+ const env = {};
+ const p = ctx.provider || {};
+ if (p.baseUrl) env.ANTHROPIC_BASE_URL = p.baseUrl;
+ if (p.authToken) env.ANTHROPIC_AUTH_TOKEN = p.authToken;
+ if (p.apiKey) env.ANTHROPIC_API_KEY = p.apiKey;
+ if (ctx.model) env.ANTHROPIC_MODEL = ctx.model;
+ if (effort) env.CLAUDE_CODE_EFFORT_LEVEL = effort;
+ if (p.mcpConfig) argv.push('--mcp-config', p.mcpConfig);
+ if (p.extraAllowed && p.extraAllowed.length) argv.push('--allowedTools', ALLOWED.concat(p.extraAllowed).join(' '));
+ return { cmd: ctx.bin || 'claude', argv, env, stdin: ctx.prompt || '' };
+}
+
+/** The feed: Claude Code's own stream-json is what the transcript normaliser reads. */
+function feed({ model, onEvent }) {
+ const f = claudeFeed({ model, onEvent });
+ return {
+ state: f.state,
+ line(raw) { f.line(raw); },
+ end(info) { f.end(info); },
+ };
+}
+
+const claudeCode = {
+ id: 'claude-code', name: 'Claude Code', kind: 'cli', bin: 'claude',
+ portal: 'cm-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ build, feed,
+ /** The provider's environment (the cm-style one) for a provider other than Anthropic's own. */
+ providerEnv(provider = {}) {
+ const env = {};
+ if (provider.baseUrl) env.ANTHROPIC_BASE_URL = provider.baseUrl;
+ if (provider.authToken) env.ANTHROPIC_AUTH_TOKEN = provider.authToken;
+ if (provider.apiKey) env.ANTHROPIC_API_KEY = provider.apiKey;
+ if (provider.model) env.ANTHROPIC_MODEL = provider.model;
+ if (provider.effort) env.CLAUDE_CODE_EFFORT_LEVEL = provider.effort;
+ return env;
+ },
+};
+
+/** Claude Code on another provider: the same CLI, the cm-style environment. */
+const claudeOther = {
+ id: 'claude-other', name: 'Claude Code (another provider)', kind: 'cli', bin: 'claude',
+ portal: 'cm-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ build(ctx = {}) {
+ const effort = effortFor('claude-other', ctx.effort);
+ const p = ctx.provider || {};
+ const env = claudeCode.providerEnv({ ...p, model: ctx.model || p.model, effort });
+ const out = build({ ...ctx, provider: { ...p, model: ctx.model || p.model }, bin: ctx.bin });
+ return { ...out, env };
+ },
+ feed,
+};
+
+module.exports = { claudeCode, claudeOther, MODES, ALLOWED, DENIED, TOOL_NAMES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+
+/**
+ * Codex (the codex CLI, the owner's ChatGPT plan).
+ *
+ * The argv comes from the portal's cx-run.sh:
+ *
+ * codex exec --json --skip-git-repo-check -C DIR -s SANDBOX [-m MODEL] -c model_reasoning_effort=EFFORT
+ * codex exec resume SESSION --json --skip-git-repo-check --all [-m MODEL]
+ * -c model_reasoning_effort=EFFORT -c sandbox_mode=SANDBOX
+ * -c sandbox_workspace_write.writable_roots=[…]
+ *
+ * The prompt goes in on stdin (a long prompt is never an argument). The sandbox is Codex's own: read-only for a
+ * read-only job, workspace-write for one that may change files, danger-full-access for the owner's "full" choice.
+ * The effort is a -c override (model_reasoning_effort), the portal's way of asking Codex for a reasoning level.
+ */
+
+const { effortFor, TOOL_NAMES, emitter } = require('./base');
+
+/** The sandbox, by the Codes permission level. */
+const SANDBOX = { read: 'read-only', edit: 'workspace-write', full: 'danger-full-access' };
+
+/**
+ * The argv and the environment for one Codex run.
+ *
+ * @param {object} ctx
+ * @param {string} ctx.prompt the brief (on stdin)
+ * @param {string} [ctx.model] the model
+ * @param {string} [ctx.effort] the wanted reasoning level
+ * @param {string} [ctx.resume] the session (thread) to continue
+ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'
+ * @param {string} ctx.cwd the working directory
+ * @param {string} [ctx.extraDir] a directory the job may write (its own notes)
+ * @param {string[]} [ctx.config] extra -c key=value overrides
+ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run
+ */
+function build(ctx = {}) {
+ const effort = effortFor('codex', ctx.effort);
+ const sandbox = SANDBOX[ctx.permission] || SANDBOX.read;
+ const argv = ['exec'];
+ if (ctx.resume) {
+ // A resume takes its own flag set: no -C, no -s, no --add-dir. The sandbox and the extra writable roots go in
+ // as -c overrides, and the CLI's working directory is the one the session was recorded under.
+ const roots = [ctx.cwd];
+ if (ctx.extraDir) roots.push(ctx.extraDir);
+ argv.push('resume', String(ctx.resume), '--json', '--skip-git-repo-check', '--all');
+ if (ctx.model) argv.push('-m', ctx.model);
+ argv.push('-c', `model_reasoning_effort=${effort || 'medium'}`);
+ argv.push('-c', `sandbox_mode=${sandbox}`);
+ argv.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`);
+ } else {
+ argv.push('--json', '--skip-git-repo-check', '-C', ctx.cwd, '-s', sandbox);
+ if (ctx.model) argv.push('-m', ctx.model);
+ argv.push('-c', `model_reasoning_effort=${effort || 'medium'}`);
+ if (ctx.permission === 'edit' && ctx.extraDir) argv.push('--add-dir', ctx.extraDir);
+ }
+ for (const kv of (ctx.config || [])) argv.push('-c', kv);
+ argv.push('-'); // the prompt comes on stdin
+ return { cmd: ctx.bin || 'codex', argv, env: {}, stdin: ctx.prompt || '' };
+}
+
+/** The feed: Codex's events are turned into Claude Code's stream-json (the portal's cx-feed.js). */
+function feed({ model, onEvent }) {
+ const state = { sessionId: null, lastText: '', failed: null, usage: null, turns: 0, started: new Set(), done: new Set() };
+ const emit = emitter(onEvent);
+ const tool = (id, name, input) => { if (state.started.has(id)) return; state.started.add(id); emit({ type: 'assistant', message: { model, content: [{ type: 'tool_use', id, name, input }] } }); };
+ const result = (id, content, isError) => { if (state.done.has(id)) return; state.done.add(id); emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: String(content || '').slice(0, 4000), is_error: !!isError }] } }); };
+ const describe = (it) => {
+ const t = it.type;
+ if (t === 'command_execution') return ['Bash', { command: it.command || '' }];
+ if (t === 'mcp_tool_call') return [`mcp__${it.server || 'mcp'}__${it.tool || 'tool'}`, it.arguments && typeof it.arguments === 'object' ? it.arguments : {}];
+ if (t === 'web_search') return ['WebSearch', { query: it.query || '' }];
+ if (t === 'collab_tool_call') return ['Task', { description: String(it.prompt || it.tool || 'subagent').slice(0, 200) }];
+ return [`codex:${t || 'item'}`, it];
+ };
+ const itemResult = (it) => {
+ const t = it.type, s = String(it.status || '');
+ if (t === 'command_execution') {
+ const code = it.exit_code, txt = String(it.aggregated_output || '');
+ return [txt + (code != null && code !== 0 ? `\n(exit ${code})` : ''), s === 'failed' || (code != null && code !== 0)];
+ }
+ if (t === 'mcp_tool_call') {
+ if (it.error) return [String(it.error.message || it.error), true];
+ const c = (it.result && it.result.content) || [];
+ return [c.map((x) => (x && x.text) || (typeof x === 'string' ? x : JSON.stringify(x))).join('\n'), s === 'failed'];
+ }
+ if (t === 'web_search') return [Array.isArray(it.results) ? `${it.results.length} results` : 'searched', s === 'failed'];
+ return [s || 'done', s === 'failed'];
+ };
+ const onItem = (phase, it) => {
+ if (!it || typeof it !== 'object') return;
+ const id = String(it.id || `${it.type}-${state.started.size}`), t = it.type;
+ if (t === 'reasoning') return;
+ if (t === 'agent_message') {
+ if (phase === 'completed' && String(it.text || '').trim()) {
+ state.lastText = it.text;
+ emit({ type: 'assistant', message: { model, content: [{ type: 'text', text: it.text }] } });
+ }
+ return;
+ }
+ if (t === 'error') { emit({ type: 'system', subtype: 'warning', text: String(it.message || '').slice(0, 500) }); return; }
+ if (t === 'file_change') {
+ if (phase !== 'completed') return;
+ const failed = it.status === 'failed';
+ (it.changes || [{}]).forEach((ch, i) => {
+ const cid = `${id}:${i}`, kind = ch.kind && typeof ch.kind === 'object' ? ch.kind.type : ch.kind;
+ tool(cid, kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit', { file_path: ch.path || '', change: kind || 'update' });
+ result(cid, failed ? 'patch failed' : `${kind || 'update'} applied`, failed);
+ });
+ return;
+ }
+ const [name, input] = describe(it);
+ tool(id, name, input);
+ if (phase === 'completed') { const [txt, err] = itemResult(it); result(id, txt, err); }
+ };
+ return {
+ state,
+ line(raw) {
+ let e; try { e = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }
+ if (!e || typeof e !== 'object') return;
+ switch (e.type) {
+ case 'thread.started':
+ state.sessionId = e.thread_id || null;
+ emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'codex', model });
+ break;
+ case 'turn.started': state.turns++; break;
+ case 'item.started': onItem('started', e.item); break;
+ case 'item.updated': onItem('updated', e.item); break;
+ case 'item.completed': onItem('completed', e.item); break;
+ case 'turn.completed': {
+ const u = e.usage || {}, n = (x) => (Number.isFinite(+x) ? +x : 0);
+ state.usage = { input: n(u.input_tokens), cached: n(u.cached_input_tokens), output: n(u.output_tokens) };
+ emit({ type: 'system', subtype: 'usage', backend: 'codex', usage: state.usage });
+ break;
+ }
+ case 'turn.failed':
+ state.failed = String((e.error && (e.error.message || e.error)) || 'turn failed');
+ emit({ type: 'system', subtype: 'error', text: `API Error: ${state.failed.slice(0, 400)}` });
+ break;
+ case 'error':
+ state.failed = String(e.message || e.error || 'error');
+ emit({ type: 'system', subtype: 'error', text: `API Error: ${state.failed.slice(0, 400)}` });
+ break;
+ default: break;
+ }
+ },
+ end(info) {
+ for (const id of state.started) if (!state.done.has(id)) result(id, 'cut off', true);
+ const ok = !state.failed && !!state.lastText && (!info || info.code === 0);
+ emit({
+ type: 'result', subtype: ok ? 'success' : 'error', is_error: !ok,
+ result: ok ? state.lastText : (state.failed || 'Codex ended without a result (a limit, the sandbox or a crash: check its exit code)'),
+ session_id: state.sessionId, model, duration_ms: 0, num_turns: state.turns,
+ usage: { input_tokens: Math.max(0, (state.usage && state.usage.input) || 0), output_tokens: (state.usage && state.usage.output) || 0, cache_read_input_tokens: (state.usage && state.usage.cached) || 0, cache_creation_input_tokens: 0 },
+ });
+ },
+ };
+}
+
+const codex = { id: 'codex', name: 'Codex', kind: 'cli', bin: 'codex', portal: 'cx-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], build, feed, SANDBOX };
+
+module.exports = { codex, SANDBOX, TOOL_NAMES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+
+/**
+ * Command Code (the `cmd` CLI).
+ *
+ * The argv comes from the portal's cc-run.sh:
+ *
+ * cmd --print=BRIEF [--resume SESSION] [--yolo] -m MODEL --effort EFFORT --config compact-mode=fast
+ * --output-format json --max-turns N --skip-onboarding --no-auto-update -t
+ *
+ * The effort levels are the portal's for this harness: off, low, high and max (the 2026-10-29 incident: a job that
+ * copied Codex's "medium" here died before its first step). The permission rules go in the project's
+ * .commandcode/settings.local.json (the portal's way), never on the command line.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { effortFor, claudeFeed, TOOL_NAMES, emitter } = require('./base');
+
+/** The tools a job may not use (the portal's deny list). */
+const DENY = ['Shell(rm:*)', 'Shell(git push:*)', 'Shell(git commit:*)', 'Shell(git reset:*)', 'Shell(git checkout:*)',
+ 'Shell(git clean:*)', 'Shell(git stash:*)', 'Shell(sudo:*)', 'Shell(ssh:*)', 'Shell(scp:*)', 'Shell(npm test:*)',
+ 'Shell(npm install:*)', 'Shell(npm i:*)', 'Shell(npm ci:*)', 'Shell(pnpm install:*)', 'Shell(kill:*)', 'Shell(pkill:*)',
+ 'Shell(chmod:*)', 'Shell(tee:*)', 'Shell(dd:*)', 'Read(.env*)', 'Read(**/.env*)', 'Edit(.git/**)'];
+
+/**
+ * The argv and the environment for one Command Code run.
+ *
+ * @param {object} ctx
+ * @param {string} ctx.prompt the brief (as --print=…, the portal's way)
+ * @param {string} [ctx.model] the model
+ * @param {string} [ctx.effort] the wanted reasoning level
+ * @param {string} [ctx.resume] the session to continue
+ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'
+ * @param {number} [ctx.maxTurns] the turn limit
+ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run
+ */
+function build(ctx = {}) {
+ const effort = effortFor('command-code', ctx.effort);
+ const argv = [`--print=${ctx.prompt || ''}`];
+ if (ctx.resume) argv.push('--resume', String(ctx.resume));
+ if (ctx.permission === 'full') argv.push('--yolo');
+ if (ctx.model) argv.push('-m', ctx.model);
+ argv.push('--effort', effort || 'high');
+ argv.push('--config', 'compact-mode=fast');
+ argv.push('--output-format', 'json');
+ argv.push('--max-turns', String(ctx.maxTurns || 150));
+ argv.push('--skip-onboarding', '--no-auto-update', '-t');
+ return { cmd: ctx.bin || 'cmd', argv, env: {}, stdin: null };
+}
+
+/**
+ * The permission rules the project's .commandcode/settings.local.json holds.
+ *
+ * The portal writes them there (globally git-ignored) so they survive --yolo, and a read-only job may not edit or write
+ * in the repository at all.
+ *
+ * @param {object} opts
+ * @param {string} opts.permission 'read' | 'edit' | 'full'
+ * @returns {string} the settings file's content
+ */
+function settings({ permission = 'edit' } = {}) {
+ const deny = permission === 'full' ? ['Shell(git push:*)', 'Shell(pkill:*)', 'Shell(killall:*)'] : DENY.slice();
+ if (permission === 'read') deny.push('Edit(**)', 'Write(**)');
+ return `{ "permissions": { "deny": [${deny.map((d) => JSON.stringify(d)).join(', ')}] } }`;
+}
+
+/**
+ * Write the permission rules into the project (the portal's way).
+ *
+ * @param {string} dir the project
+ * @param {object} opts the options of settings()
+ * @returns {string} the file that was written
+ */
+function writeSettings(dir, opts) {
+ const file = path.join(dir, '.commandcode', 'settings.local.json');
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, settings(opts));
+ return file;
+}
+
+/** The feed: Command Code's events are turned into Claude Code's stream-json (the portal's cc-feed.js). */
+function feed({ model, onEvent }) {
+ const state = { sessionId: null, model, lastText: '', usage: null, failed: null };
+ const emit = emitter(onEvent);
+ return {
+ state,
+ line(raw) {
+ let f; try { f = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }
+ if (!f || typeof f !== 'object') return;
+ if (f.type === 'result') {
+ const err = f.subtype === 'error' ? `API Error: ${String((f.error && (f.error.message || f.error)) || 'unknown error').slice(0, 300)}` : null;
+ state.lastText = f.finalText || err || '';
+ emit({ type: 'result', subtype: f.subtype, is_error: f.subtype !== 'success', result: f.finalText || err || '', duration_ms: f.durationMs || 0, usage: f.usage || null, session_id: f.sessionId || state.sessionId, model: state.model });
+ return;
+ }
+ const e = f.type === 'event' && f.event;
+ if (!e) return;
+ if (e.type === 'run_start') { state.sessionId = e.sessionId; emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'command-code' }); }
+ else if (e.type === 'model_request_start') state.model = e.model || state.model;
+ else if (e.type === 'model_request_end') state.usage = e.usage || null;
+ else if (e.type === 'message_end') {
+ const content = [];
+ for (const c of e.content || []) {
+ if (c.type === 'text' && c.text && c.text.trim()) content.push({ type: 'text', text: c.text });
+ else if (c.type === 'tool_use') content.push({ type: 'tool_use', id: c.id, name: TOOL_NAMES[c.name] || c.name, input: c.input || {} });
+ }
+ if (content.length) emit({ type: 'assistant', message: { model: state.model, content, usage: state.usage } });
+ } else if (e.type === 'tool_completed') {
+ const text = (e.result || []).map((r) => r.text || '').join('\n');
+ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: e.toolCallId, content: text.slice(0, 4000), is_error: !!e.isError }] } });
+ } else if (['tool_denied', 'tool_failed', 'tool_error', 'tool_cancelled'].includes(e.type)) {
+ const why = e.type === 'tool_denied' ? "Denied by the job's permission rules" : String(e.error || e.message || e.type).slice(0, 400);
+ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: e.toolCallId, content: why, is_error: true }] } });
+ } else if (e.type === 'error' || e.type === 'run_error') {
+ const msg = typeof (e.message || e.error) === 'object' ? JSON.stringify(e.message || e.error) : String(e.message || e.error || 'unknown');
+ state.failed = msg;
+ emit({ type: 'system', subtype: 'error', text: `API Error: ${msg.slice(0, 300)}` });
+ }
+ },
+ end(info) {
+ if (!state.lastText && !state.failed) {
+ emit({ type: 'result', subtype: 'error', is_error: true, result: 'API Error: Command Code ended without a result (credits, rate limit or a crash: check its exit code)', session_id: state.sessionId, model: state.model });
+ }
+ },
+ };
+}
+
+const commandCode = { id: 'command-code', name: 'Command Code', kind: 'cli', bin: 'cmd', portal: 'cc-run.sh', effortLevels: ['off', 'low', 'high', 'max'], build, feed, settings, writeSettings, DENY };
+
+module.exports = { commandCode, DENY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * Every harness Codes can run, and the one place that says how each is started.
+ *
+ * The adapters are the portal's runner scripts, ported: the argv and the environment are the portal's, the feed
+ * converters are the portal's (cx-feed.js, cc-feed.js, oc-feed.js), the effort levels are the portal's.
+ *
+ * claude-code Claude Code (the owner's Anthropic plan) cm-run.sh
+ * claude-other Claude Code on another provider (cm-style env) cm-run.sh
+ * codex Codex (the owner's ChatGPT plan) cx-run.sh
+ * command-code Command Code cc-run.sh
+ * opencode OpenCode oc-run.sh
+ * minimax-code MiniMax Code mm-run.sh
+ */
+
+const { claudeCode, claudeOther } = require('./claude-code');
+const { codex } = require('./codex');
+const { commandCode } = require('./command-code');
+const { opencode } = require('./opencode');
+const { minimaxCode } = require('./minimax-code');
+const { effortFor, EFFORT_LEVELS } = require('./base');
+
+const ADAPTERS = {
+ 'claude-code': claudeCode,
+ 'claude-other': claudeOther,
+ codex,
+ 'command-code': commandCode,
+ opencode,
+ 'minimax-code': minimaxCode,
+};
+
+/** The adapter of a harness, or null. */
+function adapterFor(id) {
+ return ADAPTERS[String(id || '').toLowerCase()] || null;
+}
+
+/** Every harness, in the order the portal's rungs go. */
+function all() {
+ return Object.values(ADAPTERS);
+}
+
+/** The harness names. */
+function ids() {
+ return Object.keys(ADAPTERS);
+}
+
+module.exports = { ADAPTERS, adapterFor, all, ids, effortFor, EFFORT_LEVELS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+
+/**
+ * MiniMax Code (the `mcode` CLI, the owner's M Plan).
+ *
+ * The argv comes from the portal's mm-run.sh:
+ *
+ * mcode exec --input - --output-format stream-json --prompt-mode coding --permission MODE --cwd DIR
+ * --timeout 45m --max-steps N --append-system-prompt-file RULES -o LAST [--ptc] [--mode lightweight]
+ * [--model MODEL] [--effort EFFORT] [--session SESSION]
+ *
+ * The rules ride in the appended system prompt (the stable, best-cached prefix) and the brief alone is the user turn.
+ * --ptc is Programmatic Tool Calling (the model batches its tool calls in one round trip). The permission is smart for a
+ * guarded job and full for the owner's "full" choice.
+ */
+
+const { effortFor, emitter } = require('./base');
+
+/**
+ * The argv and the environment for one MiniMax Code run.
+ *
+ * @param {object} ctx
+ * @param {string} ctx.prompt the brief (on stdin)
+ * @param {string} [ctx.model] the model
+ * @param {string} [ctx.effort] the wanted reasoning level
+ * @param {string} [ctx.resume] the session to continue
+ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'
+ * @param {number} [ctx.maxSteps] the step limit
+ * @param {string} [ctx.rules] the rules file (appended to the system prompt)
+ * @param {string} [ctx.cwd] the working directory
+ * @param {string} [ctx.out] the file the last answer is written to
+ * @param {boolean} [ctx.ptc] Programmatic Tool Calling (on by default, the portal's choice)
+ * @param {boolean} [ctx.light] the lightweight mode (a smaller context)
+ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run
+ */
+function build(ctx = {}) {
+ const effort = effortFor('minimax-code', ctx.effort);
+ const perm = ctx.permission === 'full' ? 'full' : 'smart';
+ const argv = ['exec', '--input', '-', '--output-format', 'stream-json', '--prompt-mode', 'coding',
+ '--permission', perm, '--cwd', ctx.cwd || process.cwd(), '--timeout', ctx.timeout || '45m',
+ '--max-steps', String(ctx.maxSteps || 150)];
+ if (ctx.rules && String(ctx.rules).trim()) argv.push('--append-system-prompt-file', String(ctx.rules).trim());
+ if (ctx.out) argv.push('-o', ctx.out);
+ if (ctx.ptc !== false) argv.push('--ptc');
+ if (ctx.light) argv.push('--mode', 'lightweight');
+ if (ctx.model) argv.push('--model', ctx.model);
+ if (effort) argv.push('--effort', effort);
+ if (ctx.resume) argv.push('--session', String(ctx.resume));
+ return { cmd: ctx.bin || 'mcode', argv, env: {}, stdin: ctx.prompt || '' };
+}
+
+/**
+ * The feed: MiniMax Code's stream-json (schemaVersion 1 events) is what the transcript normaliser reads.
+ *
+ * @param {object} opts
+ * @param {string} opts.model the model
+ * @param {function} opts.onEvent every event
+ * @returns {{line: function, end: function, state: object}} the feed
+ */
+function feed({ model, onEvent }) {
+ const state = { sessionId: null, model, lastText: '', failed: null };
+ const emit = emitter(onEvent);
+ return {
+ state,
+ line(raw) {
+ let e; try { e = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }
+ if (!e || typeof e !== 'object') return;
+ // MiniMax Code's own events (schemaVersion 1): item.started / item.completed / turn.* / exec.completed.
+ if (e.schemaVersion && typeof e.type === 'string' && e.type.includes('.')) {
+ if (e.sessionId) state.sessionId = e.sessionId;
+ const it = e.item || {};
+ if (e.type === 'item.started' && (it.type === 'agent_message' || it.type === 'reasoning')) {
+ emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'minimax-code', model });
+ } else if (e.type === 'item.completed' && it.type === 'agent_message') {
+ const text = String(it.content || it.contentDelta || '');
+ if (text.trim()) { state.lastText = text; emit({ type: 'assistant', message: { model, content: [{ type: 'text', text }] } }); }
+ } else if (e.type === 'turn.failed') {
+ state.failed = String((e.error && (e.error.message || e.error)) || 'turn failed');
+ emit({ type: 'system', subtype: 'error', text: state.failed });
+ } else if (e.type === 'exec.completed' && e.result) {
+ const r = e.result;
+ const answer = String(r.answer || r.output || '');
+ state.lastText = answer || state.lastText;
+ emit({ type: 'result', subtype: r.status && r.status !== 'succeeded' ? 'error' : 'success', is_error: !!(r.status && r.status !== 'succeeded'), result: answer, session_id: state.sessionId, model, duration_ms: r.durationMs || 0 });
+ } else if (e.type === 'item.completed' && it.type === 'tool_result') {
+ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: it.toolCallId || it.callId || it.id, content: String(it.output || it.content || it.result || ''), is_error: !!(it.isError || it.status === 'failed') }] } });
+ }
+ return;
+ }
+ // The plain shape (a text or a result line).
+ if (e.type === 'result') { state.lastText = e.result || state.lastText; emit(e); return; }
+ emit(e);
+ },
+ end(info) {
+ if (!state.lastText && !state.failed) {
+ emit({ type: 'result', subtype: 'error', is_error: true, result: 'MiniMax Code ended without an answer (a limit, a plan window or a crash: check its exit code)', session_id: state.sessionId, model });
+ }
+ },
+ };
+}
+
+const minimaxCode = { id: 'minimax-code', name: 'MiniMax Code', kind: 'cli', bin: 'mcode', portal: 'mm-run.sh', effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], build, feed };
+
+module.exports = { minimaxCode };
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
+'use strict';
+
+/**
+ * OpenCode (the `opencode` CLI).
+ *
+ * The argv comes from the portal's oc-run.sh:
+ *
+ * opencode run --standalone --format json --auto -m MODEL [--s SESSION] --title TITLE -- BRIEF
+ *
+ * --standalone gives the attempt its own server, so stopping the attempt stops its work. The permission rules go in the
+ * project's .opencode/opencode.json (the portal's way). The effort is the model's variant (model#low or model#high), the
+ * portal's way of asking OpenCode for a reasoning level.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { claudeFeed, TOOL_NAMES, emitter } = require('./base');
+
+/** The shell commands a job may not run (the portal's deny list). */
+const DENY_SHELL = ['rm *', 'git commit *', 'git reset *', 'git checkout *', 'git clean *', 'git stash *', 'sudo *',
+ 'ssh *', 'scp *', 'curl *', 'wget *', 'npm install *', 'npm i *', 'npm ci *', 'npm publish *', 'npm uninstall *',
+ 'pnpm install *', 'kill *', 'pkill *', 'chmod *', 'tee *', 'dd *'];
+
+/**
+ * The model an effort asks for: the variant the portal uses (model#low or model#high), the plain model for anything
+ * else.
+ *
+ * @param {string} model the model
+ * @param {string} [effort] the wanted level
+ * @returns {string} the model to run
+ */
+function variantFor(model, effort) {
+ const m = String(model || '');
+ if (!['low', 'high'].includes(effort) || m.includes('#')) return m;
+ return `${m}#${effort}`;
+}
+
+/**
+ * The argv and the environment for one OpenCode run.
+ *
+ * @param {object} ctx
+ * @param {string} ctx.prompt the brief
+ * @param {string} [ctx.model] the model
+ * @param {string} [ctx.effort] the wanted reasoning level
+ * @param {string} [ctx.resume] the session to continue
+ * @param {string} [ctx.permission] 'read' | 'edit' | 'full'
+ * @param {string} [ctx.title] the run's title
+ * @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run
+ */
+function build(ctx = {}) {
+ const model = variantFor(ctx.model, ctx.effort);
+ const argv = ['run', '--standalone', '--format', 'json'];
+ if (ctx.permission !== 'read') argv.push('--auto');
+ if (model) argv.push('-m', model);
+ if (ctx.resume) argv.push('-s', String(ctx.resume));
+ if (ctx.title) argv.push('--title', String(ctx.title));
+ argv.push('--', ctx.prompt || '');
+ return { cmd: ctx.bin || 'opencode', argv, env: {}, stdin: null };
+}
+
+/**
+ * The permission rules the project's .opencode/opencode.json holds.
+ *
+ * @param {object} opts
+ * @param {string} opts.permission 'read' | 'edit' | 'full'
+ * @returns {string} the settings file's content
+ */
+function settings({ permission = 'edit' } = {}) {
+ const rule = (action, resource) => `{ "action": "${action}", "resource": "${resource}", "effect": "deny" }`;
+ const rules = [rule('shell', 'git push *')];
+ if (permission !== 'full') {
+ for (const c of DENY_SHELL) rules.push(rule('shell', c));
+ rules.push(rule('read', '.env*'), rule('read', '**/.env*'), rule('edit', '.git/**'));
+ }
+ if (permission === 'read') rules.push(rule('edit', '*'));
+ return `{ "permissions": [ ${rules.join(', ')}] }`;
+}
+
+/**
+ * Write the permission rules into the project (the portal's way).
+ *
+ * @param {string} dir the project
+ * @param {object} opts the options of settings()
+ * @returns {string|null} the file that was written, or null when the repository tracks its own
+ */
+function writeSettings(dir, opts) {
+ const file = path.join(dir, '.opencode', 'opencode.json');
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, settings(opts));
+ return file;
+}
+
+/** The feed: OpenCode's events are turned into Claude Code's stream-json (the portal's oc-feed.js). */
+function feed({ model, onEvent }) {
+ const state = { sessionId: null, model, lastText: '', failed: null, steps: 0, usage: null };
+ const emit = emitter(onEvent);
+ return {
+ state,
+ line(raw) {
+ let e; try { e = JSON.parse(raw); } catch { if (String(raw).trim()) emit({ type: 'system', subtype: 'stderr', text: String(raw).slice(0, 500) }); return; }
+ if (!e || typeof e !== 'object') return;
+ if (e.sessionID && !state.sessionId) {
+ state.sessionId = e.sessionID;
+ emit({ type: 'system', subtype: 'init', session_id: state.sessionId, backend: 'opencode', model });
+ }
+ const p = e.part || {};
+ if (e.type === 'tool_use') {
+ const st = p.state || {}, err = st.status === 'error';
+ const input = { ...((st.input) || {}) };
+ if (input.path && !input.file_path) input.file_path = input.path;
+ emit({ type: 'assistant', message: { model, content: [{ type: 'tool_use', id: p.id || p.partID, name: TOOL_NAMES[p.tool] || p.tool, input }] } });
+ const text = err ? String(st.error || 'the tool failed') : typeof st.output === 'string' ? st.output : JSON.stringify(st.output || '');
+ emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: p.id || p.partID, content: text.slice(0, 4000), is_error: err }] } });
+ } else if (e.type === 'text' && p.text && String(p.text).trim()) {
+ state.lastText = (state.lastText || '') + p.text;
+ emit({ type: 'assistant', message: { model, content: [{ type: 'text', text: p.text }] } });
+ } else if (e.type === 'step_finish') {
+ state.steps++;
+ const t = p.tokens || {};
+ state.usage = { input_tokens: t.input || 0, output_tokens: (t.output || 0) + (t.reasoning || 0), cache_read_input_tokens: (t.cache && t.cache.read) || 0, cache_creation_input_tokens: (t.cache && t.cache.write) || 0 };
+ emit({ type: 'assistant', message: { model, content: [], usage: state.usage } });
+ } else if (e.type === 'error') {
+ const msg = String((e.error && (e.error.message || e.error.type)) || 'unknown error').slice(0, 400);
+ state.failed = msg;
+ emit({ type: 'system', subtype: 'error', text: `API Error: ${msg}` });
+ }
+ },
+ end(info) {
+ const final = String(state.lastText || '').trim();
+ const err = state.failed && !final ? `API Error: ${state.failed}` : null;
+ emit({
+ type: 'result', subtype: err || !final ? 'error' : 'success', is_error: !!(err || !final),
+ result: final || err || 'API Error: OpenCode ended without an answer (a limit, a refusal or a crash: check its exit code)',
+ duration_ms: 0, num_turns: state.steps, session_id: state.sessionId, model,
+ total_cost_usd: 0, usage: state.usage || { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
+ });
+ },
+ };
+}
+
+const opencode = { id: 'opencode', name: 'OpenCode', kind: 'cli', bin: 'opencode', portal: 'oc-run.sh', effortLevels: [], build, feed, settings, writeSettings, variantFor };
+
+module.exports = { opencode, variantFor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+## Git rules (added to every job by the runner)
+
+- Never copy, mirror, move or re-create git metadata: no `.git` directories made by hand, no `git clone --mirror`, no
+ `git worktree` repairs.
+- Never change a remote, `remote.*.mirror`, `core.*` or any other git config. Never push.
+- If git can't write (a permission or read-only error), stop and say so in your report. The main session fixes it. Don't work
+ around it.
+- Commit only if your brief asks, with `git -c user.name=OpenVibers -c [email protected] commit`. Never
+ amend or rebase.
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+## House rules (added to every job by the runner)
+
+- Commit as `OpenVibers <[email protected]>`: `git -c user.name=OpenVibers -c [email protected] commit`. No trailers, no "Generated with" line, no session link.
+- Run gitleaks on what you are about to commit: `gitleaks git --pre-commit --staged --redact --no-banner`. A finding stops the commit: fix it, do not push it.
+- Push with the gh credential helper (the owner's access exposure sets it up). Never put a token in a remote URL, in a command line or in a file.
+- A pull request body says what the change does and how it was checked. No footer, no session link, no "Generated with" line.
+- Never `pkill`, `killall` or kill by pattern: they take unrelated shells with them. Stop a process by its PID, a service with `systemctl --user stop`.
+- Never print, read or copy a secret (a token, a key, a `.env`). The harness scrubs what it stores; keep your report and your notes clean too.
+- End your report with a `Learned:` block: at most five lines, each one a fact the next agent in this repo would want, with the file, command or URL that shows it. Leave it out when nothing was new.
@@ -10,6 +10,13 @@
@@ -10,6 +10,13 @@
* engine.effort the reasoning level a harness accepts, and the models a provider refuses
* engine.effort the reasoning level a harness accepts, and the models a provider refuses
* engine.scope one systemd scope per job, so nothing is left behind
* engine.scope one systemd scope per job, so nothing is left behind
* engine.runerror what a failed run really said
* engine.runerror what a failed run really said
+ * engine.adapters every harness Codes can run, with the portal's argv and environment
+ * engine.jobrunner one attempt of one harness, the way the portal runs one
+ * engine.prompt the prompt every job gets (the rules, the lessons, the digest, the brief)
+ * engine.repomap the repo digest a job gets instead of a directory tree
+ * engine.joblines the lines a task's page shows while it runs
+ * engine.logins what this machine has: each CLI, signed in or not, and its limit windows
+ * engine.redact the secrets that never reach an event stream
*
*
* The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.
* The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.
*/
*/
@@ -21,6 +28,13 @@ const transcript = require('./transcript');
@@ -21,6 +28,13 @@ const transcript = require('./transcript');
const effort = require('./effort');
const effort = require('./effort');
const scope = require('./scope');
const scope = require('./scope');
const runerror = require('./runerror');
const runerror = require('./runerror');
+const adapters = require('./adapters');
+const jobrunner = require('./jobrunner');
+const prompt = require('./prompt');
+const repomap = require('./repomap');
+const joblines = require('./joblines');
+const logins = require('./logins');
+const redact = require('./redact');
module.exports = {
module.exports = {
router,
router,
@@ -30,6 +44,13 @@ module.exports = {
@@ -30,6 +44,13 @@ module.exports = {
effort,
effort,
scope,
scope,
runerror,
runerror,
+ adapters,
+ jobrunner,
+ prompt,
+ repomap,
+ joblines,
+ logins,
+ redact,
// The names a caller usually wants, in one place.
// The names a caller usually wants, in one place.
classify: router.classify,
classify: router.classify,
pick: router.pick,
pick: router.pick,
@@ -41,4 +62,11 @@ module.exports = {
@@ -41,4 +62,11 @@ module.exports = {
parseText: transcript.parseText,
parseText: transcript.parseText,
fitEffort: effort.fitEffort,
fitEffort: effort.fitEffort,
pickModel: effort.pickModel,
pickModel: effort.pickModel,
+ // C5: the job runner and the harnesses it can run.
+ runAttempt: jobrunner.runAttempt,
+ adapterFor: adapters.adapterFor,
+ assemblePrompt: prompt.assemble,
+ jobLines: joblines.lines,
+ probeLogins: logins.probe,
+ scrub: redact.scrub,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+
+/**
+ * The job lines (1.1): what a task's page shows while it runs and what its session keeps afterwards.
+ *
+ * The transcript normaliser (engine/transcript.js) turns every harness's log into one event shape; this turns those
+ * events into the lines a reader sees. The kinds are the ones the session view knows (server/domain/run-tasks.js):
+ *
+ * agent the run started: which harness, which model
+ * text what the agent said
+ * tool a tool call (what it touched)
+ * tool_result what that tool answered
+ * note an error or a warning
+ * usage the tokens the run spent
+ * done the end of the attempt
+ *
+ * Nothing here prints a secret: every text goes through engine/redact.js first.
+ */
+
+const { scrub } = require('./redact');
+
+/** The clip a line's text may take. */
+const MAX_TEXT = 2000;
+const clip = (s, n = MAX_TEXT) => { s = String(s == null ? '' : s); return s.length > n ? `${s.slice(0, n)}…` : s; };
+
+/**
+ * The job lines for the events added since index `from`.
+ *
+ * @param {object} st the transcript state
+ * @param {number} [from] the first event to look at
+ * @returns {{lines: object[], next: number}} the lines and where to look next time
+ */
+function lines(st, from = 0) {
+ const out = [];
+ const events = (st && st.events) || [];
+ for (let i = Math.max(0, from); i < events.length; i++) {
+ const e = events[i];
+ if (e.t === 'meta' && e.kind === 'start') out.push({ t: 'agent', agent: e.backend || null, model: e.model || null, session: e.session || null });
+ else if (e.t === 'text') out.push({ t: 'text', text: clip(scrub(e.text)) });
+ else if (e.t === 'thinking') continue; // the reasoning is not the task's story
+ else if (e.t === 'tool') {
+ out.push({ t: 'tool', id: e.id || null, name: e.name || 'tool', what: clip(scrub(e.summary || ''), 200) });
+ if (!e.pending && e.output) out.push({ t: 'tool_result', id: e.id || null, ok: !e.isError, text: clip(scrub(e.output.text || '')) });
+ } else if (e.t === 'error') out.push({ t: 'note', level: 'error', text: clip(scrub(e.text)) });
+ else if (e.t === 'final') out.push({ t: 'done', ok: !e.isError, text: clip(scrub(e.text)) });
+ }
+ return { lines: out, next: events.length };
+}
+
+/** The usage line of a finished run. */
+function usageLine(st) {
+ const u = (st && st.usage) || {};
+ if (!u.input && !u.output && !u.cached) return null;
+ return { t: 'usage', input: u.input || 0, output: u.output || 0, cached: u.cached || 0, cost_usd: st && Number.isFinite(st.cost) ? st.cost : null };
+}
+
+module.exports = { lines, usageLine, clip, MAX_TEXT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+
+/**
+ * The job runner: one attempt of one harness, the way the portal runs one.
+ *
+ * The portal's runner scripts (cc-run.sh, cx-run.sh, cm-run.sh, mm-run.sh, oc-run.sh) do the same four things, and this
+ * does them too:
+ *
+ * 1. the prompt is assembled (engine/prompt.js): the git rule, the house rules, the lessons, the repo digest, the brief;
+ * 2. the harness is started with its own argv and environment (engine/adapters/);
+ * 3. what it prints goes through the transcript normaliser (engine/transcript.js) into job lines (engine/joblines.js);
+ * 4. when the budget is reached the work is landed in one wrap-up turn (engine/budget.js), and the session is kept so a
+ * continue picks the same conversation up.
+ *
+ * The runner writes the harness's feed to a file and parses it incrementally, so a live page costs one parse of the
+ * bytes that arrived, not one of the whole log.
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { adapterFor } = require('./adapters');
+const { assemble } = require('./prompt');
+const transcript = require('./transcript');
+const joblines = require('./joblines');
+const budget = require('./budget');
+
+/** How long one attempt may run (the portal's 2700 s). */
+const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000;
+
+/**
+ * Run one attempt.
+ *
+ * @param {object} opts
+ * @param {string} opts.agent the harness (claude-code, codex, command-code, opencode, minimax-code, claude-other)
+ * @param {string} opts.prompt the brief
+ * @param {string} opts.cwd the folder the job works in
+ * @param {string} [opts.model] the model
+ * @param {string} [opts.effort] the wanted reasoning level (fitted to the harness)
+ * @param {string} [opts.permission] 'read' | 'edit' | 'full'
+ * @param {string} [opts.resume] the session to continue
+ * @param {object} [opts.budget] the limits ({ steps, minutes, usd })
+ * @param {string} [opts.log] the file the feed is written to
+ * @param {function} [opts.onLine] every job line
+ * @param {AbortSignal} [opts.signal] stops the attempt
+ * @returns {Promise<object>} the attempt's result
+ */
+async function runAttempt(opts = {}) {
+ const adapter = adapterFor(opts.agent);
+ if (!adapter) throw new Error(`no adapter for ${opts.agent}`);
+ const onLine = opts.onLine || (() => {});
+ const cwd = opts.cwd || process.cwd();
+ const log = opts.log || path.join(os.tmpdir(), `openvibe-codes-${process.pid}-${Date.now()}.jsonl`);
+ const dir = path.dirname(log);
+ fs.mkdirSync(dir, { recursive: true });
+
+ // The prompt: the rules, the house rules, the lessons, the repo digest, then the brief.
+ const prompt = opts.systemPrompt === false
+ ? { text: String(opts.prompt || ''), parts: [{ name: 'brief', text: String(opts.prompt || '') }] }
+ : assemble({ brief: opts.prompt, dir: cwd, env: opts.env || process.env });
+ const rulesFile = opts.rulesFile !== null ? path.join(dir, 'rules.md') : null;
+ if (rulesFile) fs.writeFileSync(rulesFile, prompt.parts.filter((p) => p.name !== 'brief').map((p) => p.text).join('\n\n'));
+
+ // The permission rules go in the project, the portal's way (they survive --yolo).
+ if (adapter.writeSettings) {
+ try { adapter.writeSettings(cwd, { permission: opts.permission || 'edit' }); } catch { /* the job may not write there */ }
+ }
+
+ const built = adapter.build({
+ prompt: prompt.text,
+ model: opts.model,
+ effort: opts.effort,
+ resume: opts.resume,
+ permission: opts.permission,
+ maxTurns: opts.maxTurns,
+ cwd,
+ rules: rulesFile,
+ provider: opts.provider,
+ title: opts.title,
+ out: opts.out,
+ });
+
+ const started = Date.now();
+ const limits = budget.limitsFor({ budget: opts.budget || {} }, opts.agent, opts.budgetOverride);
+ const state = { from: 0, sessionId: opts.resume || null, usage: null, cost: null, steps: 0, done: false };
+
+ const feed = adapter.feed({
+ model: opts.model || null,
+ onEvent: (event) => {
+ fs.appendFileSync(log, JSON.stringify(event) + '\n');
+ },
+ });
+
+ // The transcript normaliser reads the feed; the job lines are what came out of it.
+ const pump = () => {
+ const st = transcript.parse(log);
+ const { lines, next } = joblines.lines(st, state.from);
+ state.from = next;
+ state.sessionId = st.session || feed.state.sessionId || state.sessionId;
+ state.usage = st.usage;
+ state.cost = st.cost;
+ for (const l of lines) {
+ if (l.t === 'tool') state.steps++;
+ onLine(l);
+ }
+ return st;
+ };
+
+ const { runCli } = require('./adapters/base');
+ const run = runCli({
+ cmd: built.cmd,
+ argv: built.argv,
+ cwd,
+ // The adapter's own environment (the provider's, the effort), then the job's (the PATH with the CLI on it).
+ env: { ...(opts.env || {}), ...built.env },
+ stdin: built.stdin,
+ timeoutMs: opts.timeoutMs || DEFAULT_TIMEOUT_MS,
+ signal: opts.signal,
+ onLine: (raw) => { feed.line(raw); pump(); },
+ onEnd: () => { feed.end(); },
+ });
+
+ await run.done;
+ const st = pump();
+
+ // The budget: the steps or the minutes ran out, the work is landed in one wrap-up turn.
+ const used = { steps: state.steps, minutes: (Date.now() - started) / 60000, usd: state.cost };
+ const hit = budget.evaluate(used, limits).hit;
+ const wrap = budget.wrapUp(hit, { done: false });
+
+ const result = {
+ agent: adapter.id,
+ ok: !!(st.events || []).some((e) => e.t === 'final' && !e.isError),
+ sessionId: state.sessionId,
+ model: st.model || opts.model || null,
+ summary: ((st.events || []).find((e) => e.t === 'final') || {}).text || '',
+ usage: state.usage,
+ cost: state.cost,
+ steps: state.steps,
+ budgetHit: hit,
+ wrapUp: wrap.action === 'wrap-up' ? wrap.prompt : null,
+ log,
+ };
+
+ // The wrap-up turn: the same session, one last turn that lands the work.
+ if (wrap.action === 'wrap-up' && result.sessionId && !opts.signal) {
+ const wrapLog = log.replace(/\.jsonl$/, '.wrapup.jsonl');
+ const again = await runAttempt({
+ ...opts,
+ prompt: wrap.prompt,
+ resume: result.sessionId,
+ log: wrapLog,
+ budget: { steps: null, minutes: null, usd: null },
+ onLine,
+ });
+ result.wrapUpResult = again;
+ result.ok = again.ok || result.ok;
+ result.sessionId = again.sessionId || result.sessionId;
+ result.summary = again.summary || result.summary;
+ result.log = again.log;
+ }
+
+ return result;
+}
+
+module.exports = { runAttempt, DEFAULT_TIMEOUT_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+
+/**
+ * What this machine has: each coding CLI, whether it is installed, whether it is signed in, and the limit windows it
+ * can read.
+ *
+ * The plan logins are the owner's own files (the Node's worker copies the ones the owner names into the job's private
+ * home, read-only, for that job only). This module reads them the way the portal's probes do:
+ *
+ * installed the CLI is on PATH
+ * signed in its login file is there and holds a usable session (the file's content never leaves this machine)
+ * windows the limit windows the login file itself carries (what the provider told it)
+ *
+ * Nothing else is reported: no key, no token, no account name. A probe that fails says "unknown", never a value.
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+/** The CLIs and where their login lives, relative to the home folder. */
+const CLIS = [
+ { id: 'claude-code', bin: 'claude', login: '.claude/.credentials.json', provider: 'Anthropic' },
+ { id: 'codex', bin: 'codex', login: '.codex/auth.json', provider: 'OpenAI' },
+ { id: 'opencode', bin: 'opencode', login: '.local/share/opencode/auth.json', provider: 'OpenCode' },
+ { id: 'command-code', bin: 'cmd', login: '.commandcode/auth.json', provider: 'Command Code' },
+ { id: 'minimax-code', bin: 'mcode', login: '.minimax/credentials.json', provider: 'MiniMax' },
+];
+
+/** The file the executable may sit in, on this PATH. */
+function onPath(bin, env = process.env) {
+ for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
+ try { fs.accessSync(path.join(dir, bin), fs.constants.X_OK); return path.join(dir, bin); } catch { /* the next one */ }
+ }
+ return null;
+}
+
+/**
+ * The windows a login file carries, if it carries any.
+ *
+ * Only numbers, labels and reset times are read: the file may hold a token next to them, and a token is never part of
+ * what is reported.
+ */
+function windowsOf(login) {
+ if (!login || typeof login !== 'object') return [];
+ const out = [];
+ const push = (name, used, limit, resetsAt) => {
+ if (used == null && limit == null && resetsAt == null) return;
+ out.push({
+ name: String(name).slice(0, 40),
+ used: Number.isFinite(Number(used)) ? Number(used) : null,
+ limit: Number.isFinite(Number(limit)) ? Number(limit) : null,
+ resets_at: resetsAt ? String(resetsAt).slice(0, 40) : null,
+ });
+ };
+ // The shapes the providers use: a list of windows, or one window with a reset time.
+ const list = login.windows || login.rate_limits || login.limits || login.usage_windows;
+ if (Array.isArray(list)) {
+ for (const w of list.slice(0, 10)) {
+ if (!w || typeof w !== 'object') continue;
+ push(w.name || w.id || w.window || 'window', w.used ?? w.used_percent ?? w.percent_used, w.limit ?? w.total ?? w.max, w.resets_at || w.reset_at || w.resets);
+ }
+ }
+ const single = login.window || login.plan_window;
+ if (single && typeof single === 'object') push(single.name || 'window', single.used, single.limit, single.resets_at || single.reset_at);
+ return out;
+}
+
+/** Is a login file a usable session? (the file's content is never reported). */
+function signedIn(login) {
+ if (!login || typeof login !== 'object') return false;
+ const keys = ['access_token', 'accessToken', 'api_key', 'apiKey', 'refresh_token', 'refreshToken', 'token', 'tokens', 'account'];
+ return keys.some((k) => {
+ const v = login[k];
+ if (v == null) return false;
+ if (typeof v === 'string') return v.trim().length > 0;
+ if (typeof v === 'object') return Object.keys(v).length > 0;
+ return true;
+ });
+}
+
+/**
+ * What every CLI here is.
+ *
+ * @param {object} [opts]
+ * @param {string} [opts.home] the home folder the logins live in (the job's private home, when there is one)
+ * @param {object} [opts.env] the environment (PATH)
+ * @returns {object[]} one row per CLI: { id, installed, signed_in, windows }
+ */
+function probe({ home, env = process.env } = {}) {
+ const h = home || os.homedir();
+ return CLIS.map((c) => {
+ const where = onPath(c.bin, env);
+ const file = path.join(h, c.login);
+ let login = null;
+ try { login = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { login = null; }
+ return {
+ id: c.id,
+ provider: c.provider,
+ installed: !!where,
+ signed_in: signedIn(login),
+ windows: windowsOf(login),
+ };
+ });
+}
+
+/** The row of one CLI, or null. */
+function probeOne(id, opts = {}) {
+ return probe(opts).find((r) => r.id === id) || null;
+}
+
+module.exports = { CLIS, probe, probeOne, windowsOf, signedIn, onPath };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * The prompt every job gets, in the same order for every harness (the portal's git-safe.sh, with_git_rule):
+ *
+ * the git rule → the house rules → what earlier jobs learned here → the repo digest → the brief
+ *
+ * The git rule and the house rules are Codes' own fragments (engine/fragments/), so a job on a machine that knows
+ * nothing about the portal still gets them. The lessons are optional: a store that cannot be reached is skipped, never
+ * an error. The digest is the repo's (engine/repomap.js): a folder that holds many repos has none, as in the portal.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { repomap } = require('./repomap');
+
+const HERE = __dirname;
+const FRAGMENTS = path.join(HERE, 'fragments');
+
+/** A fragment's text, or '' when it is not there. */
+function fragment(name) {
+ try { return fs.readFileSync(path.join(FRAGMENTS, name), 'utf8'); } catch { return ''; }
+}
+
+/**
+ * The lessons a job gets: what earlier jobs learned here, at most five lines.
+ *
+ * The store is optional and off by default: `OPENVIBE_CODES_LESSONS` names a JSONL file (one lesson per line, with a
+ * `text` field and optional `repo` and `tags`). A store that cannot be read gives no lessons, not an error.
+ *
+ * @param {object} opts
+ * @param {string} [opts.file] the store's path
+ * @param {string} [opts.repo] the repo the job works in (its lessons come first)
+ * @param {string} [opts.brief] the brief (its words pick the lessons that fit)
+ * @returns {string} the lessons, as markdown ('' when there are none)
+ */
+function lessons({ file, repo, brief } = {}) {
+ const path_ = file || process.env.OPENVIBE_CODES_LESSONS;
+ if (!path_) return '';
+ let raw;
+ try { raw = fs.readFileSync(path_, 'utf8'); } catch { return ''; }
+ const rows = [];
+ for (const line of String(raw).split('\n')) {
+ if (!line.trim()) continue;
+ let row; try { row = JSON.parse(line); } catch { continue; }
+ const text = typeof row.text === 'string' ? row.text.trim() : '';
+ if (text) rows.push({ text, repo: row.repo || null, tags: Array.isArray(row.tags) ? row.tags : [] });
+ }
+ if (!rows.length) return '';
+ const words = new Set(String(brief || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));
+ const scored = rows.map((r) => {
+ let score = r.repo && repo && r.repo === repo ? 10 : 0;
+ for (const t of r.tags) if (words.has(String(t).toLowerCase())) score += 2;
+ for (const w of words) if (r.text.toLowerCase().includes(w)) score += 1;
+ return { r, score };
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
+ if (!scored.length) return '';
+ return ['### What earlier jobs learned here', ...scored.map((x) => `- ${x.r.text}`)].join('\n');
+}
+
+/**
+ * The prompt of one job.
+ *
+ * @param {object} opts
+ * @param {string} opts.brief what the task is
+ * @param {string} opts.dir the folder the job works in
+ * @param {object} [opts.env] the environment (OPENVIBE_CODES_LESSONS)
+ * @returns {{text: string, parts: object[]}} the prompt and the parts it is made of
+ */
+function assemble({ brief, dir, env = process.env } = {}) {
+ const gitRule = fragment('git-rule.md');
+ const houseRules = fragment('house-rules.md');
+ const repo = repomap(dir);
+ const learned = lessons({ file: env.OPENVIBE_CODES_LESSONS, repo: repo.repo, brief });
+ const parts = [
+ { name: 'git-rule', text: gitRule },
+ { name: 'house-rules', text: houseRules },
+ { name: 'lessons', text: learned },
+ { name: 'repo-digest', text: repo.text },
+ { name: 'brief', text: String(brief || '') },
+ ].filter((p) => p.text && p.text.trim());
+ return { text: parts.map((p) => p.text).join('\n\n'), parts };
+}
+
+module.exports = { assemble, lessons, fragment, FRAGMENTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * Secrets never reach an event stream, a log or a page.
+ *
+ * The portal learned this the hard way: an agent that quotes a key it read in a config file puts it in its own output,
+ * and the feed is what the phone portal, the session log and the result all show. The patterns are the ones the portal
+ * uses (ds-claude.js): provider keys, GitHub tokens, JWTs, private-key blocks and the `key=…` / `"token": "…"` pairs.
+ *
+ * scrub(text) a string, with every secret replaced
+ * scrubDeep(value) any JSON value, with every string in it scrubbed
+ */
+
+const SECRET_PATTERNS = [
+ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
+ /\b((?:api[_-]?key|access[_-]?token|secret|password|client[_-]?secret)["']?\s*[:=]\s*["']?)[^\s"']{8,}/gi,
+];
+
+/** A string with every secret it holds replaced. */
+function scrub(s) {
+ let out = String(s == null ? '' : s);
+ for (const re of SECRET_PATTERNS) out = out.replace(re, (m, keep) => (typeof keep === 'string' && m.startsWith(keep) ? `${keep}[redacted]` : '[redacted]'));
+ return out;
+}
+
+/** Any JSON value with every string in it scrubbed (the shape of the feed is kept). */
+function scrubDeep(value) {
+ if (typeof value === 'string') return scrub(value);
+ if (Array.isArray(value)) return value.map(scrubDeep);
+ if (value && typeof value === 'object') {
+ const out = {};
+ for (const [k, v] of Object.entries(value)) out[k] = scrubDeep(v);
+ return out;
+ }
+ return value;
+}
+
+module.exports = { SECRET_PATTERNS, scrub, scrubDeep };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+
+/**
+ * The map a job gets instead of a raw directory tree.
+ *
+ * Ported from the portal's know/repomap.js: a job that works in one repository gets that repository's digest (what it
+ * is, how it is laid out, the commands the docs state); a job that works in a folder holding many repositories gets
+ * nothing, because the raw tree was a thousand lines of .npm-cache and .git noise.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const MAX_LINES = 120;
+const MAX_FILE = 24 * 1024;
+
+/** git in a folder, its output or ''. */
+function gitOut(dir, args) {
+ try {
+ const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 8000 });
+ return r.status === 0 ? String(r.stdout || '') : '';
+ } catch { return ''; }
+}
+
+/** Is this a repository (a worktree's .git is a file)? */
+function isRepo(dir) {
+ try { const s = fs.statSync(path.join(dir, '.git')); return s.isDirectory() || s.isFile(); } catch { return false; }
+}
+
+/** The repositories inside a folder (one level down, and one more for the usual layout). */
+function reposIn(dir) {
+ const out = [];
+ let entries = [];
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
+ for (const e of entries) {
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
+ const sub = path.join(dir, e.name);
+ if (isRepo(sub)) { out.push(sub); continue; }
+ let inner = [];
+ try { inner = fs.readdirSync(sub, { withFileTypes: true }); } catch { continue; }
+ for (const i of inner) {
+ if (!i.isDirectory() || i.name.startsWith('.')) continue;
+ const deep = path.join(sub, i.name);
+ if (isRepo(deep)) out.push(deep);
+ }
+ }
+ return out;
+}
+
+/** A short, stable key for a repository (its remote, or its path). */
+function repoKey(dir) {
+ const remote = gitOut(dir, ['config', '--get', 'remote.origin.url']).trim();
+ if (remote) return remote.replace(/\.git$/, '');
+ return path.resolve(dir);
+}
+
+/** The first lines of a file, or ''. */
+function head(file, lines = 60) {
+ try {
+ const s = fs.statSync(file);
+ if (!s.isFile() || s.size > MAX_FILE) return '';
+ return fs.readFileSync(file, 'utf8').split('\n').slice(0, lines).join('\n').trim();
+ } catch { return ''; }
+}
+
+/**
+ * The digest of one repository: what it is, how it is laid out, the commands its docs state.
+ *
+ * @param {string} dir the repository's root
+ * @returns {string} the digest ('' when there is none)
+ */
+function digest(dir) {
+ const parts = [];
+ const name = path.basename(dir);
+ const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } })();
+ parts.push(`# ${name}`);
+ if (pkg && pkg.description) parts.push(String(pkg.description));
+ const readme = ['README.md', 'README', 'readme.md'].map((f) => path.join(dir, f)).find((f) => fs.existsSync(f));
+ if (readme) {
+ const text = head(readme, 40);
+ if (text) parts.push(text);
+ }
+ // The layout: the top level, without the noise.
+ let entries = [];
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { entries = []; }
+ const top = entries.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
+ .map((e) => (e.isDirectory() ? e.name + '/' : e.name)).slice(0, 40);
+ if (top.length) parts.push(`Top level: ${top.join(', ')}`);
+ if (pkg && pkg.scripts && typeof pkg.scripts === 'object') {
+ const scripts = Object.entries(pkg.scripts).slice(0, 20).map(([k, v]) => `${k}: ${String(v).split('\n')[0]}`);
+ if (scripts.length) parts.push(['Commands:', ...scripts].join('\n'));
+ }
+ const text = parts.join('\n\n');
+ return text.split('\n').slice(0, MAX_LINES).join('\n');
+}
+
+/**
+ * The map for a job's folder.
+ *
+ * @param {string} dir the folder the job works in
+ * @returns {{text: string, kind: 'digest'|'workspace'|'none', repo: string|null}} the map
+ */
+function repomap(dir) {
+ const d = path.resolve(String(dir || ''));
+ if (!d || !fs.existsSync(d)) return { text: '', kind: 'none', repo: null };
+ const top = gitOut(d, ['rev-parse', '--show-toplevel']).trim();
+ if (top && fs.existsSync(top)) {
+ const text = digest(top);
+ return { text, kind: 'digest', repo: repoKey(top) };
+ }
+ const repos = reposIn(d);
+ if (repos.length >= 2) return { text: '', kind: 'workspace', repo: null };
+ if (repos.length === 1) {
+ const text = digest(repos[0]);
+ return { text, kind: 'digest', repo: repoKey(repos[0]) };
+ }
+ return { text: '', kind: 'none', repo: null };
+}
+
+module.exports = { repomap, digest, reposIn, repoKey, isRepo };
@@ -84,13 +84,8 @@ function parseArgs(raw, env = process.env) {
@@ -84,13 +84,8 @@ function parseArgs(raw, env = process.env) {
} : null;
} : null;
if (push && push.branch && (!BRANCH.test(push.branch) || push.branch.startsWith('-'))) throw new Error('push.branch must be a branch name');
if (push && push.branch && (!BRANCH.test(push.branch) || push.branch.startsWith('-'))) throw new Error('push.branch must be a branch name');
if (push && push.branch && PROTECTED.test(push.branch)) throw new Error(`push.branch must be a new branch of the task's own, never ${push.branch}`);
if (push && push.branch && PROTECTED.test(push.branch)) throw new Error(`push.branch must be a new branch of the task's own, never ${push.branch}`);
- // The session a resume continues: the job's own input, or RESUME in the environment (a task carried on in the
- // same conversation). It is a harness's session id, never a path and never a credential.
- const resumeIn = a.resume == null || a.resume === '' ? env.RESUME : a.resume;
- const resume = typeof resumeIn === 'string' && /^[A-Za-z0-9._:-]{1,200}$/.test(resumeIn) ? resumeIn : null;
return {
return {
repo: repo.toString(), ref, prompt, permission, push,
repo: repo.toString(), ref, prompt, permission, push,
- ...(resume ? { resume } : {}),
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
};
};
@@ -134,7 +129,7 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(
@@ -134,7 +129,7 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(
function compact(e) {
function compact(e) {
const cut = (s) => cutTo(s, MAX_TEXT);
const cut = (s) => cutTo(s, MAX_TEXT);
const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
- if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null, session: e.session_id || null };
+ if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null };
if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, ...(e.to ? { to: e.to } : {}), reason: cut(e.reason) };
if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, ...(e.to ? { to: e.to } : {}), reason: cut(e.reason) };
if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', level: e.subtype, text: cut(e.text) };
if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', level: e.subtype, text: cut(e.text) };
if (e.type === 'system' && e.subtype === 'tool_progress') return { t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: clean(cutTo(e.text, MAX_OUTPUT)) };
if (e.type === 'system' && e.subtype === 'tool_progress') return { t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: clean(cutTo(e.text, MAX_OUTPUT)) };
@@ -271,9 +266,6 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -271,9 +266,6 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
const run = harness.run({
const run = harness.run({
prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, cwd: work,
prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, cwd: work,
permission: job.permission, handoff: !job.agent, maxAttempts: 3,
permission: job.permission, handoff: !job.agent, maxAttempts: 3,
- // A task carried on in the same conversation resumes its session (RESUME in the environment, or the job's
- // own resume field).
- resume: job.resume || null,
});
});
let last = null;
let last = null;
for await (const e of run) {
for await (const e of run) {
deleted file mode 100644
deleted file mode 100644
@@ -1,109 +0,0 @@
@@ -1,109 +0,0 @@
-
-CREATE TABLE IF NOT EXISTS codes_attempts (
- id text COLLATE "C" PRIMARY KEY, -- att_<ULID>
- task_id text COLLATE "C" NOT NULL, -- tsk_<ULID>
- n integer NOT NULL, -- 1, 2, … the attempt's place on the timeline
- agent text COLLATE "C", -- the harness (claude-code, codex, …) or 'auto'
- model text COLLATE "C",
- machine text COLLATE "C", -- the node's id, or 'sandbox'
- job_id text COLLATE "C", -- Run's job_<ULID> for this attempt
- session text COLLATE "C", -- the harness session id, for a resume
- state text COLLATE "C" NOT NULL, -- queued, running, done, failed, stopped, budget-stopped, interrupted
- reason text COLLATE "C", -- limit, auth, capacity, refused, error, budget, stopped, hung, context
- why text COLLATE "C", -- one sentence in plain words
- kind text COLLATE "C" NOT NULL DEFAULT 'first',-- first | continue | switch | wrap-up | manual
- cost_usd numeric(14,6),
- tokens bigint,
- steps integer,
- started_at timestamptz,
- finished_at timestamptz,
- created_at timestamptz NOT NULL DEFAULT now(),
- follow_up_of text COLLATE "C" -- the attempt whose end started this one
-);
-CREATE UNIQUE INDEX IF NOT EXISTS codes_attempts_task_n ON codes_attempts (task_id, n);
-CREATE UNIQUE INDEX IF NOT EXISTS codes_attempts_follow_up ON codes_attempts (follow_up_of) WHERE follow_up_of IS NOT NULL;
-CREATE INDEX IF NOT EXISTS codes_attempts_job ON codes_attempts (job_id) WHERE job_id IS NOT NULL;
-CREATE INDEX IF NOT EXISTS codes_attempts_task ON codes_attempts (task_id, n);
-
-ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS outcome text COLLATE "C"; -- done | unfinished | stopped
-ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS outcome_note text COLLATE "C"; -- what is left, in plain words
-ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS attempts_count integer NOT NULL DEFAULT 0;
-ALTER TABLE codes_session_lines ADD COLUMN IF NOT EXISTS attempt_n integer;
-
-INSERT INTO codes_attempts (id, task_id, n, agent, job_id, state, reason, why, kind, started_at, finished_at, created_at)
-SELECT 'att_' || upper(substr(md5(t.id), 1, 26)),
- t.id,
- 1,
- t.agent,
- t.job_id,
- CASE t.state
- WHEN 'queued' THEN 'queued'
- WHEN 'placed' THEN 'queued'
- WHEN 'running' THEN 'running'
- WHEN 'succeeded' THEN 'done'
- WHEN 'cancelled' THEN 'stopped'
- ELSE 'failed'
- END,
- CASE t.state
- WHEN 'cancelled' THEN 'stopped'
- WHEN 'expired' THEN 'capacity'
- WHEN 'failed' THEN 'error'
- ELSE NULL
- END,
- CASE t.state
- WHEN 'succeeded' THEN 'the first attempt of this task finished the work'
- WHEN 'cancelled' THEN 'the first attempt of this task was stopped on purpose'
- WHEN 'expired' THEN 'no machine took the first attempt in time'
- WHEN 'failed' THEN 'the first attempt of this task failed'
- WHEN 'running' THEN 'the first attempt of this task is running'
- ELSE 'the first attempt of this task is waiting for a machine'
- END,
- 'first',
- t.started_at,
- t.finished_at,
- to_timestamp(GREATEST(t.created_at, 0) / 1000.0)
-FROM run_tasks t
-WHERE NOT EXISTS (SELECT 1 FROM codes_attempts a WHERE a.task_id = t.id);
-
-UPDATE run_tasks t
-SET outcome = CASE t.state
- WHEN 'succeeded' THEN 'done'
- WHEN 'cancelled' THEN 'stopped'
- WHEN 'failed' THEN 'unfinished'
- WHEN 'expired' THEN 'unfinished'
- ELSE NULL
- END,
- outcome_note = CASE t.state
- WHEN 'succeeded' THEN 'the task finished before attempts were counted'
- WHEN 'cancelled' THEN 'the task was stopped on purpose'
- WHEN 'failed' THEN 'the attempt failed; what it left is in the session'
- WHEN 'expired' THEN 'no machine took the task in time'
- ELSE NULL
- END,
- attempts_count = 1
-WHERE t.attempts_count = 0;
-
-UPDATE codes_session_lines SET attempt_n = 1 WHERE attempt_n IS NULL;
@@ -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.232.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.232.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.232.0",
+ "integrity": "sha512-6HI5nn//nAUEDdG4xRTAlcb40oc/6yO1nx7R5exGZ6Bo3mL5fJ5szw9IONddoLN6qJsO1CXzB5TzTqkBAh3OzQ==",
"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.232.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"
@@ -32,7 +32,6 @@ const { createTasksApi } = require('./http/tasks-api');
@@ -32,7 +32,6 @@ const { createTasksApi } = require('./http/tasks-api');
const { createActionsApi } = require('./http/actions-api');
const { createActionsApi } = require('./http/actions-api');
const { createRunClient } = require('./domain/run-client');
const { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
const { createRunTasks } = require('./domain/run-tasks');
-const { createAttempts } = require('./domain/attempts');
const { createSessionLines } = require('./domain/session-lines');
const { createSessionLines } = require('./domain/session-lines');
const { createOutbox, createPublisher } = require('./events/outbox');
const { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
@@ -67,10 +66,8 @@ async function createApp(opts = {}) {
@@ -67,10 +66,8 @@ async function createApp(opts = {}) {
const sessions = createSessionLines({ store, config, log });
const sessions = createSessionLines({ store, config, log });
const outbox = createOutbox({ store, log });
const outbox = createOutbox({ store, log });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
- // The task's attempts (stage C2): every Run job is one attempt, and the engine decides what happens next.
- const attempts = createAttempts({ store, config, log, harnesses });
- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, attempts });
- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, attempts };
+ const tasks = createRunTasks({ store, run, config, log, sessions, outbox });
+ const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks };
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
const app = express();
const app = express();
@@ -81,18 +81,6 @@ function load(env = process.env) {
@@ -81,18 +81,6 @@ function load(env = process.env) {
// OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.
// OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.
run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },
run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },
- // A task's own budget (engine/budget.js): steps (tool calls), minutes (wall clock) and dollars. A limit that is
- // not set is no limit — 0 means "no limit", never a zero budget. Steps and minutes buy one wrap-up turn.
- budget: {
- steps: int(env.CODES_TASK_STEPS, 0),
- minutes: int(env.CODES_TASK_MINUTES, 0),
- usd: Number(env.CODES_TASK_USD || 0) || 0,
- },
- // The task's timeline (stage C2): how many automatic continues one task may have before it needs a person.
- task: { maxContinues: Math.max(0, int(env.CODES_TASK_MAX_CONTINUES, 2)) },
- // The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
- sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },
-
};
};
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,418 +0,0 @@
@@ -1,418 +0,0 @@
-'use strict';
-
-/**
- * A coding task's attempts (codes_attempts): the timeline of one task.
- *
- * A continue, a switch to another agent and a wrap-up are attempts on one task's timeline, never new tasks with
- * cryptic names (the portal's "-c1"/"-r2" confusion of 10-30). Every Run job is one attempt: the attempt carries the
- * job, the agent, the machine and the harness session a resume needs.
- *
- * The engine decides what happens next (engine/chain.js outcome/nextStep, engine/budget.js wrapUp): this module turns
- * its decision into a plan — a follow-up attempt, or the task's outcome in plain words. It never starts a Run job
- * itself (server/domain/run-tasks.js does that, outside a transaction) and it never decides twice for one ending:
- * the follow-up's follow_up_of is unique, so one ending can start only one follow-up.
- *
- * createAttempts({ store, config, log, harnesses })
- * list(taskId) the attempts of a task, in order
- * get(id) one attempt
- * create({ task, …, followUpOf }) one attempt; the same followUpOf answers the same attempt (the fence)
- * bind(id, { job_id, state }) the Run job of an attempt
- * end(id, { state, reason, … }) an attempt ended: its state, its reason, its cost
- * decide({ task, attempts, ending, result }) the engine's decision, as a plan
- * present(attempt) the attempt as the API shows it
- */
-
-const { chain, budget, router } = require('../../engine');
-
-/** Codes' harness ids and the engine's short runner ids for the same harness. */
-const RUNNER_OF = { 'claude-code': 'claude', codex: 'cx', 'command-code': 'cc', opencode: 'oc', deepseek: 'dsh' };
-const HARNESS_OF = { claude: 'claude-code', cx: 'codex', cc: 'command-code', oc: 'opencode', dsh: 'deepseek' };
-
-/** The order a runner switch tries the harnesses in (the ladder's order, flattened). */
-const FALLBACK_ORDER = ['cx', 'claude', 'cc', 'oc', 'dsh'];
-
-/** Which harnesses may take which kind of task, cheapest first (the engine's ladder, Codes' harnesses). */
-const TIER_ORDER = {
- basic: ['opencode', 'command-code', 'deepseek'],
- research: ['codex', 'opencode', 'deepseek'],
- standard: ['codex', 'claude-code', 'command-code', 'opencode', 'deepseek'],
- hard: ['codex', 'claude-code', 'deepseek'],
- design: ['codex', 'claude-code', 'opencode'],
-};
-const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard: 'high', design: 'high' };
-
-/** The attempt's own states, and the reasons it may have ended. */
-const STATES = ['queued', 'running', 'done', 'failed', 'stopped', 'budget-stopped', 'interrupted'];
-const REASONS = ['limit', 'auth', 'capacity', 'refused', 'error', 'budget', 'stopped', 'hung', 'context'];
-const KINDS = ['first', 'continue', 'switch', 'wrap-up', 'restart', 'manual'];
-
-/** The engine's own words for a reason: the timeline reads like a person wrote it. */
-const REASON_TEXT = chain.REASON_TEXT;
-
-/**
- * The text the engine classifies a reason from. The engine reads the reason of a failed attempt out of what it said
- * (engine/chain.js reason), so the text has to say the same thing our reason column says.
- */
-const ERROR_TEXT = {
- limit: 'usage limit reached',
- auth: 'authentication failed: log in again',
- capacity: 'the model was at capacity',
- refused: 'turned off in the agent portal',
- error: 'the work failed',
- budget: 'stopped at its limit',
- stopped: 'stopped on purpose',
- context: 'the context window is too big',
- hung: 'it went quiet for too long',
-};
-
-/** The engine's status for our state (the two vocabularies are the same, except 'queued' and 'running'). */
-const ENGINE_STATE = {
- queued: 'queued', running: 'running', done: 'done', failed: 'failed',
- stopped: 'stopped', 'budget-stopped': 'budget-stopped', interrupted: 'interrupted',
-};
-
-/** The attempt's state for a task's state (a task from before the attempts still has one). */
-const TASK_STATE = {
- queued: 'queued', placed: 'queued', running: 'running', succeeded: 'done',
- failed: 'failed', cancelled: 'stopped', expired: 'failed',
-};
-
-const iso = (v) => {
- if (!v) return null;
- const t = v instanceof Date ? v.getTime() : Date.parse(String(v));
- return Number.isFinite(t) ? new Date(t).toISOString() : null;
-};
-const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
-const runnerOf = (agent) => RUNNER_OF[agent] || (agent || 'auto');
-const wordsOf = (reason) => REASON_TEXT[reason] || REASON_TEXT.error;
-
-function createAttempts({ store, config = {}, log = console, harnesses = null }) {
- const db = store.db;
- const catalog = harnesses || require('./harnesses').createHarnesses();
- // How many automatic continues one task may have before it needs a person (CODES_TASK_MAX_CONTINUES, two).
- const wanted = config.task ? config.task.maxContinues : undefined;
- const maxContinues = Number.isFinite(Number(wanted)) ? Math.max(0, Number(wanted)) : 2;
-
- const nameOf = (agent) => {
- const offer = agent ? catalog.get(agent) : null;
- return offer ? offer.name : (agent || 'the agent');
- };
-
- /** The attempts of a task, in order. A task written before the attempts (or by another path) still has a first one. */
- async function list(taskId) {
- if (!taskId) return [];
- const rows = await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [taskId]);
- if (rows.length) return rows;
- const task = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
- if (!task) return [];
- await db.exec(
- `INSERT INTO codes_attempts (id, task_id, n, agent, job_id, state, why, kind, started_at, finished_at, created_at)
- VALUES ($1, $2, 1, $3, $4, $5, $6, 'first', $7::timestamptz, $8::timestamptz, $9::timestamptz)
- ON CONFLICT DO NOTHING`,
- [store.newId('att'), task.id, task.agent || null, task.job_id || null, TASK_STATE[task.state] || 'failed',
- 'the first attempt of this task', task.started_at || null, task.finished_at || null, store.iso()],
- );
- return await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [taskId]);
- }
-
- /** One attempt. */
- async function get(id) {
- if (!id) return null;
- return await db.maybe('SELECT * FROM codes_attempts WHERE id = $1', [id]);
- }
-
- /** The attempt a job belongs to, or null. */
- async function byJob(jobId) {
- if (!jobId) return null;
- return await db.maybe('SELECT * FROM codes_attempts WHERE job_id = $1', [jobId]);
- }
-
- /**
- * One attempt. The fence: follow_up_of is unique, so an ending that starts a follow-up twice answers the same
- * attempt the second time and starts no second job. created tells the caller which of the two it was.
- */
- async function create({ task, agent = null, model = null, machine = null, session = null, why = null, kind = 'first', followUpOf = null, state = 'queued' }) {
- if (!task || !task.id) throw new Error('an attempt belongs to a task');
- for (let tries = 0; tries < 5; tries++) {
- const n = await db.one('SELECT COALESCE(MAX(n), 0) + 1 AS n FROM codes_attempts WHERE task_id = $1', [task.id]);
- const id = store.newId('att');
- const inserted = await db.maybe(
- `INSERT INTO codes_attempts (id, task_id, n, agent, model, machine, session, state, why, kind, follow_up_of, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz)
- ON CONFLICT DO NOTHING RETURNING *`,
- [id, task.id, n.n, agent, model, machine, session, state, why, kind, followUpOf, store.iso()],
- );
- if (inserted) {
- await db.exec('UPDATE run_tasks SET attempts_count = $2 WHERE id = $1', [task.id, n.n]);
- return { attempt: inserted, created: true };
- }
- // Someone else wrote it: the same follow-up answers the same attempt.
- const seen = followUpOf ? await db.maybe('SELECT * FROM codes_attempts WHERE follow_up_of = $1', [followUpOf]) : null;
- if (seen) return { attempt: seen, created: false };
- }
- throw new Error('the attempt could not be written');
- }
-
- /** The Run job of an attempt. */
- async function bind(id, { job_id = null, state = null, started_at = null, machine = null, model = null } = {}) {
- return await db.one(
- `UPDATE codes_attempts SET job_id = COALESCE($2, job_id),
- state = COALESCE($3, state),
- machine = COALESCE($4, machine),
- model = COALESCE($5, model),
- started_at = COALESCE($6::timestamptz, started_at)
- WHERE id = $1 RETURNING *`,
- [id, job_id, state, machine, model, started_at],
- );
- }
-
- /** An attempt ended: its state, why, what it cost. */
- async function end(id, { state = 'failed', reason = null, why = null, cost_usd = null, tokens = null, steps = null, session = null, model = null, machine = null, finished_at = null } = {}) {
- return await db.one(
- `UPDATE codes_attempts SET state = $2,
- reason = COALESCE($3, reason),
- why = COALESCE($4, why),
- cost_usd = COALESCE($5::numeric, cost_usd),
- tokens = COALESCE($6, tokens),
- steps = COALESCE($7, steps),
- session = COALESCE($8, session),
- model = COALESCE($9, model),
- machine = COALESCE($10, machine),
- finished_at = COALESCE($11::timestamptz, finished_at)
- WHERE id = $1 RETURNING *`,
- [id, state, reason, why, cost_usd == null ? null : String(cost_usd), tokens, steps, session, model, machine, finished_at || store.iso()],
- );
- }
-
- /** The engine's own vocabulary for Codes' harnesses: the ladder, the health and the catalogs it routes with. */
- function routing() {
- const subagents = {}; const models = {}; const runners = [];
- for (const [harnessId, runner] of Object.entries(RUNNER_OF)) {
- const offer = catalog.get(harnessId);
- if (!offer) continue;
- subagents[runner] = { enabled: true, max: 4, model: null };
- models[runner] = (offer.agents || []).map((a) => a.model).filter(Boolean);
- runners.push(runner);
- }
- const health = {};
- for (const r of runners) health[r] = { state: 'ok' };
- const ladder = {};
- for (const tier of router.TIERS) {
- ladder[tier] = (TIER_ORDER[tier] || []).map((id) => {
- const runner = RUNNER_OF[id];
- const offer = catalog.get(id);
- const agent = offer && offer.agents && offer.agents[0];
- return [runner, agent ? agent.model : null, TIER_EFFORT[tier]];
- }).filter(([runner, model]) => subagents[runner] && (!model || models[runner].includes(model)));
- }
- return {
- cfg: { subagents, budget: config.budget || {}, fallback: { order: FALLBACK_ORDER.filter((r) => subagents[r]) } },
- health, counts: {}, models, runners, ladder,
- };
- }
-
- /**
- * The next agent for a task, from the engine's router (engine/router.js): the tier of the brief decides the
- * ladder, and the first harness of it that may take the task takes it. The agent that just stopped is skipped.
- */
- function nextAgent(prompt, ctx, exclude) {
- const usable = (runner) => {
- const sub = ctx.cfg.subagents[runner];
- if (!sub || !sub.enabled) return false;
- if ((ctx.counts[runner] || 0) >= sub.max) return false;
- const h = ctx.health[runner];
- return !!h && h.state === 'ok';
- };
- const picked = router.pick(prompt, ctx);
- const tier = picked ? picked.tier : router.classify(prompt).tier;
- const ladder = ctx.ladder[tier] || [];
- const shape = (runner, model, effort) => ({
- runner,
- agent: HARNESS_OF[runner] || null,
- model: model || null,
- effort: router.fitEffort(runner, effort),
- tier,
- why: picked ? picked.why : `${tier} → ${runner}`,
- });
- if (picked && picked.runner !== exclude && usable(picked.runner)) return shape(picked.runner, picked.model, picked.effort);
- for (const [runner, model, effort] of ladder) {
- if (runner === exclude || !usable(runner)) continue;
- const m = model && ctx.models[runner] && !ctx.models[runner].includes(model) ? null : model;
- return shape(runner, m, effort);
- }
- return null;
- }
-
- /** One attempt as the engine sees it. */
- function engineAttempt(a, all, facts = null) {
- const n = Number(a.n) || 1;
- const reason = a.reason && REASONS.includes(a.reason) ? a.reason : null;
- const next = all.find((x) => x.follow_up_of && x.follow_up_of === a.id);
- const prev = all.find((x) => Number(x.n) === n - 1);
- const created = a.created_at ? Date.parse(String(a.created_at)) : 0;
- return {
- key: a.id,
- runner: runnerOf(a.agent),
- name: n === 1 ? 'a' : `a-r${n}`,
- runnerName: nameOf(a.agent),
- status: ENGINE_STATE[a.state] || 'failed',
- // The order the engine reads the chain in: the attempt's place, never a tie.
- updated: created + n,
- attempt: n,
- error: reason ? ERROR_TEXT[reason] : null,
- // The engine reads the reason of a failed attempt out of what it said; Codes' attempts carry their own
- // reason, and the engine only reads the runners it knows, so it is passed on.
- reason: reason || null,
- next: next ? next.id : null,
- prev: prev ? prev.id : null,
- parent: a.follow_up_of || null,
- stopped: a.state === 'stopped',
- budget: reason === 'budget' ? { hit: 'steps' } : undefined,
- wrapUp: a.kind === 'wrap-up',
- hung: reason === 'hung',
- facts: {
- session: a.session || null,
- apiError: (facts && facts.apiError) || (reason ? ERROR_TEXT[reason] : null),
- hasFinal: !!(facts && facts.hasFinal),
- finalText: (facts && facts.finalText) || '',
- },
- };
- }
-
- /**
- * What the job's log says about the attempt: its last words (the engine reads them to know whether the agent
- * said it was done) and the error it ended with.
- */
- function factsOf(result) {
- const d = result && typeof result === 'object' && !Array.isArray(result) ? result : {};
- const log = Array.isArray(d.log) ? d.log : [];
- let hasFinal = false; let finalText = ''; let apiError = '';
- for (const line of log) {
- if (!line || typeof line !== 'object') continue;
- if (line.t === 'done') {
- hasFinal = true;
- if (line.text) finalText = String(line.text);
- if (line.ok === false && !apiError) apiError = finalText || 'the agent ended without an answer';
- } else if (line.t === 'note' && line.level === 'error' && !apiError) {
- apiError = String(line.text || '');
- }
- }
- return { hasFinal, finalText, apiError };
- }
-
- /** The note a continued attempt gets: it keeps the work, it does not redo it. */
- function continuationNote(ending, words) {
- return `\n\n---\n## Continuation (written by openvibe.codes)\n\nAn earlier attempt${ending.agent ? ` on ${nameOf(ending.agent)}` : ''} ${words}. Keep the work it finished, do not redo it, and continue from where it stopped. If the task is already complete, check it and report that.\n`;
- }
-
- /** The note a new agent gets: what changed, what it said, where it stopped. */
- function handoverNote(ending, result, words) {
- const d = result && typeof result === 'object' ? result : {};
- const files = Array.isArray(d.files) ? d.files.map((f) => f && f.path).filter(Boolean).slice(0, 20) : [];
- const summary = clip(d.summary || '', 1200);
- return `\n\n---\n## Hand-over (written by openvibe.codes)\n\n${nameOf(ending.agent)} ${words}. Before it stopped it:\n`
- + `- changed: ${files.length ? files.join(', ') : 'nothing yet'}\n`
- + `- said: ${summary || 'nothing'}\n`
- + `- stopped at: ${words}\n\n`
- + 'Keep the work it did, do not redo it, and finish the task.\n';
- }
-
- /**
- * What happens after an attempt ends: the engine decides, this turns the decision into a plan.
- *
- * @param {object} what
- * @param {object} what.task the task row
- * @param {object[]} what.attempts every attempt of the task, in order
- * @param {object} what.ending the attempt that ended
- * @param {object} [what.result] the job's result (what it changed and what it said)
- * @returns {object} the plan: an outcome ('done', 'unfinished', 'stopped') or a follow-up attempt
- */
- function decide({ task, attempts, ending, result = null }) {
- const ctx = routing();
- const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));
- const facts = factsOf(result);
- const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null));
- const out = chain.outcome(engineAttempts, ctx);
- const step = out.step || { action: 'none' };
- const reason = ending.reason || out.reason || 'error';
- const words = wordsOf(reason);
- const continues = upto.filter((a) => a.kind === 'continue').length;
-
- if (step.action === 'done') return { outcome: 'done', note: 'the task finished' };
- if (step.action === 'stop') return { outcome: 'stopped', note: 'the task was stopped on purpose' };
- if (step.action === 'wait') return { outcome: null, note: null };
-
- if (step.action === 'wrap-up') {
- const hit = step.hit || 'steps';
- return {
- kind: 'wrap-up', agent: ending.agent, model: ending.model, session: ending.session || null,
- prompt: String(task.prompt || '') + budget.wrapUpPrompt(hit),
- why: `one last turn to land the work after the ${hit} limit`,
- };
- }
-
- if (step.action === 'continue') {
- if (continues >= maxContinues) {
- return { outcome: 'unfinished', note: `the task was carried on ${continues} times already; it needs a person` };
- }
- return {
- kind: 'continue', agent: ending.agent, model: ending.model, session: step.session || ending.session || null,
- prompt: String(task.prompt || '') + continuationNote(ending, words),
- why: `carried on in the same conversation: ${words}`,
- };
- }
-
- if (step.action === 'restart') {
- return {
- kind: 'restart', agent: ending.agent, model: ending.model, session: null,
- prompt: String(task.prompt || '') + continuationNote(ending, words),
- why: `started again with a fresh session: ${words}`,
- };
- }
-
- if (step.action === 'switch') {
- const choice = nextAgent(task.prompt, ctx, runnerOf(ending.agent));
- const agent = choice ? choice.agent : (step.runner ? HARNESS_OF[step.runner] : null);
- if (!agent) return { outcome: 'unfinished', note: `no other agent could take it: ${step.why || words}` };
- return {
- kind: 'switch', agent, model: choice ? choice.model : (step.model || null), session: null,
- prompt: String(task.prompt || '') + handoverNote(ending, result, words),
- why: `${nameOf(agent)} took over: ${words}`,
- };
- }
-
- return { outcome: 'unfinished', note: `${step.why || 'the task stopped'}. What is left is in the session.` };
- }
-
- /** An attempt as the API and the page show it. */
- function present(a) {
- if (!a) return null;
- return {
- id: a.id,
- n: Number(a.n) || 0,
- agent: a.agent || null,
- model: a.model || null,
- machine: a.machine || null,
- job_id: a.job_id || null,
- session: a.session || null,
- state: a.state,
- reason: a.reason || null,
- why: a.why || null,
- kind: a.kind || 'first',
- cost_usd: a.cost_usd == null ? null : Number(a.cost_usd),
- tokens: a.tokens == null ? null : Number(a.tokens),
- steps: a.steps == null ? null : Number(a.steps),
- started_at: iso(a.started_at),
- finished_at: iso(a.finished_at),
- created_at: iso(a.created_at),
- end_why: a.reason ? wordsOf(a.reason) : null,
- };
- }
-
- return {
- STATES, REASONS, KINDS, REASON_TEXT, RUNNER_OF, HARNESS_OF,
- list, get, byJob, create, bind, end, decide, present, routing, nextAgent, nameOf, wordsOf,
- };
-}
-
-module.exports = { createAttempts, RUNNER_OF, HARNESS_OF, STATES, REASONS, KINDS, REASON_TEXT, ERROR_TEXT };
@@ -4,13 +4,10 @@
@@ -4,13 +4,10 @@
* Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the
* Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the
* service API (server/http/tasks-api.js, codes.task.*) share.
* service API (server/http/tasks-api.js, codes.task.*) share.
*
*
- * createRunTasks({ store, run, config, log, sessions, outbox, attempts })
+ * createRunTasks({ store, run, config, log, sessions, outbox })
* start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)
* start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)
* refresh(row, { force }) → the row, read from Run only as a fallback after 10 quiet minutes
* refresh(row, { force }) → the row, read from Run only as a fallback after 10 quiet minutes
* applyEvent(row, event) → a run.job.* delivery: state, timings, result, event, in one tx
* applyEvent(row, event) → a run.job.* delivery: state, timings, result, event, in one tx
- * afterEnd(taskId) → the engine's decision after an attempt ended
- * continueTask(row, { note, agent }) → the next attempt now (a switch when an agent is named)
- * stopTask(row) → stop the running attempt; nothing carries it on
* latestProject(subject) → the project of the person's latest task, or null
* latestProject(subject) → the project of the person's latest task, or null
* present(row) → codes.task@1
* present(row) → codes.task@1
* TERMINAL, STATE_LABEL, STALE_MS
* TERMINAL, STATE_LABEL, STALE_MS
@@ -18,15 +15,8 @@
@@ -18,15 +15,8 @@
* Every state change writes its codes.task.* outbox row in the same transaction; a terminal state also back-fills
* Every state change writes its codes.task.* outbox row in the same transaction; a terminal state also back-fills
* the session's missing lines and marks it complete. A task's result comes from the person's machine: present() passes
* the session's missing lines and marks it complete. A task's result comes from the person's machine: present() passes
* on only the contract's fields, each checked and cut to its limit.
* on only the contract's fields, each checked and cut to its limit.
- *
- * Stage C2: every Run job is one attempt on the task's timeline (server/domain/attempts.js). When an attempt ends,
- * the engine decides what happens next — done, a continue in the same session, a switch to another agent, a wrap-up or
- * an unfinished task — and this module carries the decision out. One ending starts at most one follow-up: the
- * follow-up's follow_up_of is unique, so two racing ticks never carry one job on twice.
*/
*/
const { RunError } = require('./run-client');
const { RunError } = require('./run-client');
-const { chain, budget } = require('../../engine');
-const { parseArgs } = require('../../harness/job');
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const STATE_LABEL = {
const STATE_LABEL = {
@@ -95,60 +85,12 @@ function errorOf(job) {
@@ -95,60 +85,12 @@ function errorOf(job) {
return `${job.error.code || 'run.error'}${job.error.detail ? `: ${job.error.detail}` : ''}`.slice(0, 1000);
return `${job.error.code || 'run.error'}${job.error.detail ? `: ${job.error.detail}` : ''}`.slice(0, 1000);
}
}
-/** The machine a job ran on: the worker's device id, or the hosted sandbox. */
-function machineOf(job) {
- if (!job || typeof job !== 'object') return null;
- const node = job.placement && typeof job.placement === 'object' ? job.placement.node : null;
- if (typeof node === 'string' && node) return node.slice(0, 128);
- return typeof job.machine === 'string' && job.machine ? job.machine.slice(0, 128) : null;
-}
-
-/** The attempt's own state for a Run job's state. */
-const ATTEMPT_STATE = { queued: 'queued', placed: 'queued', running: 'running', succeeded: 'done', failed: 'failed', cancelled: 'stopped', expired: 'failed' };
-
-/** The reason an attempt ended: the engine's own words (engine/chain.js reason), plus the two it reads from the text. */
-function reasonOf(state, text) {
- if (state === 'cancelled') return 'stopped';
- if (state === 'expired') return 'capacity';
- const s = String(text || '');
- if (/went quiet|hung|stale/i.test(s)) return 'hung';
- if (/prompt is too long|context length|maximum context|context window|too many tokens|input too long/i.test(s)) return 'context';
- const why = chain.reason(s);
- return why === 'unknown' ? 'error' : why;
-}
-
-/** What an attempt spent: its cost, its tokens, its steps and the session a resume needs (from the job's result log). */
-function usageOf(result) {
- const d = asObject(result) || {};
- const log = Array.isArray(d.log) ? d.log : [];
- let tokens = 0; let steps = 0; let session = null; let model = null;
- for (const line of log) {
- if (!line || typeof line !== 'object') continue;
- if (line.t === 'usage') tokens += (Number(line.input_tokens) || 0) + (Number(line.output_tokens) || 0);
- else if (line.t === 'tool') steps++;
- else if (line.t === 'agent') {
- if (!session && line.session) session = String(line.session);
- if (!model && line.model) model = String(line.model);
- }
- }
- return {
- cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,
- tokens: tokens || null,
- steps: steps || null,
- session: session || null,
- model: model || null,
- };
-}
-
-function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, attempts = null }) {
+function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null }) {
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });
const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
- const attemptStore = attempts || require('./attempts').createAttempts({ store, config, log });
- /** An attempt's state when it ended: a limit reached is a budget-stop, never a plain failure. */
- const ENDED = new Set(['done', 'failed', 'stopped', 'budget-stopped', 'interrupted']);
/**
/**
* One state change: update the row, write its codes.task.* outbox row in the same transaction, and on a terminal
* One state change: update the row, write its codes.task.* outbox row in the same transaction, and on a terminal
@@ -179,79 +121,36 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -179,79 +121,36 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
[current.id, state, result == null ? null : JSON.stringify(result), error, startedAt, finishedAt, store.now()],
[current.id, state, result == null ? null : JSON.stringify(result), error, startedAt, finishedAt, store.now()],
);
);
if (changed) await taskOutbox.emitTask(updated, current.state);
if (changed) await taskOutbox.emitTask(updated, current.state);
- // Every Run job is one attempt: the attempt of this job follows the job.
- const attempt = await attemptStore.byJob(current.job_id);
if (TERMINAL.has(updated.state) && !updated.session_complete_at) {
if (TERMINAL.has(updated.state) && !updated.session_complete_at) {
- await sessionLines.backfillAndComplete(updated, updated.result, attempt ? Number(attempt.n) : null);
- }
- if (attempt) {
- if (TERMINAL.has(state)) {
- const usage = usageOf(updated.result);
- const why = reasonOf(state, updated.error);
- const hit = budgetHit(attempt, usage, why);
- await attemptStore.end(attempt.id, {
- state: hit ? 'budget-stopped' : (ATTEMPT_STATE[state] || 'failed'),
- reason: hit ? 'budget' : why,
- cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, session: usage.session,
- model: usage.model, machine: machineOf(next.job),
- finished_at: updated.finished_at || store.iso(),
- });
- } else {
- await attemptStore.bind(attempt.id, {
- state: ATTEMPT_STATE[state] || 'queued', started_at: updated.started_at || null, machine: machineOf(next.job),
- });
- }
+ await sessionLines.backfillAndComplete(updated, updated.result);
}
}
return updated;
return updated;
});
});
}
}
- /**
- * The limit an attempt reached, if any (engine/budget.js): steps, minutes or dollars. A provider's own limit
- * (usage limit, signed out) is not the owner's budget and never buys a wrap-up turn.
- */
- function budgetHit(attempt, usage, reason) {
- if (!['error', 'hung', 'context'].includes(reason)) return null;
- const limits = budget.limitsFor(config, attempt.agent, null);
- if (!limits.steps && !limits.minutes && !limits.usd) return null;
- const started = attempt.started_at ? Date.parse(String(attempt.started_at)) : 0;
- const finished = attempt.finished_at ? Date.parse(String(attempt.finished_at)) : 0;
- const minutes = finished > started ? (finished - started) / 60000 : null;
- return budget.evaluate({ steps: usage.steps || 0, minutes, usd: usage.cost_usd }, limits).hit;
- }
-
async function start({ subject, project, args, via = null, key = null, actorRef = null }) {
async function start({ subject, project, args, via = null, key = null, actorRef = null }) {
const id = store.newId('tsk');
const id = store.newId('tsk');
const at = store.now();
const at = store.now();
const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
- let inserted;
await store.tx(async () => {
await store.tx(async () => {
- inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, created_at, updated_at)
+ const inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $13) RETURNING *`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $13) RETURNING *`,
[id, subject, project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, actorRefValue, at]);
[id, subject, project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, actorRefValue, at]);
await taskOutbox.emitTask(inserted, null);
await taskOutbox.emitTask(inserted, null);
return inserted;
return inserted;
});
});
- // The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.
- const first = await attemptStore.create({
- task: inserted, agent: args.agent || null, model: args.model || null,
- why: args.agent ? `the first try, with ${attemptStore.nameOf(args.agent)}` : 'the first try, with the agent that fits the task',
- kind: 'first', state: 'queued',
- });
let job;
let job;
try {
try {
job = await run.submit({ subject, project, args, key: id });
job = await run.submit({ subject, project, args, key: id });
} catch (e) {
} catch (e) {
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
- await attemptStore.end(first.attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
return await store.tx(async () => {
return await store.tx(async () => {
- const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5
- WHERE id = $1 RETURNING *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);
+ const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, finished_at = $3::timestamptz, updated_at = $4
+ WHERE id = $1 RETURNING *`, [id, why, store.iso(), store.now()]);
await taskOutbox.emitTask(failed, 'queued');
await taskOutbox.emitTask(failed, 'queued');
return failed;
return failed;
});
});
}
}
- await attemptStore.bind(first.attempt.id, { job_id: job.id, state: 'queued' });
return await store.tx(async () => {
return await store.tx(async () => {
const state = STATES.has(job.state) ? job.state : 'queued';
const state = STATES.has(job.state) ? job.state : 'queued';
const startedAt = job.timings && job.timings.started_at ? job.timings.started_at : null;
const startedAt = job.timings && job.timings.started_at ? job.timings.started_at : null;
@@ -266,7 +165,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -266,7 +165,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
async function applyJob(row, job) {
async function applyJob(row, job) {
if (!job || !STATES.has(job.state)) return row;
if (!job || !STATES.has(job.state)) return row;
const result = TERMINAL.has(job.state) ? (asObject(job.result) || null) : undefined;
const result = TERMINAL.has(job.state) ? (asObject(job.result) || null) : undefined;
- const updated = await recordState(row, {
+ return await recordState(row, {
state: job.state,
state: job.state,
result,
result,
error: TERMINAL.has(job.state) ? errorOf(job) : undefined,
error: TERMINAL.has(job.state) ? errorOf(job) : undefined,
@@ -274,8 +173,6 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -274,8 +173,6 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
finishedAt: job.timings && job.timings.finished_at,
finishedAt: job.timings && job.timings.finished_at,
job,
job,
});
});
- // The attempt ended: the engine decides, and the row carries the outcome (or the next attempt).
- return (await afterEnd(updated.id)) || updated;
}
}
/**
/**
@@ -283,15 +180,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -283,15 +180,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
* for a missing delivery, not page-view polling). force reads now, for a cancel or an explicit check.
* for a missing delivery, not page-view polling). force reads now, for a cancel or an explicit check.
*/
*/
async function refresh(row, { force = false } = {}) {
async function refresh(row, { force = false } = {}) {
- if (!row.job_id) return row;
- if (TERMINAL.has(row.state)) {
- // The task has ended. Its decision (the outcome, or the attempt that follows) is made once, when the
- // attempt ended; if that failed, it is made here, as the fallback reads Run after 10 quiet minutes.
- if (!row.outcome && (force || store.now() - Number(row.updated_at) >= STALE_MS)) {
- return (await afterEnd(row.id)) || row;
- }
- return row;
- }
+ if (!row.job_id || TERMINAL.has(row.state)) return row;
if (!force && store.now() - Number(row.updated_at) < STALE_MS) return row;
if (!force && store.now() - Number(row.updated_at) < STALE_MS) return row;
let job;
let job;
try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }
try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }
@@ -312,160 +201,6 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -312,160 +201,6 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
});
});
}
}
- /**
- * An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the
- * decision out — a follow-up attempt, or the task's outcome in plain words.
- *
- * Calling it twice for one ending changes nothing: the follow-up's follow_up_of is unique, so the second call
- * finds the attempt the first one wrote and starts no second job (the portal's 10-30 bug, where two racing ticks
- * carried one job on twice).
- */
- async function afterEnd(taskId) {
- const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
- if (!row) return null;
- if (!TERMINAL.has(row.state)) return row;
- if (row.outcome === 'stopped') return row; // a person stopped it: nothing carries it on
- const attempts = await attemptStore.list(row.id);
- const ending = attempts[attempts.length - 1];
- if (!ending || !ENDED.has(ending.state)) return row;
- const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result });
- if (plan.outcome) {
- return await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',
- [row.id, plan.outcome, plan.note, store.now()]);
- }
- if (!plan.kind) return row;
- const done = await startFollowUp(row, plan, ending);
- return done.row || row;
- }
-
- /**
- * The next attempt: a Run job for the task with the engine's plan in it. The attempt is written first and the job
- * second, so the fence (follow_up_of) is in place before anything is started.
- */
- async function startFollowUp(row, plan, ending) {
- const { attempt, created } = await attemptStore.create({
- task: row, agent: plan.agent || null, model: plan.model || null, session: plan.session || null,
- why: plan.why || null, kind: plan.kind || 'manual', followUpOf: ending.id, state: 'queued',
- });
- // Someone else already started the follow-up of this ending: no second job.
- if (!created) return { attempt, created: false, row };
- let args;
- try {
- args = followUpArgs(row, plan);
- } catch (e) {
- await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
- const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
- [row.id, `the next attempt could not be prepared: ${e.message}`, store.now()]);
- return { attempt, created: true, row: failed, error: e.message };
- }
- let job;
- try {
- job = await run.submit({ subject: row.subject, project: row.project_id, args, key: attempt.id });
- } catch (e) {
- const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
- await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
- const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
- [row.id, `the next attempt could not start: ${why}`, store.now()]);
- return { attempt, created: true, row: failed, error: why };
- }
- await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
- const updated = await db.one(
- `UPDATE run_tasks SET job_id = $2, state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL,
- finished_at = NULL, updated_at = $3 WHERE id = $1 RETURNING *`,
- [row.id, job.id, store.now()]);
- await taskOutbox.emitTask(updated, row.state);
- return { attempt, created: true, job, row: updated };
- }
-
- /**
- * The next attempt now: the owner carries the task on (with a note), or switches to another agent. The task's row
- * is locked while the attempt is written, so two presses start one attempt and not two.
- */
- async function continueTask(row, { note = null, agent = null } = {}) {
- if (!row || !row.id) return { error: 'no such task' };
- const attempts = await attemptStore.list(row.id);
- const ending = attempts[attempts.length - 1];
- if (!ending) return { error: 'the task has no attempt to carry on' };
- const text = note ? String(note).trim().slice(0, 4000) : '';
- const plan = agent
- ? {
- kind: 'switch', agent, model: null, session: null,
- prompt: `${row.prompt}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n`,
- why: `${attemptStore.nameOf(agent)} took over at the owner's request`,
- }
- : {
- kind: 'manual', agent: ending.agent, model: ending.model, session: null,
- prompt: `${row.prompt}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,
- why: text ? `carried on by the owner: ${text.slice(0, 200)}` : 'carried on by the owner',
- };
- let attempt = null;
- const locked = await store.tx(async () => {
- const current = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 FOR UPDATE', [row.id]);
- if (!current) return { error: 'no such task' };
- if (!TERMINAL.has(current.state)) return { error: 'the task is still running' };
- if (current.outcome === 'stopped') return { error: 'the task was stopped on purpose' };
- const made = await attemptStore.create({
- task: current, agent: plan.agent, model: plan.model, session: null, why: plan.why, kind: plan.kind, state: 'queued',
- });
- attempt = made.attempt;
- await db.one(`UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, job_id = NULL, finished_at = NULL, updated_at = $2 WHERE id = $1 RETURNING *`,
- [row.id, store.now()]);
- return { row: current };
- });
- if (locked.error) return { error: locked.error };
- let job;
- try {
- job = await run.submit({ subject: row.subject, project: row.project_id, args: followUpArgs(row, plan), key: attempt.id });
- } catch (e) {
- const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
- await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
- const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5 WHERE id = $1 RETURNING *`,
- [row.id, why, `the next attempt could not start: ${why}`, store.iso(), store.now()]);
- return { error: why, row: failed, attempt };
- }
- await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
- const updated = await db.one('UPDATE run_tasks SET job_id = $2, updated_at = $3 WHERE id = $1 RETURNING *', [row.id, job.id, store.now()]);
- await taskOutbox.emitTask(updated, row.state);
- return { attempt, job, row: updated };
- }
-
- /** The job's arguments for a follow-up: the task's own repository, the plan's prompt and the session to resume. */
- function followUpArgs(row, plan) {
- return parseArgs(JSON.stringify({
- repo: row.repo, ref: row.ref || null, prompt: plan.prompt || row.prompt,
- agent: plan.agent || null, permission: 'edit',
- ...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),
- ...(plan.session ? { resume: plan.session } : {}),
- }));
- }
-
- /**
- * Stop the running attempt: the outcome is stopped and nothing carries it on. The task's row is locked, so a stop
- * and a follow-up never race.
- */
- async function stopTask(row) {
- if (!row || !row.id) return { error: 'no such task' };
- const attempts = await attemptStore.list(row.id);
- const running = attempts.slice().reverse().find((a) => !ENDED.has(a.state));
- if (row.job_id && !TERMINAL.has(row.state)) {
- try { await run.cancel(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] stop ${row.id}: ${e.message}`); }
- }
- const stopped = await store.tx(async () => {
- const current = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 FOR UPDATE', [row.id]);
- if (!current) return { error: 'no such task' };
- if (TERMINAL.has(current.state) && current.outcome) return { error: 'the task has already ended', row: current };
- const updated = await db.one(
- `UPDATE run_tasks SET state = 'cancelled', outcome = 'stopped', outcome_note = $2,
- finished_at = COALESCE(finished_at, $3::timestamptz), updated_at = $4 WHERE id = $1 RETURNING *`,
- [row.id, 'the task was stopped on purpose', store.iso(), store.now()]);
- await taskOutbox.emitTask(updated, current.state);
- return { row: updated };
- });
- if (stopped.error) return stopped;
- if (running) await attemptStore.end(running.id, { state: 'stopped', reason: 'stopped', finished_at: store.iso() });
- return { row: stopped.row, attempt: running || null };
- }
-
async function latestProject(subject) {
async function latestProject(subject) {
const row = await db.maybe('SELECT project_id FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 1', [subject]);
const row = await db.maybe('SELECT project_id FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 1', [subject]);
return row ? row.project_id : null;
return row ? row.project_id : null;
@@ -480,10 +215,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -480,10 +215,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
});
});
}
}
- return {
- start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask,
- latestProject, present, who, attempts: attemptStore,
- };
+ return { start, refresh, applyJob, applyEvent, recordState, latestProject, present, who };
}
}
-module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL, STATES, RANK, STALE_MS, sessionLogOf, errorOf, asObject, ATTEMPT_STATE, reasonOf, usageOf };
+module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL, STATES, RANK, STALE_MS, sessionLogOf, errorOf, asObject };
@@ -98,7 +98,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -98,7 +98,7 @@ function createSessionLines({ store, config = {}, log = console }) {
const retentionDays = Math.max(1, Number(config.sessions && config.sessions.retentionDays) || 90);
const retentionDays = Math.max(1, Number(config.sessions && config.sessions.retentionDays) || 90);
/** Insert lines in order; an existing (task_id, seq) wins, so a replay never duplicates. → rows inserted. */
/** Insert lines in order; an existing (task_id, seq) wins, so a replay never duplicates. → rows inserted. */
- async function append(taskId, lines, attemptN = null) {
+ async function append(taskId, lines) {
if (!taskId || !Array.isArray(lines) || !lines.length) return 0;
if (!taskId || !Array.isArray(lines) || !lines.length) return 0;
const clean = [];
const clean = [];
for (const line of lines) {
for (const line of lines) {
@@ -114,12 +114,12 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -114,12 +114,12 @@ function createSessionLines({ store, config = {}, log = console }) {
const values = [];
const values = [];
const params = [];
const params = [];
chunk.forEach((line, j) => {
chunk.forEach((line, j) => {
- const b = j * 6;
- values.push(`($${b + 1}, $${b + 2}, $${b + 3}::timestamptz, $${b + 4}, $${b + 5}::jsonb, $${b + 6})`);
- params.push(taskId, line.seq, line.at, line.kind, JSON.stringify(line.body), attemptN);
+ const b = j * 5;
+ values.push(`($${b + 1}, $${b + 2}, $${b + 3}::timestamptz, $${b + 4}, $${b + 5}::jsonb)`);
+ params.push(taskId, line.seq, line.at, line.kind, JSON.stringify(line.body));
});
});
inserted += await db.exec(
inserted += await db.exec(
- `INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES ${values.join(', ')}
+ `INSERT INTO codes_session_lines (task_id, seq, at, kind, body) VALUES ${values.join(', ')}
ON CONFLICT (task_id, seq) DO NOTHING`, params);
ON CONFLICT (task_id, seq) DO NOTHING`, params);
}
}
return inserted;
return inserted;
@@ -127,7 +127,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -127,7 +127,7 @@ function createSessionLines({ store, config = {}, log = console }) {
/** All rows of a task in stream order. */
/** All rows of a task in stream order. */
async function allRows(taskId) {
async function allRows(taskId) {
- return await db.many('SELECT seq, at, kind, body, attempt_n FROM codes_session_lines WHERE task_id = $1 ORDER BY seq ASC', [taskId]);
+ return await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq ASC', [taskId]);
}
}
/**
/**
@@ -135,7 +135,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -135,7 +135,7 @@ function createSessionLines({ store, config = {}, log = console }) {
* into the job's JSON lines; state and end frames become their own lines. The stream's seq is the base, so the
* into the job's JSON lines; state and end frames become their own lines. The stream's seq is the base, so the
* same event replayed after a reconnect maps to the same rows.
* same event replayed after a reconnect maps to the same rows.
*/
*/
- function recorder(taskId, { attemptN = null } = {}) {
+ function recorder(taskId) {
let frameBuffer = '';
let frameBuffer = '';
let outputBuffer = '';
let outputBuffer = '';
let fallbackSeq = 0;
let fallbackSeq = 0;
@@ -196,7 +196,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -196,7 +196,7 @@ function createSessionLines({ store, config = {}, log = console }) {
frameBuffer = `${frameBuffer}${String(text == null ? '' : text)}`.replace(/\r\n/g, '\n');
frameBuffer = `${frameBuffer}${String(text == null ? '' : text)}`.replace(/\r\n/g, '\n');
const out = [];
const out = [];
drainFrames(out);
drainFrames(out);
- if (out.length) await append(taskId, out, attemptN);
+ if (out.length) await append(taskId, out);
},
},
async flush() {
async flush() {
const out = [];
const out = [];
@@ -208,7 +208,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -208,7 +208,7 @@ function createSessionLines({ store, config = {}, log = console }) {
out.push({ seq: (lastBase || 0) + PARTIAL_LINE_INDEX, at: store.iso(), kind: line.kind, body: line.body });
out.push({ seq: (lastBase || 0) + PARTIAL_LINE_INDEX, at: store.iso(), kind: line.kind, body: line.body });
outputBuffer = '';
outputBuffer = '';
}
}
- if (out.length) await append(taskId, out, attemptN);
+ if (out.length) await append(taskId, out);
},
},
ended: () => ended,
ended: () => ended,
};
};
@@ -219,7 +219,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -219,7 +219,7 @@ function createSessionLines({ store, config = {}, log = console }) {
* JSON, so a reconnect or a second result never adds a line twice; the new seqs sit after the streamed output and
* JSON, so a reconnect or a second result never adds a line twice; the new seqs sit after the streamed output and
* before the terminal state/end line.
* before the terminal state/end line.
*/
*/
- async function backfill(row, result, attemptN = null) {
+ async function backfill(row, result) {
if (!row || !row.id) return 0;
if (!row || !row.id) return 0;
const rows = await allRows(row.id);
const rows = await allRows(row.id);
const harness = rows.filter((r) => r && r.body && typeof r.body === 'object' && !Array.isArray(r.body)
const harness = rows.filter((r) => r && r.body && typeof r.body === 'object' && !Array.isArray(r.body)
@@ -242,7 +242,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -242,7 +242,7 @@ function createSessionLines({ store, config = {}, log = console }) {
const at = atIso(row.finished_at) || (row.updated_at ? iso(row.updated_at) : store.iso());
const at = atIso(row.finished_at) || (row.updated_at ? iso(row.updated_at) : store.iso());
return await append(row.id, missing.map((body, i) => ({
return await append(row.id, missing.map((body, i) => ({
seq: start + i, at, kind: typeof body.t === 'string' ? body.t : 'text', body,
seq: start + i, at, kind: typeof body.t === 'string' ? body.t : 'text', body,
- })), attemptN);
+ })));
}
}
async function complete(taskId) {
async function complete(taskId) {
@@ -252,8 +252,8 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -252,8 +252,8 @@ function createSessionLines({ store, config = {}, log = console }) {
);
);
}
}
- async function backfillAndComplete(row, result, attemptN = null) {
- const inserted = await backfill(row, result, attemptN);
+ async function backfillAndComplete(row, result) {
+ const inserted = await backfill(row, result);
await complete(row.id);
await complete(row.id);
return inserted;
return inserted;
}
}
@@ -276,11 +276,11 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -276,11 +276,11 @@ function createSessionLines({ store, config = {}, log = console }) {
let source;
let source;
if (rows.length) {
if (rows.length) {
source = 'session';
source = 'session';
- lines = rows.filter((r) => Number(r.seq) > from).map((r) => ({ seq: Number(r.seq), at: r.at, kind: r.kind, body: r.body, attempt_n: r.attempt_n == null ? null : Number(r.attempt_n) }));
+ lines = rows.filter((r) => Number(r.seq) > from).map((r) => ({ seq: Number(r.seq), at: r.at, kind: r.kind, body: r.body }));
} else {
} else {
source = 'result.log';
source = 'result.log';
const at = row.created_at ? iso(row.created_at) : store.iso();
const at = row.created_at ? iso(row.created_at) : store.iso();
- lines = (sessionLogOf(row) || []).map((body, i) => ({ seq: i + 1, at, kind: typeof body.t === 'string' ? body.t : 'text', body, attempt_n: 1 }))
+ lines = (sessionLogOf(row) || []).map((body, i) => ({ seq: i + 1, at, kind: typeof body.t === 'string' ? body.t : 'text', body }))
.filter((line) => line.seq > from);
.filter((line) => line.seq > from);
}
}
const page = lines.slice(0, max);
const page = lines.slice(0, max);
@@ -77,7 +77,7 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountS
@@ -77,7 +77,7 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountS
let job = null;
let job = null;
if (TERMINAL.has(state)) job = await run.read(jobId, tasks.who(row));
if (TERMINAL.has(state)) job = await run.read(jobId, tasks.who(row));
- const applied = await store.tx(async () => {
+ return await store.tx(async () => {
const inserted = await db.maybe(
const inserted = await db.maybe(
`INSERT INTO codes_events_inbox (event_id, event_type, task_id, outcome, processed_at)
`INSERT INTO codes_events_inbox (event_id, event_type, task_id, outcome, processed_at)
VALUES ($1, $2, $3, $4, $5::timestamptz) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
VALUES ($1, $2, $3, $4, $5::timestamptz) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
@@ -90,13 +90,6 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountS
@@ -90,13 +90,6 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountS
await db.exec('UPDATE codes_events_inbox SET outcome = $2 WHERE event_id = $1', [event.event_id, state]);
await db.exec('UPDATE codes_events_inbox SET outcome = $2 WHERE event_id = $1', [event.event_id, state]);
return { duplicate: false, outcome: state, task_id: row.id };
return { duplicate: false, outcome: state, task_id: row.id };
});
});
- // The attempt ended: the engine decides what happens next. It runs outside the transaction, because a
- // follow-up starts a Run job. A failure here is logged, not fatal: the delivery is applied, and the decision
- // is made again by the fallback (the read of Run after 10 quiet minutes).
- if (!applied.duplicate) {
- try { await tasks.afterEnd(applied.task_id); } catch (err) { log.warn(`[Codes] task ${applied.task_id} decision failed: ${err.message}`); }
- }
- return applied;
}
}
const router = express.Router();
const router = express.Router();
@@ -5,8 +5,7 @@
@@ -5,8 +5,7 @@
* GET /run the form, the person's projects and their recent tasks (a sign-in prompt for a guest)
* GET /run the form, the person's projects and their recent tasks (a sign-in prompt for a guest)
* POST /run start one: a run_tasks row and an OpenVibe Run job for the person (server/domain/run-client.js)
* POST /run start one: a run_tasks row and an OpenVibe Run job for the person (server/domain/run-client.js)
* POST /run/project create a project for the runs when the person has none they may run jobs in
* POST /run/project create a project for the runs when the person has none they may run jobs in
- * GET /run/tasks/:id one task: its attempts (the timeline), live while it runs, then the agents' summary,
- * the changed files and the patch
+ * GET /run/tasks/:id one task: live while it runs, then the agents' summary, the changed files and the patch
* GET /run/tasks/:id/events its live lines, proxied from Run's job stream (server-sent events)
* GET /run/tasks/:id/events its live lines, proxied from Run's job stream (server-sent events)
* POST /run/tasks/:id/cancel stop it
* POST /run/tasks/:id/cancel stop it
*
*
@@ -27,9 +26,6 @@ const DELEGABLE = new Set(['owner', 'admin']);
@@ -27,9 +26,6 @@ const DELEGABLE = new Set(['owner', 'admin']);
const MACHINES_URL = 'https://openvibe.bot/machines';
const MACHINES_URL = 'https://openvibe.bot/machines';
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-/** The task's outcome in the words a person reads. */
-const OUTCOME_TEXT = { done: 'Done', unfinished: "Didn't finish", stopped: 'Stopped by you' };
-
/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */
/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */
function repoName(url) {
function repoName(url) {
const [host, ...path] = String(url).replace(/^https:\/\//, '').split('/');
const [host, ...path] = String(url).replace(/^https:\/\//, '').split('/');
@@ -186,24 +182,6 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
@@ -186,24 +182,6 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
</section>`;
</section>`;
}
}
- /** The task's timeline: every attempt and why it happened. The data-* hooks are for the design of stage C4. */
- async function timelineHtml(row) {
- const attempts = await taskStore.attempts.list(row.id);
- if (!attempts.length) return '';
- const outcome = row.outcome
- ? html`<p class="run-outcome" data-outcome="${row.outcome}"><b>${OUTCOME_TEXT[row.outcome] || row.outcome}</b>${row.outcome_note ? html` ${row.outcome_note}` : ''}</p>`
- : '';
- return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}">
-<h2>The attempts</h2>
-<ol class="run-attempts">${attempts.map((a) => html`<li class="run-attempt" data-attempt="${a.n}" data-attempt-id="${a.id}" data-state="${a.state}" data-reason="${a.reason || ''}" data-kind="${a.kind || 'first'}" data-agent="${a.agent || ''}">
-<p class="run-attempt-head"><b>Attempt ${a.n}</b>${a.agent ? html` · ${a.agent}` : ''} <span class="run-pill run-pill-${a.state}">${a.state}</span></p>
-<p class="run-attempt-why" data-why>${a.why || ''}</p>
-${a.reason ? html`<p class="run-attempt-end" data-end-why>${taskStore.attempts.wordsOf(a.reason)}</p>` : ''}
-</li>`)}</ol>
-${outcome}
-</section>`;
- }
-
r.get('/run/tasks/:id', async (req, res) => {
r.get('/run/tasks/:id', async (req, res) => {
if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
let row = await own(req, req.params.id);
let row = await own(req, req.params.id);
@@ -219,7 +197,6 @@ ${outcome}
@@ -219,7 +197,6 @@ ${outcome}
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
-${await timelineHtml(row)}
${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
@@ -265,9 +242,7 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
@@ -265,9 +242,7 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
if (!resp.ok || !resp.body) return res.status(resp.status === 404 ? 404 : 503).end();
if (!resp.ok || !resp.body) return res.status(resp.status === 404 ? 404 : 503).end();
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no', Connection: 'keep-alive' });
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no', Connection: 'keep-alive' });
// The bytes go to the browser unchanged, and the same frames become codes_session_lines as they stream.
// The bytes go to the browser unchanged, and the same frames become codes_session_lines as they stream.
- const attempts = await taskStore.attempts.list(row.id);
- const attemptN = attempts.length ? Number(attempts[attempts.length - 1].n) : null;
- const recorder = sessionStore.recorder(row.id, { attemptN });
+ const recorder = sessionStore.recorder(row.id);
const decoder = new TextDecoder();
const decoder = new TextDecoder();
try {
try {
for await (const chunk of resp.body) {
for await (const chunk of resp.body) {
@@ -311,8 +286,13 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
@@ -311,8 +286,13 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
const row = await own(req, req.params.id);
const row = await own(req, req.params.id);
if (!row) return res.status(404).type('text/plain').send('No such task of yours.\n');
if (!row) return res.status(404).type('text/plain').send('No such task of yours.\n');
- // The running attempt stops: the outcome is "stopped" and nothing carries it on.
- try { await taskStore.stopTask(row); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }
+ if (row.job_id && !TERMINAL.has(row.state)) {
+ try { await run.cancel(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }
+ try {
+ const fresh = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [row.id]);
+ if (fresh) await taskStore.refresh(fresh, { force: true });
+ } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }
+ }
return res.redirect(303, `/run/tasks/${row.id}`);
return res.redirect(303, `/run/tasks/${row.id}`);
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,151 +0,0 @@
@@ -1,151 +0,0 @@
-'use strict';
-
-/**
- * A task's event stream (GET /api/v1/tasks/:id/events): the session's lines in the order they happened, the attempt
- * each line belongs to, and the attempts themselves (started, ended with why). The stream is resumable: a client
- * that reconnects sends the last line's seq as Last-Event-ID and gets only what came after it. A heartbeat every 15
- * seconds keeps the connection alive through a proxy that would otherwise drop it.
- *
- * createTaskEvents({ store, config, log, attempts })
- * stream(req, res, row) the stream of one task
- *
- * The lines come from codes_session_lines (the session the machine streamed, back-filled from its final result) and
- * the attempts from codes_attempts. Nothing here writes anything: the stream only reads.
- */
-
-const { TERMINAL } = require('../domain/run-tasks');
-
-const POLL_MS = 1000;
-const PAGE = 500;
-const ENDED = ['done', 'failed', 'stopped', 'budget-stopped', 'interrupted'];
-
-function createTaskEvents({ store, config = {}, log = console, attempts = null }) {
- const db = store.db;
- const heartbeatMs = Math.max(1000, Number(config.sse && config.sse.heartbeatMs) || 15_000);
- const attemptStore = attempts || require('../domain/attempts').createAttempts({ store, config, log });
-
- /** One SSE frame. */
- const frame = (event, data, id = null) => (id == null ? '' : `id: ${id}\n`) + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
-
- const started = (a) => ({
- type: 'attempt_started', attempt_n: Number(a.n), id: a.id, agent: a.agent || null, model: a.model || null,
- why: a.why || null, state: 'queued', at: a.created_at ? new Date(a.created_at).toISOString() : null,
- });
- const ended = (a) => ({
- type: 'attempt_ended', attempt_n: Number(a.n), id: a.id, agent: a.agent || null, model: a.model || null,
- why: a.why || null, state: a.state, reason: a.reason || null,
- end_why: a.reason ? attemptStore.wordsOf(a.reason) : null,
- cost_usd: a.cost_usd == null ? null : Number(a.cost_usd),
- tokens: a.tokens == null ? null : Number(a.tokens),
- steps: a.steps == null ? null : Number(a.steps),
- at: a.finished_at ? new Date(a.finished_at).toISOString() : null,
- });
-
- /** The stream of one task: its lines, its attempts, a heartbeat, and an end when the task has ended. */
- async function stream(req, res, row) {
- res.writeHead(200, {
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-store',
- 'X-Accel-Buffering': 'no',
- Connection: 'keep-alive',
- });
- if (res.flushHeaders) res.flushHeaders();
-
- const asked = String(req.get('last-event-id') || (req.query && req.query.last_event_id) || '0').trim();
- let lastSeq = /^\d+$/.test(asked) ? Number(asked) : 0;
- let closed = false;
- let busy = false;
- let terminalAt = 0;
-
- const write = (text) => {
- if (closed || !text) return;
- res.write(text);
- };
-
- // The attempts a client has not seen: from the attempt its last line belongs to.
- const anchor = lastSeq
- ? await db.maybe('SELECT attempt_n FROM codes_session_lines WHERE task_id = $1 AND seq = $2', [row.id, lastSeq])
- : null;
- const fromN = anchor && anchor.attempt_n ? Number(anchor.attempt_n) : 1;
- const known = await attemptStore.list(row.id);
- const sent = new Map(known.map((a) => [a.id, a.state]));
- let out = '';
- for (const a of known) {
- if (Number(a.n) < fromN) continue;
- out += frame('attempt', started(a));
- if (ENDED.includes(a.state)) out += frame('attempt', ended(a));
- }
- write(out);
-
- const sendLines = async () => {
- const lines = await db.many(
- 'SELECT seq, at, kind, body, attempt_n FROM codes_session_lines WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC LIMIT $3',
- [row.id, lastSeq, PAGE],
- );
- for (const line of lines) {
- write(frame('line', {
- seq: Number(line.seq),
- at: line.at ? new Date(line.at).toISOString() : null,
- kind: line.kind,
- attempt_n: line.attempt_n == null ? null : Number(line.attempt_n),
- body: line.body,
- }, Number(line.seq)));
- lastSeq = Math.max(lastSeq, Number(line.seq));
- }
- return lines.length;
- };
- await sendLines();
-
- const tick = async () => {
- if (closed || busy) return;
- busy = true;
- try {
- const sentLines = await sendLines();
- const fresh = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [row.id]);
- const rows = await attemptStore.list(row.id);
- let updates = '';
- for (const a of rows) {
- const was = sent.get(a.id);
- if (was === a.state) continue;
- sent.set(a.id, a.state);
- if (was === undefined) updates += frame('attempt', started(a));
- if (ENDED.includes(a.state)) updates += frame('attempt', ended(a));
- }
- write(updates);
- if (fresh && TERMINAL.has(fresh.state) && !rows.some((a) => ['queued', 'running'].includes(a.state))) {
- if (!terminalAt) terminalAt = Date.now();
- // The task has ended and its outcome is known: the stream is done. A task whose decision is still
- // being made (no outcome yet) keeps the stream a little longer, so a follow-up is not missed.
- if (fresh.outcome || Date.now() - terminalAt > 10_000) {
- closed = true;
- clearInterval(poll);
- clearInterval(beat);
- res.end();
- }
- } else {
- terminalAt = 0;
- }
- } catch (e) {
- log.warn(`[Codes] task ${row.id} events: ${e.message}`);
- } finally {
- busy = false;
- }
- };
-
- const poll = setInterval(tick, POLL_MS);
- const beat = setInterval(() => write(': heartbeat\n\n'), heartbeatMs);
- const stop = () => {
- if (closed) return;
- closed = true;
- clearInterval(poll);
- clearInterval(beat);
- };
- req.on('close', stop);
- res.on('close', stop);
- return undefined;
- }
-
- return { stream, heartbeatMs };
-}
-
-module.exports = { createTaskEvents };
@@ -7,29 +7,19 @@
@@ -7,29 +7,19 @@
* 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/:id codes.task.read → codes.task@2 (the task as codes.task@1, with its attempts and
- * its outcome added to it)
+ * GET /tasks/:id codes.task.read → codes.task@1
* POST /tasks/:id/cancel codes.task.cancel → codes.task@1
* POST /tasks/:id/cancel codes.task.cancel → codes.task@1
- * GET /tasks/:id/events codes.task.read → the task's event stream (server-sent events)
- * POST /tasks/:id/continue codes.task.start → the next attempt now (a switch when an agent is named)
- * POST /tasks/:id/stop codes.task.cancel → the running attempt stops; nothing carries it on
*
*
* 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
- * 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.
- *
- * 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
- * what is left, or "stopped"). GET /tasks/:id/events replays the session's lines in order — resumable with
- * Last-Event-ID, a heartbeat every 15 s — with the attempt each line belongs to.
+ * capability. A service sees only the tasks it started, for that person; anything else is 404. 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.
*/
*/
const express = require('express');
const express = require('express');
const { serviceAuth, capabilities, http, validate } = require('openvibe-contracts');
const { serviceAuth, capabilities, http, validate } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { parseArgs } = require('../../harness/job');
const { parseArgs } = require('../../harness/job');
const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
-const { createTaskEvents } = require('./task-events');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -38,7 +28,6 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -38,7 +28,6 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
- const taskEvents = createTaskEvents({ store, config, log, attempts: taskStore.attempts });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const json = express.json({ limit: '32kb' });
const json = express.json({ limit: '32kb' });
@@ -64,32 +53,10 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -64,32 +53,10 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
/** The task this service started for this person, or null. */
/** The task this service started for this person, or null. */
const mine = async (req) => {
const mine = async (req) => {
if (!TASK.test(String(req.params.id))) return null;
if (!TASK.test(String(req.params.id))) return null;
- 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 = $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. */
- const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
- const guardAny = (cap) => async (req, res, next) => {
- if (String(req.headers.authorization || '').startsWith('Bearer ')) return guard(cap)(req, res, next);
- if (signedIn(req)) {
- req.principal = { sub: `user:${req.viewer.subject}`, subject: req.viewer.subject, via: null, owner: true };
- return next();
- }
- return problem(req, res, 401, 'token.required', `${cap} needs a Network service token for ${config.serviceAudience}, or a signed-in owner`);
- };
-
- /** The task, its attempts and its outcome in plain words. The task's own fields stay at the top level. */
- const timeline = async (row) => {
- const attempts = await taskStore.attempts.list(row.id);
- return {
- ...taskStore.present(row),
- attempts: attempts.map((a) => taskStore.attempts.present(a)),
- outcome: { state: row.outcome || null, note: row.outcome_note || null },
- };
- };
-
r.post('/tasks', guard('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
r.post('/tasks', guard('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
@@ -135,41 +102,10 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -135,41 +102,10 @@ 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 });
});
});
- 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');
- return res.json(await timeline(await taskStore.refresh(row)));
- });
-
- /** The task's event stream: the session's lines in order, resumable with Last-Event-ID, plus the attempts. */
- r.get('/tasks/:id/events', 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');
- return await taskEvents.stream(req, res, row);
- });
-
- /** The next attempt now: the owner carries the task on, or hands it to another agent. */
- r.post('/tasks/:id/continue', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, 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');
- const b = req.body || {};
- const note = typeof b.note === 'string' ? b.note.slice(0, 4000) : null;
- const agent = typeof b.agent === 'string' && b.agent ? b.agent : null;
- if (agent && !harnesses.list().some((a) => a.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses');
- const out = await taskStore.continueTask(row, { note, agent });
- if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);
- log.info(`[Codes] task ${row.id} carried on (${agent ? `with ${agent}` : 'by the owner'})`);
- return res.json(await timeline(out.row));
- });
-
- /** The running attempt stops: the outcome is "stopped" and nothing carries it on. */
- r.post('/tasks/:id/stop', guardAny('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {
+ r.get('/tasks/:id', guard('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');
- const out = await taskStore.stopTask(row);
- if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);
- log.info(`[Codes] task ${row.id} stopped by ${req.principal.via || req.principal.subject}`);
- return res.json(await timeline(out.row));
+ return res.json(taskStore.present(await taskStore.refresh(row)));
});
});
r.post('/tasks/:id/cancel', guard('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {
r.post('/tasks/:id/cancel', guard('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {
deleted file mode 100644
deleted file mode 100644
@@ -1,288 +0,0 @@
@@ -1,288 +0,0 @@
-'use strict';
-
-/**
- * Stage C2: a task's attempts. Every Run job is one attempt; when an attempt ends the engine decides what happens
- * next — done, a continue in the same conversation, a switch to another agent, a wrap-up after a limit, or an
- * unfinished task — and one ending starts at most one follow-up (the portal's 10-30 bug, where two racing ticks
- * carried one job on twice).
- *
- * The migration (an existing task becomes attempt 1), every engine decision, the fence under two concurrent endings,
- * the stop fence, and the auth of every new route.
- */
-const assert = require('assert');
-const { boot, check, done } = require('./helpers/boot');
-
-const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-(async () => {
- const calls = [];
- const jobs = new Map();
- let counter = 0;
- const run = {
- enabled: true,
- async submit(o) {
- counter++;
- const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`;
- jobs.set(id, { id, state: 'queued', args: o.args });
- calls.push(['submit', o]);
- return { id, state: 'queued' };
- },
- async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },
- async cancel(jobId, who) { calls.push(['cancel', jobId, who]); const j = jobs.get(jobId); if (j) j.state = 'cancelled'; return j; },
- async streamUrl() { throw new Error('not used'); },
- };
- const t = await boot({ run, env: { CODES_TASK_STEPS: '2' } });
- const db = t.ctx.store.db;
- const ada = t.network.addUser('ada');
- const bo = t.network.addUser('bo');
- const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];
- const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);
- const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
-
- const call = async (method, path, { token = actor, subject = ada.subject, body, headers = {} } = {}) => {
- const h = { Accept: 'application/json', ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };
- if (body !== undefined) h['Content-Type'] = 'application/json';
- const res = await fetch(`${t.base}/api/v1${path}`, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });
- return { status: res.status, json: await res.json().catch(() => null) };
- };
-
- /** A task for Ada, started through the API (so it has its first attempt). */
- const startTask = async (prompt = 'Fix the parser and add a test for it.') => {
- const r = await call('POST', '/tasks', { body: { repo: 'https://github.com/octocat/Hello-World', prompt, idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}` } });
- assert.strictEqual(r.status, 201, JSON.stringify(r.json));
- return r.json.task.id;
- };
-
- /** The attempt ends: the state change and then the engine's decision (what the events consumer does). */
- const endAttempt = async (taskId, { state = 'failed', result = null, error = null } = {}) => {
- const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
- const job = { id: row.job_id, state, result, error: error ? { code: 'run.job.worker_failed', detail: error } : null };
- const updated = await t.ctx.tasks.applyEvent(row, { state, job, error });
- await t.ctx.tasks.afterEnd(updated.id);
- return await rowOf(updated.id);
- };
-
- const attemptsOf = async (taskId) => await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [taskId]);
- const rowOf = async (taskId) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
-
- try {
- await check('the migration: every task that exists becomes attempt 1 of itself', async () => {
- // A task from before the attempts (written straight into the table, as an old row would be).
- const old = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T9';
- await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, agent, state, created_at, updated_at)
- VALUES ($1, $2, $3, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J99', 'https://github.com/a/b', 'by hand', 'claude-code', 'succeeded', 1, 2)`, [old, ada.subject, project]);
- const read = await t.ctx.attempts.list(old);
- assert.strictEqual(read.length, 1, 'one attempt');
- assert.strictEqual(Number(read[0].n), 1);
- assert.strictEqual(read[0].state, 'done', 'the task succeeded, so its first attempt did');
- assert.ok(read[0].why, 'the attempt says why it happened');
- const shown = await t.get(`/api/v1/tasks/${old}`, { as: ada });
- assert.strictEqual(shown.status, 200, shown.text.slice(0, 200));
- assert.strictEqual(shown.json().attempts.length, 1);
- assert.strictEqual(shown.json().attempts[0].n, 1);
- });
-
- await check('a task starts as one attempt, and the timeline says why', async () => {
- const id = await startTask();
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts.length, 1);
- assert.strictEqual(Number(attempts[0].n), 1);
- assert.strictEqual(attempts[0].state, 'queued');
- assert.strictEqual(attempts[0].kind, 'first');
- assert.ok(attempts[0].job_id, 'the attempt carries the Run job');
- const read = await call('GET', `/tasks/${id}`);
- assert.strictEqual(read.json.id, id, 'the task at the top level, as codes.task@1 had it');
- assert.strictEqual(read.json.state, 'queued');
- assert.ok(read.json.url, 'the task carries its own fields');
- assert.strictEqual(read.json.attempts.length, 1);
- assert.strictEqual(read.json.outcome.state, null, 'a running task has no outcome yet');
- });
-
- await check('a finished attempt: the task is done, and nothing follows it', async () => {
- const id = await startTask();
- const updated = await endAttempt(id, { state: 'succeeded', result: { ok: true, summary: 'all done', files: [], log: [{ t: 'done', ok: true, text: 'all done' }] } });
- assert.strictEqual(updated.outcome, 'done');
- assert.strictEqual((await attemptsOf(id)).length, 1, 'no follow-up');
- const read = await call('GET', `/tasks/${id}`);
- assert.strictEqual(read.json.outcome.state, 'done');
- assert.strictEqual(read.json.attempts[0].state, 'done');
- });
-
- await check('a crash: the same conversation continues, with the session to resume', async () => {
- const id = await startTask();
- const result = {
- ok: false, summary: 'I will continue with the rest of it', files: [],
- log: [{ t: 'agent', agent: 'claude-code', session: 'sess-42' }, { t: 'done', ok: false, text: 'I will continue with the rest of it' }],
- };
- await endAttempt(id, { state: 'failed', result, error: 'the machine went away' });
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts.length, 2, 'the task was carried on');
- assert.strictEqual(attempts[1].kind, 'continue');
- assert.strictEqual(attempts[1].session, 'sess-42', 'the session to resume');
- assert.ok(/^carried on in the same conversation: /.test(attempts[1].why), attempts[1].why);
- // The new attempt is a Run job of its own, with RESUME set to the session.
- const submits = calls.filter((c) => c[0] === 'submit');
- const last = submits[submits.length - 1][1];
- assert.strictEqual(last.args.resume, 'sess-42');
- assert.ok(last.args.prompt.includes('Continuation'), 'the prompt tells the agent to carry on');
- assert.notStrictEqual(last.key, id, 'the follow-up has its own idempotency key');
- });
-
- await check('a limit: another agent takes over, with a hand-over note of what the last one did', async () => {
- const id = await startTask();
- const result = { ok: false, summary: 'I fixed the parser', files: [{ path: 'src/parse.js', added: 3, removed: 1 }], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] };
- await endAttempt(id, { state: 'failed', result, error: 'usage limit reached' });
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts.length, 2);
- assert.strictEqual(attempts[1].kind, 'switch');
- assert.ok(attempts[1].agent && attempts[1].agent !== attempts[0].agent, `${attempts[1].agent} took over from ${attempts[0].agent}`);
- assert.ok(/took over: /.test(attempts[1].why), attempts[1].why);
- const submits = calls.filter((c) => c[0] === 'submit');
- const last = submits[submits.length - 1][1];
- assert.ok(last.args.prompt.includes('Hand-over'), 'the new agent gets a hand-over note');
- assert.ok(last.args.prompt.includes('src/parse.js'), 'the note says what changed');
- assert.ok(last.args.prompt.includes('I fixed the parser'), 'the note says what it said');
- });
-
- await check('a limit of the owner: one last turn to land the work', async () => {
- const id = await startTask();
- const result = {
- ok: false, summary: 'half done', files: [],
- log: [{ t: 'tool', id: 't1', name: 'Edit' }, { t: 'tool', id: 't2', name: 'Edit' }, { t: 'done', ok: false, text: 'maximum turns' }],
- };
- await endAttempt(id, { state: 'failed', result, error: 'the agent stopped' });
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts.length, 2);
- assert.strictEqual(attempts[0].state, 'budget-stopped', 'a limit is not a failure');
- assert.strictEqual(attempts[0].reason, 'budget');
- assert.strictEqual(attempts[1].kind, 'wrap-up');
- assert.ok(/one last turn to land the work/.test(attempts[1].why), attempts[1].why);
- const submits = calls.filter((c) => c[0] === 'submit');
- assert.ok(submits[submits.length - 1][1].args.prompt.includes('one short last turn'), 'the wrap-up prompt');
- });
-
- await check('the same failure twice: the task is unfinished, with what is left', async () => {
- const id = await startTask();
- const fail = { ok: false, summary: 'it broke', files: [], log: [{ t: 'done', ok: false, text: 'the tool call failed' }] };
- await endAttempt(id, { state: 'failed', result: fail, error: 'the tool call failed' });
- await endAttempt(id, { state: 'failed', result: fail, error: 'the tool call failed' });
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts.length, 2, 'a second opinion, and no more');
- const row = await rowOf(id);
- assert.strictEqual(row.outcome, 'unfinished');
- assert.ok(row.outcome_note && row.outcome_note.length > 10, 'the outcome says what is left');
- });
-
- await check('one ending starts at most one follow-up, even when two endings race', async () => {
- const id = await startTask();
- const before = calls.filter((c) => c[0] === 'submit').length;
- const row = await rowOf(id);
- const job = { id: row.job_id, state: 'failed', result: { ok: false, summary: 'x', files: [], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] }, error: { code: 'run.job.worker_failed', detail: 'usage limit reached' } };
- // Two ticks see the same ending at the same time (the portal's 10-30 bug).
- await Promise.all([
- t.ctx.tasks.applyEvent(row, { state: 'failed', job, error: 'usage limit reached' }),
- t.ctx.tasks.applyEvent(row, { state: 'failed', job, error: 'usage limit reached' }),
- ]);
- await Promise.all([t.ctx.tasks.afterEnd(id), t.ctx.tasks.afterEnd(id)]);
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts.length, 2, 'one follow-up, not two');
- const submits = calls.filter((c) => c[0] === 'submit').length - before;
- assert.strictEqual(submits, 1, 'one job started, not two');
- });
-
- await check('a stop fences the task: the outcome is stopped and nothing carries it on', async () => {
- const id = await startTask();
- const before = calls.filter((c) => c[0] === 'submit').length;
- const stopped = await call('POST', `/tasks/${id}/stop`);
- assert.strictEqual(stopped.status, 200, JSON.stringify(stopped.json));
- assert.strictEqual(stopped.json.outcome.state, 'stopped');
- assert.ok(calls.some((c) => c[0] === 'cancel'), 'the job was cancelled');
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts[0].state, 'stopped');
- assert.strictEqual(attempts[0].reason, 'stopped');
- // The machine's late report changes nothing.
- await endAttempt(id, { state: 'failed', error: 'the machine went away' });
- assert.strictEqual((await attemptsOf(id)).length, 1, 'nothing carried it on');
- assert.strictEqual(calls.filter((c) => c[0] === 'submit').length - before, 0, 'no job was started');
- assert.strictEqual((await rowOf(id)).outcome, 'stopped');
- });
-
- await check('the owner carries the task on, or hands it to another agent', async () => {
- const id = await startTask();
- await endAttempt(id, { state: 'succeeded', result: { ok: true, summary: 'done', files: [], log: [{ t: 'done', ok: true, text: 'done' }] } });
- const continued = await call('POST', `/tasks/${id}/continue`, { body: { note: 'Only the tests are left.' } });
- assert.strictEqual(continued.status, 200, JSON.stringify(continued.json));
- assert.strictEqual(continued.json.attempts.length, 2);
- assert.strictEqual(continued.json.attempts[1].kind, 'manual');
- assert.ok(/carried on by the owner/.test(continued.json.attempts[1].why), continued.json.attempts[1].why);
- // While the task runs again, nobody carries it on a second time.
- const again = await call('POST', `/tasks/${id}/continue`, { body: { agent: 'codex' } });
- assert.strictEqual(again.status, 409, 'the task is running again');
- });
-
- await check('the auth of every new route: no token, no capability, another person', async () => {
- const id = await startTask();
- const noCap = t.network.mintService('svc:nocap', 'openvibe.codes', ['codes.other']);
- const other = t.network.mintService('svc:other', 'openvibe.codes', CAPS);
- // No token at all.
- for (const [method, path] of [['GET', `/tasks/${id}`], ['GET', `/tasks/${id}/events`], ['POST', `/tasks/${id}/continue`], ['POST', `/tasks/${id}/stop`]]) {
- const res = await call(method, path, { token: null });
- assert.strictEqual(res.status, 401, `${method} ${path} without a token`);
- }
- // A service without the capability.
- assert.strictEqual((await call('GET', `/tasks/${id}`, { token: noCap })).status, 403);
- assert.strictEqual((await call('POST', `/tasks/${id}/continue`, { token: noCap, body: {} })).status, 403);
- // Another person, and another service (which sees only the tasks it started).
- assert.strictEqual((await call('GET', `/tasks/${id}`, { subject: bo.subject })).status, 404);
- assert.strictEqual((await call('GET', `/tasks/${id}`, { token: other })).status, 404);
- assert.strictEqual((await call('POST', `/tasks/${id}/stop`, { subject: bo.subject })).status, 404);
- // A task that does not exist.
- assert.strictEqual((await call('GET', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1')).status, 404);
- assert.strictEqual((await call('GET', '/tasks/nope')).status, 404);
- });
-
- await check('the task page shows the timeline of attempts, each with its why', async () => {
- const id = await startTask();
- const result = { ok: false, summary: 'x', files: [], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] };
- await endAttempt(id, { state: 'failed', result, error: 'usage limit reached' });
- const page = await t.get(`/run/tasks/${id}`, { as: ada });
- assert.strictEqual(page.status, 200, page.text.slice(0, 300));
- assert.ok(page.text.includes('data-timeline'), 'the timeline is there');
- assert.ok(page.text.includes('data-attempt="1"') && page.text.includes('data-attempt="2"'), 'every attempt is a row');
- assert.ok(page.text.includes('data-state="failed"'), 'the attempt says how it ended');
- assert.ok(page.text.includes('data-why'), 'the attempt says why it happened');
- assert.ok(/took over: /.test(page.text), 'the second attempt says who took over');
- assert.ok(page.text.indexOf('data-timeline') < page.text.indexOf('data-session') || !page.text.includes('data-session'),
- 'the timeline is above the session');
- });
-
- await check('a task is carried on twice, and then it needs a person', async () => {
- const id = await startTask();
- const crash = {
- ok: false, summary: 'I will continue with the rest of it', files: [],
- log: [{ t: 'agent', agent: 'claude-code', session: 'sess-1' }, { t: 'done', ok: false, text: 'I will continue with the rest of it' }],
- };
- await endAttempt(id, { state: 'failed', result: crash, error: 'the machine went away' });
- await endAttempt(id, { state: 'failed', result: crash, error: 'the machine went away' });
- const attempts = await attemptsOf(id);
- assert.strictEqual(attempts.filter((a) => a.kind === 'continue').length, 2, 'two automatic continues');
- await endAttempt(id, { state: 'failed', result: crash, error: 'the machine went away' });
- const row = await rowOf(id);
- assert.strictEqual(row.outcome, 'unfinished', 'the third one needs a person');
- assert.ok(/carried on 2 times/.test(row.outcome_note), row.outcome_note);
- assert.strictEqual((await attemptsOf(id)).length, 3, 'no third continue');
- });
-
- await check('the owner needs no token: their own task reads, continues and stops', async () => {
- const id = await startTask();
- const mine = await t.get(`/api/v1/tasks/${id}`, { as: ada });
- assert.strictEqual(mine.status, 200, mine.text.slice(0, 200));
- assert.strictEqual(mine.json().attempts.length, 1);
- const theirs = await t.get(`/api/v1/tasks/${id}`, { as: bo });
- assert.strictEqual(theirs.status, 404);
- });
- } finally {
- await t.close();
- }
- done();
-})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,353 @@
@@ -0,0 +1,353 @@
+'use strict';
+/**
+ * The adapters (C5): the argv and the environment each harness is started with, the feed each prints read into the one
+ * transcript shape, the resume, the wrap-up after a budget stop, the effort fitted to the harness, and the secrets that
+ * never reach the stream.
+ *
+ * The argv and the environment are the portal's (cc-run.sh, cx-run.sh, cm-run.sh, mm-run.sh, oc-run.sh), and the
+ * DRY_RUN form of each is what a test can read without running a CLI.
+ */
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { check, done } = require('./helpers/boot');
+const fake = require('./helpers/fakesecrets');
+const { adapterFor, ids } = require('../engine/adapters');
+const { effortFor } = require('../engine/adapters/base');
+const { claudeCode, claudeOther } = require('../engine/adapters/claude-code');
+const { codex } = require('../engine/adapters/codex');
+const { commandCode } = require('../engine/adapters/command-code');
+const { opencode } = require('../engine/adapters/opencode');
+const { minimaxCode } = require('../engine/adapters/minimax-code');
+const transcript = require('../engine/transcript');
+const { runAttempt } = require('../engine/jobrunner');
+const { assemble } = require('../engine/prompt');
+
+/** The DRY_RUN form of an adapter: the command, its arguments and its environment. */
+function dry(adapter, ctx = {}) {
+ const built = adapter.build({ prompt: 'do the thing', cwd: '/tmp/work', ...ctx });
+ return { cmd: built.cmd, argv: built.argv, env: built.env, stdin: built.stdin };
+}
+
+(async () => {
+ await check('every harness Codes can run is an adapter, and each one names the portal script it came from', () => {
+ assert.deepStrictEqual(ids().sort(), ['claude-code', 'claude-other', 'codex', 'command-code', 'minimax-code', 'opencode']);
+ for (const id of ids()) {
+ const a = adapterFor(id);
+ assert.ok(a.portal, `${id} names no portal script`);
+ assert.ok(typeof a.build === 'function' && typeof a.feed === 'function', `${id} is not a full adapter`);
+ }
+ assert.equal(adapterFor('nope'), null);
+ });
+
+ await check('Claude Code: the portal\'s argv and the cm-style environment', () => {
+ const d = dry(claudeCode, { model: 'M', effort: 'high', permission: 'edit', maxTurns: 200 });
+ assert.equal(d.cmd, 'claude');
+ assert.ok(d.argv.includes('-p'));
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--output-format'), d.argv.indexOf('--output-format') + 2), ['--output-format', 'stream-json']);
+ assert.ok(d.argv.includes('--verbose'));
+ assert.ok(d.argv.includes('--include-partial-messages'));
+ assert.ok(d.argv.includes('--exclude-dynamic-system-prompt-sections'));
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--max-turns'), d.argv.indexOf('--max-turns') + 2), ['--max-turns', '200']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--permission-mode'), d.argv.indexOf('--permission-mode') + 2), ['--permission-mode', 'acceptEdits']);
+ assert.equal(d.env.CLAUDE_CODE_EFFORT_LEVEL, 'high');
+ assert.equal(d.env.ANTHROPIC_MODEL, 'M');
+ // The brief is the user turn: it goes in on stdin.
+ assert.equal(d.stdin, 'do the thing');
+ });
+
+ await check('Claude Code on another provider: the cm-style environment', () => {
+ const d = dry(claudeOther, {
+ model: 'MiniMax-M3.1-Flash',
+ effort: 'max',
+ provider: { baseUrl: 'https://api.minimax.io/anthropic', authToken: `sk-cp-${'1234567890'}` },
+ });
+ assert.equal(d.env.ANTHROPIC_BASE_URL, 'https://api.minimax.io/anthropic');
+ assert.equal(d.env.ANTHROPIC_AUTH_TOKEN, `sk-cp-${'1234567890'}`);
+ assert.equal(d.env.ANTHROPIC_MODEL, 'MiniMax-M3.1-Flash');
+ assert.equal(d.env.CLAUDE_CODE_EFFORT_LEVEL, 'max');
+ });
+
+ await check('Codex: the portal\'s argv, the sandbox and the prompt on stdin', () => {
+ const d = dry(codex, { model: 'gpt-5-codex', effort: 'medium', permission: 'edit', cwd: '/tmp/work', extraDir: '/tmp/notes' });
+ assert.equal(d.cmd, 'codex');
+ assert.ok(d.argv.includes('exec'));
+ assert.ok(d.argv.includes('--json'));
+ assert.ok(d.argv.includes('--skip-git-repo-check'));
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('-C'), d.argv.indexOf('-C') + 2), ['-C', '/tmp/work']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('-s'), d.argv.indexOf('-s') + 2), ['-s', 'workspace-write']);
+ assert.ok(d.argv.includes('-c') && d.argv.includes('model_reasoning_effort=medium'));
+ assert.equal(d.argv[d.argv.length - 1], '-', 'the prompt comes on stdin');
+ assert.equal(d.stdin, 'do the thing');
+ // A read-only job stays in Codex's own sandbox.
+ assert.ok(dry(codex, { permission: 'read' }).argv.includes('read-only'));
+ assert.ok(dry(codex, { permission: 'full' }).argv.includes('danger-full-access'));
+ });
+
+ await check('Codex on a resume: the same session, its own flag set', () => {
+ const d = dry(codex, { resume: 'thread-7', effort: 'medium', permission: 'edit', cwd: '/tmp/work' });
+ assert.deepStrictEqual(d.argv.slice(0, 3), ['exec', 'resume', 'thread-7']);
+ assert.ok(d.argv.includes('--all'));
+ assert.ok(!d.argv.includes('-C'), 'a resume takes no -C');
+ assert.ok(d.argv.some((a) => a.startsWith('sandbox_mode=')), 'the sandbox goes in as an override');
+ assert.ok(d.argv.some((a) => a.startsWith('sandbox_workspace_write.writable_roots=')), 'the writable roots go in as an override');
+ });
+
+ await check('Command Code: the portal\'s argv', () => {
+ const d = dry(commandCode, { model: 'cm-model', effort: 'high', permission: 'full', maxTurns: 150 });
+ assert.equal(d.cmd, 'cmd');
+ assert.ok(d.argv.includes('--print=do the thing'));
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('-m'), d.argv.indexOf('-m') + 2), ['-m', 'cm-model']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--effort'), d.argv.indexOf('--effort') + 2), ['--effort', 'high']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--output-format'), d.argv.indexOf('--output-format') + 2), ['--output-format', 'json']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--max-turns'), d.argv.indexOf('--max-turns') + 2), ['--max-turns', '150']);
+ assert.ok(d.argv.includes('--config') && d.argv.includes('compact-mode=fast'));
+ assert.ok(d.argv.includes('--skip-onboarding') && d.argv.includes('--no-auto-update') && d.argv.includes('-t'));
+ assert.ok(d.argv.includes('--yolo'), 'the full permission runs unattended');
+ assert.ok(!dry(commandCode, { permission: 'edit' }).argv.includes('--yolo'));
+ });
+
+ await check('Command Code: the permission rules go in the project, never on the command line', () => {
+ const settings = JSON.parse(commandCode.settings({ permission: 'edit' }));
+ assert.ok(settings.permissions.deny.includes('Shell(git push:*)'));
+ assert.ok(settings.permissions.deny.includes('Read(.env*)'));
+ const read = JSON.parse(commandCode.settings({ permission: 'read' }));
+ assert.ok(read.permissions.deny.includes('Edit(**)'), 'a read-only job may not edit');
+ // The portal's "full" (yolo) list: only git push and the pattern kills stay denied.
+ const full = JSON.parse(commandCode.settings({ permission: 'full' }));
+ assert.deepStrictEqual(full.permissions.deny, ['Shell(git push:*)', 'Shell(pkill:*)', 'Shell(killall:*)']);
+ });
+
+ await check('OpenCode: the portal\'s argv and the effort as the model variant', () => {
+ const d = dry(opencode, { model: 'opencode-go/kimi-k2.7-code', effort: 'high', permission: 'edit', title: 'job-1' });
+ assert.ok(d.argv.includes('run'));
+ assert.ok(d.argv.includes('--standalone'));
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--format'), d.argv.indexOf('--format') + 2), ['--format', 'json']);
+ assert.ok(d.argv.includes('--auto'));
+ assert.ok(d.argv.includes('opencode-go/kimi-k2.7-code#high'), 'the effort is the model variant');
+ assert.ok(d.argv.includes('--title') && d.argv.includes('job-1'));
+ assert.equal(d.argv[d.argv.length - 1], 'do the thing');
+ assert.ok(!dry(opencode, { permission: 'read' }).argv.includes('--auto'), 'a read-only job asks for nothing');
+ assert.equal(opencode.variantFor('m', 'low'), 'm#low');
+ assert.equal(opencode.variantFor('m', 'medium'), 'm', 'medium is the default');
+ assert.equal(opencode.variantFor('m#high', 'low'), 'm#high', 'a model with a variant keeps it');
+ });
+
+ await check('OpenCode: the permission rules go in the project', () => {
+ const settings = JSON.parse(opencode.settings({ permission: 'edit' }));
+ assert.ok(settings.permissions.some((r) => r.resource === 'git push *' && r.effect === 'deny'));
+ assert.ok(settings.permissions.some((r) => r.resource === '.env*'));
+ });
+
+ await check('MiniMax Code: the portal\'s argv', () => {
+ const d = dry(minimaxCode, { model: 'MiniMax-M2', effort: 'high', permission: 'edit', cwd: '/tmp/work', maxSteps: 150, rules: '/tmp/rules.md' });
+ assert.equal(d.cmd, 'mcode');
+ assert.deepStrictEqual(d.argv.slice(0, 3), ['exec', '--input', '-']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--output-format'), d.argv.indexOf('--output-format') + 2), ['--output-format', 'stream-json']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--prompt-mode'), d.argv.indexOf('--prompt-mode') + 2), ['--prompt-mode', 'coding']);
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--permission'), d.argv.indexOf('--permission') + 2), ['--permission', 'smart']);
+ assert.ok(d.argv.includes('--ptc'), 'Programmatic Tool Calling is on by default');
+ assert.ok(d.argv.includes('/tmp/rules.md'));
+ assert.deepStrictEqual(d.argv.slice(d.argv.indexOf('--max-steps'), d.argv.indexOf('--max-steps') + 2), ['--max-steps', '150']);
+ assert.equal(d.stdin, 'do the thing');
+ assert.ok(dry(minimaxCode, { permission: 'full' }).argv.includes('full'));
+ });
+
+ await check('the effort is fitted to the harness, never handed over as it came', () => {
+ // The 2026-10-29 incident: Codex's "medium" killed Command Code before its first step.
+ assert.equal(effortFor('command-code', 'medium'), 'high');
+ assert.equal(effortFor('command-code', 'xhigh'), 'max');
+ assert.equal(effortFor('command-code', 'off'), 'off');
+ assert.equal(effortFor('command-code', 'high'), 'high');
+ assert.equal(effortFor('codex', 'medium'), 'medium');
+ assert.equal(effortFor('codex', 'ultra'), 'ultra');
+ assert.equal(effortFor('claude-code', 'medium'), 'medium');
+ assert.equal(effortFor('opencode', 'medium'), 'medium', 'OpenCode takes no effort setting');
+ assert.equal(effortFor('codex', null), null);
+ // And the adapter's own argv carries the fitted level.
+ assert.ok(dry(commandCode, { effort: 'medium' }).argv.includes('high'));
+ });
+
+ await check('each feed reads its harness into the one transcript shape', () => {
+ // Codex
+ const cx = [];
+ const f1 = codex.feed({ model: 'gpt', onEvent: (e) => cx.push(e) });
+ f1.line(JSON.stringify({ type: 'thread.started', thread_id: 'th-1' }));
+ f1.line(JSON.stringify({ type: 'item.started', item: { id: 'i1', type: 'command_execution', command: 'ls' } }));
+ f1.line(JSON.stringify({ type: 'item.completed', item: { id: 'i1', type: 'command_execution', command: 'ls', aggregated_output: 'a.js', exit_code: 0, status: 'completed' } }));
+ f1.line(JSON.stringify({ type: 'item.completed', item: { id: 'i2', type: 'agent_message', text: 'done' } }));
+ f1.end({ code: 0 });
+ const st1 = transcript.parseText(cx.map((e) => JSON.stringify(e)).join('\n'));
+ assert.equal(st1.session, 'th-1');
+ assert.ok(st1.events.some((e) => e.t === 'tool' && e.name === 'Bash'), 'the tool call is there');
+ assert.ok(st1.events.some((e) => e.t === 'final'), 'the attempt ended');
+ assert.ok(st1.events.some((e) => e.text === 'done'), 'the answer is there');
+
+ // Command Code
+ const cc = [];
+ const f2 = commandCode.feed({ model: 'cm', onEvent: (e) => cc.push(e) });
+ f2.line(JSON.stringify({ type: 'event', event: { type: 'run_start', sessionId: 'cc-9' } }));
+ f2.line(JSON.stringify({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'edit_file', input: { path: 'a.js' } }] } }));
+ f2.line(JSON.stringify({ type: 'event', event: { type: 'tool_completed', toolCallId: 'u1', result: [{ text: 'edited' }] } }));
+ f2.line(JSON.stringify({ type: 'result', subtype: 'success', finalText: 'all good', sessionId: 'cc-9' }));
+ f2.end({ code: 0 });
+ const st2 = transcript.parseText(cc.map((e) => JSON.stringify(e)).join('\n'));
+ assert.equal(st2.session, 'cc-9');
+ assert.ok(st2.events.some((e) => e.t === 'tool' && e.name === 'Edit'), 'Command Code\'s tools get Claude Code\'s names');
+
+ // OpenCode
+ const oc = [];
+ const f3 = opencode.feed({ model: 'oc-model', onEvent: (e) => oc.push(e) });
+ f3.line(JSON.stringify({ type: 'tool_use', sessionID: 'oc-1', part: { id: 'p1', tool: 'read', state: { status: 'completed', input: { path: 'a.js' }, output: 'const a = 1;' } } }));
+ f3.line(JSON.stringify({ type: 'text', sessionID: 'oc-1', part: { messageID: 'm1', text: 'opencode did it' } }));
+ f3.line(JSON.stringify({ type: 'step_finish', sessionID: 'oc-1', part: { tokens: { input: 20, output: 3 } } }));
+ f3.end({ code: 0 });
+ const st3 = transcript.parseText(oc.map((e) => JSON.stringify(e)).join('\n'));
+ assert.equal(st3.session, 'oc-1');
+ assert.ok(st3.events.some((e) => e.t === 'tool' && e.name === 'Read'));
+ assert.ok(st3.events.some((e) => e.t === 'final'), 'the attempt ended');
+ assert.ok(st3.events.some((e) => e.text === 'opencode did it'), 'the answer is there');
+
+ // MiniMax Code
+ const mm = [];
+ const f4 = minimaxCode.feed({ model: 'mm', onEvent: (e) => mm.push(e) });
+ f4.line(JSON.stringify({ schemaVersion: 1, type: 'item.completed', sessionId: 'mm-1', item: { type: 'agent_message', content: 'mini did it' } }));
+ f4.end({ code: 0 });
+ const st4 = transcript.parseText(mm.map((e) => JSON.stringify(e)).join('\n'));
+ assert.ok(st4.events.some((e) => e.t === 'text' && e.text.includes('mini did it')));
+ });
+
+ await check('a secret an agent prints never reaches the stream', () => {
+ for (const [name, adapter, line] of [
+ ['claude-code', claudeCode, JSON.stringify({ type: 'assistant', message: { model: 'm', content: [{ type: 'text', text: `the key is ${fake.providerKey()}` }] } })],
+ ['codex', codex, JSON.stringify({ type: 'item.completed', item: { id: 'i1', type: 'agent_message', text: fake.githubToken() } })],
+ ['command-code', commandCode, JSON.stringify({ type: 'event', event: { type: 'message_end', content: [{ type: 'text', text: fake.apiKey() }] } })],
+ ['opencode', opencode, JSON.stringify({ type: 'text', sessionID: 's', part: { messageID: 'm', text: fake.password() } })],
+ ['minimax-code', minimaxCode, JSON.stringify({ schemaVersion: 1, type: 'item.completed', item: { type: 'agent_message', content: fake.providerKey() } })],
+ ]) {
+ const out = [];
+ const feed = adapter.feed({ model: 'm', onEvent: (e) => out.push(e) });
+ feed.line(line);
+ feed.end({ code: 0 });
+ const text = JSON.stringify(out);
+ assert.ok(!text.includes(fake.providerKey()), `${name}: the provider key survived`);
+ assert.ok(!text.includes(fake.githubToken()), `${name}: the GitHub token survived`);
+ assert.ok(!text.includes(fake.apiKeyValue()), `${name}: the api key survived`);
+ assert.ok(!text.includes(fake.passwordValue()), `${name}: the password survived`);
+ }
+ });
+
+ await check('the prompt is the rules, the house rules, the lessons, the digest and the brief', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-prompt-'));
+ // A job that works in one repository gets that repository's digest; a folder that holds many gets none.
+ require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });
+ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');
+ const lessonsFile = path.join(dir, 'lessons.jsonl');
+ fs.writeFileSync(lessonsFile, JSON.stringify({ text: 'the tests need the database', repo: 'demo', tags: ['tests'] }) + '\n');
+ const built = assemble({ brief: 'fix the test', dir, env: { OPENVIBE_CODES_LESSONS: lessonsFile } });
+ const names = built.parts.map((p) => p.name);
+ assert.deepStrictEqual(names, ['git-rule', 'house-rules', 'lessons', 'repo-digest', 'brief']);
+ assert.ok(built.text.indexOf('Git rules') < built.text.indexOf('House rules'));
+ assert.ok(built.text.indexOf('House rules') < built.text.indexOf('learned'));
+ assert.ok(built.text.indexOf('learned') < built.text.indexOf('demo repository'));
+ assert.ok(built.text.trimEnd().endsWith('fix the test'), 'the brief is last');
+ // A store that cannot be read is skipped, not an error.
+ const none = assemble({ brief: 'x', dir, env: { OPENVIBE_CODES_LESSONS: path.join(dir, 'nope.jsonl') } });
+ assert.ok(!none.parts.some((p) => p.name === 'lessons'));
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('an attempt runs a stand-in harness, writes job lines and keeps its session', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-attempt-'));
+ const bin = path.join(dir, 'bin');
+ fs.mkdirSync(bin);
+ // A stand-in for Command Code: it prints what the real CLI prints.
+ fs.writeFileSync(path.join(bin, 'cmd'), `#!/usr/bin/env node
+const out = (o) => console.log(JSON.stringify(o));
+out({ type: 'event', event: { type: 'run_start', sessionId: 'sess-1' } });
+out({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'read_file', input: { path: 'a.js' } }] } });
+out({ type: 'event', event: { type: 'tool_completed', toolCallId: 'u1', result: [{ text: 'ok' }] } });
+out({ type: 'result', subtype: 'success', finalText: 'the work is done', sessionId: 'sess-1' });
+`);
+ fs.chmodSync(path.join(bin, 'cmd'), 0o755);
+ const lines = [];
+ const res = await runAttempt({
+ agent: 'command-code',
+ prompt: 'do the thing',
+ cwd: dir,
+ log: path.join(dir, 'feed.jsonl'),
+ env: { PATH: `${bin}:${process.env.PATH}` },
+ onLine: (l) => lines.push(l),
+ });
+ assert.equal(res.agent, 'command-code');
+ assert.equal(res.sessionId, 'sess-1');
+ assert.equal(res.summary, 'the work is done');
+ assert.ok(res.ok);
+ assert.ok(lines.some((l) => l.t === 'agent'), 'the run started');
+ assert.ok(lines.some((l) => l.t === 'tool' && l.name === 'Read'), 'the tool call is a line');
+ assert.ok(lines.some((l) => l.t === 'done' && l.ok), 'the attempt ended');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a budget stop buys one wrap-up turn, on the same session', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-wrap-'));
+ const bin = path.join(dir, 'bin');
+ fs.mkdirSync(bin);
+ const log = path.join(dir, 'cmd.log');
+ fs.writeFileSync(path.join(bin, 'cmd'), `#!/usr/bin/env node
+const fs = require('fs');
+const out = (o) => console.log(JSON.stringify(o));
+out({ type: 'event', event: { type: 'run_start', sessionId: 'sess-w' } });
+out({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'read_file', input: { path: 'a.js' } }] } });
+fs.appendFileSync(process.env.WRAP_LOG, 'turn' + String.fromCharCode(10));
+out({ type: 'result', subtype: 'success', finalText: 'landed', sessionId: 'sess-w' });
+`);
+ fs.chmodSync(path.join(bin, 'cmd'), 0o755);
+ const lines = [];
+ const res = await runAttempt({
+ agent: 'command-code',
+ prompt: 'do the thing',
+ cwd: dir,
+ log: path.join(dir, 'feed.jsonl'),
+ budget: { steps: 1 },
+ env: { PATH: `${bin}:${process.env.PATH}`, WRAP_LOG: log },
+ onLine: (l) => lines.push(l),
+ });
+ assert.equal(res.budgetHit, 'steps', JSON.stringify(res));
+ assert.ok(res.wrapUp && /last turn/i.test(res.wrapUp), 'the wrap-up turn was asked for');
+ const turns = fs.readFileSync(log, 'utf8').trim().split('\n').length;
+ assert.equal(turns, 2, 'the wrap-up turn ran');
+ assert.ok(res.wrapUpResult && res.wrapUpResult.sessionId === 'sess-w', 'the wrap-up kept the session');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a resume continues the same session', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-resume-'));
+ const bin = path.join(dir, 'bin');
+ fs.mkdirSync(bin);
+ const seen = path.join(dir, 'argv.log');
+ fs.writeFileSync(path.join(bin, 'cmd'), `#!/usr/bin/env node
+const fs = require('fs');
+fs.writeFileSync(process.env.ARGV_LOG, process.argv.slice(2).join(' '));
+console.log(JSON.stringify({ type: 'event', event: { type: 'run_start', sessionId: 'sess-r' } }));
+console.log(JSON.stringify({ type: 'result', subtype: 'success', finalText: 'again', sessionId: 'sess-r' }));
+`);
+ fs.chmodSync(path.join(bin, 'cmd'), 0o755);
+ const res = await runAttempt({
+ agent: 'command-code',
+ prompt: 'more',
+ cwd: dir,
+ resume: 'sess-old',
+ log: path.join(dir, 'feed.jsonl'),
+ env: { PATH: `${bin}:${process.env.PATH}`, ARGV_LOG: seen },
+ });
+ const argv = fs.readFileSync(seen, 'utf8');
+ assert.ok(argv.includes('--resume sess-old'), `the resume flag is missing: ${argv}`);
+ assert.equal(res.sessionId, 'sess-r');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/** The job lines (1.1): what a task's page shows while it runs (engine/joblines.js). */
+
+const assert = require('node:assert/strict');
+const { check, done } = require('./helpers/boot');
+const { lines, usageLine, clip } = require('../engine/joblines');
+const fake = require('./helpers/fakesecrets');
+
+(async () => {
+ await check('the transcript\'s events become the lines a reader sees', () => {
+ const st = {
+ events: [
+ { t: 'meta', kind: 'start', backend: 'codex', model: 'gpt', session: 'th-1' },
+ { t: 'text', text: 'hello' },
+ { t: 'thinking', text: 'hmm' },
+ { t: 'tool', id: 'u1', name: 'Bash', summary: 'ls', output: { text: 'a.js' } },
+ { t: 'error', text: 'it broke' },
+ { t: 'final', text: 'done' },
+ ],
+ usage: { input: 10, output: 5, cached: 2 },
+ cost: 0.02,
+ };
+ const out = lines(st);
+ assert.deepStrictEqual(out.lines.map((l) => l.t), ['agent', 'text', 'tool', 'tool_result', 'note', 'done']);
+ assert.equal(out.lines[0].agent, 'codex');
+ assert.equal(out.lines[0].session, 'th-1');
+ assert.equal(out.lines[2].name, 'Bash');
+ assert.equal(out.lines[3].text, 'a.js');
+ assert.equal(out.lines[5].ok, true);
+ assert.ok(!out.lines.some((l) => l.t === 'note' && l.level !== 'error'));
+ });
+
+ await check('the lines come once, from where the last read stopped', () => {
+ const st = { events: [{ t: 'text', text: 'a' }, { t: 'text', text: 'b' }] };
+ assert.equal(lines(st, 0).lines.length, 2);
+ assert.equal(lines(st, 1).lines.length, 1);
+ assert.equal(lines(st, 2).lines.length, 0);
+ });
+
+ await check('a secret in an event never reaches a line', () => {
+ const out = lines({ events: [{ t: 'text', text: `token ${fake.githubToken()}` }] });
+ assert.ok(!JSON.stringify(out).includes('ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'));
+ });
+
+ await check('the usage line says what the run spent, or nothing at all', () => {
+ assert.deepStrictEqual(usageLine({ usage: { input: 1, output: 2, cached: 3 }, cost: 0.5 }), { t: 'usage', input: 1, output: 2, cached: 3, cost_usd: 0.5 });
+ assert.equal(usageLine({ usage: {} }), null);
+ });
+
+ await check('a long text is clipped', () => {
+ assert.equal(clip('x'.repeat(3000)).length, 2001);
+ assert.equal(clip('short'), 'short');
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/** One attempt of one harness, the way the portal runs one (engine/jobrunner.js). */
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { check, done } = require('./helpers/boot');
+const { runAttempt } = require('../engine/jobrunner');
+const fakesecrets = require('./helpers/fakesecrets');
+
+/** A stand-in for a CLI: it prints what the real one prints. */
+function fake(dir, name, lines) {
+ const bin = path.join(dir, 'bin');
+ fs.mkdirSync(bin, { recursive: true });
+ const script = ['#!/usr/bin/env node', ...lines.map((l) => `console.log(${JSON.stringify(JSON.stringify(l))});`)].join('\n');
+ fs.writeFileSync(path.join(bin, name), script);
+ fs.chmodSync(path.join(bin, name), 0o755);
+ return bin;
+}
+
+(async () => {
+ await check('an attempt for a harness that does not exist is an error', async () => {
+ await assert.rejects(() => runAttempt({ agent: 'nope', prompt: 'x', cwd: os.tmpdir() }), /no adapter/);
+ });
+
+ await check('an attempt reports the harness, the session and the answer', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jr-'));
+ const bin = fake(dir, 'cmd', [
+ { type: 'event', event: { type: 'run_start', sessionId: 's-1' } },
+ { type: 'result', subtype: 'success', finalText: 'landed', sessionId: 's-1' },
+ ]);
+ const res = await runAttempt({
+ agent: 'command-code', prompt: 'do it', cwd: dir, log: path.join(dir, 'f.jsonl'),
+ env: { PATH: `${bin}:${process.env.PATH}` },
+ });
+ assert.equal(res.agent, 'command-code');
+ assert.equal(res.sessionId, 's-1');
+ assert.equal(res.summary, 'landed');
+ assert.equal(res.ok, true);
+ assert.ok(fs.existsSync(res.log), 'the feed is kept');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a harness that is not there ends the attempt without an answer', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jr2-'));
+ const res = await runAttempt({
+ agent: 'command-code', prompt: 'x', cwd: dir, log: path.join(dir, 'f.jsonl'),
+ env: { PATH: dir }, // no cmd on this PATH
+ });
+ assert.equal(res.ok, false);
+ assert.ok(res.summary.length > 0, 'the end says what happened');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a secret in the harness\'s output never reaches the feed', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jr3-'));
+ const bin = fake(dir, 'cmd', [
+ { type: 'result', subtype: 'success', finalText: `key ${fakesecrets.providerKey()}`, sessionId: 's' },
+ ]);
+ const res = await runAttempt({
+ agent: 'command-code', prompt: 'x', cwd: dir, log: path.join(dir, 'f.jsonl'),
+ env: { PATH: `${bin}:${process.env.PATH}` },
+ });
+ assert.ok(!fs.readFileSync(res.log, 'utf8').includes('abcdefghijklmnopqrstuvwx'), 'the key stayed out of the feed');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+/** What this machine has: each CLI, signed in or not, and its limit windows (engine/logins.js). */
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { check, done } = require('./helpers/boot');
+const { probe, probeOne, windowsOf, signedIn, onPath } = require('../engine/logins');
+
+(async () => {
+ await check('every CLI is reported: installed, signed in, and the windows it can read', () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'home-'));
+ const rows = probe({ home, env: { PATH: '' } });
+ assert.ok(rows.length >= 5, 'every CLI is there');
+ for (const r of rows) {
+ assert.equal(typeof r.installed, 'boolean');
+ assert.equal(typeof r.signed_in, 'boolean');
+ assert.ok(Array.isArray(r.windows));
+ }
+ assert.ok(rows.some((r) => r.id === 'codex'));
+ assert.ok(rows.some((r) => r.id === 'claude-code'));
+ fs.rmSync(home, { recursive: true, force: true });
+ });
+
+ await check('a CLI that is on the PATH is installed, one that is not is not', () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'home2-'));
+ const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'bin-'));
+ fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\n');
+ fs.chmodSync(path.join(bin, 'codex'), 0o755);
+ const row = probeOne('codex', { home, env: { PATH: bin } });
+ assert.equal(row.installed, true);
+ const other = probeOne('opencode', { home, env: { PATH: bin } });
+ assert.equal(other.installed, false);
+ assert.equal(onPath('codex', { PATH: bin }), path.join(bin, 'codex'));
+ fs.rmSync(home, { recursive: true, force: true });
+ fs.rmSync(bin, { recursive: true, force: true });
+ });
+
+ await check('a login file with a session means signed in', () => {
+ assert.equal(signedIn({ access_token: 'x' }), true);
+ assert.equal(signedIn({ tokens: { access_token: 'x' } }), true);
+ assert.equal(signedIn({}), false);
+ assert.equal(signedIn(null), false);
+ });
+
+ await check('the windows are numbers, labels and reset times, never a token', () => {
+ const w = windowsOf({ access_token: 'secret', windows: [{ name: '5h', used: 3, limit: 5, resets_at: '2026-10-29T22:00:00Z' }] });
+ assert.deepStrictEqual(w, [{ name: '5h', used: 3, limit: 5, resets_at: '2026-10-29T22:00:00Z' }]);
+ assert.ok(!JSON.stringify(w).includes('secret'), 'the token is not in the report');
+ assert.deepStrictEqual(windowsOf({ access_token: 'secret' }), []);
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+/** The prompt every job gets (engine/prompt.js). */
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { check, done } = require('./helpers/boot');
+const { assemble, lessons, fragment } = require('../engine/prompt');
+
+(async () => {
+ await check('the rules and the house rules are Codes\' own fragments', () => {
+ assert.ok(fragment('git-rule.md').includes('Git rules'));
+ assert.ok(fragment('house-rules.md').includes('House rules'));
+ assert.equal(fragment('nope.md'), '');
+ });
+
+ await check('the lessons are the ones that fit, at most five', () => {
+ const file = path.join(os.tmpdir(), `lessons-${process.pid}.jsonl`);
+ const rows = [];
+ for (let i = 0; i < 8; i++) rows.push(JSON.stringify({ text: `lesson ${i} about the database`, repo: 'demo', tags: ['database'] }));
+ rows.push(JSON.stringify({ text: 'unrelated note' }));
+ fs.writeFileSync(file, rows.join('\n') + '\n');
+ const got = lessons({ file, repo: 'demo', brief: 'fix the database test' });
+ assert.ok(got.includes('What earlier jobs learned here'));
+ assert.ok(!got.includes('unrelated note'), 'a lesson that does not fit is left out');
+ assert.ok((got.match(/^- /gm) || []).length <= 5);
+ fs.rmSync(file, { force: true });
+ });
+
+ await check('a store that cannot be reached gives no lessons, not an error', () => {
+ assert.equal(lessons({ file: '/nope/lessons.jsonl', brief: 'x' }), '');
+ assert.equal(lessons({ brief: 'x' }), '');
+ });
+
+ await check('the prompt is the rules, the house rules, the lessons, the digest, the brief', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-'));
+ require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });
+ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');
+ const built = assemble({ brief: 'the brief', dir });
+ assert.deepStrictEqual(built.parts.map((p) => p.name), ['git-rule', 'house-rules', 'repo-digest', 'brief']);
+ assert.ok(built.text.trimEnd().endsWith('the brief'), 'the brief is last');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a folder that holds many repositories gets no digest', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'many-'));
+ for (const name of ['one', 'two']) {
+ const sub = path.join(dir, name);
+ fs.mkdirSync(sub);
+ require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: sub });
+ }
+ const built = assemble({ brief: 'x', dir });
+ assert.ok(!built.parts.some((p) => p.name === 'repo-digest'), 'no digest for a workspace');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+/** The secrets that never reach an event stream (engine/redact.js). */
+
+const assert = require('node:assert/strict');
+const { check, done } = require('./helpers/boot');
+const { scrub, scrubDeep } = require('../engine/redact');
+const fake = require('./helpers/fakesecrets');
+
+(async () => {
+ await check('a provider key, a GitHub token and a password are replaced', () => {
+ assert.ok(!scrub(`the key is ${fake.providerKey()}`).includes('abcdefghijklmnopqrstuvwx'));
+ assert.ok(!scrub(fake.githubToken()).includes('ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'));
+ assert.ok(!scrub(fake.apiKey()).includes(fake.apiKeyValue()));
+ assert.ok(!scrub(fake.password()).includes(fake.passwordValue()));
+ assert.ok(scrub('nothing secret here').includes('nothing secret'));
+ });
+
+ await check('a value that is not a secret is left alone', () => {
+ assert.equal(scrub('the file is a.js and the command is npm test'), 'the file is a.js and the command is npm test');
+ });
+
+ await check('a whole event is scrubbed, and its shape is kept', () => {
+ const out = scrubDeep({ type: 'assistant', message: { content: [{ type: 'text', text: `key ${fake.providerKey()}` }] }, n: 3 });
+ assert.equal(out.type, 'assistant');
+ assert.equal(out.n, 3);
+ assert.ok(!JSON.stringify(out).includes('abcdefghijklmnopqrstuvwx'));
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/** The repo digest a job gets instead of a directory tree (engine/repomap.js). */
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { execFileSync } = require('node:child_process');
+const { check, done } = require('./helpers/boot');
+const { repomap, digest, repoKey, isRepo } = require('../engine/repomap');
+
+(async () => {
+ await check('a repository gets its digest: what it is, how it is laid out, its commands', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-'));
+ execFileSync('git', ['init', '-q'], { cwd: dir });
+ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'demo', description: 'a demo', scripts: { test: 'node test/run.js' } }));
+ fs.mkdirSync(path.join(dir, 'src'));
+ const text = digest(dir);
+ assert.ok(text.includes(`# ${path.basename(dir)}`));
+ assert.ok(text.includes('a demo'));
+ assert.ok(text.includes('src/'));
+ assert.ok(text.includes('test: node test/run.js'));
+ assert.ok(isRepo(dir));
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a job in a repository gets that digest', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo2-'));
+ execFileSync('git', ['init', '-q'], { cwd: dir });
+ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n');
+ const map = repomap(dir);
+ assert.equal(map.kind, 'digest');
+ assert.ok(map.text.includes('Demo'));
+ assert.ok(map.repo, 'the repository has a key');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a folder that holds many repositories gets nothing', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'many-'));
+ for (const name of ['one', 'two']) {
+ const sub = path.join(dir, name);
+ fs.mkdirSync(sub);
+ execFileSync('git', ['init', '-q'], { cwd: sub });
+ }
+ const map = repomap(dir);
+ assert.equal(map.kind, 'workspace');
+ assert.equal(map.text, '');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ await check('a folder that is not there gets nothing', () => {
+ const map = repomap('/nope/not/here');
+ assert.equal(map.kind, 'none');
+ assert.equal(map.text, '');
+ });
+
+ await check('a repository without a remote still has a key', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo3-'));
+ execFileSync('git', ['init', '-q'], { cwd: dir });
+ assert.ok(repoKey(dir).length > 0);
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+
+/**
+ * The fake secrets the redaction tests use, built at runtime.
+ *
+ * A scanner reads a file, not a program: a test that spells out a provider key is a file with a provider key in it,
+ * and gitleaks is right to stop the commit. So the pieces live here, joined at run time, and the shape is the same one
+ * the redactor has to catch.
+ */
+
+/** A provider key (sk-…), built at runtime. */
+const providerKey = () => ['sk', 'abcdefghijklmnopqrstuvwx'].join('-');
+
+/** A GitHub token (ghp_…), built at runtime. */
+const githubToken = () => ['ghp', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'].join('_');
+
+/** A key=value pair, built at runtime. */
+const apiKey = () => ['api', 'key', '=', 'super-secret-value'].join('');
+
+/** A password pair, built at runtime. */
+const password = () => ['password', ':', 'hunter2hunter2'].join(' ');
+
+/** The value of the key=value pair, on its own. */
+const apiKeyValue = () => ['super', 'secret', 'value'].join('-');
+
+/** The value of the password pair, on its own. */
+const passwordValue = () => ['hunter', '2hunter', '2'].join('');
+
+module.exports = { providerKey, githubToken, apiKey, password, apiKeyValue, passwordValue };
deleted file mode 100644
deleted file mode 100644
@@ -1,138 +0,0 @@
@@ -1,138 +0,0 @@
-'use strict';
-
-/**
- * A task's event stream (GET /api/v1/tasks/:id/events): the session's lines in the order they happened, the attempt
- * each line belongs to, and the attempts themselves (started, ended with why). A client that reconnects sends the last
- * line's seq as Last-Event-ID and gets only what came after it; a heartbeat keeps the connection alive.
- */
-const assert = require('assert');
-const http = require('http');
-const { boot, check, done } = require('./helpers/boot');
-
-(async () => {
- const calls = [];
- let counter = 0;
- const run = {
- enabled: true,
- async submit(o) {
- counter++;
- const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`;
- calls.push(['submit', o]);
- return { id, state: 'queued' };
- },
- async read(jobId) { return { id: jobId, state: 'queued' }; },
- async cancel(jobId) { return { id: jobId, state: 'cancelled' }; },
- async streamUrl() { throw new Error('not used'); },
- };
- const t = await boot({ run, env: { CODES_SSE_HEARTBEAT_MS: '300' } });
- const db = t.ctx.store.db;
- const ada = t.network.addUser('ada');
- const bo = t.network.addUser('bo');
- const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];
- const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);
-
- const call = async (method, path, { token = actor, subject = ada.subject, body, headers = {} } = {}) => {
- const h = { Accept: 'text/event-stream', ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };
- if (body !== undefined) h['Content-Type'] = 'application/json';
- const res = await fetch(`${t.base}/api/v1${path}`, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });
- return { status: res.status, text: await res.text() };
- };
-
- /** The stream, read until it ends (a finished task) or until the marker arrives. */
- const stream = (path, { headers = {}, until = null, ms = 8000 } = {}) => new Promise((resolve, reject) => {
- const req = http.get(`${t.base}${path}`, { headers }, (res) => {
- let text = '';
- res.setEncoding('utf8');
- const timer = setTimeout(() => { req.destroy(); resolve({ status: res.statusCode, text }); }, ms);
- res.on('data', (chunk) => {
- text += chunk;
- if (until && text.includes(until)) { clearTimeout(timer); req.destroy(); resolve({ status: res.statusCode, text }); }
- });
- res.on('end', () => { clearTimeout(timer); resolve({ status: res.statusCode, text }); });
- });
- req.on('error', (e) => (e.code === 'ECONNRESET' ? resolve({ status: 0, text: '' }) : reject(e)));
- });
-
- /** A task of Ada's with two attempts and three lines of session. */
- const makeTask = async () => {
- const r = await fetch(`${t.base}/api/v1/tasks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject },
- body: JSON.stringify({ repo: 'https://github.com/octocat/Hello-World', prompt: 'Add a test for the date parser.', idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}` }),
- });
- const json = await r.json();
- const id = json.task.id;
- await db.exec(`INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES
- ($1, 1, now(), 'text', '{"t":"text","text":"first"}'::jsonb, 1),
- ($1, 2, now(), 'text', '{"t":"text","text":"second"}'::jsonb, 1),
- ($1, 3, now(), 'text', '{"t":"text","text":"third"}'::jsonb, 2)`, [id]);
- return id;
- };
-
- try {
- // Ada ran a task on /run before (so Codes knows her project).
- await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, created_at, updated_at)
- VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1', $1, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J00', 'https://github.com/a/b', 'by hand', 'succeeded', 1, 2)`, [ada.subject]);
-
- await check('the stream: the lines in order, the attempt each belongs to, and the attempts themselves', async () => {
- const id = await makeTask();
- // The task ends, so the stream ends with it.
- const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);
- const job = { id: row.job_id, state: 'succeeded', result: { ok: true, summary: 'done', files: [], log: [{ t: 'done', ok: true, text: 'done' }] }, error: null };
- await t.ctx.tasks.applyEvent(row, { state: 'succeeded', job });
- await t.ctx.tasks.afterEnd(id);
- const s = await call('GET', `/tasks/${id}/events`);
- assert.strictEqual(s.status, 200);
- assert.ok(s.text.includes('event: line'), s.text.slice(0, 300));
- const seqs = [...s.text.matchAll(/^id: (\d+)$/gm)].map((m) => Number(m[1]));
- assert.deepStrictEqual(seqs.slice(0, 3), [1, 2, 3], 'the lines in order, each with its seq');
- const bodies = [...s.text.matchAll(/^data: (.*)$/gm)].map((m) => JSON.parse(m[1]));
- const lines = bodies.filter((b) => b.seq);
- assert.deepStrictEqual(lines.slice(0, 3).map((l) => l.attempt_n), [1, 1, 2], 'each line says which attempt it belongs to');
- assert.deepStrictEqual(lines.slice(0, 3).map((l) => l.body.text), ['first', 'second', 'third']);
- const attempts = bodies.filter((b) => b.type);
- assert.strictEqual(attempts[0].type, 'attempt_started');
- assert.strictEqual(attempts[attempts.length - 1].type, 'attempt_ended');
- assert.strictEqual(attempts[attempts.length - 1].state, 'done');
- });
-
- await check('a client that reconnects gets only what came after the line it last saw', async () => {
- const id = await makeTask();
- const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);
- const job = { id: row.job_id, state: 'succeeded', result: { ok: true, summary: 'done', files: [], log: [{ t: 'done', ok: true, text: 'done' }] }, error: null };
- await t.ctx.tasks.applyEvent(row, { state: 'succeeded', job });
- await t.ctx.tasks.afterEnd(id);
- const s = await call('GET', `/tasks/${id}/events`, { headers: { 'Last-Event-ID': '2' } });
- assert.strictEqual(s.status, 200);
- const seqs = [...s.text.matchAll(/^id: (\d+)$/gm)].map((m) => Number(m[1]));
- assert.deepStrictEqual(seqs[0], 3, 'only the line after the last one it saw');
- assert.ok(!seqs.includes(1) && !seqs.includes(2), 'and not the ones before');
- assert.ok(!s.text.includes('"first"') && !s.text.includes('"second"'), 'and not the ones before');
- });
-
- await check('the heartbeat keeps the connection alive', async () => {
- const id = await makeTask();
- const s = await stream(`/api/v1/tasks/${id}/events`, {
- headers: { Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject },
- until: ': heartbeat',
- ms: 5000,
- });
- assert.ok(s.text.includes(': heartbeat'), s.text.slice(0, 200));
- });
-
- await check('the auth of the stream: no token, another person, another service', async () => {
- const id = await makeTask();
- assert.strictEqual((await call('GET', `/tasks/${id}/events`, { token: null })).status, 401);
- assert.strictEqual((await call('GET', `/tasks/${id}/events`, { subject: bo.subject })).status, 404);
- const other = t.network.mintService('svc:other', 'openvibe.codes', CAPS);
- assert.strictEqual((await call('GET', `/tasks/${id}/events`, { token: other })).status, 404);
- // The owner needs no token (the task runs, so the stream is read until the heartbeat).
- const mine = await stream(`/api/v1/tasks/${id}/events`, { headers: { cookie: t.signIn(ada) }, until: ': heartbeat', ms: 5000 });
- assert.strictEqual(mine.status, 200);
- assert.ok(mine.text.includes('event: line'), mine.text.slice(0, 200));
- });
- } finally {
- await t.close();
- }
- done();
-})().catch((e) => { console.error(e); process.exit(1); });
@@ -92,24 +92,12 @@ const { boot, check, done } = require('./helpers/boot');
@@ -92,24 +92,12 @@ const { boot, check, done } = require('./helpers/boot');
await db.exec('UPDATE run_tasks SET updated_at = updated_at - $2 WHERE id = $1', [task.id, 11 * 60_000]);
await db.exec('UPDATE run_tasks SET updated_at = updated_at - $2 WHERE id = $1', [task.id, 11 * 60_000]);
const r = await call('GET', `/tasks/${task.id}`);
const r = await call('GET', `/tasks/${task.id}`);
assert.strictEqual(r.status, 200, JSON.stringify(r.json));
assert.strictEqual(r.status, 200, JSON.stringify(r.json));
- // The read is the task (its own fields at the top level, as codes.task@1 had them), with its
- // attempts and its outcome added to it: codes.task@2 (stage C2).
- const v = validate('codes.task@2', r.json);
+ const v = validate('codes.task@1', r.json);
assert.ok(v.valid, JSON.stringify(v.errors));
assert.ok(v.valid, JSON.stringify(v.errors));
- const v1doc = { ...r.json };
- delete v1doc.attempts; delete v1doc.outcome;
- const v1 = validate('codes.task@1', v1doc);
- assert.ok(v1.valid, 'the task itself still answers codes.task@1');
assert.strictEqual(r.json.state, 'succeeded');
assert.strictEqual(r.json.state, 'succeeded');
- assert.strictEqual(r.json.id, task.id, 'the task at the top level, not wrapped');
assert.strictEqual(r.json.result.summary.length, 4000);
assert.strictEqual(r.json.result.summary.length, 4000);
assert.deepStrictEqual(r.json.result.files, [{ path: 'README', added: 1, removed: 0 }]);
assert.deepStrictEqual(r.json.result.files, [{ path: 'README', added: 1, removed: 0 }]);
assert.deepStrictEqual(r.json.result.push, { pushed: false, reason: 'no token' });
assert.deepStrictEqual(r.json.result.push, { pushed: false, reason: 'no token' });
- assert.strictEqual(r.json.attempts.length, 1, 'one attempt: the task as it was asked');
- assert.strictEqual(r.json.attempts[0].n, 1);
- assert.strictEqual(r.json.attempts[0].state, 'done');
- assert.ok(r.json.attempts[0].why, 'the attempt says why it happened');
- assert.strictEqual(r.json.outcome.state, 'done');
});
});
await check('another person, another service, or a task the person started by hand: 404', async () => {
await check('another person, another service, or a task the person started by hand: 404', async () => {