openvibers/openvibe.codes Commits
Show Link compute choices in Codes setup and composer (#335)
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 Link 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.link/machines), whose OpenVibe Link 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). || Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |--| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-node skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. |++| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-link skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. || 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 and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. || Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |@@@ -59,7 +59,7 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amonreview verdict, cost, or branch/PR code in `server/` — the strings do not appear. T16's product line is"repository browser, workspace, editor, terminal, task feed, tests, diff, harness explanation, review verdict, cost,branch/PR flow — usable hosted and on a Link (T14) for local harnesses", and T16's dependency line is-- "T1 (Fabric offers), T2 (projects/grants), T5 (metering/Vibes), T17 (Actor for planning), T14 (Run/Node for++ "T1 (Fabric offers), T2 (projects/grants), T5 (metering/Vibes), T17 (Actor for planning), T14 (Run/Link forexecution)". The plan does not use the phrase "forge model"; the repository/branch/PR authority it describes isForgejo/GitHub under T16, and the project/grant authority is T2.- **Forgejo is a Codes deliverable that must not gate Codes.** T16: "**Forgejo belongs here,** not in generic@@@ -88,7 +88,7 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amon(`api.js:61-81`). Nothing in this surface launches a harness, streams a run, meters usage or resumes a session.5. **The write half is deferred, and its dependencies are named, not guessed.** Workspace, branch, PR/review, usage andexecution depend on T1 (`platform.agent-offer@1` / Fabric offers), T2 (projects and grants), T5 (metering/Vibes),-- T14 (Run/Node execution) and T17 (Actor planning), per T16's dependency line. This record fixes no interface for++ T14 (Run/Link execution) and T17 (Actor planning), per T16's dependency line. This record fixes no interface forthem; they are **open** until those tracks land.6. **Forgejo does not gate the fabric.** Codes must understand repository/branch/fork/PR/MR, CI and provenance, andthe platform supports GitHub and Forgejo adapters "regardless of which is canonical" (T16). The migration to@@@ -45,7 +45,7 @@ openvibe-codes task stop tsk_01J…- **`--permission review|edit|full`**: `review` is a read-only task (research, an audit, a review) that runs atthe harness's `read` level; `edit` is the default; `full` may also run commands.- **`--machine`** is `auto` only: Codes places the task on a machine of yours by itself (listed at-- <https://openvibe.bot/machines>). A named machine is refused rather than silently ignored.++ <https://openvibe.link/machines>). A named machine is refused rather than silently ignored.- **`--json`** prints the API's answer instead of the readable log.`task show` without `--follow` prints the task, its attempts and its outcome once. With `--follow` it reads@@@ -1,9 +1,9 @@# engine/The agent engine: the pieces of the private agent portal (learned in production, on this workstation, since June 2026)--that every Codes surface shares — the CLI on a Node, the hosted runner and, until it retires, the portal itself.++that every Codes surface shares — the CLI on a Link, the hosted runner and, until it retires, the portal itself.--CommonJS, Node 22, no dependencies.++CommonJS, Link 22, no dependencies.```jsconst engine = require('./engine');@@@ -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.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.0"@@@ -1149,9 +1149,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.372.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",++ "integrity": "sha512-HnfBnkhfMXYNbW4NnpTAMqZc0uV9d4XvwA20VaZXZFbBTPZiClYfoZ0/TWWWh24BFANx9TSzC+4jE0WDHKuNhA==","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.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.0"@@@ -38,7 +38,7 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);const MESSAGE_STATES = ['queued', 'running', 'done', 'failed', 'stopped'];const SAFETY = ['ask', 'project', 'full'];const MODEL = /^[A-Za-z0-9._:-]{1,100}$/;--const NODE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;++const NODE = /^(?:lnk_)?[0-9A-HJKMNP-TV-Z]{26}$/;const FRAGMENT = path.join(__dirname, '..', '..', 'engine', 'fragments', 'conductor.md');const MAX_MESSAGE = 16000;const MAX_ATTACHMENTS = 10;@@@ -250,7 +250,7 @@ function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = c}/**-- * The OpenVibe.Bot device id (`dev_…`) of one of the person's machines, given the id Codes knows it by (`nod_…`, or++ * The OpenVibe.Bot device id (`dev_…`) of one of the person's machines, given the id Codes knows it by (`lnk_…`, or* the device id itself): what a Run job is pinned to (Codes C9c; Run's `job.node`). null when Bot's list has no such* machine; a Bot that does not answer throws (with a status), so a caller never pins to a guess.*/@@@ -4,11 +4,11 @@* A person's machines, as OpenVibe.Network knows them (Codes C4b).** A machine is paired by redeeming a one-time code on OpenVibe.Network (docs: OpenVibe Link README and-- * internal/link/network.go): openvibe.bot/machines mints `pair_…` with a code that lasts ten minutes, and the machine-- * redeems it -> `openvibe-node pair --network <URL> --pairing pair_… --code ABCD-1234`. The result is a node principal-- * (`nod_…`) owned by the person; that principal, its status and when it was last seen are what this module reads.++ * internal/link/network.go): openvibe.link/machines mints `pair_…` with a code that lasts ten minutes, and the machine++ * redeems it -> `openvibe-link pair --network <URL> --pairing pair_… --code ABCD-1234`. The result is a node principal++ * (`lnk_…`) owned by the person; that principal, its status and when it was last seen are what this module reads.*-- * Codes never mints a pairing: that is Bot's, holding network.node.manage. Codes reads the person's own machines with++ * Codes never mints a pairing: that is Bot's, holding network.link.manage. Codes reads the person's own machines with* their own session token (GET /api/v1/me/nodes accepts it as a Bearer) and can revoke one (the person's own route).** createMachines({ config, fetchImpl, log })@@@ -22,7 +22,7 @@*/const ONLINE_MS = 5 * 60_000;--const NODE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;++const NODE = /^(?:lnk_)?[0-9A-HJKMNP-TV-Z]{26}$/;const iso = (v) => {if (!v) return null;@@@ -103,7 +103,27 @@ function createMachines({ config, fetchImpl = globalThis.fetch, log = console }return { ok: true, machine: present(out.doc) };}-- return { list, revoke, present, STATE_WORD };++ /** Link's placement sources; a read does not start a rental or reserve a machine. */++ async function compute(token, kind) {++ if (!token) return { ok: false, sources: [], candidates: [], problem: { detail: 'Sign in to see where work can run.' } };++ try {++ const out = await call(token, 'GET', `/api/v1/link/compute${kind ? `?kind=${encodeURIComponent(kind)}` : ''}`);++ if (out.ok) return { ok: true, sources: out.doc.sources || [], candidates: out.doc.candidates || [] };++ return { ok: false, sources: [], candidates: [], problem: { detail: out.doc?.detail || `Link answered ${out.status}` } };++ } catch (e) {++ return { ok: false, sources: [], candidates: [], problem: { detail: `Link did not answer: ${String(e && e.message).slice(0, 120)}` } };++ }++ }++++ async function pick(token, id) {++ if (!token || !id) return { ok: false, problem: { detail: 'Choose a Link machine first.' } };++ try {++ const out = await call(token, 'POST', '/api/v1/link/compute/pick', { kind: 'personal', id });++ return out.ok ? { ok: true, choice: out.doc.choice } : { ok: false, problem: { detail: out.doc?.detail || 'This machine is unavailable.' } };++ } catch (e) { return { ok: false, problem: { detail: `Link did not answer: ${String(e && e.message).slice(0, 120)}` } }; }++ }++++ return { list, revoke, compute, pick, present, STATE_WORD };}module.exports = { createMachines, present, STATE_WORD, ONLINE_MS };@@@ -552,7 +552,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, oureturn `${base.slice(0, Math.max(0, 16000 - section.length))}${section}`;}-- async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null }) {++ async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null, pin = null }) {++ if (pin && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(pin)) throw new Error('the Link machine id is invalid');const id = store.newId('tsk');const at = store.now();const claims = await claimFiles(subject, attachmentIds);@@@ -607,9 +608,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst placementKind = placed ? `harness.${taskKind}` : 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, placement_line, placement_option_key, placement_step_kind, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $16) RETURNING *`,-- [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, at]);++ 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, placement_line, placement_option_key, placement_step_kind, pin, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17, $17) RETURNING *`,++ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, at]);if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });await taskOutbox.emitTask(inserted, null);return inserted;@@@ -639,7 +640,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou// travels with the job's input and the machine's worker hands it to the harness.const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: agent || null, budget });await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });-- job = await run.submit({ subject, project, args: await withEnv(subject, jobArgs, funding.env), key: id });++ job = await run.submit({ subject, project, args: await withEnv(subject, jobArgs, funding.env), key: id, ...(pin ? { node: pin } : {}) });} catch (e) {const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });@@@ -867,7 +868,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou// through AI is capped like any other attempt.const funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });await attemptStore.bind(attempt.id, { run_mode: funding.mode });-- job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id });++ job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id, ...(row.pin ? { node: row.pin } : {}) });} 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() });@@@ -956,7 +957,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });await attemptStore.bind(attempt.id, { run_mode: funding.mode });const args = followUpArgs(row, plan);-- job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id });++ job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id, ...(row.pin ? { node: row.pin } : {}) });} 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() });@@@ -23,7 +23,7 @@ const failover = require('./conductor-failover');const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const SAFETY = ['ask', 'project', 'full'];const AGENT = /^[a-z0-9-]{1,40}$/;--const MACHINE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;++const MACHINE = /^(?:lnk_)?[0-9A-HJKMNP-TV-Z]{26}$/;/** The most a person may set (the portal's own cap): a number of steps or minutes, or none at all. */const LIMIT_MAX = 100000;@@@ -137,6 +137,11 @@ function createC4Routes(ctx) {return { ok: false, machines: [], problem: { status: e && e.status, detail: 'your machines could not be read: sign in again' } };}}++ async function computeOf(req, res = null) {++ if (!viewer(req)) return { ok: false, sources: [], candidates: [] };++ try { return await sso.asViewer(req, res, (token) => machines.compute(token)); }++ catch (e) { return { ok: false, sources: [], candidates: [], problem: { detail: String(e && e.message || 'Link did not answer') } }; }++ }/** The person's own recent repositories (no GitHub call): what the pickers offer first. */async function recentRepos(req, limit = 8) {if (!viewer(req)) return [];@@@ -498,7 +503,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</hif (row.machine) {const known = await machinesOf(req, res);const hit = (known.machines || []).find((m) => m.id === row.machine || m.node_id === row.machine);-- label = hit ? (hit.name || hit.node_id) : (/^nod_/.test(row.machine) ? 'your machine' : row.machine);++ label = hit ? (hit.name || hit.node_id) : (/^lnk_/.test(row.machine) ? 'your machine' : row.machine);}return await conductorPage(req, res, row, label);}@@@ -513,7 +518,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</hif (machineName) {const known = await machinesOf(req, res);const hit = (known.machines || []).find((m) => m.id === machineName || m.node_id === machineName);-- machineName = hit ? (hit.name || hit.node_id || 'Your machine') : (/^nod_/.test(machineName) ? 'Your machine' : machineName);++ machineName = hit ? (hit.name || hit.node_id || 'Your machine') : (/^lnk_/.test(machineName) ? 'Your machine' : machineName);}const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;const events = `${taskApi}/events`;@@@ -671,7 +676,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductorsreturn `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div><p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}--${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}++${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.link/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}</section>`;};@@@ -737,45 +742,8 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"}});-- // ── Machines: Node's pairing flow with a one-time code, and each machine's state ───────────────────────────-- const CODE_SHAPE = /^[0-9A-HJKMNP-TV-Z]{8}$/;-- const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));-- r.get('/machines', async (req, res) => {-- const mine = viewer(req) ? await machinesOf(req, res) : { ok: false, machines: [] };-- const raw = normaliseCode(req.query.code);-- const badCode = raw.length > 0 && !CODE_SHAPE.test(raw);-- const code = CODE_SHAPE.test(raw) ? `${raw.slice(0, 4)}-${raw.slice(4)}` : '';-- const list = (mine.machines || []).map((m) => {-- const kind = m.state === 'online' ? 'ok' : m.state === 'revoked' ? 'bad' : 'skip';-- return `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${kind}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${esc(machines.STATE_WORD[m.state] || m.state)}${m.last_seen_at ? ` · seen ${esc(age(m.last_seen_at))}` : ' · never seen'}${m.paired_for && m.paired_for.service ? ` · paired for ${esc(m.paired_for.service)}` : ''}</span>${m.state === 'revoked' ? pill('Revoked', 'bad') : `<form method="post" action="/machines/${esc(m.id)}/revoke"><button class="c4-opill c4-opill-quiet" type="submit">Revoke</button></form>`}</li>`;-- }).join('');-- const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2></div>--<p class="c4-sub">One command, one code</p>--<ol class="c4-steps">--<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. It lasts ten minutes and works once.</li>--<li><b>Run the installer on the machine</b>, with the code it gives you: <code class="c4-code-inline">curl -fsSL https://openvibe.link/install | sh -s -- --worker</code></li>--<li><b>It pairs itself</b>, then appears here as Online. A machine runs your jobs and nobody else's.</li>--</ol>--<form class="c4-form c4-form-inline" method="get" action="/machines">-- <label>Have a code? <input name="code" value="${esc(String(req.query.code || ''))}" placeholder="ABCD-1234" inputmode="text" autocomplete="off" spellcheck="false" maxlength="12"></label>-- <button class="c4-button" type="submit">Show the command</button>--</form>--${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad') : ''}--${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.link/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}--</section>`;-- const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2></div><p class="c4-sub">${(mine.machines || []).length} paired</p><p class="c4-hint"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a>.</p>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;-- page(req, res, 'Machines', wrap(content));-- });-- r.post('/machines/:id/revoke', form, async (req, res) => {-- if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/machines')}`);-- if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');-- try {-- const out = await sso.asViewer(req, res, (token) => machines.revoke(token, req.params.id));-- return res.redirect(303, `/machines?${out.ok ? 'ok' : 'error'}=${encodeURIComponent(out.ok ? 'The machine is revoked: it can no longer run your work' : (out.problem && out.problem.detail) || 'the machine could not be revoked')}`);-- } catch (e) {-- return res.redirect(303, `/machines?error=${encodeURIComponent(String((e && e.message) || 'the machine could not be revoked').slice(0, 180))}`);-- }-- });++ // Machine management lives on Link. Existing Codes bookmarks lead to the owner’s Link hub.++ r.get('/machines', (_req, res) => res.redirect(301, 'https://openvibe.link/machines'));// ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────r.get('/usage', async (req, res) => {@@@ -897,7 +865,7 @@ ${conductorSection}// reads the database only, through ctx.setupProgress (the chat app's home asks it).const setupRoutes = require('./setup').createSetupRoutes(ctx, {page: (req, res, title, body) => page(req, res, title, body, { css: ['css/setup.css'], scripts: ['js/setup.js'] }),-- wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS, YOUR_AI, PROVIDER_NAMES,++ wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, computeOf, usageMeter, SAFETY_OPTIONS, YOUR_AI, PROVIDER_NAMES,});r.use(setupRoutes);ctx.setupProgress = setupRoutes.setupProgress;@@@ -208,7 +208,7 @@ function createChatRoutes(ctx) {nav: [place('Conductor', '/conductor', 'conductor'),place('Agents', '/agents', 'agents'),-- place('Machines', '/machines', 'machines'),++ place('Machines', 'https://openvibe.link/machines', 'machines'),place('OpenVibe Git', '/git', 'repo'),place('Memory', '/memory', 'memory'),place('Knowledge', '/knowledge', 'proof'),@@@ -515,11 +515,19 @@ function createChatRoutes(ctx) {const out = await Promise.race([read, late]).finally(() => clearTimeout(timer));return out && out.ok ? (out.machines || []).filter((m) => m && m.state !== 'revoked') : null;}++ async function computeFor(req) {++ const token = req.token || (req.viewer && req.viewer.token) || null;++ if (!token || !machines?.compute) return null;++ let timer;++ const late = new Promise((resolve) => { timer = setTimeout(resolve, 1500, null); if (timer.unref) timer.unref(); });++ const read = Promise.resolve().then(() => machines.compute(token)).catch(() => null);++ return await Promise.race([read, late]).finally(() => clearTimeout(timer));++ }/** Where an agent's work runs, in one line: the person's own computer (which one, or how to pair one). */function whereAgents(list) {const tail = '<span class="ch-where-tail">in a fresh copy of the code, with a pull request at the end</span>';if (list == null) return `${ico('machines')}<span>Runs on your computer ${tail}</span>`;-- if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="/machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;++ if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="https://openvibe.link/machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;const on = list.find((m) => m.state === 'online');const m = on || list[0];const name = esc(m.name || m.node_id || 'your computer');@@@ -532,9 +540,10 @@ function createChatRoutes(ctx) {r.get('/', async (req, res, next) => {if (!viewer(req)) return next();const settings = await userSettings.get(req.viewer.subject).catch(() => ({}));-- const [repos, mine] = await Promise.all([++ const [repos, mine, compute] = await Promise.all([github ? github.recent(req.viewer.subject, 6).catch(() => []) : [],machinesFor(req),++ computeFor(req),]);const asked = String(req.query.repo || '');// A repository shows by its name (owner/name), not a cut-off URL; the server reads either.@@@ -567,11 +576,14 @@ function createChatRoutes(ctx) {const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;const ready = await readyFor(req);const catalog = await aiModels();++ const choices = (compute?.candidates || []).map((m) => `<option value="${esc(m.id)}"${m.status !== 'ready' ? ' disabled' : ''}${optionAttrs({ state: m.status === 'ready' ? 'ok' : 'off', badge: m.status === 'ready' ? 'Ready' : 'Unavailable', desc: m.reason || (m.price ? `$${m.price.amount_usd}/${m.price.unit}` : 'No rental fee') })}>${esc(m.name)} · ${m.status === 'ready' ? 'Ready' : esc(m.reason || 'Unavailable')}</option>`).join('');++ const otherSources = (compute?.sources || []).filter((s) => s.kind !== 'personal').map((s) => `<option value="" disabled>${esc(s.name)} · ${s.status === 'coming_soon' ? 'Coming soon' : esc(s.reason || 'Unavailable')}${s.price ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}</option>`).join('');++ const runsOnPicker = `<label class="ch-pick ch-for-agents" title="Runs on"><span class="ch-sr">Runs on</span>${ico('machines')}<select name="machine" data-ov-select="bare" data-ov-title="Runs on"><option value="">Auto · your ready Link machine</option>${choices}${otherSources}</select>${ico('chevron', 'ch-caret')}</label>`;++// In the bar: the clip and, for your own code, the repository (the one choice that cannot be guessed). Everything// else is behind Options (11-09, the owner: "too many options… hidden under a cog"), and a changed one shows as a chip.const pickers = repoPick;-- const options = kindPick + projectPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready, catalog }) + effortPicker('', 'ch-for-agents');-- // The repositories a person used last are a tap away (the box also searches every one they can reach).++ const options = kindPick + projectPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready, catalog }) + effortPicker('', 'ch-for-agents') + runsOnPicker; // The repositories a person used last are a tap away (the box also searches every one they can reach).const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;const picked = `<p class="ch-picked ch-for-openvibe" data-ch-picked data-ch-match-url="/chat/match" aria-live="polite">${ico('ov')}<span data-ch-picked-text>Say what you want changed and which site, in your own words: Codes works out which projects to change.</span></p>`;@@@ -668,6 +680,13 @@ function createChatRoutes(ctx) {try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }if (!project) return back('Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');if (agent) await userSettings.save(req.viewer.subject, { default_agent: agent }).catch(() => {});++ let pin = null;++ if (b.machine) {++ const token = req.token || (req.viewer && req.viewer.token) || null;++ const chosen = await machines.pick(token, String(b.machine)).catch(() => null);++ if (!chosen?.ok || !chosen.choice?.id) return back((chosen?.problem && chosen.problem.detail) || 'Link could not check that machine.');++ pin = chosen.choice.id;++ }if (targets && targets.length > 1) {// One request across several projects: a task for each, each told what the others are, so the parts agree.const names = targets.map((x) => x.repository.replace(/^OpenVibers\//, ''));@@@ -675,12 +694,12 @@ function createChatRoutes(ctx) {for (const [i, x] of targets.entries()) {const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));-- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [] });++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [], pin });if (!first) first = row;}return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);}-- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids });++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids, pin });return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);});@@@ -114,7 +114,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,/*** The machine a conductor runs on, pinned (Codes C9c): its OpenVibe.Bot device id (`dev_…`), which is what Run-- * places a job on. Network knows the machine by its principal (`nod_…`); Bot's list names both. A machine Codes++ * places a job on. Network knows the machine by its principal (`lnk_…`); Bot's list names both. A machine Codes* cannot find there, or a Bot that does not answer, is a problem — a conductor must never fall back to running* wherever Run likes, because its folder and its memory are on the chosen machine.*/@@@ -127,7 +127,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };}-- if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.bot/machines: add it there, so a conductor can run where its folder is' } };++ if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.link/machines: add it there, so a conductor can run where its folder is' } };if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };return { device: hit.device };}@@@ -313,8 +313,8 @@ function createMemoryRoutes(ctx, kit) {+ rows.length + (rows.length === 1 ? ' source' : ' sources') + '</span></div>';if (!rows.length) {return head + '<div class="c4-emptyline">Nothing here yet. On your computer, run '-- + '<code class="c4-code-inline">openvibe-node memory upload</code>: it shows what it found and asks before sending anything. '-- + '<a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/memory.md#the-upload">The Link docs</a></div></section>';++ + '<code class="c4-code-inline">openvibe-link memory upload</code>: it shows what it found and asks before sending anything. '++ + '<a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/memory.md#the-upload">The Link docs</a></div></section>';}const sourceRow = (row) => {const fresh = row.seen && Date.now() - row.seen < SEVEN_DAYS;@@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.const statusTable = table(['Piece', 'State'], [[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],-- [html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.bot/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],++ [html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],@@@ -11,7 +11,7 @@* POST /run/tasks/:id/cancel stop it** Every task is its owner's alone (another person's is 404). Forms post only from this site. The job runs where Run-- * places it: on a machine the person added on openvibe.bot/machines, never anyone else's (contracts 0.161.0).++ * places it: on a machine the person added on openvibe.link/machines, never anyone else's (contracts 0.161.0).*/const express = require('express');const { serviceAuth, capabilities, http } = require('openvibe-contracts');@@@ -24,7 +24,7 @@ const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks')const { createSessionLines } = require('../domain/session-lines');const DELEGABLE = new Set(['owner', 'admin']);--const MACHINES_URL = 'https://openvibe.bot/machines';++const MACHINES_URL = 'https://openvibe.link/machines';const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;/** The task's outcome in the words a person reads. */@@@ -139,7 +139,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`title: 'Run on your machine', index: true,body: html`<h1>Run a coding task on your own machine</h1><p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>--<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.bot/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>++<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol><p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,});}@@@ -149,7 +149,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`]);const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';const body = html`<h1>Run on your machine</h1>--<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.bot/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>++<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.link/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>${defaults}${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}@@@ -233,7 +233,7 @@ ${tasksHtml(recent)}`;const files = Array.isArray(d.files) ? d.files : [];const push = d.push || null;const verdict = row.state !== 'succeeded'-- ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.bot/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`++ ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.link/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;const ended = !(session && row.state === 'succeeded' && d.ok);@@@ -28,7 +28,7 @@ const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover'const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';function createSetupRoutes(ctx, kit) {-- const { page, wrap, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS = [], YOUR_AI, PROVIDER_NAMES = {} } = kit;++ const { page, wrap, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, computeOf, usageMeter, SAFETY_OPTIONS = [], YOUR_AI, PROVIDER_NAMES = {} } = kit;const { store, fleet, harnesses, connections, sso, forge, userSettings } = ctx;const db = store.db;const r = asyncRouter();@@@ -40,6 +40,7 @@ function createSetupRoutes(ctx, kit) {async function readAll(req, res) {const subject = req.viewer.subject;const machineList = await machinesOf(req, res);++ const compute = await computeOf(req, res);let fleetProblem = null;if (fleet) {try {@@@ -54,16 +55,16 @@ function createSetupRoutes(ctx, kit) {try { git = forge && forge.enabled ? await forge.link(subject) : null; } catch { git = null; }const settings = await userSettings.get(subject);const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));-- return { machines: machineList, rows, fleetProblem, keys, git, settings, saved };++ return { machines: machineList, compute, rows, fleetProblem, keys, git, settings, saved };}function stepsOf(d) {const live = (d.machines.machines || []).filter((m) => m.state !== 'revoked');-- const online = live.filter((m) => m.state === 'online');++ const online = (d.compute.candidates || []).filter((m) => m.status === 'ready');const ready = d.rows.filter((x) => x.state === 'ok' || x.state === 'low');const credentials = (d.keys && d.keys.credentials) || [];return [-- { id: 'machine', title: 'Your machine', short: 'Machine', done: online.length > 0, warn: !online.length && live.length > 0, required: true },++ { id: 'machine', title: 'Pick where it runs', short: 'Runs on', done: online.length > 0, warn: !online.length && live.length > 0, required: true },{ id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },{ id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },{ id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!d.git, optional: true },@@@ -80,14 +81,16 @@ function createSetupRoutes(ctx, kit) {function machineBody(d) {const list = (d.machines.machines || []).filter((m) => m.state !== 'revoked');++ const sources = d.compute.sources || [];++ const sourceRows = sources.map((s) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${s.status === 'ready' ? ' c4-dot-ok' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(s.name)}</span><span class="c4-row-note">${s.status === 'ready' ? 'Ready' : s.status === 'coming_soon' ? 'Coming soon' : 'Unavailable'}${s.price && s.price.amount_usd != null ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}${s.reason ? ` · ${esc(s.reason)}` : ''}</span></li>`).join('');const rows = list.map((m) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${m.state === 'online' ? 'ok' : 'skip'}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${m.state === 'online' ? 'Online: ready for your work' : 'Offline: start it, and it comes back here'}</span></li>`).join('');const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';const how = `<ol class="c4-setup-how">--<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines" rel="noopener">openvibe.bot/machines</a>. It lasts ten minutes.</li>++<li><b>Get a one-time code</b> on <a href="https://openvibe.link/machines" rel="noopener">openvibe.link/machines</a>. It lasts ten minutes.</li><li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.link/install | sh -s -- --worker', 'On the machine')}</li><li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;-- return `${problem}${rows ? `<ul class="c4-rows">${rows}</ul>` : ''}${list.length ? `<details class="c4-setup-more"><summary>Pair another machine</summary>${how}</details>` : how}--<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;++ return `${problem}${d.compute.ok === false && d.compute.problem ? note(esc(d.compute.problem.detail), 'bad') : ''}<p class="c4-hint">Choose a place for your work. Link checks readiness before a task can start. Rentals need a live price and a spending limit.</p>${sourceRows ? `<ul class="c4-rows">${sourceRows}</ul>` : ''}${rows ? `<ul class="c4-rows">${rows}</ul>` : ''}${list.length ? `<details class="c4-setup-more"><summary>Pair another machine</summary>${how}</details>` : how}++<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="https://openvibe.link/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;}function agentsBody(d) {@@@ -166,7 +169,7 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptylineconst requiredLeft = steps.filter((s) => s.required && !s.done).length;const pctDone = Math.round((done / steps.length) * 100);const subs = {-- machine: 'Where your agents run: one of your own computers, paired once.',++ machine: 'Your Link machines, OpenVibe cloud, Exchange rentals and community machines.',agents: 'The coding agents on it, signed in with your own plans.',keys: 'Keys for the models you pay for by use.',accounts: 'Where your work is pushed.',@@@ -78,7 +78,7 @@ ${audience ? html`<div class="field"><label for="sk-audience">Who may read it</ldescription: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',crumbs: [{ label: 'Skills' }],body: html`<h1>Skills</h1>--<p class="lede">Skills, rules and MCP servers kept per person and per project, versioned, and synced into each agent tool's own folder on your machine. Add one from the gallery; <code>openvibe-node skills sync</code> writes it inside its marked block, with a dry run, a diff and an undo.</p>++<p class="lede">Skills, rules and MCP servers kept per person and per project, versioned, and synced into each agent tool's own folder on your machine. Add one from the gallery; <code>openvibe-link skills sync</code> writes it inside its marked block, with a dry run, a diff and an undo.</p>${error ? notice(error, 'bad') : ''}<form class="inline-form" method="get" action="/skills" role="search"><label>Search the gallery <input name="q" value="${q}" maxlength="80"></label>@@@ -207,7 +207,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task}const project = await taskStore.latestProject(p.subject);if (!project) {-- return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.bot/machines)`);++ return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.link/machines)`);}let row;try {@@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/boot');const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');--const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';++const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';/** A child MCP server that answers one JSON-RPC message at a time. */function mcpChild(env) {@@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/boot');const RESET = nextWednesdayUtc();// The machine, as OpenVibe.Bot answers it: the page reads Bot and keeps the latest reading per machine.t.bot.addMachine({-- id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),++ id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }] },{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },@@@ -32,9 +32,9 @@ const { boot, check, done } = require('./helpers/boot');});await t.ctx.store.db.exec(`INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)-- VALUES ($1,'dev_ada_laptop','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),-- ($1,'dev_ada_laptop','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),-- ($1,'dev_ada_laptop','nod_ada','ada-laptop','opencode',true,false,'[]'::jsonb,true,now(),now())`,++ VALUES ($1,'dev_ada_laptop','lnk_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),++ ($1,'dev_ada_laptop','lnk_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),++ ($1,'dev_ada_laptop','lnk_ada','ada-laptop','opencode',true,false,'[]'::jsonb,true,now(),now())`,[ada.subject, JSON.stringify([{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }])],);@@@ -8,12 +8,14 @@ const { boot, check, done } = require('./helpers/boot');const ada = t.network.addUser('c4-ada');try {await check('all C4 pages render readable server content without JavaScript', async () => {-- for (const [path, label] of [['/', 'What should an agent do?'], ['/tasks', 'Tasks'], ['/agents', 'Agents'], ['/machines', 'Machines'], ['/usage', 'Usage'], ['/settings', 'Settings']]) {++ for (const [path, label] of [['/', 'What should an agent do?'], ['/tasks', 'Tasks'], ['/agents', 'Agents'], ['/usage', 'Usage'], ['/settings', 'Settings']]) {const r = await t.get(path);assert.strictEqual(r.status, 200, `${path} status`);assert.ok(r.text.includes(label), `${path} content`);assert.ok(r.text.includes('ov-workspace'), `${path} reserves the Frame app bar's height`);}++ const machines = await t.get('/machines');++ assert.strictEqual(machines.status, 301, '/machines moves to the Link product');const task = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV');assert.strictEqual(task.status, 200);assert.ok(task.text.includes('Sign in to see your work'));@@@ -12,8 +12,8 @@const assert = require('assert');const { boot, check, done } = require('./helpers/boot');--const NODE = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV';--const OTHER = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAC';++const NODE = 'lnk_01ARZ3NDEKTSV4RRFFQ69G5FAV';++const OTHER = 'lnk_01ARZ3NDEKTSV4RRFFQ69G5FAC';const TASK = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';const TASK2 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAW';const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';@@@ -139,29 +139,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';assert.ok(empty.text.includes('No keys yet.') && empty.text.includes('Add one on Your AI'));});-- await check('Machines: the person’s own machines, their state, and Node’s pairing code flow', async () => {++ await check('Machine management leads to Link', async () => {const page = await t.get('/machines', { as: ada });-- assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('Desk'), 'the paired machine is listed');-- assert.ok(page.text.includes('Online'), 'with its state');-- assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');-- assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');-- assert.ok(page.text.includes('All your machines are on openvibe.network/machines'), 'the page points at the network’s machines hub');-- assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ovk-sidebar-open'), 'the app has no tab bar: the rail is its navigation (L31)');-- const coded = await t.get('/machines?code=abcd-2345', { as: ada });-- assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ovk-sidebar-open'), 'a query string keeps the page in the app');-- assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');-- const wrong = await t.get('/machines?code=nope', { as: ada });-- assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');-- const revoke = await t.get(`/machines/${NODE}/revoke`, { as: ada, method: 'POST', form: { x: '1' } });-- assert.strictEqual(revoke.status, 303);-- const after = await t.get('/machines', { as: ada });-- assert.ok(after.text.includes('Revoked'), 'the machine is revoked');-- // Bo cannot revoke Ada's machine.-- t.network.addNode(ada.subject, { principal: 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAA', node_id: 'node-other', name: 'Other', status: 'active', last_seen_at: new Date().toISOString() });-- const stolen = await t.get('/machines/nod_01ARZ3NDEKTSV4RRFFQ69G5FAA/revoke', { as: bo, method: 'POST', form: { x: '1' } });-- assert.strictEqual(stolen.status, 303);-- assert.ok(String(stolen.headers.get('location')).includes('error='), 'another person’s machine is refused');++ assert.strictEqual(page.status, 301);++ assert.strictEqual(page.headers.get('location'), 'https://openvibe.link/machines');});await check('Settings are remembered per person', async () => {@@@ -8,7 +8,7 @@const assert = require('assert');const { boot, check, done } = require('./helpers/boot');--const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';++const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';// Measured from now, so the fleet stays spent however late the test runs (fixed dates went stale on 2026-11-02).const RESET_WEEK = new Date(Date.now() + 4.5 * 86400000).toISOString();const RESET_MONTH = new Date(Date.now() + 2.5 * 86400000).toISOString();@@@ -52,11 +52,11 @@ const RESET_MONTH = new Date(Date.now() + 2.5 * 86400000).toISOString();});await db.exec(`INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)-- VALUES ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),-- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','opencode',true,true,$3::jsonb,true,now(),now()),-- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','command-code',true,true,$4::jsonb,true,now(),now()),-- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),-- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','deepseek',true,true,'[]'::jsonb,true,now(),now())`,++ VALUES ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','opencode',true,true,$3::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','command-code',true,true,$4::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','deepseek',true,true,'[]'::jsonb,true,now(),now())`,[ada.subject, window('week', 100, RESET_WEEK), window('month', 100, RESET_MONTH), window('week', 96, RESET_WEEK)],);@@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/boot');const failover = require('../server/domain/conductor-failover');const job = require('../harness/job');--const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';++const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';const HOUR = 3600_000;@@@ -12,7 +12,7 @@ const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;--const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';++const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';(async () => {@@@ -134,7 +134,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';});await check('a conductor is started only where its folder is: no such machine on Bot, away, or Bot itself away', async () => {-- const other = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';++ const other = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';t.network.addNode(ada.subject, { principal: other, node_id: 'node_ada_desktop', name: 'ada-desktop', status: 'active', last_seen_at: new Date().toISOString() });const before = submits().length;const unknown = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: other });@@@ -27,7 +27,7 @@ const DIR = path.join(__dirname, '..', 'engine');for (const m of source.matchAll(/require\('([^']+)'\)/g)) {const name = m[1];if (name.startsWith('.')) continue;-- assert.ok(builtins.has(name.replace(/^node:/, '')), `${file} requires ${name}, which is not a node builtin`);++ assert.ok(builtins.has(name.replace(/^link:/, '')), `${file} requires ${name}, which is not a node builtin`);}}});@@@ -26,11 +26,11 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });try {await check('the heartbeat report is stored per machine and agent, and answered per machine', async () => {let doc = machines([-- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },] },-- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: false, fleet: [++ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: false, fleet: [{ agent: 'opencode', installed: true, signed_in: false, windows: [] },] },]);@@@ -39,7 +39,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });assert.equal(out.ok, true);assert.equal(out.machines.length, 2);const laptop = out.machines.find((m) => m.id === 'dev_ada_laptop');-- assert.equal(laptop.node_id, 'nod_ada');++ assert.equal(laptop.node_id, 'lnk_ada');assert.equal(laptop.agents.length, 2);assert.equal(laptop.agents[0].windows[0].pct, 100, 'the pct is derived from used/limit');assert.equal(laptop.agents[0].windows[0].resets_at, new Date(RESET).toISOString());@@@ -49,7 +49,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });await check('the page rows: one per agent, a state each, usable first', async () => {const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([-- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },{ agent: 'minimax-code', installed: true, signed_in: true, windows: [{ name: 'week', used: 10, limit: 100, resets_at: RESET }] },{ agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 84, resets_at: RESET }] },@@@ -71,7 +71,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });await check('the router reads the fleet per runner, with the machine that has room', async () => {const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([-- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },] },@@@ -86,10 +86,10 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });await check('a plan with room on one machine is usable, and the meter shows the fullest reading', async () => {const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([-- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },] },-- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [++ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 10, resets_at: RESET }] },] },]) });@@@ -104,10 +104,10 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });await check('a plan that is full on every machine is limited', async () => {const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([-- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },] },-- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [++ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET }] },] },]) });@@@ -119,17 +119,17 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });await check('a machine gone from Bot\'s answer is gone here; one that reports no fleet keeps its reading', async () => {const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([-- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [] },] },-- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [++ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true, fleet: [{ agent: 'opencode', installed: true, signed_in: false, windows: [] },] },]) });await fleet.refresh(SUBJECT, 'tok');// The desk's Node is old and reports no fleet; the laptop is unplugged from the answer.const next = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([-- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true },++ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true },]) });await next.refresh(SUBJECT, 'tok');const { machines: left } = await next.latest(SUBJECT);@@@ -360,7 +360,22 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }: { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });}-- // ── Network's node principals (Codes C4b): a person's own machines ──++ if (url.pathname === '/api/v1/link/compute' && req.method === 'GET') {++ const u = userFrom(req);++ if (!u) return problem(json, 401, 'auth.required', 'sign in to see where work can run');++ const candidates = [...st.nodes.values()].filter((n) => n.owner === u.subject && n.view.status === 'active').map((n) => ({++ id: n.view.principal, principal_id: n.view.principal, kind: 'personal', name: n.view.name || n.view.principal,++ status: n.view.last_seen_at ? 'ready' : 'unavailable', reason: n.view.last_seen_at ? null : 'This machine is offline.',++ price: { amount_usd: 0, unit: 'hour' },++ }));++ return json(200, { sources: [++ { kind: 'personal', name: 'Your Link machines', status: candidates.some((c) => c.status === 'ready') ? 'ready' : 'unavailable', reason: null },++ { kind: 'cloud', name: 'OpenVibe cloud machines', status: 'coming_soon', reason: 'No cloud pool is accepting jobs yet.' },++ { kind: 'exchange', name: 'Exchange GPU rentals', status: 'coming_soon', reason: 'A live quote is required.', price: null },++ { kind: 'community', name: 'Community machines', status: 'coming_soon', reason: 'Shared placement is being connected.' },++ ], candidates });++ }++ // ── Network's Link principals (Codes C4b): a person's own machines ──if (url.pathname === '/api/v1/me/nodes' || url.pathname.startsWith('/api/v1/me/nodes/')) {const u = userFrom(req);if (!u) return problem(json, 401, 'auth.required', 'sign in to see your machines');nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++const assert = require('node:assert/strict');++const { spawnSync } = require('node:child_process');++const path = require('node:path');++const root = path.resolve(__dirname, '..');++const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');++const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });++assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');++assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);++console.log(' ok old Link brand and principal IDs are absent');@@@ -15,7 +15,7 @@ const { spawn } = require('child_process');const { boot, check, done } = require('./helpers/boot');const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');--const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';++const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';function mcpChild(env) {const child = spawn(process.execPath, [MCP], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });@@@ -313,7 +313,7 @@ await check('an empty store shows the empty state and how to fill it', async ()const res = await h.get('/memory', { as: user });assert.equal(res.status, 200);assert.ok(res.text.includes('Nothing here yet'), 'the empty line');-- assert.ok(res.text.includes('openvibe-node memory upload'), 'the command');++ assert.ok(res.text.includes('openvibe-link memory upload'), 'the command');assert.ok(res.text.includes('docs/memory.md'), 'the docs');} finally { await h.close(); await store.close(); }});@@@ -44,7 +44,7 @@ const { boot, check, done } = require('./helpers/boot');return r.json.task;};// The machine the fleet reports, and the agent it runs (a real fleet row, as Bot's answer would carry).-- t.bot.addMachine({ id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', node_id: 'nod_01J8Z4M2Q0R7T9YV3K6N8P1W2X', name: 'ada-laptop', online: true,++ t.bot.addMachine({ id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', node_id: 'lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2X', name: 'ada-laptop', online: true,fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [] }] });// A person starts a service's task in the project of their latest task: seed one for each.for (const [i, subject] of [[1, ada.subject], [2, bo.subject]]) {@@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');await check('a guest is told what it is and asked to sign in', async () => {const r = await t.get('/run');assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('Run a coding task on your own machine') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.bot/machines'));++ assert.ok(r.text.includes('Run a coding task on your own machine') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));});let project;@@@ -59,7 +59,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer','server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model','server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',-- 'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',++ 'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a lnk_ shape, never a URL from a person','server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person','server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person','server/http/memory-share.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory-store/{id}/share with the person\'s own session token: the configured base, the fixed share path and the id from the API route; a JSON body is forwarded as-is, never a URL from a person',@@@ -8,7 +8,7 @@ const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const { readiness, optionAttrs } = require('../server/domain/readiness');--const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';++const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';(async () => {@@@ -168,7 +168,7 @@ function taskDoc(over = {}) {assert.match(noToken.err, /no Network token/);const badOption = await run(['list', '--nope']);assert.strictEqual(badOption.code, 2);-- const namedMachine = await run(['start', '--repo', 'acme/thing', '--file', path.join(dir, 'brief.md'), '--machine', 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1']);++ const namedMachine = await run(['start', '--repo', 'acme/thing', '--file', path.join(dir, 'brief.md'), '--machine', 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1']);assert.strictEqual(namedMachine.code, 2);assert.match(namedMachine.err, /only --machine auto/);});
@@ -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 Link 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.link/machines), whose OpenVibe Link 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). |
| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
-| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-node skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. |
+| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-link skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. |
| 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 and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
@@ -59,7 +59,7 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amon
@@ -59,7 +59,7 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amon
review verdict, cost, or branch/PR code in `server/` — the strings do not appear. T16's product line is
review verdict, cost, or branch/PR code in `server/` — the strings do not appear. T16's product line is
"repository browser, workspace, editor, terminal, task feed, tests, diff, harness explanation, review verdict, cost,
"repository browser, workspace, editor, terminal, task feed, tests, diff, harness explanation, review verdict, cost,
branch/PR flow — usable hosted and on a Link (T14) for local harnesses", and T16's dependency line is
branch/PR flow — usable hosted and on a Link (T14) for local harnesses", and T16's dependency line is
- "T1 (Fabric offers), T2 (projects/grants), T5 (metering/Vibes), T17 (Actor for planning), T14 (Run/Node for
+ "T1 (Fabric offers), T2 (projects/grants), T5 (metering/Vibes), T17 (Actor for planning), T14 (Run/Link for
execution)". The plan does not use the phrase "forge model"; the repository/branch/PR authority it describes is
execution)". The plan does not use the phrase "forge model"; the repository/branch/PR authority it describes is
Forgejo/GitHub under T16, and the project/grant authority is T2.
Forgejo/GitHub under T16, and the project/grant authority is T2.
- **Forgejo is a Codes deliverable that must not gate Codes.** T16: "**Forgejo belongs here,** not in generic
- **Forgejo is a Codes deliverable that must not gate Codes.** T16: "**Forgejo belongs here,** not in generic
@@ -88,7 +88,7 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amon
@@ -88,7 +88,7 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amon
(`api.js:61-81`). Nothing in this surface launches a harness, streams a run, meters usage or resumes a session.
(`api.js:61-81`). Nothing in this surface launches a harness, streams a run, meters usage or resumes a session.
5. **The write half is deferred, and its dependencies are named, not guessed.** Workspace, branch, PR/review, usage and
5. **The write half is deferred, and its dependencies are named, not guessed.** Workspace, branch, PR/review, usage and
execution depend on T1 (`platform.agent-offer@1` / Fabric offers), T2 (projects and grants), T5 (metering/Vibes),
execution depend on T1 (`platform.agent-offer@1` / Fabric offers), T2 (projects and grants), T5 (metering/Vibes),
- T14 (Run/Node execution) and T17 (Actor planning), per T16's dependency line. This record fixes no interface for
+ T14 (Run/Link execution) and T17 (Actor planning), per T16's dependency line. This record fixes no interface for
them; they are **open** until those tracks land.
them; they are **open** until those tracks land.
6. **Forgejo does not gate the fabric.** Codes must understand repository/branch/fork/PR/MR, CI and provenance, and
6. **Forgejo does not gate the fabric.** Codes must understand repository/branch/fork/PR/MR, CI and provenance, and
the platform supports GitHub and Forgejo adapters "regardless of which is canonical" (T16). The migration to
the platform supports GitHub and Forgejo adapters "regardless of which is canonical" (T16). The migration to
@@ -45,7 +45,7 @@ openvibe-codes task stop tsk_01J…
@@ -45,7 +45,7 @@ openvibe-codes task stop tsk_01J…
- **`--permission review|edit|full`**: `review` is a read-only task (research, an audit, a review) that runs at
- **`--permission review|edit|full`**: `review` is a read-only task (research, an audit, a review) that runs at
the harness's `read` level; `edit` is the default; `full` may also run commands.
the harness's `read` level; `edit` is the default; `full` may also run commands.
- **`--machine`** is `auto` only: Codes places the task on a machine of yours by itself (listed at
- **`--machine`** is `auto` only: Codes places the task on a machine of yours by itself (listed at
- <https://openvibe.bot/machines>). A named machine is refused rather than silently ignored.
+ <https://openvibe.link/machines>). A named machine is refused rather than silently ignored.
- **`--json`** prints the API's answer instead of the readable log.
- **`--json`** prints the API's answer instead of the readable log.
`task show` without `--follow` prints the task, its attempts and its outcome once. With `--follow` it reads
`task show` without `--follow` prints the task, its attempts and its outcome once. With `--follow` it reads
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
# engine/
# engine/
The agent engine: the pieces of the private agent portal (learned in production, on this workstation, since June 2026)
The agent engine: the pieces of the private agent portal (learned in production, on this workstation, since June 2026)
-that every Codes surface shares — the CLI on a Node, the hosted runner and, until it retires, the portal itself.
+that every Codes surface shares — the CLI on a Link, the hosted runner and, until it retires, the portal itself.
-CommonJS, Node 22, no dependencies.
+CommonJS, Link 22, no dependencies.
```js
```js
const engine = require('./engine');
const engine = require('./engine');
@@ -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.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.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.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.372.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
+ "integrity": "sha512-HnfBnkhfMXYNbW4NnpTAMqZc0uV9d4XvwA20VaZXZFbBTPZiClYfoZ0/TWWWh24BFANx9TSzC+4jE0WDHKuNhA==",
"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.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -38,7 +38,7 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
@@ -38,7 +38,7 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const MESSAGE_STATES = ['queued', 'running', 'done', 'failed', 'stopped'];
const MESSAGE_STATES = ['queued', 'running', 'done', 'failed', 'stopped'];
const SAFETY = ['ask', 'project', 'full'];
const SAFETY = ['ask', 'project', 'full'];
const MODEL = /^[A-Za-z0-9._:-]{1,100}$/;
const MODEL = /^[A-Za-z0-9._:-]{1,100}$/;
-const NODE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
+const NODE = /^(?:lnk_)?[0-9A-HJKMNP-TV-Z]{26}$/;
const FRAGMENT = path.join(__dirname, '..', '..', 'engine', 'fragments', 'conductor.md');
const FRAGMENT = path.join(__dirname, '..', '..', 'engine', 'fragments', 'conductor.md');
const MAX_MESSAGE = 16000;
const MAX_MESSAGE = 16000;
const MAX_ATTACHMENTS = 10;
const MAX_ATTACHMENTS = 10;
@@ -250,7 +250,7 @@ function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = c
@@ -250,7 +250,7 @@ function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = c
}
}
/**
/**
- * The OpenVibe.Bot device id (`dev_…`) of one of the person's machines, given the id Codes knows it by (`nod_…`, or
+ * The OpenVibe.Bot device id (`dev_…`) of one of the person's machines, given the id Codes knows it by (`lnk_…`, or
* the device id itself): what a Run job is pinned to (Codes C9c; Run's `job.node`). null when Bot's list has no such
* the device id itself): what a Run job is pinned to (Codes C9c; Run's `job.node`). null when Bot's list has no such
* machine; a Bot that does not answer throws (with a status), so a caller never pins to a guess.
* machine; a Bot that does not answer throws (with a status), so a caller never pins to a guess.
*/
*/
@@ -4,11 +4,11 @@
@@ -4,11 +4,11 @@
* A person's machines, as OpenVibe.Network knows them (Codes C4b).
* A person's machines, as OpenVibe.Network knows them (Codes C4b).
*
*
* A machine is paired by redeeming a one-time code on OpenVibe.Network (docs: OpenVibe Link README and
* A machine is paired by redeeming a one-time code on OpenVibe.Network (docs: OpenVibe Link README and
- * internal/link/network.go): openvibe.bot/machines mints `pair_…` with a code that lasts ten minutes, and the machine
- * redeems it -> `openvibe-node pair --network <URL> --pairing pair_… --code ABCD-1234`. The result is a node principal
- * (`nod_…`) owned by the person; that principal, its status and when it was last seen are what this module reads.
+ * internal/link/network.go): openvibe.link/machines mints `pair_…` with a code that lasts ten minutes, and the machine
+ * redeems it -> `openvibe-link pair --network <URL> --pairing pair_… --code ABCD-1234`. The result is a node principal
+ * (`lnk_…`) owned by the person; that principal, its status and when it was last seen are what this module reads.
*
*
- * Codes never mints a pairing: that is Bot's, holding network.node.manage. Codes reads the person's own machines with
+ * Codes never mints a pairing: that is Bot's, holding network.link.manage. Codes reads the person's own machines with
* their own session token (GET /api/v1/me/nodes accepts it as a Bearer) and can revoke one (the person's own route).
* their own session token (GET /api/v1/me/nodes accepts it as a Bearer) and can revoke one (the person's own route).
*
*
* createMachines({ config, fetchImpl, log })
* createMachines({ config, fetchImpl, log })
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
*/
*/
const ONLINE_MS = 5 * 60_000;
const ONLINE_MS = 5 * 60_000;
-const NODE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
+const NODE = /^(?:lnk_)?[0-9A-HJKMNP-TV-Z]{26}$/;
const iso = (v) => {
const iso = (v) => {
if (!v) return null;
if (!v) return null;
@@ -103,7 +103,27 @@ function createMachines({ config, fetchImpl = globalThis.fetch, log = console }
@@ -103,7 +103,27 @@ function createMachines({ config, fetchImpl = globalThis.fetch, log = console }
return { ok: true, machine: present(out.doc) };
return { ok: true, machine: present(out.doc) };
}
}
- return { list, revoke, present, STATE_WORD };
+ /** Link's placement sources; a read does not start a rental or reserve a machine. */
+ async function compute(token, kind) {
+ if (!token) return { ok: false, sources: [], candidates: [], problem: { detail: 'Sign in to see where work can run.' } };
+ try {
+ const out = await call(token, 'GET', `/api/v1/link/compute${kind ? `?kind=${encodeURIComponent(kind)}` : ''}`);
+ if (out.ok) return { ok: true, sources: out.doc.sources || [], candidates: out.doc.candidates || [] };
+ return { ok: false, sources: [], candidates: [], problem: { detail: out.doc?.detail || `Link answered ${out.status}` } };
+ } catch (e) {
+ return { ok: false, sources: [], candidates: [], problem: { detail: `Link did not answer: ${String(e && e.message).slice(0, 120)}` } };
+ }
+ }
+
+ async function pick(token, id) {
+ if (!token || !id) return { ok: false, problem: { detail: 'Choose a Link machine first.' } };
+ try {
+ const out = await call(token, 'POST', '/api/v1/link/compute/pick', { kind: 'personal', id });
+ return out.ok ? { ok: true, choice: out.doc.choice } : { ok: false, problem: { detail: out.doc?.detail || 'This machine is unavailable.' } };
+ } catch (e) { return { ok: false, problem: { detail: `Link did not answer: ${String(e && e.message).slice(0, 120)}` } }; }
+ }
+
+ return { list, revoke, compute, pick, present, STATE_WORD };
}
}
module.exports = { createMachines, present, STATE_WORD, ONLINE_MS };
module.exports = { createMachines, present, STATE_WORD, ONLINE_MS };
@@ -552,7 +552,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -552,7 +552,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return `${base.slice(0, Math.max(0, 16000 - section.length))}${section}`;
return `${base.slice(0, Math.max(0, 16000 - section.length))}${section}`;
}
}
- async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null }) {
+ async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null, pin = null }) {
+ if (pin && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(pin)) throw new Error('the Link machine id is invalid');
const id = store.newId('tsk');
const id = store.newId('tsk');
const at = store.now();
const at = store.now();
const claims = await claimFiles(subject, attachmentIds);
const claims = await claimFiles(subject, attachmentIds);
@@ -607,9 +608,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -607,9 +608,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const placementKind = placed ? `harness.${taskKind}` : null;
const placementKind = placed ? `harness.${taskKind}` : null;
let inserted;
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, placement_line, placement_option_key, placement_step_kind, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $16) RETURNING *`,
- [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, at]);
+ 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, placement_line, placement_option_key, placement_step_kind, pin, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17, $17) RETURNING *`,
+ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, at]);
if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });
if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });
await taskOutbox.emitTask(inserted, null);
await taskOutbox.emitTask(inserted, null);
return inserted;
return inserted;
@@ -639,7 +640,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -639,7 +640,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// travels with the job's input and the machine's worker hands it to the harness.
// travels with the job's input and the machine's worker hands it to the harness.
const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: agent || null, budget });
const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: agent || null, budget });
await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });
await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });
- job = await run.submit({ subject, project, args: await withEnv(subject, jobArgs, funding.env), key: id });
+ job = await run.submit({ subject, project, args: await withEnv(subject, jobArgs, funding.env), key: id, ...(pin ? { node: pin } : {}) });
} 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: refusal ? 'refused' : 'error', finished_at: store.iso() });
await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });
@@ -867,7 +868,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -867,7 +868,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// through AI is capped like any other attempt.
// through AI is capped like any other attempt.
const funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });
const funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });
await attemptStore.bind(attempt.id, { run_mode: funding.mode });
await attemptStore.bind(attempt.id, { run_mode: funding.mode });
- job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id });
+ job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id, ...(row.pin ? { node: row.pin } : {}) });
} 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(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
@@ -956,7 +957,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -956,7 +957,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });
const funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });
await attemptStore.bind(attempt.id, { run_mode: funding.mode });
await attemptStore.bind(attempt.id, { run_mode: funding.mode });
const args = followUpArgs(row, plan);
const args = followUpArgs(row, plan);
- job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id });
+ job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id, ...(row.pin ? { node: row.pin } : {}) });
} 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(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
@@ -23,7 +23,7 @@ const failover = require('./conductor-failover');
@@ -23,7 +23,7 @@ const failover = require('./conductor-failover');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SAFETY = ['ask', 'project', 'full'];
const SAFETY = ['ask', 'project', 'full'];
const AGENT = /^[a-z0-9-]{1,40}$/;
const AGENT = /^[a-z0-9-]{1,40}$/;
-const MACHINE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
+const MACHINE = /^(?:lnk_)?[0-9A-HJKMNP-TV-Z]{26}$/;
/** The most a person may set (the portal's own cap): a number of steps or minutes, or none at all. */
/** The most a person may set (the portal's own cap): a number of steps or minutes, or none at all. */
const LIMIT_MAX = 100000;
const LIMIT_MAX = 100000;
@@ -137,6 +137,11 @@ function createC4Routes(ctx) {
@@ -137,6 +137,11 @@ function createC4Routes(ctx) {
return { ok: false, machines: [], problem: { status: e && e.status, detail: 'your machines could not be read: sign in again' } };
return { ok: false, machines: [], problem: { status: e && e.status, detail: 'your machines could not be read: sign in again' } };
}
}
}
}
+ async function computeOf(req, res = null) {
+ if (!viewer(req)) return { ok: false, sources: [], candidates: [] };
+ try { return await sso.asViewer(req, res, (token) => machines.compute(token)); }
+ catch (e) { return { ok: false, sources: [], candidates: [], problem: { detail: String(e && e.message || 'Link did not answer') } }; }
+ }
/** The person's own recent repositories (no GitHub call): what the pickers offer first. */
/** The person's own recent repositories (no GitHub call): what the pickers offer first. */
async function recentRepos(req, limit = 8) {
async function recentRepos(req, limit = 8) {
if (!viewer(req)) return [];
if (!viewer(req)) return [];
@@ -498,7 +503,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
@@ -498,7 +503,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
if (row.machine) {
if (row.machine) {
const known = await machinesOf(req, res);
const known = await machinesOf(req, res);
const hit = (known.machines || []).find((m) => m.id === row.machine || m.node_id === row.machine);
const hit = (known.machines || []).find((m) => m.id === row.machine || m.node_id === row.machine);
- label = hit ? (hit.name || hit.node_id) : (/^nod_/.test(row.machine) ? 'your machine' : row.machine);
+ label = hit ? (hit.name || hit.node_id) : (/^lnk_/.test(row.machine) ? 'your machine' : row.machine);
}
}
return await conductorPage(req, res, row, label);
return await conductorPage(req, res, row, label);
}
}
@@ -513,7 +518,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
@@ -513,7 +518,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
if (machineName) {
if (machineName) {
const known = await machinesOf(req, res);
const known = await machinesOf(req, res);
const hit = (known.machines || []).find((m) => m.id === machineName || m.node_id === machineName);
const hit = (known.machines || []).find((m) => m.id === machineName || m.node_id === machineName);
- machineName = hit ? (hit.name || hit.node_id || 'Your machine') : (/^nod_/.test(machineName) ? 'Your machine' : machineName);
+ machineName = hit ? (hit.name || hit.node_id || 'Your machine') : (/^lnk_/.test(machineName) ? 'Your machine' : machineName);
}
}
const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;
const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;
const events = `${taskApi}/events`;
const events = `${taskApi}/events`;
@@ -671,7 +676,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
@@ -671,7 +676,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>
return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>
<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>
<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>
${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}
${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}
-${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}
+${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.link/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}
</section>`;
</section>`;
};
};
@@ -737,45 +742,8 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"
@@ -737,45 +742,8 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"
}
}
});
});
- // ── Machines: Node's pairing flow with a one-time code, and each machine's state ───────────────────────────
- const CODE_SHAPE = /^[0-9A-HJKMNP-TV-Z]{8}$/;
- const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));
- r.get('/machines', async (req, res) => {
- const mine = viewer(req) ? await machinesOf(req, res) : { ok: false, machines: [] };
- const raw = normaliseCode(req.query.code);
- const badCode = raw.length > 0 && !CODE_SHAPE.test(raw);
- const code = CODE_SHAPE.test(raw) ? `${raw.slice(0, 4)}-${raw.slice(4)}` : '';
- const list = (mine.machines || []).map((m) => {
- const kind = m.state === 'online' ? 'ok' : m.state === 'revoked' ? 'bad' : 'skip';
- return `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${kind}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${esc(machines.STATE_WORD[m.state] || m.state)}${m.last_seen_at ? ` · seen ${esc(age(m.last_seen_at))}` : ' · never seen'}${m.paired_for && m.paired_for.service ? ` · paired for ${esc(m.paired_for.service)}` : ''}</span>${m.state === 'revoked' ? pill('Revoked', 'bad') : `<form method="post" action="/machines/${esc(m.id)}/revoke"><button class="c4-opill c4-opill-quiet" type="submit">Revoke</button></form>`}</li>`;
- }).join('');
- const pairCard = `<section class="c4-card" id="pair"><div class="c4-card-head"><h2>Pair a machine</h2></div>
-<p class="c4-sub">One command, one code</p>
-<ol class="c4-steps">
-<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. It lasts ten minutes and works once.</li>
-<li><b>Run the installer on the machine</b>, with the code it gives you: <code class="c4-code-inline">curl -fsSL https://openvibe.link/install | sh -s -- --worker</code></li>
-<li><b>It pairs itself</b>, then appears here as Online. A machine runs your jobs and nobody else's.</li>
-</ol>
-<form class="c4-form c4-form-inline" method="get" action="/machines">
- <label>Have a code? <input name="code" value="${esc(String(req.query.code || ''))}" placeholder="ABCD-1234" inputmode="text" autocomplete="off" spellcheck="false" maxlength="12"></label>
- <button class="c4-button" type="submit">Show the command</button>
-</form>
-${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad') : ''}
-${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.link/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}
-</section>`;
- const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2></div><p class="c4-sub">${(mine.machines || []).length} paired</p><p class="c4-hint"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a>.</p>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;
- page(req, res, 'Machines', wrap(content));
- });
- r.post('/machines/:id/revoke', form, async (req, res) => {
- if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/machines')}`);
- if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
- try {
- const out = await sso.asViewer(req, res, (token) => machines.revoke(token, req.params.id));
- return res.redirect(303, `/machines?${out.ok ? 'ok' : 'error'}=${encodeURIComponent(out.ok ? 'The machine is revoked: it can no longer run your work' : (out.problem && out.problem.detail) || 'the machine could not be revoked')}`);
- } catch (e) {
- return res.redirect(303, `/machines?error=${encodeURIComponent(String((e && e.message) || 'the machine could not be revoked').slice(0, 180))}`);
- }
- });
+ // Machine management lives on Link. Existing Codes bookmarks lead to the owner’s Link hub.
+ r.get('/machines', (_req, res) => res.redirect(301, 'https://openvibe.link/machines'));
// ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────
// ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────
r.get('/usage', async (req, res) => {
r.get('/usage', async (req, res) => {
@@ -897,7 +865,7 @@ ${conductorSection}
@@ -897,7 +865,7 @@ ${conductorSection}
// reads the database only, through ctx.setupProgress (the chat app's home asks it).
// reads the database only, through ctx.setupProgress (the chat app's home asks it).
const setupRoutes = require('./setup').createSetupRoutes(ctx, {
const setupRoutes = require('./setup').createSetupRoutes(ctx, {
page: (req, res, title, body) => page(req, res, title, body, { css: ['css/setup.css'], scripts: ['js/setup.js'] }),
page: (req, res, title, body) => page(req, res, title, body, { css: ['css/setup.css'], scripts: ['js/setup.js'] }),
- wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS, YOUR_AI, PROVIDER_NAMES,
+ wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, computeOf, usageMeter, SAFETY_OPTIONS, YOUR_AI, PROVIDER_NAMES,
});
});
r.use(setupRoutes);
r.use(setupRoutes);
ctx.setupProgress = setupRoutes.setupProgress;
ctx.setupProgress = setupRoutes.setupProgress;
@@ -208,7 +208,7 @@ function createChatRoutes(ctx) {
@@ -208,7 +208,7 @@ function createChatRoutes(ctx) {
nav: [
nav: [
place('Conductor', '/conductor', 'conductor'),
place('Conductor', '/conductor', 'conductor'),
place('Agents', '/agents', 'agents'),
place('Agents', '/agents', 'agents'),
- place('Machines', '/machines', 'machines'),
+ place('Machines', 'https://openvibe.link/machines', 'machines'),
place('OpenVibe Git', '/git', 'repo'),
place('OpenVibe Git', '/git', 'repo'),
place('Memory', '/memory', 'memory'),
place('Memory', '/memory', 'memory'),
place('Knowledge', '/knowledge', 'proof'),
place('Knowledge', '/knowledge', 'proof'),
@@ -515,11 +515,19 @@ function createChatRoutes(ctx) {
@@ -515,11 +515,19 @@ function createChatRoutes(ctx) {
const out = await Promise.race([read, late]).finally(() => clearTimeout(timer));
const out = await Promise.race([read, late]).finally(() => clearTimeout(timer));
return out && out.ok ? (out.machines || []).filter((m) => m && m.state !== 'revoked') : null;
return out && out.ok ? (out.machines || []).filter((m) => m && m.state !== 'revoked') : null;
}
}
+ async function computeFor(req) {
+ const token = req.token || (req.viewer && req.viewer.token) || null;
+ if (!token || !machines?.compute) return null;
+ let timer;
+ const late = new Promise((resolve) => { timer = setTimeout(resolve, 1500, null); if (timer.unref) timer.unref(); });
+ const read = Promise.resolve().then(() => machines.compute(token)).catch(() => null);
+ return await Promise.race([read, late]).finally(() => clearTimeout(timer));
+ }
/** Where an agent's work runs, in one line: the person's own computer (which one, or how to pair one). */
/** Where an agent's work runs, in one line: the person's own computer (which one, or how to pair one). */
function whereAgents(list) {
function whereAgents(list) {
const tail = '<span class="ch-where-tail">in a fresh copy of the code, with a pull request at the end</span>';
const tail = '<span class="ch-where-tail">in a fresh copy of the code, with a pull request at the end</span>';
if (list == null) return `${ico('machines')}<span>Runs on your computer ${tail}</span>`;
if (list == null) return `${ico('machines')}<span>Runs on your computer ${tail}</span>`;
- if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="/machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;
+ if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="https://openvibe.link/machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;
const on = list.find((m) => m.state === 'online');
const on = list.find((m) => m.state === 'online');
const m = on || list[0];
const m = on || list[0];
const name = esc(m.name || m.node_id || 'your computer');
const name = esc(m.name || m.node_id || 'your computer');
@@ -532,9 +540,10 @@ function createChatRoutes(ctx) {
@@ -532,9 +540,10 @@ function createChatRoutes(ctx) {
r.get('/', async (req, res, next) => {
r.get('/', async (req, res, next) => {
if (!viewer(req)) return next();
if (!viewer(req)) return next();
const settings = await userSettings.get(req.viewer.subject).catch(() => ({}));
const settings = await userSettings.get(req.viewer.subject).catch(() => ({}));
- const [repos, mine] = await Promise.all([
+ const [repos, mine, compute] = await Promise.all([
github ? github.recent(req.viewer.subject, 6).catch(() => []) : [],
github ? github.recent(req.viewer.subject, 6).catch(() => []) : [],
machinesFor(req),
machinesFor(req),
+ computeFor(req),
]);
]);
const asked = String(req.query.repo || '');
const asked = String(req.query.repo || '');
// A repository shows by its name (owner/name), not a cut-off URL; the server reads either.
// A repository shows by its name (owner/name), not a cut-off URL; the server reads either.
@@ -567,11 +576,14 @@ function createChatRoutes(ctx) {
@@ -567,11 +576,14 @@ function createChatRoutes(ctx) {
const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
const ready = await readyFor(req);
const ready = await readyFor(req);
const catalog = await aiModels();
const catalog = await aiModels();
+ const choices = (compute?.candidates || []).map((m) => `<option value="${esc(m.id)}"${m.status !== 'ready' ? ' disabled' : ''}${optionAttrs({ state: m.status === 'ready' ? 'ok' : 'off', badge: m.status === 'ready' ? 'Ready' : 'Unavailable', desc: m.reason || (m.price ? `$${m.price.amount_usd}/${m.price.unit}` : 'No rental fee') })}>${esc(m.name)} · ${m.status === 'ready' ? 'Ready' : esc(m.reason || 'Unavailable')}</option>`).join('');
+ const otherSources = (compute?.sources || []).filter((s) => s.kind !== 'personal').map((s) => `<option value="" disabled>${esc(s.name)} · ${s.status === 'coming_soon' ? 'Coming soon' : esc(s.reason || 'Unavailable')}${s.price ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}</option>`).join('');
+ const runsOnPicker = `<label class="ch-pick ch-for-agents" title="Runs on"><span class="ch-sr">Runs on</span>${ico('machines')}<select name="machine" data-ov-select="bare" data-ov-title="Runs on"><option value="">Auto · your ready Link machine</option>${choices}${otherSources}</select>${ico('chevron', 'ch-caret')}</label>`;
+
// In the bar: the clip and, for your own code, the repository (the one choice that cannot be guessed). Everything
// In the bar: the clip and, for your own code, the repository (the one choice that cannot be guessed). Everything
// else is behind Options (11-09, the owner: "too many options… hidden under a cog"), and a changed one shows as a chip.
// else is behind Options (11-09, the owner: "too many options… hidden under a cog"), and a changed one shows as a chip.
const pickers = repoPick;
const pickers = repoPick;
- const options = kindPick + projectPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready, catalog }) + effortPicker('', 'ch-for-agents');
- // The repositories a person used last are a tap away (the box also searches every one they can reach).
+ const options = kindPick + projectPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready, catalog }) + effortPicker('', 'ch-for-agents') + runsOnPicker; // The repositories a person used last are a tap away (the box also searches every one they can reach).
const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
const picked = `<p class="ch-picked ch-for-openvibe" data-ch-picked data-ch-match-url="/chat/match" aria-live="polite">${ico('ov')}<span data-ch-picked-text>Say what you want changed and which site, in your own words: Codes works out which projects to change.</span></p>`;
const picked = `<p class="ch-picked ch-for-openvibe" data-ch-picked data-ch-match-url="/chat/match" aria-live="polite">${ico('ov')}<span data-ch-picked-text>Say what you want changed and which site, in your own words: Codes works out which projects to change.</span></p>`;
@@ -668,6 +680,13 @@ function createChatRoutes(ctx) {
@@ -668,6 +680,13 @@ function createChatRoutes(ctx) {
try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
if (!project) return back('Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');
if (!project) return back('Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');
if (agent) await userSettings.save(req.viewer.subject, { default_agent: agent }).catch(() => {});
if (agent) await userSettings.save(req.viewer.subject, { default_agent: agent }).catch(() => {});
+ let pin = null;
+ if (b.machine) {
+ const token = req.token || (req.viewer && req.viewer.token) || null;
+ const chosen = await machines.pick(token, String(b.machine)).catch(() => null);
+ if (!chosen?.ok || !chosen.choice?.id) return back((chosen?.problem && chosen.problem.detail) || 'Link could not check that machine.');
+ pin = chosen.choice.id;
+ }
if (targets && targets.length > 1) {
if (targets && targets.length > 1) {
// One request across several projects: a task for each, each told what the others are, so the parts agree.
// One request across several projects: a task for each, each told what the others are, so the parts agree.
const names = targets.map((x) => x.repository.replace(/^OpenVibers\//, ''));
const names = targets.map((x) => x.repository.replace(/^OpenVibers\//, ''));
@@ -675,12 +694,12 @@ function createChatRoutes(ctx) {
@@ -675,12 +694,12 @@ function createChatRoutes(ctx) {
for (const [i, x] of targets.entries()) {
for (const [i, x] of targets.entries()) {
const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;
const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;
const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));
const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));
- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [] });
+ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [], pin });
if (!first) first = row;
if (!first) first = row;
}
}
return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);
return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);
}
}
- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids });
+ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids, pin });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
});
});
@@ -114,7 +114,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -114,7 +114,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
/**
/**
* The machine a conductor runs on, pinned (Codes C9c): its OpenVibe.Bot device id (`dev_…`), which is what Run
* The machine a conductor runs on, pinned (Codes C9c): its OpenVibe.Bot device id (`dev_…`), which is what Run
- * places a job on. Network knows the machine by its principal (`nod_…`); Bot's list names both. A machine Codes
+ * places a job on. Network knows the machine by its principal (`lnk_…`); Bot's list names both. A machine Codes
* cannot find there, or a Bot that does not answer, is a problem — a conductor must never fall back to running
* cannot find there, or a Bot that does not answer, is a problem — a conductor must never fall back to running
* wherever Run likes, because its folder and its memory are on the chosen machine.
* wherever Run likes, because its folder and its memory are on the chosen machine.
*/
*/
@@ -127,7 +127,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -127,7 +127,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);
log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);
return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };
return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };
}
}
- if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.bot/machines: add it there, so a conductor can run where its folder is' } };
+ if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.link/machines: add it there, so a conductor can run where its folder is' } };
if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };
if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };
return { device: hit.device };
return { device: hit.device };
}
}
@@ -313,8 +313,8 @@ function createMemoryRoutes(ctx, kit) {
@@ -313,8 +313,8 @@ function createMemoryRoutes(ctx, kit) {
+ rows.length + (rows.length === 1 ? ' source' : ' sources') + '</span></div>';
+ rows.length + (rows.length === 1 ? ' source' : ' sources') + '</span></div>';
if (!rows.length) {
if (!rows.length) {
return head + '<div class="c4-emptyline">Nothing here yet. On your computer, run '
return head + '<div class="c4-emptyline">Nothing here yet. On your computer, run '
- + '<code class="c4-code-inline">openvibe-node memory upload</code>: it shows what it found and asks before sending anything. '
- + '<a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/memory.md#the-upload">The Link docs</a></div></section>';
+ + '<code class="c4-code-inline">openvibe-link memory upload</code>: it shows what it found and asks before sending anything. '
+ + '<a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/memory.md#the-upload">The Link docs</a></div></section>';
}
}
const sourceRow = (row) => {
const sourceRow = (row) => {
const fresh = row.seen && Date.now() - row.seen < SEVEN_DAYS;
const fresh = row.seen && Date.now() - row.seen < SEVEN_DAYS;
@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.
@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.
const statusTable = table(['Piece', 'State'], [
const statusTable = table(['Piece', 'State'], [
[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
- [html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.bot/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],
+ [html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],
[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* POST /run/tasks/:id/cancel stop it
* POST /run/tasks/:id/cancel stop it
*
*
* Every task is its owner's alone (another person's is 404). Forms post only from this site. The job runs where Run
* Every task is its owner's alone (another person's is 404). Forms post only from this site. The job runs where Run
- * places it: on a machine the person added on openvibe.bot/machines, never anyone else's (contracts 0.161.0).
+ * places it: on a machine the person added on openvibe.link/machines, never anyone else's (contracts 0.161.0).
*/
*/
const express = require('express');
const express = require('express');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
@@ -24,7 +24,7 @@ const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks')
@@ -24,7 +24,7 @@ const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks')
const { createSessionLines } = require('../domain/session-lines');
const { createSessionLines } = require('../domain/session-lines');
const DELEGABLE = new Set(['owner', 'admin']);
const DELEGABLE = new Set(['owner', 'admin']);
-const MACHINES_URL = 'https://openvibe.bot/machines';
+const MACHINES_URL = 'https://openvibe.link/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. */
/** The task's outcome in the words a person reads. */
@@ -139,7 +139,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -139,7 +139,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
title: 'Run on your machine', index: true,
title: 'Run on your machine', index: true,
body: html`<h1>Run a coding task on your own machine</h1>
body: html`<h1>Run a coding task on your own machine</h1>
<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>
<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>
-<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.bot/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>
+<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>
<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,
<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,
});
});
}
}
@@ -149,7 +149,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -149,7 +149,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
]);
]);
const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';
const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';
const body = html`<h1>Run on your machine</h1>
const body = html`<h1>Run on your machine</h1>
-<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.bot/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
+<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.link/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
${defaults}
${defaults}
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
@@ -233,7 +233,7 @@ ${tasksHtml(recent)}`;
@@ -233,7 +233,7 @@ ${tasksHtml(recent)}`;
const files = Array.isArray(d.files) ? d.files : [];
const files = Array.isArray(d.files) ? d.files : [];
const push = d.push || null;
const push = d.push || null;
const verdict = row.state !== 'succeeded'
const verdict = row.state !== 'succeeded'
- ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.bot/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
+ ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.link/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
const ended = !(session && row.state === 'succeeded' && d.ok);
const ended = !(session && row.state === 'succeeded' && d.ok);
@@ -28,7 +28,7 @@ const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover'
@@ -28,7 +28,7 @@ const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover'
const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
function createSetupRoutes(ctx, kit) {
function createSetupRoutes(ctx, kit) {
- const { page, wrap, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS = [], YOUR_AI, PROVIDER_NAMES = {} } = kit;
+ const { page, wrap, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, computeOf, usageMeter, SAFETY_OPTIONS = [], YOUR_AI, PROVIDER_NAMES = {} } = kit;
const { store, fleet, harnesses, connections, sso, forge, userSettings } = ctx;
const { store, fleet, harnesses, connections, sso, forge, userSettings } = ctx;
const db = store.db;
const db = store.db;
const r = asyncRouter();
const r = asyncRouter();
@@ -40,6 +40,7 @@ function createSetupRoutes(ctx, kit) {
@@ -40,6 +40,7 @@ function createSetupRoutes(ctx, kit) {
async function readAll(req, res) {
async function readAll(req, res) {
const subject = req.viewer.subject;
const subject = req.viewer.subject;
const machineList = await machinesOf(req, res);
const machineList = await machinesOf(req, res);
+ const compute = await computeOf(req, res);
let fleetProblem = null;
let fleetProblem = null;
if (fleet) {
if (fleet) {
try {
try {
@@ -54,16 +55,16 @@ function createSetupRoutes(ctx, kit) {
@@ -54,16 +55,16 @@ function createSetupRoutes(ctx, kit) {
try { git = forge && forge.enabled ? await forge.link(subject) : null; } catch { git = null; }
try { git = forge && forge.enabled ? await forge.link(subject) : null; } catch { git = null; }
const settings = await userSettings.get(subject);
const settings = await userSettings.get(subject);
const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));
const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));
- return { machines: machineList, rows, fleetProblem, keys, git, settings, saved };
+ return { machines: machineList, compute, rows, fleetProblem, keys, git, settings, saved };
}
}
function stepsOf(d) {
function stepsOf(d) {
const live = (d.machines.machines || []).filter((m) => m.state !== 'revoked');
const live = (d.machines.machines || []).filter((m) => m.state !== 'revoked');
- const online = live.filter((m) => m.state === 'online');
+ const online = (d.compute.candidates || []).filter((m) => m.status === 'ready');
const ready = d.rows.filter((x) => x.state === 'ok' || x.state === 'low');
const ready = d.rows.filter((x) => x.state === 'ok' || x.state === 'low');
const credentials = (d.keys && d.keys.credentials) || [];
const credentials = (d.keys && d.keys.credentials) || [];
return [
return [
- { id: 'machine', title: 'Your machine', short: 'Machine', done: online.length > 0, warn: !online.length && live.length > 0, required: true },
+ { id: 'machine', title: 'Pick where it runs', short: 'Runs on', done: online.length > 0, warn: !online.length && live.length > 0, required: true },
{ id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },
{ id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },
{ id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },
{ id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },
{ id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!d.git, optional: true },
{ id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!d.git, optional: true },
@@ -80,14 +81,16 @@ function createSetupRoutes(ctx, kit) {
@@ -80,14 +81,16 @@ function createSetupRoutes(ctx, kit) {
function machineBody(d) {
function machineBody(d) {
const list = (d.machines.machines || []).filter((m) => m.state !== 'revoked');
const list = (d.machines.machines || []).filter((m) => m.state !== 'revoked');
+ const sources = d.compute.sources || [];
+ const sourceRows = sources.map((s) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${s.status === 'ready' ? ' c4-dot-ok' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(s.name)}</span><span class="c4-row-note">${s.status === 'ready' ? 'Ready' : s.status === 'coming_soon' ? 'Coming soon' : 'Unavailable'}${s.price && s.price.amount_usd != null ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}${s.reason ? ` · ${esc(s.reason)}` : ''}</span></li>`).join('');
const rows = list.map((m) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${m.state === 'online' ? 'ok' : 'skip'}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${m.state === 'online' ? 'Online: ready for your work' : 'Offline: start it, and it comes back here'}</span></li>`).join('');
const rows = list.map((m) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${m.state === 'online' ? 'ok' : 'skip'}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${m.state === 'online' ? 'Online: ready for your work' : 'Offline: start it, and it comes back here'}</span></li>`).join('');
const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';
const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';
const how = `<ol class="c4-setup-how">
const how = `<ol class="c4-setup-how">
-<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines" rel="noopener">openvibe.bot/machines</a>. It lasts ten minutes.</li>
+<li><b>Get a one-time code</b> on <a href="https://openvibe.link/machines" rel="noopener">openvibe.link/machines</a>. It lasts ten minutes.</li>
<li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.link/install | sh -s -- --worker', 'On the machine')}</li>
<li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.link/install | sh -s -- --worker', 'On the machine')}</li>
<li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;
<li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;
- return `${problem}${rows ? `<ul class="c4-rows">${rows}</ul>` : ''}${list.length ? `<details class="c4-setup-more"><summary>Pair another machine</summary>${how}</details>` : how}
-<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;
+ return `${problem}${d.compute.ok === false && d.compute.problem ? note(esc(d.compute.problem.detail), 'bad') : ''}<p class="c4-hint">Choose a place for your work. Link checks readiness before a task can start. Rentals need a live price and a spending limit.</p>${sourceRows ? `<ul class="c4-rows">${sourceRows}</ul>` : ''}${rows ? `<ul class="c4-rows">${rows}</ul>` : ''}${list.length ? `<details class="c4-setup-more"><summary>Pair another machine</summary>${how}</details>` : how}
+<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="https://openvibe.link/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;
}
}
function agentsBody(d) {
function agentsBody(d) {
@@ -166,7 +169,7 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline
@@ -166,7 +169,7 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline
const requiredLeft = steps.filter((s) => s.required && !s.done).length;
const requiredLeft = steps.filter((s) => s.required && !s.done).length;
const pctDone = Math.round((done / steps.length) * 100);
const pctDone = Math.round((done / steps.length) * 100);
const subs = {
const subs = {
- machine: 'Where your agents run: one of your own computers, paired once.',
+ machine: 'Your Link machines, OpenVibe cloud, Exchange rentals and community machines.',
agents: 'The coding agents on it, signed in with your own plans.',
agents: 'The coding agents on it, signed in with your own plans.',
keys: 'Keys for the models you pay for by use.',
keys: 'Keys for the models you pay for by use.',
accounts: 'Where your work is pushed.',
accounts: 'Where your work is pushed.',
@@ -78,7 +78,7 @@ ${audience ? html`<div class="field"><label for="sk-audience">Who may read it</l
@@ -78,7 +78,7 @@ ${audience ? html`<div class="field"><label for="sk-audience">Who may read it</l
description: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',
description: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',
crumbs: [{ label: 'Skills' }],
crumbs: [{ label: 'Skills' }],
body: html`<h1>Skills</h1>
body: html`<h1>Skills</h1>
-<p class="lede">Skills, rules and MCP servers kept per person and per project, versioned, and synced into each agent tool's own folder on your machine. Add one from the gallery; <code>openvibe-node skills sync</code> writes it inside its marked block, with a dry run, a diff and an undo.</p>
+<p class="lede">Skills, rules and MCP servers kept per person and per project, versioned, and synced into each agent tool's own folder on your machine. Add one from the gallery; <code>openvibe-link skills sync</code> writes it inside its marked block, with a dry run, a diff and an undo.</p>
${error ? notice(error, 'bad') : ''}
${error ? notice(error, 'bad') : ''}
<form class="inline-form" method="get" action="/skills" role="search">
<form class="inline-form" method="get" action="/skills" role="search">
<label>Search the gallery <input name="q" value="${q}" maxlength="80"></label>
<label>Search the gallery <input name="q" value="${q}" maxlength="80"></label>
@@ -207,7 +207,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -207,7 +207,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
}
}
const project = await taskStore.latestProject(p.subject);
const project = await taskStore.latestProject(p.subject);
if (!project) {
if (!project) {
- return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.bot/machines)`);
+ return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.link/machines)`);
}
}
let row;
let row;
try {
try {
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/boot');
const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');
const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');
-const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';
+const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';
/** A child MCP server that answers one JSON-RPC message at a time. */
/** A child MCP server that answers one JSON-RPC message at a time. */
function mcpChild(env) {
function mcpChild(env) {
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/boot');
const RESET = nextWednesdayUtc();
const RESET = nextWednesdayUtc();
// The machine, as OpenVibe.Bot answers it: the page reads Bot and keeps the latest reading per machine.
// The machine, as OpenVibe.Bot answers it: the page reads Bot and keeps the latest reading per machine.
t.bot.addMachine({
t.bot.addMachine({
- id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),
+ id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),
fleet: [
fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
@@ -32,9 +32,9 @@ const { boot, check, done } = require('./helpers/boot');
@@ -32,9 +32,9 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await t.ctx.store.db.exec(
await t.ctx.store.db.exec(
`INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)
`INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)
- VALUES ($1,'dev_ada_laptop','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),
- ($1,'dev_ada_laptop','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),
- ($1,'dev_ada_laptop','nod_ada','ada-laptop','opencode',true,false,'[]'::jsonb,true,now(),now())`,
+ VALUES ($1,'dev_ada_laptop','lnk_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),
+ ($1,'dev_ada_laptop','lnk_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),
+ ($1,'dev_ada_laptop','lnk_ada','ada-laptop','opencode',true,false,'[]'::jsonb,true,now(),now())`,
[ada.subject, JSON.stringify([{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }])],
[ada.subject, JSON.stringify([{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }])],
);
);
@@ -8,12 +8,14 @@ const { boot, check, done } = require('./helpers/boot');
@@ -8,12 +8,14 @@ const { boot, check, done } = require('./helpers/boot');
const ada = t.network.addUser('c4-ada');
const ada = t.network.addUser('c4-ada');
try {
try {
await check('all C4 pages render readable server content without JavaScript', async () => {
await check('all C4 pages render readable server content without JavaScript', async () => {
- for (const [path, label] of [['/', 'What should an agent do?'], ['/tasks', 'Tasks'], ['/agents', 'Agents'], ['/machines', 'Machines'], ['/usage', 'Usage'], ['/settings', 'Settings']]) {
+ for (const [path, label] of [['/', 'What should an agent do?'], ['/tasks', 'Tasks'], ['/agents', 'Agents'], ['/usage', 'Usage'], ['/settings', 'Settings']]) {
const r = await t.get(path);
const r = await t.get(path);
assert.strictEqual(r.status, 200, `${path} status`);
assert.strictEqual(r.status, 200, `${path} status`);
assert.ok(r.text.includes(label), `${path} content`);
assert.ok(r.text.includes(label), `${path} content`);
assert.ok(r.text.includes('ov-workspace'), `${path} reserves the Frame app bar's height`);
assert.ok(r.text.includes('ov-workspace'), `${path} reserves the Frame app bar's height`);
}
}
+ const machines = await t.get('/machines');
+ assert.strictEqual(machines.status, 301, '/machines moves to the Link product');
const task = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV');
const task = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV');
assert.strictEqual(task.status, 200);
assert.strictEqual(task.status, 200);
assert.ok(task.text.includes('Sign in to see your work'));
assert.ok(task.text.includes('Sign in to see your work'));
@@ -12,8 +12,8 @@
@@ -12,8 +12,8 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
-const NODE = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV';
-const OTHER = 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAC';
+const NODE = 'lnk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
+const OTHER = 'lnk_01ARZ3NDEKTSV4RRFFQ69G5FAC';
const TASK = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
const TASK = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
const TASK2 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAW';
const TASK2 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAW';
const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -139,29 +139,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -139,29 +139,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(empty.text.includes('No keys yet.') && empty.text.includes('Add one on Your AI'));
assert.ok(empty.text.includes('No keys yet.') && empty.text.includes('Add one on Your AI'));
});
});
- await check('Machines: the person’s own machines, their state, and Node’s pairing code flow', async () => {
+ await check('Machine management leads to Link', async () => {
const page = await t.get('/machines', { as: ada });
const page = await t.get('/machines', { as: ada });
- assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('Desk'), 'the paired machine is listed');
- assert.ok(page.text.includes('Online'), 'with its state');
- assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
- assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
- assert.ok(page.text.includes('All your machines are on openvibe.network/machines'), 'the page points at the network’s machines hub');
- assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ovk-sidebar-open'), 'the app has no tab bar: the rail is its navigation (L31)');
- const coded = await t.get('/machines?code=abcd-2345', { as: ada });
- assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ovk-sidebar-open'), 'a query string keeps the page in the app');
- assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
- const wrong = await t.get('/machines?code=nope', { as: ada });
- assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');
- const revoke = await t.get(`/machines/${NODE}/revoke`, { as: ada, method: 'POST', form: { x: '1' } });
- assert.strictEqual(revoke.status, 303);
- const after = await t.get('/machines', { as: ada });
- assert.ok(after.text.includes('Revoked'), 'the machine is revoked');
- // Bo cannot revoke Ada's machine.
- t.network.addNode(ada.subject, { principal: 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAA', node_id: 'node-other', name: 'Other', status: 'active', last_seen_at: new Date().toISOString() });
- const stolen = await t.get('/machines/nod_01ARZ3NDEKTSV4RRFFQ69G5FAA/revoke', { as: bo, method: 'POST', form: { x: '1' } });
- assert.strictEqual(stolen.status, 303);
- assert.ok(String(stolen.headers.get('location')).includes('error='), 'another person’s machine is refused');
+ assert.strictEqual(page.status, 301);
+ assert.strictEqual(page.headers.get('location'), 'https://openvibe.link/machines');
});
});
await check('Settings are remembered per person', async () => {
await check('Settings are remembered per person', async () => {
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
-const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';
+const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';
// Measured from now, so the fleet stays spent however late the test runs (fixed dates went stale on 2026-11-02).
// Measured from now, so the fleet stays spent however late the test runs (fixed dates went stale on 2026-11-02).
const RESET_WEEK = new Date(Date.now() + 4.5 * 86400000).toISOString();
const RESET_WEEK = new Date(Date.now() + 4.5 * 86400000).toISOString();
const RESET_MONTH = new Date(Date.now() + 2.5 * 86400000).toISOString();
const RESET_MONTH = new Date(Date.now() + 2.5 * 86400000).toISOString();
@@ -52,11 +52,11 @@ const RESET_MONTH = new Date(Date.now() + 2.5 * 86400000).toISOString();
@@ -52,11 +52,11 @@ const RESET_MONTH = new Date(Date.now() + 2.5 * 86400000).toISOString();
});
});
await db.exec(
await db.exec(
`INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)
`INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)
- VALUES ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),
- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','opencode',true,true,$3::jsonb,true,now(),now()),
- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','command-code',true,true,$4::jsonb,true,now(),now()),
- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),
- ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','deepseek',true,true,'[]'::jsonb,true,now(),now())`,
+ VALUES ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','opencode',true,true,$3::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','command-code',true,true,$4::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','lnk_ada','ada-laptop','deepseek',true,true,'[]'::jsonb,true,now(),now())`,
[ada.subject, window('week', 100, RESET_WEEK), window('month', 100, RESET_MONTH), window('week', 96, RESET_WEEK)],
[ada.subject, window('week', 100, RESET_WEEK), window('month', 100, RESET_MONTH), window('week', 96, RESET_WEEK)],
);
);
@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/boot');
const failover = require('../server/domain/conductor-failover');
const failover = require('../server/domain/conductor-failover');
const job = require('../harness/job');
const job = require('../harness/job');
-const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';
+const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';
const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';
const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';
const HOUR = 3600_000;
const HOUR = 3600_000;
@@ -12,7 +12,7 @@ const assert = require('assert');
@@ -12,7 +12,7 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
-const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
+const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
(async () => {
(async () => {
@@ -134,7 +134,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -134,7 +134,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
});
});
await check('a conductor is started only where its folder is: no such machine on Bot, away, or Bot itself away', async () => {
await check('a conductor is started only where its folder is: no such machine on Bot, away, or Bot itself away', async () => {
- const other = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';
+ const other = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';
t.network.addNode(ada.subject, { principal: other, node_id: 'node_ada_desktop', name: 'ada-desktop', status: 'active', last_seen_at: new Date().toISOString() });
t.network.addNode(ada.subject, { principal: other, node_id: 'node_ada_desktop', name: 'ada-desktop', status: 'active', last_seen_at: new Date().toISOString() });
const before = submits().length;
const before = submits().length;
const unknown = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: other });
const unknown = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: other });
@@ -27,7 +27,7 @@ const DIR = path.join(__dirname, '..', 'engine');
@@ -27,7 +27,7 @@ const DIR = path.join(__dirname, '..', 'engine');
for (const m of source.matchAll(/require\('([^']+)'\)/g)) {
for (const m of source.matchAll(/require\('([^']+)'\)/g)) {
const name = m[1];
const name = m[1];
if (name.startsWith('.')) continue;
if (name.startsWith('.')) continue;
- assert.ok(builtins.has(name.replace(/^node:/, '')), `${file} requires ${name}, which is not a node builtin`);
+ assert.ok(builtins.has(name.replace(/^link:/, '')), `${file} requires ${name}, which is not a node builtin`);
}
}
}
}
});
});
@@ -26,11 +26,11 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
@@ -26,11 +26,11 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
try {
try {
await check('the heartbeat report is stored per machine and agent, and answered per machine', async () => {
await check('the heartbeat report is stored per machine and agent, and answered per machine', async () => {
let doc = machines([
let doc = machines([
- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
] },
] },
- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: false, fleet: [
+ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: false, fleet: [
{ agent: 'opencode', installed: true, signed_in: false, windows: [] },
{ agent: 'opencode', installed: true, signed_in: false, windows: [] },
] },
] },
]);
]);
@@ -39,7 +39,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
@@ -39,7 +39,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
assert.equal(out.ok, true);
assert.equal(out.ok, true);
assert.equal(out.machines.length, 2);
assert.equal(out.machines.length, 2);
const laptop = out.machines.find((m) => m.id === 'dev_ada_laptop');
const laptop = out.machines.find((m) => m.id === 'dev_ada_laptop');
- assert.equal(laptop.node_id, 'nod_ada');
+ assert.equal(laptop.node_id, 'lnk_ada');
assert.equal(laptop.agents.length, 2);
assert.equal(laptop.agents.length, 2);
assert.equal(laptop.agents[0].windows[0].pct, 100, 'the pct is derived from used/limit');
assert.equal(laptop.agents[0].windows[0].pct, 100, 'the pct is derived from used/limit');
assert.equal(laptop.agents[0].windows[0].resets_at, new Date(RESET).toISOString());
assert.equal(laptop.agents[0].windows[0].resets_at, new Date(RESET).toISOString());
@@ -49,7 +49,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
@@ -49,7 +49,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
await check('the page rows: one per agent, a state each, usable first', async () => {
await check('the page rows: one per agent, a state each, usable first', async () => {
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [{ name: 'week', used: 10, limit: 100, resets_at: RESET }] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [{ name: 'week', used: 10, limit: 100, resets_at: RESET }] },
{ agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 84, resets_at: RESET }] },
{ agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 84, resets_at: RESET }] },
@@ -71,7 +71,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
@@ -71,7 +71,7 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
await check('the router reads the fleet per runner, with the machine that has room', async () => {
await check('the router reads the fleet per runner, with the machine that has room', async () => {
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
{ agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
] },
] },
@@ -86,10 +86,10 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
@@ -86,10 +86,10 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
await check('a plan with room on one machine is usable, and the meter shows the fullest reading', async () => {
await check('a plan with room on one machine is usable, and the meter shows the fullest reading', async () => {
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
] },
] },
- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [
+ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 10, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 10, resets_at: RESET }] },
] },
] },
]) });
]) });
@@ -104,10 +104,10 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
@@ -104,10 +104,10 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
await check('a plan that is full on every machine is limited', async () => {
await check('a plan that is full on every machine is limited', async () => {
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
] },
] },
- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [
+ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET }] },
{ agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET }] },
] },
] },
]) });
]) });
@@ -119,17 +119,17 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
@@ -119,17 +119,17 @@ const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
await check('a machine gone from Bot\'s answer is gone here; one that reports no fleet keeps its reading', async () => {
await check('a machine gone from Bot\'s answer is gone here; one that reports no fleet keeps its reading', async () => {
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
- { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { id: 'dev_ada_laptop', node_id: 'lnk_ada', name: 'ada-laptop', online: true, fleet: [
{ agent: 'codex', installed: true, signed_in: true, windows: [] },
{ agent: 'codex', installed: true, signed_in: true, windows: [] },
] },
] },
- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [
+ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true, fleet: [
{ agent: 'opencode', installed: true, signed_in: false, windows: [] },
{ agent: 'opencode', installed: true, signed_in: false, windows: [] },
] },
] },
]) });
]) });
await fleet.refresh(SUBJECT, 'tok');
await fleet.refresh(SUBJECT, 'tok');
// The desk's Node is old and reports no fleet; the laptop is unplugged from the answer.
// The desk's Node is old and reports no fleet; the laptop is unplugged from the answer.
const next = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
const next = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
- { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true },
+ { id: 'dev_ada_desk', node_id: 'lnk_desk', name: 'ada-desk', online: true },
]) });
]) });
await next.refresh(SUBJECT, 'tok');
await next.refresh(SUBJECT, 'tok');
const { machines: left } = await next.latest(SUBJECT);
const { machines: left } = await next.latest(SUBJECT);
@@ -360,7 +360,22 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -360,7 +360,22 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }
? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }
: { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });
: { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });
}
}
- // ── Network's node principals (Codes C4b): a person's own machines ──
+ if (url.pathname === '/api/v1/link/compute' && req.method === 'GET') {
+ const u = userFrom(req);
+ if (!u) return problem(json, 401, 'auth.required', 'sign in to see where work can run');
+ const candidates = [...st.nodes.values()].filter((n) => n.owner === u.subject && n.view.status === 'active').map((n) => ({
+ id: n.view.principal, principal_id: n.view.principal, kind: 'personal', name: n.view.name || n.view.principal,
+ status: n.view.last_seen_at ? 'ready' : 'unavailable', reason: n.view.last_seen_at ? null : 'This machine is offline.',
+ price: { amount_usd: 0, unit: 'hour' },
+ }));
+ return json(200, { sources: [
+ { kind: 'personal', name: 'Your Link machines', status: candidates.some((c) => c.status === 'ready') ? 'ready' : 'unavailable', reason: null },
+ { kind: 'cloud', name: 'OpenVibe cloud machines', status: 'coming_soon', reason: 'No cloud pool is accepting jobs yet.' },
+ { kind: 'exchange', name: 'Exchange GPU rentals', status: 'coming_soon', reason: 'A live quote is required.', price: null },
+ { kind: 'community', name: 'Community machines', status: 'coming_soon', reason: 'Shared placement is being connected.' },
+ ], candidates });
+ }
+ // ── Network's Link principals (Codes C4b): a person's own machines ──
if (url.pathname === '/api/v1/me/nodes' || url.pathname.startsWith('/api/v1/me/nodes/')) {
if (url.pathname === '/api/v1/me/nodes' || url.pathname.startsWith('/api/v1/me/nodes/')) {
const u = userFrom(req);
const u = userFrom(req);
if (!u) return problem(json, 401, 'auth.required', 'sign in to see your machines');
if (!u) return problem(json, 401, 'auth.required', 'sign in to see your machines');
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const root = path.resolve(__dirname, '..');
+const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');
+const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });
+assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');
+assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);
+console.log(' ok old Link brand and principal IDs are absent');
@@ -15,7 +15,7 @@ const { spawn } = require('child_process');
@@ -15,7 +15,7 @@ const { spawn } = require('child_process');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');
const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');
-const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';
+const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';
function mcpChild(env) {
function mcpChild(env) {
const child = spawn(process.execPath, [MCP], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [MCP], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -313,7 +313,7 @@ await check('an empty store shows the empty state and how to fill it', async ()
@@ -313,7 +313,7 @@ await check('an empty store shows the empty state and how to fill it', async ()
const res = await h.get('/memory', { as: user });
const res = await h.get('/memory', { as: user });
assert.equal(res.status, 200);
assert.equal(res.status, 200);
assert.ok(res.text.includes('Nothing here yet'), 'the empty line');
assert.ok(res.text.includes('Nothing here yet'), 'the empty line');
- assert.ok(res.text.includes('openvibe-node memory upload'), 'the command');
+ assert.ok(res.text.includes('openvibe-link memory upload'), 'the command');
assert.ok(res.text.includes('docs/memory.md'), 'the docs');
assert.ok(res.text.includes('docs/memory.md'), 'the docs');
} finally { await h.close(); await store.close(); }
} finally { await h.close(); await store.close(); }
});
});
@@ -44,7 +44,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -44,7 +44,7 @@ const { boot, check, done } = require('./helpers/boot');
return r.json.task;
return r.json.task;
};
};
// The machine the fleet reports, and the agent it runs (a real fleet row, as Bot's answer would carry).
// The machine the fleet reports, and the agent it runs (a real fleet row, as Bot's answer would carry).
- t.bot.addMachine({ id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', node_id: 'nod_01J8Z4M2Q0R7T9YV3K6N8P1W2X', name: 'ada-laptop', online: true,
+ t.bot.addMachine({ id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', node_id: 'lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2X', name: 'ada-laptop', online: true,
fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [] }] });
fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [] }] });
// A person starts a service's task in the project of their latest task: seed one for each.
// A person starts a service's task in the project of their latest task: seed one for each.
for (const [i, subject] of [[1, ada.subject], [2, bo.subject]]) {
for (const [i, subject] of [[1, ada.subject], [2, bo.subject]]) {
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('a guest is told what it is and asked to sign in', async () => {
await check('a guest is told what it is and asked to sign in', async () => {
const r = await t.get('/run');
const r = await t.get('/run');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('Run a coding task on your own machine') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.bot/machines'));
+ assert.ok(r.text.includes('Run a coding task on your own machine') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));
});
});
let project;
let project;
@@ -59,7 +59,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -59,7 +59,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',
'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',
'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
- 'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
+ 'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a lnk_ shape, never a URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
'server/http/memory-share.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory-store/{id}/share with the person\'s own session token: the configured base, the fixed share path and the id from the API route; a JSON body is forwarded as-is, never a URL from a person',
'server/http/memory-share.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory-store/{id}/share with the person\'s own session token: the configured base, the fixed share path and the id from the API route; a JSON body is forwarded as-is, never a URL from a person',
@@ -8,7 +8,7 @@ const assert = require('assert');
@@ -8,7 +8,7 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { readiness, optionAttrs } = require('../server/domain/readiness');
const { readiness, optionAttrs } = require('../server/domain/readiness');
-const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
+const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
(async () => {
(async () => {
@@ -168,7 +168,7 @@ function taskDoc(over = {}) {
@@ -168,7 +168,7 @@ function taskDoc(over = {}) {
assert.match(noToken.err, /no Network token/);
assert.match(noToken.err, /no Network token/);
const badOption = await run(['list', '--nope']);
const badOption = await run(['list', '--nope']);
assert.strictEqual(badOption.code, 2);
assert.strictEqual(badOption.code, 2);
- const namedMachine = await run(['start', '--repo', 'acme/thing', '--file', path.join(dir, 'brief.md'), '--machine', 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1']);
+ const namedMachine = await run(['start', '--repo', 'acme/thing', '--file', path.join(dir, 'brief.md'), '--machine', 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1']);
assert.strictEqual(namedMachine.code, 2);
assert.strictEqual(namedMachine.code, 2);
assert.match(namedMachine.err, /only --machine auto/);
assert.match(namedMachine.err, /only --machine auto/);
});
});