openvibers/openvibe.codes Commits

Show Link compute choices in Codes setup and composer (#335)

OP OpenVibers committed 4 days ago · 52e0fe5103 · 38 files, +177 −158

* Show Link compute choices in setup and composer

* Merge main, and keep the Link compute picker behind Options

Main moved the composer pickers behind Options while this branch added the
Link "Runs on" picker; both are kept, with Runs on in the Options row. An
unpinned task now leaves node off the Run submit instead of sending null, and
/machines is the Link redirect rather than a Codes page.

* Merge remote-tracking branch 'origin/main' into rename/link-1109-Codes

* Merge main and pin openvibe-contracts v0.372.0

Keeps the Link Runs-on picker, the unpinned sub-task submit and the /machines
redirect, and moves the Contracts pin to the released Link tag.

Changes

ddiff --git a/README.md b/README.md
iindex 7be91a8..4e2070d 100644
---- a/README.md
++++ b/README.md
@@@ -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`). |
ddiff --git a/docs/adr/050-codes-harness-fabric.md b/docs/adr/050-codes-harness-fabric.md
iindex deb7bcb..5f57f6d 100644
---- a/docs/adr/050-codes-harness-fabric.md
++++ b/docs/adr/050-codes-harness-fabric.md
@@@ -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
"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 for
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 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 and
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.
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
ddiff --git a/docs/cli.md b/docs/cli.md
iindex 445be61..fd089f2 100644
---- a/docs/cli.md
++++ b/docs/cli.md
@@@ -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
the harness's `read` level; `edit` is the default; `full` may also run commands.
- **`--machine`** is `auto` only: Codes places the task on a machine of yours by itself (listed at
-- <https://openvibe.bot/machines>). A named machine is refused rather than silently ignored.
++ <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
ddiff --git a/engine/README.md b/engine/README.md
iindex 87fb476..f1f50a3 100644
---- a/engine/README.md
++++ b/engine/README.md
@@@ -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.
```js
const engine = require('./engine');
ddiff --git a/package-lock.json b/package-lock.json
iindex 2f2eb7d..145c441 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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",
ddiff --git a/package.json b/package.json
iindex a8d18a3..6539289 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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"
ddiff --git a/server/domain/conductor.js b/server/domain/conductor.js
iindex 77222d5..3e6f467 100644
---- a/server/domain/conductor.js
++++ b/server/domain/conductor.js
@@@ -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;
ddiff --git a/server/domain/fleet.js b/server/domain/fleet.js
iindex 0d19f3e..5e2b9a1 100644
---- a/server/domain/fleet.js
++++ b/server/domain/fleet.js
@@@ -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.
*/
ddiff --git a/server/domain/machines.js b/server/domain/machines.js
iindex 593cf0b..7b8a36e 100644
---- a/server/domain/machines.js
++++ b/server/domain/machines.js
@@@ -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 };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 90a3459..782dff0 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -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}`;
}
-- 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, ou
const 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, ou
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 });
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() });
ddiff --git a/server/domain/user-settings.js b/server/domain/user-settings.js
iindex 51dc284..a176201 100644
---- a/server/domain/user-settings.js
++++ b/server/domain/user-settings.js
@@@ -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;
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex c4092f7..97844c4 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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</h
if (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</h
if (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 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>
<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;
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 02aa197..57bcb38 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -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)}`);
});
ddiff --git a/server/http/conductor-api.js b/server/http/conductor-api.js
iindex 5a7b4c7..ae88bb8 100644
---- a/server/http/conductor-api.js
++++ b/server/http/conductor-api.js
@@@ -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 };
}
ddiff --git a/server/http/memory.js b/server/http/memory.js
iindex a5335f8..f31f09b 100644
---- a/server/http/memory.js
++++ b/server/http/memory.js
@@@ -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;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a8dd45a..2b35ce7 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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.'],
ddiff --git a/server/http/run.js b/server/http/run.js
iindex 13f08e0..a64d165 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -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);
ddiff --git a/server/http/setup.js b/server/http/setup.js
iindex f2369e0..d7c9e8d 100644
---- a/server/http/setup.js
++++ b/server/http/setup.js
@@@ -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-emptyline
const 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.',
ddiff --git a/server/http/skills-pages.js b/server/http/skills-pages.js
iindex 8ad811e..0c68d81 100644
---- a/server/http/skills-pages.js
++++ b/server/http/skills-pages.js
@@@ -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.',
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>
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex c07a091..36b0b3b 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -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 {
ddiff --git a/test/approvals.test.js b/test/approvals.test.js
iindex ef67551..bb725ad 100644
---- a/test/approvals.test.js
++++ b/test/approvals.test.js
@@@ -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) {
ddiff --git a/test/c4-fleet.test.js b/test/c4-fleet.test.js
iindex 30e81e4..59dca6a 100644
---- a/test/c4-fleet.test.js
++++ b/test/c4-fleet.test.js
@@@ -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 }])],
);
ddiff --git a/test/c4.test.js b/test/c4.test.js
iindex ae9309b..be8dd8f 100644
---- a/test/c4.test.js
++++ b/test/c4.test.js
@@@ -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'));
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex e1b6437..665ec0a 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -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&rsquo;s own machines, their state, and Node&rsquo;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&rsquo;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&rsquo;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&rsquo;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 () => {
ddiff --git a/test/codes-auto-routing.test.js b/test/codes-auto-routing.test.js
iindex c799b78..7af9b98 100644
---- a/test/codes-auto-routing.test.js
++++ b/test/codes-auto-routing.test.js
@@@ -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)],
);
ddiff --git a/test/conductor-failover.test.js b/test/conductor-failover.test.js
iindex c4194d9..9ea1db0 100644
---- a/test/conductor-failover.test.js
++++ b/test/conductor-failover.test.js
@@@ -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;
ddiff --git a/test/conductor.test.js b/test/conductor.test.js
iindex 79ee775..b338794 100644
---- a/test/conductor.test.js
++++ b/test/conductor.test.js
@@@ -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 });
ddiff --git a/test/engine-index.test.js b/test/engine-index.test.js
iindex 374f34f..3e8fcaf 100644
---- a/test/engine-index.test.js
++++ b/test/engine-index.test.js
@@@ -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`);
}
}
});
ddiff --git a/test/fleet.test.js b/test/fleet.test.js
iindex 51ebdf0..241a318 100644
---- a/test/fleet.test.js
++++ b/test/fleet.test.js
@@@ -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);
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex ba718c5..69a75b4 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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');
ddiff --git a/test/link-brand.test.js b/test/link-brand.test.js
nnew file mode 100644
iindex 0000000..eaa904f
---- /dev/null
++++ b/test/link-brand.test.js
@@@ -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');
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
iindex adbfd84..ea29417 100644
---- a/test/mcp.test.js
++++ b/test/mcp.test.js
@@@ -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'] });
ddiff --git a/test/memory.test.js b/test/memory.test.js
iindex bcd4ee0..0e774e6 100644
---- a/test/memory.test.js
++++ b/test/memory.test.js
@@@ -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(); }
});
ddiff --git a/test/placement-a1.test.js b/test/placement-a1.test.js
iindex 95b2b57..a371d03 100644
---- a/test/placement-a1.test.js
++++ b/test/placement-a1.test.js
@@@ -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]]) {
ddiff --git a/test/run-pages.test.js b/test/run-pages.test.js
iindex f72b5f7..8bdb7ed 100644
---- a/test/run-pages.test.js
++++ b/test/run-pages.test.js
@@@ -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;
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex ffdca59..3bc5ba1 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',
ddiff --git a/test/setup.test.js b/test/setup.test.js
iindex 4c50621..eeb807d 100644
---- a/test/setup.test.js
++++ b/test/setup.test.js
@@@ -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 () => {
ddiff --git a/test/task-cli.test.js b/test/task-cli.test.js
iindex 037b6c9..adb81c9 100644
---- a/test/task-cli.test.js
++++ b/test/task-cli.test.js
@@@ -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/);
});