openvibers/openvibe.bot Commits
OpenVibe Link: the new name in pages, docs and messages (#340)
Changes
@@@ -70,8 +70,8 @@ OV_OAUTH_CLIENT_SECRET=#BOT_JOBS=on#BOT_JOBS_INTERVAL_MS=5000#BOT_INSTALLER_URL=https://openvibe.bot/install--# Where GET /install redirects (302): OpenVibe.Node's installer script. Defaults to the checksummed asset on--# OpenVibe.Node's latest release (install.sh, pinned there by Node's CI cmp guard so it never drifts with main).++# Where GET /install redirects (302): OpenVibe Link's installer script. Defaults to the checksummed asset on++# OpenVibe Link's latest release (install.sh, pinned there by its CI cmp guard so it never drifts with main).# https on raw.githubusercontent.com, github.com, objects.githubusercontent.com or openvibe.bot only; anything# else refuses to boot.#BOT_INSTALLER_SOURCE_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh@@@ -98,7 +98,7 @@ OV_OAUTH_CLIENT_SECRET=#BOT_ONVIF_TIMEOUT_MS=5000# ── OpenVibe.Billing: job usage (plan T14 L1) ───────────────--# Where the per-second readings of jobs run on paired Nodes go: billing.usage.record, POST <url>/api/v1/usage.++# Where the per-second readings of jobs run on paired Links go: billing.usage.record, POST <url>/api/v1/usage.# Bot mints its own Network service token (audience openvibe.billing, capability billing.usage.record) from# OV_OAUTH_CLIENT_ID/SECRET and OV_NETWORK_INTERNAL_URL; BOT_BILLING_TOKEN, an operator-minted service token of# the same grant, overrides it. BOT_BILLING_URL unset: readings wait in run_usage_outbox (never dropped) and a@@@ -19,16 +19,16 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.--- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.++- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Link plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.--- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.++- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Link — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRestream stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).--- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).++- Dispatching `platform.job@1` jobs to a paired Link over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).Sitemap entries use the release commit time for these code-backed pages.@@@ -40,28 +40,28 @@ Sitemap entries use the release commit time for these code-backed pages.## Devices--- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.--- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.++- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe Link `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe Link `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.++- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe Link `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).--- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.network/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Node turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.++- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.network/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Link turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).- `GET /api/v1/desk-hosts` lists where a desk may go for that person:- their own machines where "Let my agent use this computer" is on (trust `user-owned`);-- - desk hosts whose owner offers them to other people (`devices.shared_desks`, the "Offer my desks to other people" button on `/machines`), only where the Node sandboxes every desk (`desk.isolation: sandbox`, OpenVibe.Node v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.++ - desk hosts whose owner offers them to other people (`devices.shared_desks`, the "Offer my desks to other people" button on `/machines`), only where the Link sandboxes every desk (`desk.isolation: sandbox`, OpenVibe Link v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.- Each host comes with its room: a browser desk counts as half of `max_desks`.- `POST /api/v1/machines/:id/desk-leases` starts the desk. Then `POST …/desk-leases/:desk/commands` carries `screen`, `input` and `browser` (`bot.command@1` without `desk`) to that desk alone, and `DELETE …/desk-leases/:desk` stops it.- A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.- A full machine answers `409 bot.machine_busy`; anyone else gets 404.- Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).--- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).--- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).++- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).++- **The live screen** (plan T14, contracts 0.182.0, OpenVibe Link v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.- **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.- **The audit** keeps the action, seconds and fps, never the URL or key.- **The viewer.** `GET` answers the session's viewer `signaling_url` once the machine publishes, and the page plays it with the panel's WebRTC viewer.- **Stop.** `DELETE` (or Stop) tells the machine to stop and retires the key. Removing a machine archives its stream.- **Refused** with `bot.computer_off` when the machine does not offer it (`screen.stream`), and no key is made.--- **The private desk** (contracts 0.214.0 `bot.command@1` kind `desk`, OpenVibe.Node v0.15.0 on Linux): a screen of its++- **The private desk** (contracts 0.214.0 `bot.command@1` kind `desk`, OpenVibe Link v0.15.0 on Linux): a screen of itsown on the machine (Xvfb with its own bus and window manager) where an agent works without touching the owner's.- **Commands.** `desk` `{ action: start | stop | status, width?, height? }` reaches a machine whose status lists`computer.desk`. `screen`, `input` and `browser` with `desk: "private"` act there. A machine without one answers@@@ -73,7 +73,7 @@ Sitemap entries use the release commit time for these code-backed pages.- **The live screen** of the desk: `POST /machines/:id/screen` with `desk: "private"` (or the page's "Show theprivate desk" button) sends `screen_stream` with `desk: private`.- **Vibe by voice** (`server/voice.js`, migration `0024_machine_voice.sql`, contracts 0.214.0 `voice_ask` /-- `voice_answer`; consumer plan §8). On a machine whose Node runs the voice plugin (`capabilities.voice`), the owner++ `voice_answer`; consumer plan §8). On a machine whose Link runs the voice plugin (`capabilities.voice`), the ownerturns voice on and chooses a call word ("hey vibe" by default) on /machines or `POST /api/v1/machines/:id/voice`.- **Config** then carries `voice: true` and `call_word`, and the machine listens for the word on its own. Only whatthe person says after it comes as `voice_ask`.@@@ -106,7 +106,7 @@ Sitemap entries use the release commit time for these code-backed pages.The audit keeps the action and position (typed text only as a character count) under the machine. Nothing elsepasses: no programs, no files, no choice of where the screen goes.- **Desks for agents** (the Desks plan, `OpenVibe_Agent_Desks_Plan_2026-10-27.md`; contracts 0.215.0–0.218.0,-- OpenVibe.Node v0.19.0–v0.26.0). Each agent task gets a desk of its own on a machine.++ OpenVibe Link v0.19.0–v0.26.0). Each agent task gets a desk of its own on a machine.- **Named desks** (`desk-<name>`): each has its own display, browser profile and files. **Browser desks**(`kind: browser`) are a headless browser alone; a desk host or a Mac runs browser desks only.- **Programs on a desk:** `exec` with `desk`, and `detach` for an app that lasts until the desk stops.@@@ -119,8 +119,8 @@ Sitemap entries use the release commit time for these code-backed pages.machine's (`bot:machine_live:<id>`) whose `mirror_to_live` OpenRestream links to their channel. It is never theperson's own screen and never the private screen stream, and it lasts up to 30 minutes.- **Your own models** (AI on your own machines; contracts 0.219.0 `bot.command@1` kind `ai`, capability-- `bot.machine.ai`; OpenVibe.Node v0.27.0+ `ai` plugin, `install.sh --ai`).-- - **Reporting:** a machine's Node reports its model server's models (`capabilities.ai.ai`, read by++ `bot.machine.ai`; OpenVibe Link v0.27.0+ `ai` plugin, `install.sh --ai`).++ - **Reporting:** a machine's Link reports its model server's models (`capabilities.ai.ai`, read by`server/machine-ai.js`) and, from v0.29.0, how fast the last one answered there (`ai_tokens_per_s`). The API has itas `computer.ai` and `computer.ai_speed`.- **Who may ask:** the owner, or OpenVibe.AI (`svc:ai` with `bot.machine.ai`, acting for the owner) once the owner@@@ -134,7 +134,7 @@ Sitemap entries use the release commit time for these code-backed pages.(`bot.machine`, online or offline) on one cursor. Each names its page here (`links.console`), so OpenVibe.Serviceslists and links them.- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.--- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).++- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe Link's `docs/hardware-adeept.md` (T15 step 1).- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.- Profiles declare `button` commands by name and `point` commands for normalised video coordinates in `[0, 1]`. A hold button sends `down` while pressed and `up` on release; each `down` has the device deadline used for held controls. The command gate checks declarations, roles, robot limits and control specific cooldowns. The panel and read-only embed render the profile's labels, with buttons disabled for anyone who cannot drive.@@@ -225,7 +225,7 @@ Set `BOT_BILLING_URL` in `/etc/openvibe/bot.env` to OpenVibe.Billing's base URLSet `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.--`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe.Node's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with Node's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).++`/install` is proxied to the app, which answers a 302 to OpenVibe Link's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe Link's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with the repo's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).## Deploy files@@@ -42,7 +42,7 @@"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s","device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine","catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, looi, phone.camera, camera.onvif, sim.rover, relay.generic (the bare relay robot; OpenVibe.Live's old controls were converted to it and deleted from Live) and ros2.diffdrive / ros2.holonomic, seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",-- "ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe.Node's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)",++ "ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe Link's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)","curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)","a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds","command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",@@@ -57,8 +57,8 @@"webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks","the owner's agent (migration 0012 robots.agent_control): panel 'Your agent' switch; svc:actor acting for the owner only reads GET /api/v1/robots/:id/steps (state + step words) and runs POST …/steps (<= 20 steps, <= 30 s, through hub.operatorCommand, audited as the service) or POST …/commands; otherwise 403 bot.agent_not_allowed","the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",-- "panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",-- "platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",++ "panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Link's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",++ "platform.job@1 jobs dispatched to a paired Link over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record","embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)","embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot","streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",@@@ -1,6 +1,6 @@# Community AI offers through Bot--OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bot is the transport and the reporter:++OpenVibe Link's `ai` plugin can offer a person's local models to the network. Bot is the transport and the reporter:- **Report** — `server/jobs/offers.js` `buildAi()` reads every live Network-paired device whose`capabilities.ai.ai.offer.enabled` is true and publishes one offer per machine to Network's@@@ -10,7 +10,7 @@ OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bo- **Dispatch** — `POST /api/v1/machines/:id/commands` with `kind: ai` reaches a machine only from OpenVibe.AI(`svc:ai`, `bot.machine.ai`). A machine's own models need `ai_models` (the owner's "Let my AI use this computer'smodels" switch). A **community** run is marked with the `community/<model>` model prefix and is allowed when the-- machine's own offer is enabled: the offer is the opt-in, and the Node plugin enforces hours, concurrency and its++ machine's own offer is enabled: the offer is the opt-in, and the Link plugin enforces hours, concurrency and itsmodel list. The prefix never reaches the local model server.The command's value is `bot.command@1`, which is closed to extra fields, so the prefix carries the community flag@@@ -6,7 +6,7 @@ is its runbook: what runs in which order, what is backed up, how the result is v## Scope — what this PR actually changes--Bot becomes the dispatcher of plan T14: it hands `platform.job@1` jobs to a paired OpenVibe.Node over the++Bot becomes the dispatcher of plan T14: it hands `platform.job@1` jobs to a paired OpenVibe Link over the`/device` socket (`platform.job-frame@1`) and meters them per wall-clock second for OpenVibe.Billing.- `migrations/0004_run_jobs.sql` (`-- phase: expand`) — two **new** tables and their indexes:@@@ -94,7 +94,7 @@ node test/run-jobs.test.js1. The harness starts a scratch database, applies the base branch's migrations and the repository's fixtures,then this PR's migrations: exactly `0004_run_jobs.sql` applies, and a second run applies nothing.--2. It runs `node test/run-jobs.test.js`: a job reaches a connected Node and only for an advertised class; it is++2. It runs `node test/run-jobs.test.js`: a job reaches a connected Link and only for an advertised class; it isresent until acked; exited 0 writes one reading per second plus the partial last second; a lost `job_usage`is backfilled; a second `job_exit` writes nothing; a cancel before `job_started` bills nothing;`usage_read` never regresses; and with Billing unset or down readings stay queued until a later sweep@@@ -14,6 +14,6 @@ Use an isolated copy of Bot's PostgreSQL data and the same deployment configurat## Production order and way back--1. Confirm the Node installer source is reachable and that the Node release consumed by its script is published. Record the current Bot revision and take a fresh, restorable PostgreSQL backup before the guarded Bot deployment.++1. Confirm the Link installer source is reachable and that the Link release consumed by its script is published. Record the current Bot revision and take a fresh, restorable PostgreSQL backup before the guarded Bot deployment.2. Deploy the reviewed revision through the normal merge pipeline. Confirm `/api/ready` and `/api/health`, then repeat the 302, query-parameter and pairing-command checks above using a controlled test owner. Keep the installer command and pairing code out of logs and tickets.3. If the new route or pairing command is wrong, roll Bot back to the recorded prior revision through the deployment pipeline and repeat the health and pairing checks. There is no schema reversal or data restore in the ordinary rollback because this PR changes neither schema nor stored data. If an unrelated data incident requires restoring the backup, coordinate it separately with the database owner because a restore can discard writes made after the snapshot.@@@ -6,8 +6,8 @@ runbook for PR #18: what runs in which order, what is backed up, how the result## Scope — what this PR actually changes--PR #18 changes the default target of the `GET /install` redirect so it points at OpenVibe.Node's **released**--installer asset instead of a path on OpenVibe.Node's `main` branch:++PR #18 changes the default target of the `GET /install` redirect so it points at OpenVibe Link's **released**++installer asset instead of a path on OpenVibe Link's `main` branch:- `server/config.js` — the `BOT_INSTALLER_SOURCE_URL` fallback becomes`https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh` (was@@@ -84,10 +84,10 @@ adds it from this file at the verified head; it resolves to:node token → `403 bot.forbidden`; with a service token lacking the capability → `403 capability.denied`;without `project_id` → `422 bot.invalid_input`.- A valid dispatch of a job whose class the target Node advertises → `201`, `sent: true`, a `run_jobs` row in-- state `queued`, and the Node receives one `job` frame; the same body again → the same row, `sent: false`,++ state `queued`, and the Link receives one `job` frame; the same body again → the same row, `sent: false`,no second frame; a class the device does not advertise → `409 bot.class_unadvertised` with **no** row.- `GET /api/v1/jobs/:id` → `200` with the row and any captured stdout; an unknown id → `404 bot.job_not_found`.-- - `POST /api/v1/jobs/:id/cancel` → `200`, the row `cancelled`, the Node receives one `job_cancel` frame.++ - `POST /api/v1/jobs/:id/cancel` → `200`, the row `cancelled`, the Link receives one `job_cancel` frame.4. Behaviour elsewhere is unchanged: the person and node routes still answer exactly as before, and the`/device` and `/control` sockets still gate the same way.5. `docs/protocol.md` §1.2 no longer says "no HTTP route", §3 lists the three routes and `bot.job.dispatch`,@@@ -2,7 +2,7 @@Status: **done** (updated 2026-10-27). Every step below is built and in production:- steps 1–3 and 5: Contracts 0.109.0 and Bot, 2026-10-07;--- step 4: OpenVibe.Node's `relay` plugin (`plugins/relay`);++- step 4: OpenVibe Link's `relay` plugin (`plugins/relay`);- step 6: Live deleted its controls (`0011_drop_live_controls`). Its old hardware socket `/ws/control` answers**410 Gone**, naming OpenVibe.Bot, so a client that still connects there learns where to go(Live `server/net/ws-paths.js`).@@@ -17,7 +17,7 @@ one priority"):- **Chat commands reach a button robot.** `!forward`, `!left` … press the robot's buttons of the same name when ithas no drive, and any other `!word` that is not a channel sound presses the button of that name (Live#383,Chat#194).--- **The Node's lifecycle:** `openvibe-node quit` ends a running Node cleanly with nothing latched (Node 0.16.0), for++- **The Link's lifecycle:** `openvibe-node quit` ends a running Link cleanly with nothing latched (Link 0.16.0), forlaunchers that start and stop it with the rest of a stream setup. `stop` stays the kill switch.## What Live has today@@@ -48,7 +48,7 @@ lines) let viewers drive a streamer's hardware from the channel page:| Controls on/off, login required | The robot's access policy (`private` / `invite` / `queue`); controls are never anonymous || Whitelist | Operators added by @username (built) || Per-viewer rate limit, cooldowns | The command gate's per-robot limits and cooldowns (built), plus the per-button `cooldown_ms` |--| The hardware client and its stream-key socket | The OpenVibe **Node** paired to the robot, running a **relay** plugin (below); the stream key stops being a robot credential |++| The hardware client and its stream-key socket | The OpenVibe **Link** paired to the robot, running a **relay** plugin (below); the stream key stops being a robot credential || The controls panel under the video | Bot's panel embedded on the Live channel page (`/panel/:id/embed`, `LIVE_BOT_EMBED`), already built |## What has to be built@@@ -121,7 +121,7 @@ if anything, needs redoing by hand.`point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Livecommand behind each Bot button name is in the robot's audit (`live_conversions.detail` too), so the mappingis one line.--3. Point the Pi script at the paired Node instead of Live's stream-key socket. The stream key is no longer a++3. Point the Pi script at the paired Link instead of Live's stream-key socket. The stream key is no longer arobot credential.4. Robot page → **On your stream** → **Put the buttons under my OpenVibe.Live stream**, and choose who may drive.@@@ -42,7 +42,7 @@ keep connecting with it either way.**The installer command** is `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX` (or theNetwork form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the `adeept.adr036`,`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run--`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe.Node's `install/install.sh`).++`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe Link's `install/install.sh`).**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of itsowner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`@@@ -71,7 +71,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) || `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate || `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |--| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe.Node reads. The envelope `seq` is Bot's own counter and never echoes the device's |++| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe Link reads. The envelope `seq` is Bot's own counter and never echoes the device's || `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself || `error` | `code, detail` | a frame the server refused |@@@ -81,7 +81,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.|---|---|---|| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` || `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |--| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe.Node sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |++| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame || `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below || `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing || `ack` | `id` | a command ran (or, keyed by a job id, a job was accepted: §1.2) |@@@ -102,13 +102,13 @@ deadline or firmware that keeps going on its own never carries into the next tursends none. The same halt goes out when the owner or an operator takes over a turn (below).**What the order guarantees, and what it does not.** Frames on one device connection arrive in the order--Bot sent them (one WebSocket), and OpenVibe.Node handles them in that order. Two things follow:++Bot sent them (one WebSocket), and OpenVibe Link handles them in that order. Two things follow:- **Nothing decided under a turn follows that turn's halt.** Bot ends a turn and sends its halt in onesynchronous step that also moves the robot's control epoch. A queue driver's command whose gate decisionbegan before that step is refused (`bot.not_your_turn`) at the moment it would be sent, with no await betweenthe check and the send. So on the wire, everything authorised under turn N precedes N's halt.- **Lateness is bounded by the deadline.** Every motion command carries `deadline_ms` on Bot's clock, at-- most the robot's `max_command_ms` away. Node compares it with its estimate of Bot's clock on arrival, nacks++ most the robot's `max_command_ms` away. Link compares it with its estimate of Bot's clock on arrival, nacksa late one (`expired`) before any driver sees it, and gives a driver only the time left.Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never@@@ -121,7 +121,7 @@ across a Bot restart:- It moves when a turn ends, is left or is taken over. The halt for that change carries the new value, and everycommand carries the epoch current when it is sent. An owner's command right after a takeover carries the epoch itshalt moved to.--- OpenVibe.Node (Node#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,++- OpenVibe Link (Node#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,and passes the epoch to drivers (`cmd.turn_epoch`), so a driver can check it too.- It is absent until a robot's first turn change since Bot started. A command without it runs as before.@@@ -194,9 +194,9 @@ against these):| profile | device | commands ||---|---|---|--| `adeept.adr036` | Node plugin `adeept_adr036`, ordinary wheels | `drive {throttle, steer}`; `actuator` `pan`/`tilt` −1..1, `buzzer` tone 220–880 Hz, `lights` rgb (8 LEDs) |--| `adeept.adr036.mecanum` | Node plugin `adeept_adr036`, mecanum wheels | `drive {x, y, rotation}`; the same actuators |--| `cozmo` | Node plugin `cozmo` | `drive {throttle, steer}`; `actuator` `head` −1..1, `lift` 0..1, `backpack_lights`/`cube_lights` rgb; `say`; `display` text, face (`neutral happy sad surprised sleepy angry`) or image |++| `adeept.adr036` | Link plugin `adeept_adr036`, ordinary wheels | `drive {throttle, steer}`; `actuator` `pan`/`tilt` −1..1, `buzzer` tone 220–880 Hz, `lights` rgb (8 LEDs) |++| `adeept.adr036.mecanum` | Link plugin `adeept_adr036`, mecanum wheels | `drive {x, y, rotation}`; the same actuators |++| `cozmo` | Link plugin `cozmo` | `drive {throttle, steer}`; `actuator` `head` −1..1, `lift` 0..1, `backpack_lights`/`cube_lights` rgb; `say`; `display` text, face (`neutral happy sad surprised sleepy angry`) or image || `camera.onvif` | server driver `onvif` | `ptz {pan, tilt, zoom}` −1..1 || `sim.rover` | server driver `sim` | `drive {throttle, steer}` |@@@ -215,7 +215,7 @@ and audited (`kind: estop_state`, `result: refused`); nothing changes on that ro**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machineitself: `openvibe-node stop`, or a driver's own stop button through it. Only a release on that machine--(`openvibe-node resume`) lifts it; the owner's clear does not. The Node keeps refusing motion (`nack`++(`openvibe-node resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack``local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot isstopped on the robot and holds back its Clear button until the device reports the release. After the release@@@ -303,7 +303,7 @@ heartbeat or any frame brings it back. Commands are never replayed after a recon### 1.2 Jobs (platform.job-frame@1)--Bot is the dispatcher of plan T14: it hands a `platform.job@1` job to a paired OpenVibe.Node over this socket++Bot is the dispatcher of plan T14: it hands a `platform.job@1` job to a paired OpenVibe Link over this socketand meters it. The Run service (which owns `run.job.*`) calls it over the internal HTTP API under the`bot.job.dispatch` capability: `POST /api/v1/jobs`, `POST /api/v1/jobs/:id/cancel` and `GET /api/v1/jobs/:id`(`server/api/v1.js`) call the jobs service `server/jobs/index.js` `createJobs({ db, hub, usage, log, now })`, which@@@ -317,8 +317,8 @@ against `platform.job-frame@1` from the pinned openvibe-contracts; an invalid on|---|---|---|---|| server → device | `job` | `job` (platform.job@1) | sent by `dispatch` when the device is connected, and resent on every reconnect while unacked. Never sent for a `class` missing from the device's stored `capabilities.worker.runtime_classes` (dispatch answers `409 bot.class_unadvertised`; a queued job whose class is no longer advertised fails with `fault_code` `bot.class_unadvertised`) || server → device | `job_cancel` | `id` | sent by `cancel` if the job was sent and has not ended; resent on a reconnect and when a `job_started` or `ack` crosses it. A job not yet started is `cancelled` at once; a cancelled job is never sent again |--| server → device | `job_exit_ack` | `id` | after every reading of the job is committed to `run_usage_outbox`; the Node then forgets the job and stops resending its `job_exit` |--| device → server | `ack` / `nack` | `id`, `fault_code` | the Node accepted (`placed`) or refused (`failed`, `fault_code` kept) the job |++| server → device | `job_exit_ack` | `id` | after every reading of the job is committed to `run_usage_outbox`; the Link then forgets the job and stops resending its `job_exit` |++| device → server | `ack` / `nack` | `id`, `fault_code` | the Link accepted (`placed`) or refused (`failed`, `fault_code` kept) the job || device → server | `job_started` | `id, started_ms` | `running`; the first `started_ms` anchors every second and never changes || device → server | `job_stdout` | `id, chunk_seq, chunk` | the last 1 MiB of each job, in memory; a `chunk_seq` already held is dropped. Never metered || device → server | `job_usage` | `id, started_ms, second, cpu_ms?` | one reading for `second` (quantity 1); ignored once `job_exit` was taken |@@@ -1,17 +1,17 @@-- phase: expand---- Jobs on the device link (plan T14 step L1; platform.job@1 inside platform.job-frame@1): Bot is the dispatcher++-- that hands a job to a paired Link and meters it per wall-clock second for Billing. Additive only: two new-- tables, nothing existing changes, nothing to backfill.---- run_jobs one row per job id: what was sent, to which device, its state and how far it is metered-- run_usage_outbox platform.usage-sample@1 readings waiting for Billing (the openvibe-sdk outboxSchema shape).-- event_id is the reading's idempotency_key run:<job id>:<second>, so a second is queued once++-- however often the Link reports it.CREATE TABLE IF NOT EXISTS run_jobs (id text PRIMARY KEY CHECK (id ~ '^job_[0-9A-HJKMNP-TV-Z]{26}$'), -- platform.job@1 id-- node_id text NOT NULL REFERENCES devices (id), -- dev_…: the device whose Node runs it++ node_id text NOT NULL REFERENCES devices (id), -- dev_…: the device whose Link runs itproject_id text, -- prj_… of the payer; no FK, Bot holds no projectssubject text, -- who the readings are for (user:usr_…)provider text,@@@ -19,9 +19,9 @@ CREATE TABLE IF NOT EXISTS run_jobs (job jsonb NOT NULL, -- the platform.job@1 body, resent verbatim until ackedstate text NOT NULL CHECK (state IN ('queued', 'placed', 'running', 'succeeded', 'failed', 'cancelled', 'expired')),cancel_requested boolean NOT NULL DEFAULT false,-- fault_code text, -- the Node's nack, or why Bot stopped sending it++ fault_code text, -- the Link's nack, or why Bot stopped sending itsent_at bigint, -- first time the job frame went out (ms)-- started_ms bigint, -- the Node's started_ms: anchors every usage second++ started_ms bigint, -- the Link's started_ms: anchors every usage secondwall_ms bigint, -- job_exit.usage.wall_ms as billed (capped at limits.wall_ms)usage_read integer NOT NULL DEFAULT 0 CHECK (usage_read >= 0), -- seconds queued for Billing; never moves backexit_reason text, -- job_exit.reason; set once, by the first job_exit@@@ -1,5 +1,5 @@-- phase: expand-- A screen share link shows the owner's own screen (main) or the machine's private desk (contracts 0.214.0 bot.command@1++-- `desk: private`, OpenVibe Link v0.15.0): the agent works on the desk, and a room watches it there, never the owner's-- screen. Links made before this show the owner's screen, as they did.ALTER TABLE machine_screen_shares ADD COLUMN IF NOT EXISTS desk text NOT NULL DEFAULT 'main' CHECK (desk IN ('main', 'private'));@@@ -1,6 +1,6 @@-- phase: expand-- Vibe by voice (consumer plan §8, contracts 0.214.0 bot.device-message@1 voice_ask / voice_answer): the owner lets-- Vibe answer their voice on this machine, and chooses its call word. Off until they turn it on on openvibe.bot/machines;++-- the machine (OpenVibe Link's voice plugin) listens for the word only while it is on (config `voice`).ALTER TABLE devices ADD COLUMN IF NOT EXISTS voice_on boolean NOT NULL DEFAULT false;ALTER TABLE devices ADD COLUMN IF NOT EXISTS call_word text;@@@ -1,5 +1,5 @@-- phase: expand-- AI on your own machines (contracts 0.219.0, bot.command@1 kind ai; capability bot.machine.ai): the owner lets their++-- AI (OpenVibe.AI, acting for them) run a model on this machine's own model server through OpenVibe Link's ai plugin.-- Off until they turn it on on openvibe.bot/machines.ALTER TABLE devices ADD COLUMN IF NOT EXISTS ai_models boolean NOT NULL DEFAULT false;@@@ -1,7 +1,7 @@-- phase: expand-- Run sessions (contracts 0.223.0, the Desks plan's D6). A machine's owner may offer its desks to other people++-- (shared_desks, off until they turn it on on openvibe.bot/machines; only where its OpenVibe Link runs every desk in its++-- own sandbox, desk_isolation sandbox, Link v0.32.0). OpenVibe.Run leases one named desk per session there, or on the-- person's own machine (bot.desk-lease@1): only that session's commands reach the desk, and the lease ends with it.ALTER TABLE devices ADD COLUMN IF NOT EXISTS shared_desks boolean NOT NULL DEFAULT false;@@@ -1,7 +1,7 @@-- phase: expand-- What a machine's coding agents have left (Codes C9c, bot.device-message@1's heartbeat `fleet`): each agent's id,-- whether it is installed and signed in, and each limit window's label, what it has used of its limit, and when it++-- frees. Its OpenVibe Link reads it with OpenVibe.Codes' own logins probe as the machine's user and reports labels,-- numbers and times only: no key, token or account name. Bot keeps the latest reading per machine and when it stored-- it; a machine that stops beating keeps its last reading, and fleet_at says how old it is.ALTER TABLE devices ADD COLUMN IF NOT EXISTS fleet jsonb;@@@ -1,6 +1,6 @@-- phase: expand-- Plan L34 (the machines hub): the roles a machine has for the network (the catalogue is Contracts 0.257.0's-- machineRoles: codes.worker, desks.host, ai.host, bot.robot). The owner sets them on openvibe.network/machines and++-- the Link reads them on its next check-in (the config frame carries them). A machine that never had a role has the-- empty list, which is what the column starts with.ALTER TABLE devices ADD COLUMN roles jsonb NOT NULL DEFAULT '[]'::jsonb;@@@ -1,6 +1,6 @@-- phase: expand++-- The guided first run (OpenVibe Link's `openvibe-node`, docs/install.md#first-run-guided-and-measured): one row per++-- device and step, from the `node.first_run` telemetry events the Link sends over its existing device link. It powers-- the machines page's figures for a machine's first job and the admin funnel (how many installs reach each step, and-- how long each took). The fields are an allowlist: no path, username or address is stored.CREATE TABLE IF NOT EXISTS device_first_run (@@@ -254,7 +254,7 @@return;} else if (!playing) {misses += 1;-- say(s && s.online === false ? 'The machine is offline.' : misses > 20 ? 'Still waiting for the machine. Is the Node running in your desktop session?' : 'Waiting for the machine…');++ say(s && s.online === false ? 'The machine is offline.' : misses > 20 ? 'Still waiting for the machine. Is the Link running in your desktop session?' : 'Waiting for the machine…');}setTimeout(poll, playing ? 5000 : 1500);};@@@ -890,7 +890,7 @@el.textContent = text ? `Device fault: ${text}` : '';el.hidden = !text;}-- // A readout's sensor keys by the widget's capability, as the Node's drivers report them (Adeept: distance_cm and++ // A readout's sensor keys by the widget's capability, as the Link's drivers report them (Adeept: distance_cm and// line {left, middle, right}; Cozmo: cliff and picked_up), else the capability's own name. One key found shows its// value; several show each by name.const SENSOR_KEYS = { ultrasonic: ['distance_cm', 'ultrasonic'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };@@@ -14,8 +14,8 @@* DELETE /api/v1/machines/:id/desk-leases/:desk the desk is stopped and the lease ends** A machine takes a lease when it is the person's own and its owner turned on "Let my agent use this computer", or when-- * its owner offers its desks to other people (shared_desks) and its Node runs every desk in its own sandbox (describe-- * desk.isolation "sandbox", Node v0.32.0). Anything else is 404, never confirmed to exist. A leased desk is the++ * its owner offers its desks to other people (shared_desks) and its Link runs every desk in its own sandbox (describe++ * desk.isolation "sandbox", Link v0.32.0). Anything else is 404, never confirmed to exist. A leased desk is the* session's alone: the machine's own routes refuse it (api/v1.js), and on a shared host nothing but browser desks runs.*/const express = require('express');@@@ -61,7 +61,7 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>/*** How a person may use a machine for a session, or null: { device, shared, trust }. Their own machine where agents-- * may use it; another's only when its owner shares its desks and its Node sandboxes every one.++ * may use it; another's only when its owner shares its desks and its Link sandboxes every one.*/async function usable(device, subject) {if (!device || device.revoked_at) return null;@@@ -41,7 +41,7 @@ function robotSummary(row, baseUrl = null) {return { ...summary, ...consoleOf(baseUrl, `/r/${encodeURIComponent(row.id)}`) };}--/** A person's own machine (devices.owner_subject): online while its Node is connected. */++/** A person's own machine (devices.owner_subject): online while its Link is connected. */function machineSummary(row, online, baseUrl = null) {const summary = {id: row.id, kind: MACHINE_KIND, service: SERVICE,@@@ -82,12 +82,12 @@ const MACHINE_SERVICES = {'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },// AI (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server for the owner, or — only for a// machine whose ai plugin offers its models to the network — a community request that opted in (below).-- // A command that says `community: true` runs on the machine's offer to the network instead, so the Node ai plugin's++ // A command that says `community: true` runs on the machine's offer to the network instead, so the Link ai plugin's// own opt-in (capabilities.ai.offer.enabled) stands in for the "Let my AI use this computer's models" switch.'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai', community: true },};/**-- * A machine offering its models to the network (OpenVibe.Node ai plugin 0.3.0): the owner turned on "Offer to the++ * A machine offering its models to the network (OpenVibe Link ai plugin 0.3.0): the owner turned on "Offer to the* network" and the offer names its models. Hours, concurrency and price are the plugin's to enforce; Bot only decides* whether AI may send a community run here at all.*/@@@ -512,7 +512,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.json({ robot_id: req.params.id, ...result });}));-- // ── A person's own computer (the Node's computer plugin, plan T14/T17) ─────────────────────────────++ // ── A person's own computer (the Link's computer plugin, plan T14/T17) ─────────────────────────────// The machine's owner, or their agent (an agent service with bot.computer.control, acting for that owner) where the// owner turned "Let my agent use this computer" on. What may happen there is the owner's level on the machine.async function computerMachine(req, { agentNeedsOptIn = true, kind = null } = {}) {@@@ -545,17 +545,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submid: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,shared_desks: !!device.shared_desks,// What the machine does for the network (plan L34): the roles the owner gave it. The catalogue is-- // server/machine-roles.js; the Node reads them on its next check-in.++ // server/machine-roles.js; the Link reads them on its next check-in.roles: json(device.roles, []),-- // The machine's kind, its operating system and the OpenVibe.Node's version, as the Node last said.++ // The machine's kind, its operating system and the OpenVibe Link's version, as the Link last said.kind: device.kind || null,os: (st.status && typeof st.status.os === 'string' && st.status.os) || null,node_version: device.agent_version || null,last_seen_at: device.last_seen ? new Date(device.last_seen).toISOString() : null,-- // The machine's id on Network (`nod_…`) and the OpenVibe.Node's name for it, so a caller that knows the++ // The machine's id on Network (`nod_…`) and the OpenVibe Link's name for it, so a caller that knows the// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).node_id: device.node_principal || null,-- // What its coding agents have left, as its Node last reported it, and when Bot stored that reading++ // What its coding agents have left, as its Link last reported it, and when Bot stored that reading// (Codes C9c). null: the machine has reported none (no OpenVibe.Codes runner, or no reading yet).fleet: json(device.fleet, null),fleet_at: device.fleet_at ? new Date(device.fleet_at).toISOString() : null,@@@ -564,9 +564,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submlevel: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'), desk: pick('desk'), ai: pick('ai'),waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,-- // Desks running now (the Node's telemetry): where an agent's next desk has room (Desks plan D6, placement).++ // Desks running now (the Link's telemetry): where an agent's next desk has room (Desks plan D6, placement).desks_running: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,-- // How fast its last model answered there (Node v0.29.0's ai plugin): for choosing where a model runs.++ // How fast its last model answered there (Link v0.29.0's ai plugin): for choosing where a model runs.ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,} : null,@@@ -859,7 +859,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.json({ audit: rows.map(auditPresent), next_before: rows.length ? rows[rows.length - 1].id : null });}));-- // ── Jobs (Run → Bot: hand a platform.job@1 to a paired Node, server/jobs/index.js) ────────────++ // ── Jobs (Run → Bot: hand a platform.job@1 to a paired Link, server/jobs/index.js) ────────────// Internal and service-to-service only: OpenVibe.Run's Network service token, never a person's.r.post('/jobs', jobs, wrap(async (req, res) => {requireJobDispatch(req);@@@ -16,7 +16,7 @@ const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes',const trim = (u) => String(u || '').replace(/\/+$/, '');const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);const PAIRING_AUTHORITIES = ['bot', 'network'];--// Where GET /install may send the client: OpenVibe.Node's canonical script lives on GitHub (raw, a release++// Where GET /install may send the client: OpenVibe Link's canonical script lives on GitHub (raw, a release// asset) or on openvibe.bot itself. Anything else refuses to boot.const INSTALLER_SOURCE_HOSTS = ['raw.githubusercontent.com', 'github.com', 'objects.githubusercontent.com', 'openvibe.bot'];@@@ -269,7 +269,7 @@ function loadConfig(env = process.env) {},// The one-line installer the owner copies next to the pairing code. scriptUrl is what the command// prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:-- // it redirects to OpenVibe.Node's canonical one, and only this config (never a query parameter)++ // it redirects to OpenVibe Link's canonical one, and only this config (never a query parameter)// chooses the target.// Embeddable panel (plan T15 R9): the origins whose pages may frame a robot's read-only panel.embed: { origins: embedOrigins },@@@ -51,7 +51,7 @@ function newCode() {const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);--// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,++// The installer driver a robot's profile needs (OpenVibe Link install/install.sh --driver). Simulated,// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.const DRIVER_BY_PROFILE = Object.freeze({'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo', looi: 'looi',@@@ -720,7 +720,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =/*** A one-time code for `ownerSubject` to add a machine that runs their own jobs (Network pairing, ref* `worker:<owner>`; Network keeps one live code per ref, so a new code replaces an unused one). The installer-- * command turns the Node's job worker on (`--worker`). → { code, expires_at, pairing_id, installer }++ * command turns the Link's job worker on (`--worker`). → { code, expires_at, pairing_id, installer }*/async function mintMachine(ownerSubject, installerUrl) {if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');@@@ -859,7 +859,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function setWatchProbe(deviceId, value) {return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);}-- /** Let the owner's AI run models on this machine (OpenVibe Node's ai plugin), or stop it (the owner check is the caller's). */++ /** Let the owner's AI run models on this machine (OpenVibe Link's ai plugin), or stop it (the owner check is the caller's). */async function setAiModels(deviceId, value) {return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);}@@@ -873,7 +873,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}/*** The roles a machine has for the network (plan L34, step M4): the owner's choice, checked against the-- * catalogue (server/machine-roles.js) and stored whole. The Node reads them on its next check-in.++ * catalogue (server/machine-roles.js) and stored whole. The Link reads them on its next check-in.* The owner's check is the caller's.*/async function setMachineRoles(deviceId, roles) {@@@ -993,8 +993,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =WHERE id = $1 AND revoked_at IS NULL RETURNING *`,[id, JSON.stringify(fleet), iso(now())]);}-- // ── The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run) ──────────────-- // One row per device and step, from the `node.first_run` telemetry events the Node already sends. The fields are++ // ── The guided first run (OpenVibe Link's `openvibe-node`, docs/install.md#first-run) ──────────────++ // One row per device and step, from the `node.first_run` telemetry events the Link already sends. The fields are// an allowlist: a path, a username or an address is never stored even if a buggy client sends one.const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);@@@ -1,14 +1,14 @@'use strict';/**-- * A machine's coding agents, as its OpenVibe.Node reports them (Codes C9c).++ * A machine's coding agents, as its OpenVibe Link reports them (Codes C9c).*-- * The Node reads them with OpenVibe.Codes' own logins probe (`openvibe-codes logins --json`, as the machine's user)++ * The Link reads them with OpenVibe.Codes' own logins probe (`openvibe-codes logins --json`, as the machine's user)* and sends the reading with every heartbeat (`bot.device-message@1`'s `fleet`): each agent's id, whether it is* installed and signed in, and each limit window's label, what it has used of its limit, and when it frees. Bot keeps* the latest reading per machine (`devices.fleet`, `devices.fleet_at`) for Codes' Agents page, its router and its MCP.*-- * What arrives is bounded here exactly as the Node bounds it before sending, because a frame is whatever the socket++ * What arrives is bounded here exactly as the Link bounds it before sending, because a frame is whatever the socket* says: an agent id shaped like an id, at most 16 agents, at most 10 windows each, a short label and reset time, two* finite numbers. A reading never carries a key, a token or an account name, and Bot adds none: it stores what it is* sent, and nothing else. `null` means the frame was not a reading at all (not an array) and the last one stands; an@@@ -1,7 +1,7 @@'use strict';/**-- * The dispatcher (plan T14 step L1): Bot hands platform.job@1 jobs to a paired Node over the device link and++ * The dispatcher (plan T14 step L1): Bot hands platform.job@1 jobs to a paired Link over the device link and* meters them (platform.job-frame@1, docs/protocol.md "Jobs"). The Run service, which owns run.job.*, calls it* over the internal HTTP API (bot.job.dispatch): POST /api/v1/jobs, POST /api/v1/jobs/:id/cancel and* GET /api/v1/jobs/:id (server/api/v1.js), through the jobs service server/jobs/index.js, which also holds the one@@@ -15,7 +15,7 @@* on reconnect** `link` is the realtime hub (sendToDevice). A job is sent only to a device whose stored-- * capabilities.worker.runtime_classes lists its class. An unacked job is resent on every reconnect (the Node acks++ * capabilities.worker.runtime_classes lists its class. An unacked job is resent on every reconnect (the Link acks* a job it already holds again, or answers with its job_exit, and never runs one twice). job_exit_ack goes out* once every reading of the job is committed to run_usage_outbox, from which the relay delivers it to Billing.*/@@@ -31,7 +31,7 @@ const STDOUT_RING_BYTES = 1024 * 1024; // the last 1 MiB of each job's stdoutconst STDOUT_JOBS = 1024; // rings kept, oldest dropped firstconst brief = (errors) => (errors || []).slice(0, 3).map((e) => `${e.path} ${e.message}`).join('; ');--/** The classes a device's stored capabilities advertise (OpenVibe.Node status.capabilities.worker). */++/** The classes a device's stored capabilities advertise (OpenVibe Link status.capabilities.worker). */function advertisedClasses(capabilities) {const worker = json(capabilities, {}).worker;return worker && Array.isArray(worker.runtime_classes) ? worker.runtime_classes.filter((c) => typeof c === 'string') : [];@@@ -173,7 +173,7 @@ function createJobFrames({ db: getDb, usage = null, log = console, now = () => Dif (!row) return false;if (row.exit_reason != null) return true;const job = json(row.job, {});-- // Never past the wall-clock cap the payer set: the Node kills the job there.++ // Never past the wall-clock cap the payer set: the Link kills the job there.if ((msg.second + 1) * 1000 > job.limits.wall_ms) { log.warn(`[Bot] job ${row.id}: job_usage second ${msg.second} beyond limits.wall_ms, not metered`); return true; }const startedMs = row.started_ms != null ? Number(row.started_ms) : msg.started_ms;if (row.started_ms == null) await store.markStarted(t, row.id, startedMs, now());@@@ -1,7 +1,7 @@'use strict';/*** The worker offers Bot reports to Network (plan T14/T16, contracts 0.161.0, ADR-046). Every live device that runs a-- * job worker (OpenVibe.Node status.capabilities.worker.runtime_classes) is a person's own machine: one++ * job worker (OpenVibe Link status.capabilities.worker.runtime_classes) is a person's own machine: one* platform.resource-offer@1 per device, kind `node`, trust `user-owned`, naming its owner, advertising* `worker:<class>` for each class, `up` while the device is connected and `down` otherwise. The set is reported whole* as source `bot` (Network marks an offer missing from a later report down), once a minute and a moment after a device@@@ -9,7 +9,7 @@** createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }*-- * The same set carries a community AI offer (the design's "Offer to the network"): a machine whose OpenVibe.Node ai++ * The same set carries a community AI offer (the design's "Offer to the network"): a machine whose OpenVibe Link ai* plugin advertises `capabilities.ai.offer.enabled` with models, hours, concurrency, a credit price and an audience is* one user-owned provider offer, `ai:<device>`, whose detail.kind is `ai` and whose health follows its connection and* its own open/ready flags. Network validates the detail; OpenVibe.AI reads the internal list and places only requests@@@ -26,7 +26,7 @@ const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu/** The ai plugin's offer, or null when it is off or incomplete (an incomplete one is left out, never half reported). */function aiOfferOf(capabilities) {-- // The Node reports each plugin's describe under its name, so the ai plugin's {ai: {models, offer}} arrives as++ // The Link reports each plugin's describe under its name, so the ai plugin's {ai: {models, offer}} arrives as// capabilities.ai.ai; a direct capabilities.ai.offer is accepted too (an older/other reporter).let ai = json(capabilities, {}).ai;if (ai && ai.ai) ai = ai.ai;@@@ -104,7 +104,7 @@ function createOfferPublisher({ db, hub, reporter = null, aiReporter = null, logreturn offers;}-- /** The community AI offers: one per machine whose Node ai plugin advertises an enabled offer. */++ /** The community AI offers: one per machine whose Link ai plugin advertises an enabled offer. */async function buildAi() {const rows = await db.many(`SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner@@@ -3,11 +3,11 @@/*** run_jobs (migrations/0004_run_jobs.sql): the dispatcher's record of every job it handed to a device.*-- * queued stored; sent (again on every reconnect) until the Node acks it-- * placed the Node acked it++ * queued stored; sent (again on every reconnect) until the Link acks it++ * placed the Link acked it* running job_started* succeeded / failed / cancelled / expired from job_exit (exited 0 / exited ≠ 0, limit, stopped, failed /-- * cancelled / ttl); failed also when the Node nacked it++ * cancelled / ttl); failed also when the Link nacked it** A cancel before job_started marks the row cancelled at once; job_exit, which is authoritative, still sets the* final state and is still metered. exit_reason is written once, by the first job_exit: after it no usage frame@@@ -27,7 +27,7 @@ async function put(db, { job, nodeId, project = null, subject = null, provider =}const get = (db, id) => db.maybe('SELECT * FROM run_jobs WHERE id = $1', [id]);--/** The job, only if it belongs to this device: a Node never reads or meters another device's job. */++/** The job, only if it belongs to this device: a Link never reads or meters another device's job. */const ofNode = (db, id, nodeId, { lock = false } = {}) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1 AND node_id = $2${lock ? ' FOR UPDATE' : ''}`, [id, nodeId]);/** Unacked, uncancelled jobs of a device, oldest first: what a reconnect resends. */@@@ -37,7 +37,7 @@ const cancelPending = (db, nodeId) => db.many(`SELECT id FROM run_jobs WHERE nodconst markSent = (db, id, at) => db.exec('UPDATE run_jobs SET sent_at = COALESCE(sent_at, $2), updated_at = $2 WHERE id = $1', [id, at]);const markPlaced = (db, id, at) => db.exec(`UPDATE run_jobs SET state = 'placed', updated_at = $2 WHERE id = $1 AND state = 'queued'`, [id, at]);--/** The Node refused it (nack), or Bot stopped sending it: failed, ended, nothing to meter. */++/** The Link refused it (nack), or Bot stopped sending it: failed, ended, nothing to meter. */const markRefused = (db, id, faultCode, at) => db.exec(`UPDATE run_jobs SET state = CASE WHEN cancel_requested THEN 'cancelled' ELSE 'failed' END, fault_code = $2, finished_at = $3, updated_at = $3WHERE id = $1 AND state IN ('queued', 'placed', 'cancelled') AND exit_reason IS NULL AND finished_at IS NULL`, [id, faultCode, at]);@@@ -2,7 +2,7 @@"id": "adeept.adr036","name": "Adeept ADR036 4WD Smart Car Kit for Raspberry Pi","vendor": "Adeept",-- "description": "The first curated OpenVibe robot: the Adeept 4WD Smart Car with the Robot HAT — four DC motors and pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The OpenVibe Node agent drives the HAT directly, so the kit's stock control server is never exposed.",++ "description": "The first curated OpenVibe robot: the Adeept 4WD Smart Car with the Robot HAT — four DC motors and pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The OpenVibe Link agent drives the HAT directly, so the kit's stock control server is never exposed.","profile_id": "adeept.adr036","build_guide_url": "https://www.adeept.com/learn/detail-97.html","parts": [@@@ -1,7 +1,7 @@'use strict';/**-- * A machine's own models (AI on your own machines, contracts 0.219.0 kind ai), from the capabilities its OpenVibe Node-- * declares. The Node reports each plugin's describe under the plugin's name, so the ai plugin (Node v0.27.0) arrives as++ * A machine's own models (AI on your own machines, contracts 0.219.0 kind ai), from the capabilities its OpenVibe Link++ * declares. The Link reports each plugin's describe under the plugin's name, so the ai plugin (Link v0.27.0) arrives as* capabilities.ai = { ai: { models, ready, max_tokens } }; a computer plugin that offered ai itself would put it in* capabilities.computer.ai. → { models, ready, max_tokens } or null*/@@@ -1,12 +1,12 @@'use strict';/*** The catalogue of machine roles (plan L34, step M4: a product declares a machine role — a name, what it installs-- * and what the hub shows — and the Node turns it on at its next check-in).++ * and what the hub shows — and the Link turns it on at its next check-in).** The catalogue lives in the Contracts release: each product's service manifest carries its own roles* (`machineRoles`, openvibe-contracts 0.261.0), and the hub's catalogue is the union of them. This service pins* that release, so the manifests are the catalogue; the copy below is the fallback for an older pin, and goes-- * with it. Bot only ever refuses a role outside the catalogue: a role is a promise the Node has to keep, so an++ * with it. Bot only ever refuses a role outside the catalogue: a role is a promise the Link has to keep, so an* unknown one is refused rather than stored.** catalogue() the roles, as [{ id, name, installs, shows }]@@@ -19,7 +19,7 @@ const contracts = require('openvibe-contracts');const LOCAL = [{ id: 'codes.worker', name: 'Codes worker', installs: 'the Codes job runner (the coding agent and the job sandbox)', shows: 'Runs your coding jobs, such as a task you start on openvibe.codes.' },{ id: 'desks.host', name: 'Desk host', installs: 'the desk sandbox (a private browser desk for every session)', shows: "Hosts desks for agents, yours and other people's." },-- { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Node's ai plugin", shows: 'Serves your own AI models, on your own machine.' },++ { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Link's ai plugin", shows: 'Serves your own AI models, on your own machine.' },{ id: 'bot.robot', name: 'Robot', installs: 'the robot drivers the profile asks for', shows: 'Drives a robot, and takes its commands.' },];@@@ -8,14 +8,14 @@* then screen_stream start to the machine with the WHIP URL* and key → { streaming, seconds, fps, until, desk }; desk* `private` shows the machine's private desk (contracts-- * 0.214.0, Node v0.15.0) instead of the owner's own screen++ * 0.214.0, Link v0.15.0) instead of the owner's own screen* state(device, subject) → { live, signaling_url, state, stream } (the session's viewer* URL once the machine is publishing; `state` the machine's own* telemetry: live or off)* stop(device, subject, principal) screen_stream stop to the machine (when online) and the* stream's key retired with its session → { stopped }*-- * OpenVibe.Node's computer plugin captures the frames and its core publishes them (Node v0.12.0, docs/computer.md).++ * OpenVibe Link's computer plugin captures the frames and its core publishes them (Link v0.12.0, docs/computer.md).*/const { BotError } = require('./util');@@@ -65,18 +65,18 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {const desk = opts.desk == null || opts.desk === '' ? 'main' : String(opts.desk);if (!DESK.test(desk)) throw new BotError(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');-- // Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).++ // Before any key is made: the machine says whether it can (Link v0.12.0's computer plugin, a capture tool).const offered = hub.computerOf(device.id);// The person's own screen needs a live capture of it; a desk's is the desk's own (desk.screen, below): a desk// host has no screen of its own to show, only its desks.if (desk === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {-- throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session');++ throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your graphical session');}if (desk !== 'main' && !(offered && offered.desk && offered.desk.screen)) {-- throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on');++ throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on');}if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {-- throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');++ throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer');}// On the owner's OpenVibe Live channel (the Desks plan's D8): a desk only, never their own screen, on a public// stream of its own that OpenRestream links to their channel; refused with Live's reason when it cannot be.@@@ -48,10 +48,10 @@ const CAPABILITIES = new Set([/** Panel widget types (open in the profile, closed in code: a new widget is a new entry here). */const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);--/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */--// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe.Node docs/hardware-looi.md).++/** Device drivers a mapping may name. `relay` is a Link plugin with no hardware map of its own. */++// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe Link docs/hardware-looi.md).// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.--// `plugin`: a community OpenVibe Node plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;++// `plugin`: a community OpenVibe Link plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;// community profiles are reviewed and signed before robots can use them, server/submissions.js).const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);const PLUGIN_NAME = /^[a-z][a-z0-9_-]{1,40}$/;@@@ -194,7 +194,7 @@ function validateProfile(p) {return w.command != null ? { ...w, command: validateWidgetCommand(p.id, w, commands) } : { ...w };});if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);-- if (p.mapping.driver === 'plugin' && !PLUGIN_NAME.test(String(p.mapping.plugin || ''))) bad(`${p.id}: a plugin driver names its Node plugin in mapping.plugin (lower case letters, digits, - and _)`);++ if (p.mapping.driver === 'plugin' && !PLUGIN_NAME.test(String(p.mapping.plugin || ''))) bad(`${p.id}: a plugin driver names its Link plugin in mapping.plugin (lower case letters, digits, - and _)`);const l = p.limits && typeof p.limits === 'object' ? p.limits : {};const limits = {max_speed: number(l.max_speed) != null ? number(l.max_speed) : 1,@@@ -4,7 +4,7 @@"name": "LOOI desktop robot","vendor": "LOOI","kind": "bridge",-- "description": "LOOI, the tracked desk robot that uses a phone as its face: differential drive and head tilt over Bluetooth from a bridge computer (Windows, macOS or Linux) running the OpenVibe Node with the Generic LOOI driver. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, where the robot's panel shows under the stream.",++ "description": "LOOI, the tracked desk robot that uses a phone as its face: differential drive and head tilt over Bluetooth from a bridge computer (Windows, macOS or Linux) running the OpenVibe Link with the Generic LOOI driver. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, where the robot's panel shows under the stream.","capabilities": ["drive.differential", "head", "battery"],"mapping": { "driver": "looi", "plugin": "looi" },"commands": {@@@ -4,7 +4,7 @@"name": "ROS 2 robot","vendor": "ROS 2","kind": "onboard",-- "description": "Any ROS 2 robot that drives on cmd_vel (geometry_msgs/Twist): TurtleBot 3 or 4, a Jackal, a home-built base. The OpenVibe Node's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery. Mecanum and omni bases are ros2.holonomic.",++ "description": "Any ROS 2 robot that drives on cmd_vel (geometry_msgs/Twist): TurtleBot 3 or 4, a Jackal, a home-built base. The OpenVibe Link's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery. Mecanum and omni bases are ros2.holonomic.","capabilities": ["drive.differential", "camera", "sensor.ultrasonic", "battery"],"mapping": { "driver": "plugin", "plugin": "ros2" },"commands": {@@@ -4,7 +4,7 @@"name": "ROS 2 robot (mecanum or omni)","vendor": "ROS 2","kind": "onboard",-- "description": "A ROS 2 robot with mecanum or omni wheels that moves in any direction on cmd_vel (linear x and y, angular z). The OpenVibe Node's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery.",++ "description": "A ROS 2 robot with mecanum or omni wheels that moves in any direction on cmd_vel (linear x and y, angular z). The OpenVibe Link's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery.","capabilities": ["drive.mecanum", "camera", "sensor.ultrasonic", "battery"],"mapping": { "driver": "plugin", "plugin": "ros2" },"commands": {@@@ -9,7 +9,7 @@* use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are* never queued for an offline device and never replayed after a reconnect. Jobs* (platform.job-frame@1, server/jobs/dispatch.js) are the exception: an unacked `job`-- * is resent on every reconnect, and the Node never runs one id twice.++ * is resent on every reconnect, and the Link never runs one id twice.* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the* gate lives in the domain and every decision is audited.@@@ -36,32 +36,32 @@ const MAX_BACKLOG = 64; // frames a device may send before its authenticationconst ACCESS_COOKIE = 'ov_token';const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);const MAX_AGENT_VERSION = 40;--// How often a machine's unchanged fleet reading is stored again (Codes C9c): its Node re-reads its agents' logins++// How often a machine's unchanged fleet reading is stored again (Codes C9c): its Link re-reads its agents' logins// every minute, so a minute is as fresh as the reading can be, and a beat every second would be a write a second.const FLEET_REFRESH_MS = 60_000;const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave// A machine's answer to a computer command (exec output, a file, a screen) is at most 512 KB of JSON in its ack--// (OpenVibe.Node protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.++// (OpenVibe Link protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.const DEVICE_MAX_PAYLOAD = 640 * 1024;--// The Node's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its++// The Link's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its// status, and the reply window (the owner's approval on the machine plus the program's own run). `desk` (contracts--// 0.214.0, Node v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live++// 0.214.0, Link v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live// screen) act there with `desk: private`, never on the owner's own screen.const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };--const NO_DESK = 'this machine has no private desk: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on';--const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer';--const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Node v0.26.0 or newer (an older one would run it on the person\'s own screen)';--const NO_BROWSER_DESKS = 'this machine runs no browser desks: they need OpenVibe Node v0.23.0 or newer, with Chromium, Chrome, Edge or Brave';++const NO_DESK = 'this machine has no private desk: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on';++const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer';++const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Link v0.26.0 or newer (an older one would run it on the person\'s own screen)';++const NO_BROWSER_DESKS = 'this machine runs no browser desks: they need OpenVibe Link v0.23.0 or newer, with Chromium, Chrome, Edge or Brave';const NO_WHOLE_DESKS = 'this machine runs browser desks only (a whole desk needs Linux with Xvfb): start it with kind browser';--/** The kinds of desk a machine runs (Node v0.23.0: desk.kinds); an older Node's desks are all whole desks. */++/** The kinds of desk a machine runs (Link v0.23.0: desk.kinds); an older Link's desks are all whole desks. */const deskKinds = (offered) => (offered && offered.desk ? (Array.isArray(offered.desk.kinds) ? offered.desk.kinds : ['desktop']) : []);/** The desk a command names: main (the person's own screen, the default), private or desk-<name> (contracts 0.215.0). */const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.desk : 'main');// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with--// no screen of its own (no one signed in, Node v0.20.0 --desk-host) offers them on its desks only.++// no screen of its own (no one signed in, Link v0.20.0 --desk-host) offers them on its desks only.const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a--// browser of its own (a desk host for other people offers none, Node v0.32.0; its core routes it to the desks, v0.32.1).++// browser of its own (a desk host for other people offers none, Link v0.32.0; its core routes it to the desks, v0.32.1).const onDeskBrowser = (kind, value, offered) => kind === 'browser' && deskOf(value) !== 'main' && !!(offered && offered.desk);/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));@@@ -113,7 +113,7 @@ function deviceCredentialFromProtocols(header) {const value = named ? named.slice(DEVICE_PROTOCOL.length + 1) : '';return CREDENTIAL_RE.test(value) ? value : null;}--// The keys the Node's drivers report a sensor capability under (the Adeept and Cozmo plugins: distance_cm, line).++// The keys the Link's drivers report a sensor capability under (the Adeept and Cozmo plugins: distance_cm, line).const SENSOR_KEYS = { ultrasonic: ['distance_cm'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);const rgbOrNull = (c) => (c && typeof c === 'object' ? { r: finite(c.r), g: finite(c.g), b: finite(c.b) } : null);@@@ -288,7 +288,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat*/async function persistDeclared(conn, frame) {const d = conn.device;-- // OpenVibe.Node's status lists its drivers as objects ({ name, driver, state, … }, internal/protocol Status)++ // OpenVibe Link's status lists its drivers as objects ({ name, driver, state, … }, internal/protocol Status)// where its pairing request lists their names: the row keeps the names either way.const driverName = (x) => (typeof x === 'string' ? x : (x && typeof x === 'object' && typeof (x.driver || x.name) === 'string' ? x.driver || x.name : null));const msg = Array.isArray(frame.drivers) ? { ...frame, drivers: frame.drivers.map(driverName) } : frame;@@@ -414,7 +414,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (msg.fleet !== undefined) noteFleet(conn, msg.fleet);// `echo` carries the device's `t` back for its RTT; the envelope `seq` stays Bot's own counter.const ack = { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) };-- // OpenVibe.Node reads `heartbeat_ack.t` (its RTT is now - t), so echo it as `t` as well.++ // OpenVibe Link reads `heartbeat_ack.t` (its RTT is now - t), so echo it as `t` as well.if (Number.isFinite(msg.t)) ack.t = msg.t;return sendFrame(conn, 'heartbeat_ack', ack);}@@@ -493,7 +493,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}conn.telemetry = msg;if (urgent && !conn.sim) {-- // The guided first run's steps ride these events (OpenVibe.Node's docs/install.md#first-run): store the++ // The guided first run's steps ride these events (OpenVibe Link's docs/install.md#first-run): store the// step times for the machines page and the admin funnel. Not awaited: the sensor broadcast below must// keep its frame order (two telemetry frames are handled concurrently), and a storage failure must never// break a robot's telemetry. The domain ignores an event that is not a known step.@@@ -814,20 +814,20 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (screen) {// A desk's live view is the desk's own screen (desk.screen, checked below), not the person's.if (value.action === 'start' && deskOf(value) === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {-- return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session', summary);++ return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your graphical session', summary);}} else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {-- return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Node v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);++ return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Link v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);}// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,-- // one per agent task) needs a machine that runs several (Node v0.19.0: desk.named). Kind desk names its desk in++ // one per agent task) needs a machine that runs several (Link v0.19.0: desk.named). Kind desk names its desk in// value.desk too, private by default.const desk = kind === 'desk' ? (typeof value.desk === 'string' ? value.desk : 'private') : deskOf(value);if (desk !== 'main' && !(offered && offered.desk)) return refuse('bot.computer_off', NO_DESK, summary);if (desk !== 'main' && desk !== 'private' && !(offered.desk && offered.desk.named === true)) return refuse('bot.computer_off', NO_NAMED_DESKS, summary);-- // A program on a desk (Node v0.26.0: terminal.desks): an older Node would run it in the person's own session.++ // A program on a desk (Link v0.26.0: terminal.desks): an older Link would run it in the person's own session.if (kind === 'exec' && desk !== 'main' && !(offered.terminal && offered.terminal.desks === true)) return refuse('bot.computer_off', NO_DESK_PROGRAMS, summary);-- // A desk starts as a kind the machine runs (Desks plan D2): an older Node would quietly start a whole desk++ // A desk starts as a kind the machine runs (Desks plan D2): an older Link would quietly start a whole desk// for a browser desk, and a Mac runs browser desks only.if (kind === 'desk' && value.action === 'start') {const wanted = typeof value.kind === 'string' ? value.kind : 'desktop';@@@ -988,7 +988,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfunction closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }-- // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe.Node does ({volts, percent}).++ // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe Link does ({volts, percent}).function batteryFraction(telemetry) {const b = telemetry ? telemetry.battery : null;if (Number.isFinite(b)) return b;@@@ -1021,7 +1021,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}/*** The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities): the-- * capability's name, and the keys the Node's drivers report it under (public/panel.js SENSOR_KEYS).++ * capability's name, and the keys the Link's drivers report it under (public/panel.js SENSOR_KEYS).*/function readoutKeys(profile) {const keys = new Set();@@@ -5,7 +5,7 @@* `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the* hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers* `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself-- * at a motion command's `deadline_ms` unless a newer command arrived first (the Node's deadman).++ * at a motion command's `deadline_ms` unless a newer command arrived first (the Link's deadman).** attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent; pass the profile if loaded) → true/false* startAll() every existing sim robot (boot)@@@ -64,7 +64,7 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = coif (f.kind === 'drive') {sim.drive = { ...f.value };if (sim.stopTimer) clearTimeout(sim.stopTimer);-- // The deadman: no newer command by the deadline → stop, as the Node does.++ // The deadman: no newer command by the deadline → stop, as the Link does.sim.stopTimer = Number.isFinite(f.deadline_ms) ? setTimeout(() => halt(sim), Math.max(0, f.deadline_ms - now())) : null;if (sim.stopTimer && sim.stopTimer.unref) sim.stopTimer.unref();} else if (f.kind === 'actuator' && f.value && f.value.name === 'headlights') {@@@ -5,7 +5,7 @@* and signed before they run on anyone's Node). Anyone signed in submits a profile whose id is under `community.`,* with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the* profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so-- * robots can be added with it; the signature and Bot's public key are published so a Node or anyone can check it.++ * robots can be added with it; the signature and Bot's public key are published so a Link or anyone can check it.** createSubmissions({ db, config, now })* submit(person, { profile, driver_url, guide_url, license, notes }) → the submission row (pending)@@@ -2,7 +2,7 @@/*** Vibe by voice on a person's own machine (consumer plan §8; openvibe-contracts 0.214.0 bot.device-message@1* voice_ask / voice_answer, chat.vibe.ask). The owner turns voice on for a machine and chooses its call word-- * (migrations/0024_machine_voice.sql); the machine (OpenVibe Node's voice plugin) listens for the word, transcribes what++ * (migrations/0024_machine_voice.sql); the machine (OpenVibe Link's voice plugin) listens for the word, transcribes what* follows on the machine itself, and sends voice_ask. realtime.js answers `working` at once and hands the question here:* Bot asks Vibe as the owner through OpenVibe.Chat (POST /internal/chat/vibe/ask with its own service token, audience* openvibe.chat, chat.vibe.ask), which keeps it in the owner's "Voice on <machine>" chat, and the answer goes back to the@@@ -22,8 +22,8 @@ const { framePage } = require('./home');const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main';const CONNECT = {browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },-- onboard: { label: 'On-board', text: 'The OpenVibe Node runs on the robot\'s own computer and drives its hardware.' },-- bridge: { label: 'Bridge', text: 'The OpenVibe Node runs on a computer or phone next to the robot and joins the robot\'s own link. Nothing on the robot changes.' },++ onboard: { label: 'On-board', text: 'The OpenVibe Link runs on the robot\'s own computer and drives its hardware.' },++ bridge: { label: 'Bridge', text: 'The OpenVibe Link runs on a computer or phone next to the robot and joins the robot\'s own link. Nothing on the robot changes.' },server: { label: 'Server-side', text: 'Nothing to install: Bot connects to the device itself.' },phone: { label: 'Phone', text: 'The phone\'s browser is the device: scan a code, allow the camera, keep the page open. Nothing to install.' },};@@@ -36,7 +36,7 @@ const MODELS = {'adeept.adr036': {connect: 'onboard', order: 1,setup: ['Build the kit and flash Raspberry Pi OS (64-bit, Lite is enough) on its card.', 'Add the robot here and paste the one pairing command on the Pi.',-- 'The OpenVibe Node drives the Robot HAT itself, publishes the Pi camera, and stops the motors the moment commands stop. The kit\'s stock control server is never run.'],++ 'The OpenVibe Link drives the Robot HAT itself, publishes the Pi camera, and stops the motors the moment commands stop. The kit\'s stock control server is never run.'],guide: { href: `${NODE_BLOB}/docs/hardware-adeept.md`, label: 'Adeept setup guide' },},'adeept.adr036.mecanum': {@@@ -52,7 +52,7 @@ const MODELS = {},looi: {connect: 'bridge', order: 4,-- setup: ['The community\'s Generic LOOI driver bridges to the desk robot over Bluetooth from a Windows PC.', 'Install the Node with --driver looi; the phone in LOOI keeps doing what it does.'],++ setup: ['The community\'s Generic LOOI driver bridges to the desk robot over Bluetooth from a Windows PC.', 'Install the Link with --driver looi; the phone in LOOI keeps doing what it does.'],guide: { href: `${NODE_BLOB}/docs/hardware-looi.md`, label: 'LOOI driver contract' },},'phone.camera': {@@@ -79,7 +79,7 @@ const MODELS = {},'relay.generic': {connect: 'onboard', order: 6,-- setup: ['Any Raspberry Pi or Linux machine running the OpenVibe Node.', 'Each button on the panel is passed to a script of yours by the relay driver: lights, a door, a motor board.'],++ setup: ['Any Raspberry Pi or Linux machine running the OpenVibe Link.', 'Each button on the panel is passed to a script of yours by the relay driver: lights, a door, a motor board.'],guide: { href: '/docs/drivers', label: 'Write a driver' },},};@@@ -179,7 +179,7 @@ ${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}<ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p><ul class="dir-grid">${community.map(modelCard).join('')}</ul></section>` : ''}--<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator, then submit it: once it is reviewed, anyone can add a robot with it. A driver for its hardware runs under the OpenVibe Node.</p>++<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator, then submit it: once it is reviewed, anyone can add a robot with it. A driver for its hardware runs under the OpenVibe Link.</p><p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;return shell({ config, path: '/models', title: 'Models', description: 'The robots OpenVibe.Bot drives: kits, bridged robots like Cozmo and LOOI, ONVIF cameras and a simulated rover.', body });}@@@ -204,7 +204,7 @@ ${parts}${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p><ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>--<li>Check it on your machine before you install the driver (OpenVibe Node v0.6.0 or later): <code>openvibe-node profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>++<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-node profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul><p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });@@@ -264,7 +264,7 @@ function renderSubmit({ config, licenses = [], values = {}, error = null }) {<form class="dir-form" method="post" action="/robots/submit">${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>--<span class="muted dir-note">Its id is <code>community.<name></code>. A robot run by your own Node plugin uses <code>"driver": "plugin"</code> and names it in <code>"plugin"</code>.</span></label>++<span class="muted dir-note">Its id is <code>community.<name></code>. A robot run by your own Link plugin uses <code>"driver": "plugin"</code> and names it in <code>"plugin"</code>.</span></label><label class="dir-field"><span class="dir-field-label">Driver source (https)</span><input type="url" name="driver_url" maxlength="300" placeholder="https://github.com/you/your-driver" value="${esc(values.driver_url || '')}"></label><label class="dir-field"><span class="dir-field-label">Setup guide (https)</span><input type="url" name="guide_url" maxlength="300" placeholder="https://openvibe.wiki/…" value="${esc(values.guide_url || '')}"></label><label class="dir-field"><span class="dir-field-label">Driver licence</span><select name="license"><option value="">Choose…</option>${licenses.map((l) => `<option value="${esc(l)}"${l === values.license ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>@@@ -1,7 +1,7 @@'use strict';/**-- * The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Node, the++ * The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Link, the* robot-profile format, and a validator for a pasted profile.** They are rendered with the app's own page helper (server/web/render.js, the one the robots page uses), which@@@ -12,14 +12,14 @@* renderDriversPage({ config }) GET /docs/drivers* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate* renderAutomationPage({ config }) GET /docs/automation (routines, webhooks, the agent switch)-- * renderComputersPage({ config }) GET /docs/computers (the Node's computer plugin for your agents)++ * renderComputersPage({ config }) GET /docs/computers (the Link's computer plugin for your agents)* formatProblems(problems) / validationResult(result)*/const { esc } = require('./render');const { framePage } = require('./home');const simRover = require('../profiles/sim.rover.json');--// The driver protocol and the bundled drivers live in OpenVibe.Node (the Node core and its plugins/ tree).++// The driver protocol and the bundled drivers live in OpenVibe Link (the Link core and its plugins/ tree).const NODE = 'https://github.com/OpenVibers/OpenVibe.Node';const TREE = `${NODE}/tree/main`;const BLOB = `${NODE}/blob/main`;@@@ -40,7 +40,7 @@ function docsPage({ config, path, title, description, content }) {<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav><main id="main" class="docs-main">${content}--<p class="docs-foot muted">The Node and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>++<p class="docs-foot muted">The Link and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p></main></div>`;return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });@@@ -54,9 +54,9 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}function renderDocsIndex({ config }) {const content = `<h1>Build for OpenVibe.Bot</h1>--<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Node runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>++<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Link runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p><section class="card"><h2>Write a driver</h2>--<p>A driver is a separate program the OpenVibe Node starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p>++<p>A driver is a separate program the OpenVibe Link starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p><p>${inLink('/docs/drivers', 'Write a driver')} covers the lifecycle, every message, the safety rules, a minimal Python driver, and how to test and contribute it.</p></section><section class="card"><h2>Robot profiles</h2><p>A profile (<code>bot.robot-profile@1</code>) says what the robot can do, which commands its driver accepts, and which widgets the panel shows. The panel is a function of the profile: a new robot needs a profile, not code.</p>@@@ -64,13 +64,13 @@ function renderDocsIndex({ config }) {<section class="card"><h2>Automate a robot</h2><p>${inLink('/docs/automation', 'Routines, webhooks and your agent')}: steps on a schedule, signed event deliveries to your server, and letting your OpenVibe.Actor agent drive.</p></section><section class="card"><h2>Your computer for your agents</h2>--<p>${inLink('/docs/computers', 'Your computer')}: the Node's computer plugin, its levels, approvals on the computer, desks, your own models, and the API.</p></section>++<p>${inLink('/docs/computers', 'Your computer')}: the Link's computer plugin, its levels, approvals on the computer, desks, your own models, and the API.</p></section><section class="card"><h2>Robots</h2><p>Add a robot and drive it from any browser. The <b>Simulated rover</b> runs inside Bot with no hardware, so you can see your profile's panel before anything is wired up.</p><p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section><h2>Where things live</h2><ul>--<li>The <b>OpenVibe Node</b> (${out(NODE, 'OpenVibers/OpenVibe.Node')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>++<li>The <b>OpenVibe Link</b> (${out(NODE, 'OpenVibers/OpenVibe.Node')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li><li>Drivers are Node <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li><li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')}, ${out(`${TREE}/plugins/relay`, 'relay')} and ${out(`${TREE}/plugins/ros2`, 'ros2')} (any ROS 2 robot on <code>cmd_vel</code>, through rosbridge or rclpy; profiles <code>ros2.diffdrive</code> and <code>ros2.holonomic</code>, installer <code>--driver ros2</code>). LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</li><li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>@@@ -84,18 +84,18 @@ function renderDocsIndex({ config }) {function renderDriversPage({ config }) {const content = `<h1>Write a driver</h1>--<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe.Node.</p>++<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe Link.</p><section class="card"><h2>What a driver is</h2>--<p>A driver is a <b>separate executable</b> the OpenVibe Node core starts. It can be written in any language. The two speak <b>JSON lines over stdin/stdout</b>: one JSON object per line, UTF-8, in each direction. The core never links driver code; the robot drivers are Python 3 because their hardware libraries are.</p>++<p>A driver is a <b>separate executable</b> the OpenVibe Link core starts. It can be written in any language. The two speak <b>JSON lines over stdin/stdout</b>: one JSON object per line, UTF-8, in each direction. The core never links driver code; the robot drivers are Python 3 because their hardware libraries are.</p><p>Nothing but protocol lines may go to stdout. Send diagnostics to <b>stderr</b> (the Python runtime points file descriptor 1 at stderr and keeps a private copy for the protocol, because hardware libraries print).</p>--<p>The Node core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the e-stop and the deadman, and forwards already-validated commands to a driver.</p></section>++<p>The Link core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the e-stop and the deadman, and forwards already-validated commands to a driver.</p></section><section class="card"><h2>The three connection kinds</h2>--<p>The robot's profile carries a <code>kind</code>, which says where the Node runs relative to the robot:</p>++<p>The robot's profile carries a <code>kind</code>, which says where the Link runs relative to the robot:</p><ul>--<li><b>on-board</b> (<code>kind: "onboard"</code>) — the Node runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li>--<li><b>bridge</b> (<code>kind: "bridge"</code>) — the Node runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li>++<li><b>on-board</b> (<code>kind: "onboard"</code>) — the Link runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li>++<li><b>bridge</b> (<code>kind: "bridge"</code>) — the Link runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li><li><b>server-side</b> (<code>kind: "server"</code>) — there is no Node on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li></ul><p>The pairing's <code>device_kind</code> is <code>onboard</code> or <code>bridge</code>; a server-side robot is not installed on a machine.</p></section>@@@ -140,7 +140,7 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '<li>when its own sensors say so (for example a cliff or a pick-up).</li></ul><p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>--<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Node's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Node is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>++<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section><section class="card"><h2>A minimal driver in Python</h2><p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>@@@ -224,9 +224,9 @@ ${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.js<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Node. Use it to see exactly what your profile shows before wiring a driver.</p></section><section class="card"><h2>Contribute it</h2>--<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Node. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>++<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Link. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>`;-- return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Node: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });++ return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Link: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });}// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────@@@ -369,7 +369,7 @@ ${code(COMMISSION_CALL)}// ── GET /docs/computers ────────────────────────────────────────────────────────────────────────────--const COMPUTER_INSTALL = `curl -fsSL https://node.openvibe.network/install | sh -s -- \\++const COMPUTER_INSTALL = `curl -fsSL https://openvibe.link/install | sh -s -- \\--network https://openvibe.network --pairing pair_… --code ABCD-1234 --computer ask`;const COMPUTER_APPROVE = `openvibe-node approvals # what waits: id, kind, what it would doopenvibe-node approve cmd_01J… # let it run@@@ -384,13 +384,13 @@ const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…function renderComputersPage({ config }) {const content = `<h1>Your computer for your agents</h1>--<p class="muted">The OpenVibe Node's <b>computer</b> plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.</p>++<p class="muted">The OpenVibe Link's <b>computer</b> plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.</p><section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2><p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>${code(COMPUTER_INSTALL)}--<p class="muted">The installer is also at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working. What the Node is and what it does is on <a href="https://node.openvibe.network">node.openvibe.network</a>.</p>--<p>The Node then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Node in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section>++<p class="muted">The installer is also at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working. What the Link is and what it does is on <a href="https://openvibe.link">node.openvibe.network</a>.</p>++<p>The Link then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Link in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section><section class="card" aria-labelledby="levels-h"><h2 id="levels-h">Levels, set on the computer</h2><ul>@@@ -405,7 +405,7 @@ ${code(COMPUTER_INSTALL)}<p>At level <b>ask</b>, a request waits up to two minutes for your answer. You can answer in three places:</p><ul><li><b>The tray</b> (Linux and Windows). <code>openvibe-node tray</code> lists what waits, each with <b>Approve</b> and <b>Deny</b>, beside the stop and resume. It raises a notification with the same two buttons, and its icon turns amber while something waits. The installer starts it when you sign in.</li>--<li><b>A notification or dialog</b> from the Node itself when it runs in your session: buttons on Linux, a dialog whose default is Deny on macOS.</li>++<li><b>A notification or dialog</b> from the Link itself when it runs in your session: buttons on Linux, a dialog whose default is Deny on macOS.</li><li><b>The command line</b>:</li></ul>${code(COMPUTER_APPROVE)}@@@ -419,11 +419,11 @@ ${code(COMPUTER_APPROVE)}<section class="card" aria-labelledby="desks-h"><h2 id="desks-h">Desks: a computer of its own for each task</h2><p>Your agent does not use your screen. Each task gets a <b>desk</b> on one of your machines: a screen, browser, files and sign-ins of its own, so several tasks work at once without touching each other or you.</p><ul>--<li><b>Two kinds.</b> A <b>browser desk</b> is a browser alone, with no screen to draw. It costs about half as much, so it is what most web work gets, and it runs on a Mac too. A <b>whole desk</b> is a full screen where the agent can also open programs, such as an office suite or a terminal (OpenVibe Node v0.26.0; Linux).</li>++<li><b>Two kinds.</b> A <b>browser desk</b> is a browser alone, with no screen to draw. It costs about half as much, so it is what most web work gets, and it runs on a Mac too. A <b>whole desk</b> is a full screen where the agent can also open programs, such as an office suite or a terminal (OpenVibe Link v0.26.0; Linux).</li><li><b>Watch it, take it over.</b> A running task shows <b>Watch and take over</b> on its page in ${out('https://openvibe.actor', 'OpenVibe.Actor')}, and Vibe offers the same link in your chat. While you use the mouse and keyboard there, the agent waits; when you stop, it goes on.</li><li><b>It hands you the steps that are yours.</b> For a sign-in, a code sent to your phone, a CAPTCHA or a payment, the agent gets the page ready and asks you to do that step on its desk ("Needs you"). It never types a password itself.</li>--<li><b>Sign in once.</b> On OpenVibe Node v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li>--<li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Node v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li>++<li><b>Sign in once.</b> On OpenVibe Link v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li>++<li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Link v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li><li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li><li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-node stop</code>) stops them all.</li></ul>@@@ -432,7 +432,7 @@ ${code(COMPUTER_APPROVE)}<section class="card" aria-labelledby="models-c-h"><h2 id="models-c-h">Your own models</h2><p>If you run a model on your computer (Ollama, LM Studio or llama.cpp's server), your OpenVibe AI can think with it: free, private to you, and with no port opened.</p><ul>--<li><b>Set it up.</b> On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Use the models you run on it</b>, or add <code>--ai</code> to any other one (OpenVibe Node v0.28.0 or newer). The installer says which model server it finds.</li>++<li><b>Set it up.</b> On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Use the models you run on it</b>, or add <code>--ai</code> to any other one (OpenVibe Link v0.28.0 or newer). The installer says which model server it finds.</li><li><b>Turn it on.</b> The machine's card lists its models and how fast they answer there. <b>Let my AI use this computer's models</b> lets OpenVibe ask them for you; nothing reaches them before that.</li><li><b>Use it.</b> <b>Use these models in OpenVibe.Chat</b> fills in "Your AI key" on OpenVibe.Chat. Your Vibe and the AI members you set up can then think with your computer. On OpenVibe.Live, a streamer's AI viewers can too: choose <b>A model on my own computer</b> as their AI source.</li><li><b>Any of your computers.</b> Choose <b>any of my computers</b> instead of one, and OpenVibe asks the fastest of yours that is on and has the model, then the next when one is busy.</li>@@@ -444,7 +444,7 @@ ${code(COMPUTER_APPROVE)}<p>On ${inLink('/machines', 'Your machines')}, <b>Show my screen</b> shows the computer's screen live, to you only, for 10 minutes (30 seconds to 30 minutes through the API).</p><ul><li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>--<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your desktop session.</li>++<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your desktop session.</li><li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-node status</code> shows it while it runs.</li><li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-node stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li></ul>@@@ -471,9 +471,9 @@ The page answers with its URL, title, text and links. The browser is the agent's<li><code>ai</code>: <code>model</code> (one of its models), <code>messages</code> (<code>role</code> and <code>content</code>), <code>max_tokens</code>, <code>temperature</code> → <code>message</code>, <code>usage</code> and <code>ms</code>, from the computer's own model server. <code>POST /api/v1/machines/:id/ai-models</code> with <code>on</code> is the switch above; OpenVibe's own AI uses the models only while it is on.</li><li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li></ul>--<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>nod_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Node last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>++<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>nod_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Link last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>`;-- return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Node: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });++ return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Link: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });}module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage, validationResult };@@@ -52,8 +52,8 @@ const HOME_CSP = [const KINDS = [['browser', 'Simulated', 'No hardware at all: it runs inside Bot and in your browser.'],['phone', 'A phone', 'The phone\'s browser is the robot: scan a code, allow the camera, keep the page open.'],-- ['onboard', 'On-board', 'The OpenVibe Node runs on the robot\'s own computer, such as a Raspberry Pi, and drives its hardware.'],-- ['bridge', 'Bridge', 'The Node runs on a computer next to the robot and joins the robot\'s own link. Nothing on the robot changes.'],++ ['onboard', 'On-board', 'The OpenVibe Link runs on the robot\'s own computer, such as a Raspberry Pi, and drives its hardware.'],++ ['bridge', 'Bridge', 'The Link runs on a computer next to the robot and joins the robot\'s own link. Nothing on the robot changes.'],['server', 'Server-side', 'Nothing to install: Bot connects to the device itself, like an ONVIF camera.'],];// directory.js requires this module for framePage, so its words are read when a page renders, not at load.@@@ -147,7 +147,7 @@ function startSection() {title: 'Online in a minute',lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router and no stock control server left exposed.',items: [-- { title: 'Run one command on the robot', text: 'Paste the installer line on its Raspberry Pi or Linux computer. It installs the OpenVibe Node, finds the kit and shows a short code.' },++ { title: 'Run one command on the robot', text: 'Paste the installer line on its Raspberry Pi or Linux computer. It installs the OpenVibe Link, finds the kit and shows a short code.' },{ title: 'Approve it', text: 'Sign in at openvibe.bot/link and enter the code. The robot\'s credential is stored hashed, and you can revoke it any time.' },{ title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included. Share it, list it, or bind it to your OpenVibe.Live channel.' },],@@@ -186,14 +186,14 @@ function moreSection({ config, kit: k, models }) {});const model = k ? models.find((m) => m.id === k.profile_id) : null;const kitBody = k && k.parts && k.parts.length-- ? `<p class="hm-fold-lede">What to get for the robot OpenVibe.Bot was built around. The OpenVibe Node drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.</p><ul class="hm-parts">${k.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('')}</ul>++ ? `<p class="hm-fold-lede">What to get for the robot OpenVibe.Bot was built around. The OpenVibe Link drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.</p><ul class="hm-parts">${k.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('')}</ul><p class="hm-kit-actions">${link(k.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/hardware-adeept.md', 'Setup guide for the Node', 'sc-btn')}</p>`: '';const build = rows([-- ['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Node: every message and the safety rules.'],++ ['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Link: every message and the safety rules.'],['/docs/profiles', 'Robot profiles', 'The JSON that says what a robot can do and which controls its panel shows, with a validator.'],['/robots/submit', 'Submit a robot', 'Describe your robot with a profile and point at its driver. Once reviewed, anyone can add one.'],-- ['https://github.com/OpenVibers/OpenVibe.Bot', 'The source', 'OpenVibe.Bot and the OpenVibe Node are open source under the AGPL-3.0.'],++ ['https://github.com/OpenVibers/OpenVibe.Bot', 'The source', 'OpenVibe.Bot and the OpenVibe Link are open source under the AGPL-3.0.'],]);return foldBlock({id: 'more',@@@ -201,7 +201,7 @@ function moreSection({ config, kit: k, models }) {html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="hm-h">More on OpenVibe.Bot</h2>${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}--${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Node', body: `<p class="hm-fold-lede">The same OpenVibe Node runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}++${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Node', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${link('/docs', 'All build pages', 'hm-more')}</p>` })}</section>`,@@@ -229,9 +229,9 @@ function faqItems({ models }) {{ q: 'Can other people drive my robot?', a: 'Only if you say so. Add people by @username as operators or viewers, or open a queue where signed-in visitors take timed turns. Listing a robot in the directory lets anyone watch it.' },{ q: 'Can I show it on my stream?', a: 'Yes. Bind the robot to your OpenVibe.Live channel and its panel shows under your stream, where anyone you allow can take the controls.' },{ q: 'Can my AI agent drive it?', a: 'Only when you turn on Your agent for that robot. Then your OpenVibe.Actor agent can read its state and run steps as you, through the same gate, and for nobody else.' },-- { q: 'Can my agents use my computer?', a: 'Only if you install the OpenVibe Node there and say so. Each agent task gets a desk of its own, a browser or a whole screen, that you can watch and take over, at the level you set on the computer, with a kill switch there. A spare computer can host the desks instead.' },-- { q: 'Can my AI use the models on my computer?', a: 'Yes. Install the Node with --ai where Ollama, LM Studio or llama.cpp runs and turn on "Let my AI use this computer\'s models": your Vibe, your AI members and your stream\'s AI viewers can then think with them, free, and only for you.' },-- { q: 'Is it open source?', a: 'Yes. OpenVibe.Bot and the OpenVibe Node that runs on the robot are on GitHub under the AGPL-3.0, and anyone can write a driver or submit a robot profile.' },++ { q: 'Can my agents use my computer?', a: 'Only if you install the OpenVibe Link there and say so. Each agent task gets a desk of its own, a browser or a whole screen, that you can watch and take over, at the level you set on the computer, with a kill switch there. A spare computer can host the desks instead.' },++ { q: 'Can my AI use the models on my computer?', a: 'Yes. Install the Link with --ai where Ollama, LM Studio or llama.cpp runs and turn on "Let my AI use this computer\'s models": your Vibe, your AI members and your stream\'s AI viewers can then think with them, free, and only for you.' },++ { q: 'Is it open source?', a: 'Yes. OpenVibe.Bot and the OpenVibe Link that runs on the robot are on GitHub under the AGPL-3.0, and anyone can write a driver or submit a robot profile.' },].filter(Boolean);}@@@ -824,11 +824,11 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = falseconst w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];};-- // The computer plugin (Node docs/computer.md): the level its owner set on the machine, and whether their agent may use it.++ // The computer plugin (Link docs/computer.md): the level its owner set on the machine, and whether their agent may use it.const LEVELS = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };const computerOf = (m) => {const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};-- // Its own models (OpenVibe Node's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.++ // Its own models (OpenVibe Link's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.const models = aiOf(caps);const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true,offer: models.offer && typeof models.offer === 'object' ? models.offer : null,@@@ -841,11 +841,11 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = falsescreenStream: !!(c.screen && c.screen.stream === true),// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true,-- // Each desk in its own sandbox (Node v0.32.0, --desk-host --shared): it may serve other people's sessions.++ // Each desk in its own sandbox (Link v0.32.0, --desk-host --shared): it may serve other people's sessions.sandboxed: d.isolation === 'sandbox' } : null,ai };};-- // Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).++ // Vibe by voice: shown for a machine whose Link runs the voice plugin (capabilities.voice).const voiceRow = (m) => {const v = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).voice;if (!v || typeof v !== 'object') return '';@@@ -900,7 +900,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = falseif (!c.on) return `<div class="machine-computer">${aiRow}</div>`;const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';-- // The desks running now (the Node's telemetry), and the page that lists them, to watch or stop one.++ // The desks running now (the Link's telemetry), and the page that lists them, to watch or stop one.const running = Number.isInteger(m.computer_desks) && m.computer_desks > 0? ` <span class="pill">${m.computer_desks === 1 ? '1 desk running' : `${m.computer_desks} desks running`}</span>` : '';const deskLink = c.desks && m.online ? `<a class="button quiet" href="/machines/${esc(m.id)}/desks">See its desks</a>` : '';@@@ -916,7 +916,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')+ '</div>';};-- // The guided first run's one job (Node v0.39.0): what this machine is, from the first_job step Bot stored.++ // The guided first run's one job (Link v0.39.0): what this machine is, from the first_job step Bot stored.const bytesText = (n) => {if (!Number.isFinite(n) || n < 0) return '';const units = ['B', 'KB', 'MB', 'GB', 'TB'];@@@ -950,12 +950,12 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false<p class="muted">${text}</p></li>`;const add = pairing? `<h2 id="add-h">What should this computer do?</h2>--<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Node and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p>++<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Link and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p><ul class="choices" aria-labelledby="add-h">${choice('Run your coding jobs', ' --worker', 'Linux with systemd. It turns the Node\'s job worker on: jobs run as a separate user, in a sandbox, with the limits you set. For coding jobs the computer also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.')}--${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Node runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.')}++${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Link runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.')}${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}--${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Node v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/ai.md">How it works</a>.')}++${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Link v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/ai.md">How it works</a>.')}<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p><p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}" data-local-time>${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>. It works once.</p></li></ul><p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`@@@ -1145,7 +1145,7 @@ function renderPhonePage() {}/**-- * /admin/first-run: the measured first run, reviewers only. One row per step the Node reported (node.first_run), so++ * /admin/first-run: the measured first run, reviewers only. One row per step the Link reported (node.first_run), so* the number that reach each step and the median time between them answer whether installs get to a working machine.*/function renderFirstRunPage({ rows = [] }) {@@@ -1198,14 +1198,14 @@ function renderFirstRunPage({ rows = [] }) {}).join('')}</ul>`: '<p class="muted">No machine has reported a first run yet.</p>';const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>--<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Node's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>++<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header><section class="dir-sec"><h2>The funnel</h2><table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption><thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead><tbody>${rowsHtml}</tbody></table><p class="muted">${count('self_check') - failed} self-check${count('self_check') - failed === 1 ? '' : 's'} passed${failed ? `, ${failed} failed` : ''}. A failed check is still a machine that got that far; its reason is in the machine's own record.</p></section><section class="dir-sec"><h2>Most recent</h2>${recent}</section>`;-- return page('First run', body, { description: 'The guided first run of OpenVibe Node, measured step by step.', canonical: '/admin/first-run' });++ return page('First run', body, { description: 'The guided first run of OpenVibe Link, measured step by step.', canonical: '/admin/first-run' });}module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };@@@ -2,7 +2,7 @@/*** The non-API surface: the front page at `/` (web/home.js), `/robots.txt`, `/sitemap.xml`, `/llms.txt`,-- * `/llms-full.txt`, `/manifest.webmanifest`, `/install`, a 302 to OpenVibe.Node's installer script (the one-paste command,++ * `/llms-full.txt`, `/manifest.webmanifest`, `/install`, a 302 to OpenVibe Link's installer script (the one-paste command,* ADR-043), and the signed-in pages (the five-minute path and the profile-rendered panel, plan T15 step 3):** GET /robots the signed-in person's robots and the "add a robot" form@@@ -103,17 +103,17 @@ const LLMS_PAGES = [{path: '/models', title: 'Models',note: 'Every robot model Bot drives, by how it connects.',-- text: 'The robots OpenVibe.Bot drives, grouped by how they connect: simulated (the rover that runs in Bot and in the browser), on-board (the OpenVibe Node on the robot\'s own computer: the Adeept ADR036 4WD car and its mecanum build, the relay robot for any Linux board), bridge (the Node on a computer or phone next to the robot: Cozmo through PyCozmo, the LOOI desk robot over Bluetooth) and server-side (ONVIF pan-tilt-zoom cameras, nothing to install). Each model has a page with how it connects, its setup steps, its guide and the controls its panel gets.',++ text: 'The robots OpenVibe.Bot drives, grouped by how they connect: simulated (the rover that runs in Bot and in the browser), on-board (the OpenVibe Link on the robot\'s own computer: the Adeept ADR036 4WD car and its mecanum build, the relay robot for any Linux board), bridge (the Link on a computer or phone next to the robot: Cozmo through PyCozmo, the LOOI desk robot over Bluetooth) and server-side (ONVIF pan-tilt-zoom cameras, nothing to install). Each model has a page with how it connects, its setup steps, its guide and the controls its panel gets.',},{path: '/docs', title: 'Build for OpenVibe.Bot',note: 'The build index: drivers and robot profiles.',-- text: 'The build index for OpenVibe.Bot. Two things are open to build against: a driver, a separate program the OpenVibe Node runs and talks to in JSON lines over stdin/stdout, and a robot profile (bot.robot-profile@1), the JSON that says what a robot can do and which widgets its panel shows. The page links the driver guide and the profile guide, and points to the OpenVibe Node plugins tree, the protocol document and the plugin contract.',++ text: 'The build index for OpenVibe.Bot. Two things are open to build against: a driver, a separate program the OpenVibe Link runs and talks to in JSON lines over stdin/stdout, and a robot profile (bot.robot-profile@1), the JSON that says what a robot can do and which widgets its panel shows. The page links the driver guide and the profile guide, and points to the OpenVibe Link plugins tree, the protocol document and the plugin contract.',},{path: '/docs/drivers', title: 'Write a driver',note: 'The driver protocol: lifecycle, every message and the safety rules.',-- text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Node core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop, estop, resume; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',++ text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Link core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop, estop, resume; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',},{path: '/docs/profiles', title: 'Robot profiles',@@@ -128,7 +128,7 @@ const LLMS_PAGES = [{path: '/docs/computers', title: 'Your computer for your agents',note: 'The Node\'s computer plugin: levels set on the computer, approvals there, the agent switch and the API.',-- text: 'How to let your agents use your own computer through the OpenVibe Node\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-node approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.bot/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',++ text: 'How to let your agents use your own computer through the OpenVibe Link\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-node approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.bot/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',},{path: '/updates', title: 'What shipped on OpenVibe.Bot',@@@ -474,7 +474,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limireturn res.redirect(303, '/robots/submissions');}));-- // The guided first run's funnel, reviewers only (OpenVibe.Node docs/install.md): the Node reports one++ // The guided first run's funnel, reviewers only (OpenVibe Link docs/install.md): the Link reports one// node.first_run telemetry event per step, and Bot stores one row per device and step.r.get('/admin/first-run', page(async (req, res) => {const me = requireUser(req);@@@ -626,9 +626,9 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limicomputer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,ai_models: !!d.ai_models,-- // The Node ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).++ // The Link ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).ai_offer: (() => { const ai = aiOf(d.capabilities); return ai && ai.offer && typeof ai.offer === 'object' ? ai.offer : null; })(),-- // How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.++ // How fast its last model answered there (Link v0.29.0's ai plugin), and whether one is answering now.ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,ai_busy: sensors.ai_busy === 1,@@@ -642,8 +642,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limireturn mine;};const asUser = (me) => ({ kind: 'user', sub: me.subject });-- // The machine has a private desk to show (Node v0.15.0 on Linux): its owner may show it, or share it, instead.-- // The private desk is a whole desk: a machine that runs browser desks only (a Mac, Node v0.23.0) has none to show.++ // The machine has a private desk to show (Link v0.15.0 on Linux): its owner may show it, or share it, instead.++ // The private desk is a whole desk: a machine that runs browser desks only (a Mac, Link v0.23.0) has none to show.const hasDesk = (mine) => {const d = hub && (hub.computerOf(mine.id) || {}).desk;return !!d && (!Array.isArray(d.kinds) || d.kinds.includes('desktop'));@@@ -713,7 +713,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limilet maxDesks = null;let error = null;if (!hub || !hub.isOnline(mine.id)) error = 'The machine is offline: its desks stopped with it, or show here when it is back.';-- else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Node with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';++ else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Link with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';else {const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });if (r && r.result === 'ack') {@@@ -856,7 +856,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.redirect(303, '/machines');}));// The owner offers this desk host's desks to other people's sessions (OpenVibe.Run, contracts 0.223.0), or stops: only-- // where its Node sandboxes every desk (Node v0.32.0, install.sh --desk-host --shared).++ // where its Link sandboxes every desk (Link v0.32.0, install.sh --desk-host --shared).r.post('/machines/:id/shared-desks', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');@@@ -865,12 +865,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst on = req.body && req.body.on === '1';const desk = ((hub && hub.computerOf(mine.id)) || {}).desk;if (on && !(desk && desk.isolation === 'sandbox')) {-- throw new BotError(409, 'bot.computer_off', 'this machine does not sandbox its desks: install it with --desk-host --shared (OpenVibe Node v0.32.0 or newer), then try again');++ throw new BotError(409, 'bot.computer_off', 'this machine does not sandbox its desks: install it with --desk-host --shared (OpenVibe Link v0.32.0 or newer), then try again');}await domain.machines.setSharedDesks(mine.id, on);res.redirect(303, '/machines');}));-- // The owner lets Vibe answer their voice on this machine (OpenVibe Node's voice plugin), with a call word, or stops it.++ // The owner lets Vibe answer their voice on this machine (OpenVibe Link's voice plugin), with a call word, or stops it.r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');@@@ -1,5 +1,5 @@'use strict';--// The T15 lane H proving test: one end-to-end Adeept dry-run through Bot, using the frames OpenVibe.Node++// The T15 lane H proving test: one end-to-end Adeept dry-run through Bot, using the frames OpenVibe Link// actually sends (internal/protocol, internal/link, testdata/bot). An owner pairs a fake ADR036, drives// and actuates it over the /control socket, reads battery and latency back, latches and clears the e-stop,// and never sees the device's secrets in a read. BOT_WHIP_BASE is set, so pairing must answer a whip_url.@@@ -30,7 +30,7 @@ const WHIP_BASE = 'https://whip.test/ingest';await check('step 1: an owner robot pairs over HTTP with --robot/--name and gets a whip_url', async () => {const created = await t.robot(alex, { profile_id: 'adeept.adr036' });robot = created.robot;-- // OpenVibe.Node's cmdPair sends Robot, Code, AgentVersion, DeviceKind, Drivers, Capabilities and Name.++ // OpenVibe Link's cmdPair sends Robot, Code, AgentVersion, DeviceKind, Drivers, Capabilities and Name.paired = await t.call('POST', '/api/v1/pair', { token: null, body: {robot: robot.id, code: created.pairing.code, agent_version: '0.1.0', device_kind: 'onboard',drivers: ['pca9685', 'ads7830'], capabilities: { pca9685: { pan: true, tilt: true } }, name: 'adeept-dry-run',@@@ -60,7 +60,7 @@ const WHIP_BASE = 'https://whip.test/ingest';assert.strictEqual(config.estop_latched, false);assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);-- // Only now does the Node report: its link holds every frame until hello.++ // Only now does the Link report: its link holds every frame until hello.dev.send({ type: 'status', firmware: 'openvibe-node-0.1.0', capabilities: {}, agent_version: '0.1.0',device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });@@@ -102,7 +102,7 @@ const WHIP_BASE = 'https://whip.test/ingest';});// ── 4. a Node-shaped battery object becomes a numeric fraction on the robot state ────────────────-- await check("step 4: a Node {volts, percent} battery shows a non-null numeric battery", async () => {++ await check("step 4: a Link {volts, percent} battery shows a non-null numeric battery", async () => {dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, voltage: 7.42, sensors: { ultrasonic: 118, line: [0, 1, 1, 0] } });const frame = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery&& m.state.telemetry.battery.percent === 59);@@@ -118,7 +118,7 @@ const WHIP_BASE = 'https://whip.test/ingest';dev.send({ type: 'heartbeat', t: sentAt });const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack' && m.t === sentAt);assert.strictEqual(ack.echo, sentAt, 'echo carries the device t');-- assert.strictEqual(ack.t, sentAt, 't is the field OpenVibe.Node reads for its RTT');++ assert.strictEqual(ack.t, sentAt, 't is the field OpenVibe Link reads for its RTT');assert.ok(!Number.isNaN(Date.parse(ack.server_time)), `a server_time: ${ack.server_time}`);// A later beat carrying the measured round trip: Bot keeps it as the connection's latency.dev.send({ type: 'heartbeat', t: sentAt + 1000, rtt_ms: 42 });@@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/app');const start = (body) => t.call('POST', '/api/v1/claims', { token: null, body });const poll = (c, secret = c.secret) => t.call('GET', `/api/v1/claims/${c.id}`, { token: null, headers: { 'X-OV-Claim-Secret': secret } });const streamOf = (robotId) => [...t.openre.streams.values()].find((s) => s.refs.some((r) => r.type === 'robot' && r.id === robotId));-- // What OpenVibe.Node's installer sends after looking at an Adeept ADR036 on a Pi 4.++ // What OpenVibe Link's installer sends after looking at an Adeept ADR036 on a Pi 4.const pi = {hostname: 'raspi',hardware: { model: 'Raspberry Pi 4 Model B Rev 1.4', os: 'Debian GNU/Linux 12 (bookworm)', arch: 'aarch64', kit: 'adeept.adr036',@@@ -1,5 +1,5 @@'use strict';--// "Offer to the network" (the design's community machines; plan E4): a Node ai plugin advertises its owner's models,++// "Offer to the network" (the design's community machines; plan E4): a Link ai plugin advertises its owner's models,// hours, concurrency, price and audience in status.capabilities.ai.offer; Bot reports it as a user-owned provider offer// whose detail.kind is `ai`, and AI may send a community run to that machine even with "Let my AI use this computer's// models" off, because the offer is the opt-in. Without the offer the same run is refused. This is Bot's half: the@@@ -13,7 +13,7 @@ const KEY = Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' })).toSconst PROFILE = {id: 'community.ballbot', version: 1, name: 'Ballbot', vendor: 'Mia', kind: 'onboard',-- description: 'A self-balancing ball robot on an ESP32, driven by a community Node plugin.',++ description: 'A self-balancing ball robot on an ESP32, driven by a community Link plugin.',capabilities: ['drive.differential', 'battery'],mapping: { driver: 'plugin', plugin: 'ballbot' },commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } },@@@ -141,7 +141,7 @@ const plugins = require('./helpers/plugins');});// Over the socket, every Adeept (ordinary and mecanum) and Cozmo control reaches the device in the exact-- // shape its Node plugin takes (test/helpers/plugins.js transcribes the plugins).++ // shape its Link plugin takes (test/helpers/plugins.js transcribes the plugins).const PLUGIN_COMMANDS = {'adeept.adr036': [['drive', { throttle: 0.5, steer: -2 }, { throttle: 0.5, steer: -1 }],@@@ -1,7 +1,7 @@'use strict';// Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, bot.desk.session; the Desks plan's D6). Only Run, with the// capability, acting for a person: GET /api/v1/desk-hosts lists that person's own machines (agents allowed) and desk--// hosts whose owner offers them to other people and whose Node sandboxes every desk; a lease starts a browser desk,++// hosts whose owner offers them to other people and whose Link sandboxes every desk; a lease starts a browser desk,// takes commands for that desk alone and ends with DELETE; the machine's own routes never reach a leased desk; a full// machine is busy; anyone else gets 404.const assert = require('assert');@@@ -56,7 +56,7 @@ const { boot, check, done } = require('./helpers/app');const { dev } = await pairOverWs(robot.id, pairing.code);dev.send({ type: 'heartbeat', t: 1738065600500 });const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack');-- assert.strictEqual(ack.t, 1738065600500, 'OpenVibe.Node reads ack.t for its RTT');++ assert.strictEqual(ack.t, 1738065600500, 'OpenVibe Link reads ack.t for its RTT');assert.strictEqual(ack.echo, 1738065600500, 'the existing echo field is unchanged');assert.ok(ack.server_time);dev.close();@@@ -1,5 +1,5 @@'use strict';--// The guided first run, measured (OpenVibe.Node's `openvibe-node`): the Node sends one `node.first_run` telemetry++// The guided first run, measured (OpenVibe Link's `openvibe-node`): the Link sends one `node.first_run` telemetry// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.const assert = require('assert');@@@ -1,6 +1,6 @@'use strict';/**-- * What each device plugin accepts, transcribed from OpenVibe.Node (2479ef1) so Bot's profiles are tested++ * What each device plugin accepts, transcribed from OpenVibe Link (2479ef1) so Bot's profiles are tested* against the device side. A value passes only when the plugin takes it as sent and inside its ranges —* never by leaning on the plugin's own clamp. accepts(kind, value) returns null, or why it would be refused.*@@@ -8,12 +8,12 @@* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py* SDK = plugins/sdk/openvibe_plugin/__init__.py*-- * LOOI = OpenVibe.Node docs/hardware-looi.md (a community driver; the contract it implements).++ * LOOI = OpenVibe Link docs/hardware-looi.md (a community driver; the contract it implements).* RELAY = the relay plugin (plan T15 R9 step 4), a bridge with no hardware of its own.* ROS2 = plugins/ros2/openvibe_ros2/__init__.py (Ros2.handle): drive only (say needs say_topic, which no shipped* profile assumes); throttle/steer, or x/y/rotation when the profile's base is holonomic.*-- * camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the++ * camera.onvif and sim.rover are `kind: "server"` profiles with no Link plugin; their contract is the* value table in docs/protocol.md.*/const num = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;@@@ -133,12 +133,12 @@ function cozmo({ maxSay = 200 } = {}) {}/**-- * RELAY (relay.generic): the Node plugin that forwards a robot's button and video-click commands to the++ * RELAY (relay.generic): the Link plugin that forwards a robot's button and video-click commands to the* owner's local script (plan T15 R9 step 4). It takes every declared button name and the point layer as the* gate builds them (server/domain buildValue); it holds no hardware of its own.*/const relay = {-- name: 'relay (Node plugin)',++ name: 'relay (Link plugin)',accepts(kind, v) {if (kind === 'halt') return halt(v);if (!isObj(v)) return `${kind} value must be an object`;@@@ -198,7 +198,7 @@ const server = {};/**-- * LOOI = OpenVibe.Node docs/hardware-looi.md, the contract the community's Generic LOOI driver implements: drive is++ * LOOI = OpenVibe Link docs/hardware-looi.md, the contract the community's Generic LOOI driver implements: drive is* throttle/steer -1..1, the one actuator is head -1..1, and halt.*/const looi = {@@@ -218,7 +218,7 @@ const looi = {},};--/** The contract a profile's commands must meet: its Node plugin, or the server driver. */++/** The contract a profile's commands must meet: its Link plugin, or the server driver. *//*** The phone driver = public/phone-device.js onCommand: say {text}; display {face} (its FACES) or {text}; actuator* torch (true/false) and chime ({hz} or null); halt. Anything else is nacked unsupported.@@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');await check('GET /: no promise the service cannot keep (video waits for OpenRestream; no store, no unsupported boards)', async () => {const html = await (await get('/')).text();assert.match(html, /sends its camera over WebRTC to OpenRestream and the panel plays it live/);-- // ROS 2 left this list with Bot#95 (ros2.diffdrive, ros2.holonomic, the Node's plugins/ros2 driver).++ // ROS 2 left this list with Bot#95 (ros2.diffdrive, ros2.holonomic, the Link's plugins/ros2 driver).// Webhooks left this list when they shipped (routines and webhooks, migration 0011, /docs/automation).for (const claim of [/ESP32/, /Android/, /arrive paired/i, /\bbuy\b/i]) assert.ok(!claim.test(html), `no ${claim}`);});@@@ -1,5 +1,5 @@'use strict';--// GET /install (plan T15 step 1): a 302 to OpenVibe.Node's installer script, chosen by config only and checked at++// GET /install (plan T15 step 1): a 302 to OpenVibe Link's installer script, chosen by config only and checked at// boot (https, allow-listed host). The pairing's installer command carries `--driver <kind>` from the profile,// and nothing for a profile whose driver is `none`.const assert = require('assert');@@@ -162,7 +162,7 @@ const { boot, check, done } = require('./helpers/app');m.c.close();});-- await check('named desks (contracts 0.215.0, Node v0.19.0): one per agent task, each its own screen; an older Node runs one', async () => {++ await check('named desks (contracts 0.215.0, Link v0.19.0): one per agent task, each its own screen; an older Link runs one', async () => {const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });const start = await cmd(multi.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });@@@ -177,11 +177,11 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual(rows.map((r) => [r.kind, r.value]), [['desk', { action: 'start', desk: 'desk-task-a' }], ['browser', { action: 'open', host: 'example.org', desk: 'desk-task-a' }], ['desk', { action: 'list' }],], 'the audit names the desk');-- // A machine on an older Node runs its private desk only: a named one is refused there, never sent.++ // A machine on an older Link runs its private desk only: a named one is refused there, never sent.const sent = seen.length;const old = await cmd(single.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });assert.deepStrictEqual([old.status, old.json.code], [409, 'bot.computer_off']);-- assert.match(old.json.detail || old.json.reason || '', /Node v0\.19\.0/);++ assert.match(old.json.detail || old.json.reason || '', /Link v0\.19\.0/);assert.strictEqual((await cmd(single.deviceId, { kind: 'screen', value: { desk: 'desk-task-a' } }, { user: alex })).json.code, 'bot.computer_off');assert.strictEqual(seen.length, sent, 'nothing reached the older machine');// Names follow the contract.@@@ -200,7 +200,7 @@ const { boot, check, done } = require('./helpers/app');single.c.close();});-- await check('browser desks (contracts 0.216.0, Node v0.23.0): a desk starts as a kind the machine runs; the audit names it; an older Node is never sent one', async () => {++ await check('browser desks (contracts 0.216.0, Link v0.23.0): a desk starts as a kind the machine runs; the audit names it; an older Link is never sent one', async () => {const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 };const both = await machine(alex, { ...COMPUTER, desk: { ...desk, kinds: ['desktop', 'browser'] } });const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, kinds: ['browser'] } });@@@ -215,16 +215,16 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual([whole.status, whole.json.code], [409, 'bot.computer_off']);assert.match(whole.json.detail || whole.json.reason || '', /browser desks only/);const older = await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a', kind: 'browser' } }, { user: alex });-- assert.deepStrictEqual([older.status, older.json.code], [409, 'bot.computer_off'], 'an older Node would start a whole desk instead');-- assert.match(older.json.detail || older.json.reason || '', /Node v0\.23\.0/);++ assert.deepStrictEqual([older.status, older.json.code], [409, 'bot.computer_off'], 'an older Link would start a whole desk instead');++ assert.match(older.json.detail || older.json.reason || '', /Link v0\.23\.0/);const bad = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-c', kind: 'vm' } }, { user: alex });assert.strictEqual(bad.status, 422);assert.strictEqual(seen.length, before, 'nothing refused reached a machine');-- // An older Node still starts its whole desks as before (no kind).++ // An older Link still starts its whole desks as before (no kind).assert.strictEqual((await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');const audit = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3 ORDER BY id', [both.deviceId, 'desk', 'ack']);assert.deepStrictEqual(audit.map((r) => r.value.kind), ['browser', 'desktop'], 'the audit names the kind');-- // A kept profile (contracts 0.217.0, Node v0.24.0) reaches the machine and the audit names it.++ // A kept profile (contracts 0.217.0, Link v0.24.0) reaches the machine and the audit names it.const kept = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-k', kind: 'browser', profile: 'agent' } }, { user: alex });assert.strictEqual(kept.json.result, 'ack', kept.text);assert.strictEqual(seen.at(-1).value.profile, 'agent');@@@ -236,7 +236,7 @@ const { boot, check, done } = require('./helpers/app');old.c.close();});-- await check('programs on a desk (contracts 0.218.0, Node v0.26.0): sent to a Node that runs them, audited with the desk; an older Node never gets one', async () => {++ await check('programs on a desk (contracts 0.218.0, Link v0.26.0): sent to a Link that runs them, audited with the desk; an older Link never gets one', async () => {const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] };const now = await machine(alex, { ...COMPUTER, terminal: { ...COMPUTER.terminal, desks: true }, desk });const old = await machine(alex, { ...COMPUTER, desk });@@@ -248,14 +248,14 @@ const { boot, check, done } = require('./helpers/app');const before = seen.length;const refused = await cmd(old.deviceId, { kind: 'exec', value: { argv: ['xterm'], desk: 'desk-task-a' } }, { user: alex });assert.deepStrictEqual([refused.status, refused.json.code], [409, 'bot.computer_off']);-- assert.match(refused.json.detail || refused.json.reason || '', /Node v0\.26\.0/);++ assert.match(refused.json.detail || refused.json.reason || '', /Link v0\.26\.0/);assert.strictEqual(seen.length, before, 'never sent');assert.strictEqual((await cmd(old.deviceId, { kind: 'exec', value: { argv: ['echo', 'hi'] } }, { user: alex })).json.result, 'ack', 'its own session as before');now.c.close();old.c.close();});-- await check('a desk host (Node v0.20.0 --desk-host): no screen of its own, yet its desks are seen and driven', async () => {++ await check('a desk host (Link v0.20.0 --desk-host): no screen of its own, yet its desks are seen and driven', async () => {// Nobody is signed in to a desk host: it offers no screen or input of its own, only its desks' (desk.screen, desk.input).const { screen: _s, input: _i, ...headless } = COMPUTER;const host = await machine(alex, { ...headless, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });@@@ -362,7 +362,7 @@ const { boot, check, done } = require('./helpers/app');const hostCmd = /<code[^>]*>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);assert.ok(hostCmd, 'the desk host install command');assert.ok(!hostCmd[1].includes('--worker'), 'a desk host never runs the job worker too');-- assert.ok(page.includes('Use the models you run on it'), 'AI on your own machines (Node v0.28.0 --ai)');++ assert.ok(page.includes('Use the models you run on it'), 'AI on your own machines (Link v0.28.0 --ai)');const aiCmd = /<code[^>]*>(curl -fsSL [^<]+ --ai)<\/code>/.exec(page);assert.ok(aiCmd && !aiCmd[1].includes('--worker'), 'the models install command');host.c.close();@@@ -446,7 +446,7 @@ const { boot, check, done } = require('./helpers/app');assert.ok(!/secret (query|result)|web_search/.test(JSON.stringify(toolRow)), 'no tool names, arguments or results in the audit');const orphan = await cmd(gpu.deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'tool', content: 'x' }] } }, asAi(alex.subject));assert.strictEqual(orphan.status, 422, 'a tool message needs the call it answers');-- // A real Node reports the ai plugin under its own name, and a machine may run it with no computer plugin.++ // A real Link reports the ai plugin under its own name, and a machine may run it with no computer plugin.const own = await machine(alex, null);own.c.send(frame('status', { capabilities: { ai: { ai: { models: ['qwen2.5-1.5b-instruct'], ready: true, max_tokens: 256 } } }, drivers: [], faults: [] }));await t.wait(300);@@@ -456,7 +456,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await cmd(own.deviceId, { kind: 'ai', value: { model: 'qwen2.5-1.5b-instruct', messages: [{ role: 'user', content: 'hi' }] } }, { user: alex })).json.result, 'ack');const noExec = await cmd(own.deviceId, { kind: 'exec', value: { argv: ['id'] } }, { user: alex });assert.strictEqual(noExec.json.code, 'bot.computer_off', 'no terminal without the computer plugin');-- // Node v0.29.0's ai plugin says how fast its last model answered there (after the 2 Hz telemetry window).++ // Link v0.29.0's ai plugin says how fast its last model answered there (after the 2 Hz telemetry window).await t.wait(600);own.c.send(frame('telemetry', { sensors: { ai_busy: 0, ai_model: 'qwen2.5-1.5b-instruct', ai_tokens_per_s: 14.6 } }));await t.wait(600);@@@ -1,10 +1,10 @@'use strict';--// The fleet (Codes C9c): a machine's OpenVibe.Node reports what its coding agents have left with each heartbeat++// The fleet (Codes C9c): a machine's OpenVibe Link reports what its coding agents have left with each heartbeat// (bot.device-message@1's `fleet`), and Bot keeps the latest reading per machine for Codes' Agents page, its router and// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, nod_…), which is the same machine// Run pins a job to by its device id (dev_…): one row, both names.//--// What this checks: a reading is stored and answered; it is bounded exactly as the Node bounds it (16 agents, 10 windows,++// What this checks: a reading is stored and answered; it is bounded exactly as the Link bounds it (16 agents, 10 windows,// an id shaped like an id, short labels, numbers only), and a frame that is not a reading changes nothing; the owner// sees their own reading and nobody else does; a machine that has reported nothing answers null; the reading is stored// again when it changes or a minute has passed, never on every beat.@@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };const seen = [];-- /** A machine of `owner` whose fake Node answers screen_stream like Node v0.12.0. */++ /** A machine of `owner` whose fake Node answers screen_stream like Link v0.12.0. */async function machine(owner, computer = SCREEN) {const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });@@@ -159,7 +159,7 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });});-- await check('a desk on the live screen: private, or a named desk on a Node that runs several (contracts 0.215.0); never a bad name', async () => {++ await check('a desk on the live screen: private, or a named desk on a Link that runs several (contracts 0.215.0); never a bad name', async () => {const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true };const multi = await machine(alex, { ...SCREEN, desk: { ...desk, named: true, max_desks: 4 } });const r = await screen('POST', multi.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-task-a' } });@@@ -174,7 +174,7 @@ const { boot, check, done } = require('./helpers/app');single.c.close();});-- await check('a desk host (Node v0.20.0 --desk-host) shows its desks live though it has no screen of its own', async () => {++ await check('a desk host (Link v0.20.0 --desk-host) shows its desks live though it has no screen of its own', async () => {const host = await machine(alex, { files: SCREEN.files, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });// Its page offers its desks, never a screen it does not have.const idle = await (await fetch(`${t.base}/machines/${host.deviceId}/screen?desk=desk-task-a`, { headers: cookie(alex) })).text();@@@ -2,7 +2,7 @@// The machines hub (plan L34, step M2): Bot stays the registry of the machines. A person sees their own// machines and nobody else's; the network's hub (a service holding bot.machine.manage) acts for a person it// names in X-OV-Subject; a role outside the catalogue is refused; a pairing code is one code for the person;--// the roles reach the Node in its config frame; and a machine's online state is reported to Network once per++// the roles reach the Link in its config frame; and a machine's online state is reported to Network once per// change.const assert = require('assert');const { boot, check, done } = require('./helpers/app');@@@ -120,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(own.status, 201, own.text);});-- await check('the roles reach the Node in its config frame', async () => {++ await check('the roles reach the Link in its config frame', async () => {const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(alexMachine.principal)}` } });const config = await c.waitFor((m) => m.type === 'config');assert.ok(config, 'the machine gets its config');@@@ -115,13 +115,13 @@ const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;assert.strictEqual(after.kind, 'bridge');assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);assert.strictEqual(after.agent_version, '0.3.1');-- // The Node's own status frame lists drivers as objects (OpenVibe.Node internal/protocol Status): the names stay.++ // The Link's own status frame lists drivers as objects (OpenVibe Link internal/protocol Status): the names stay.const errors = c.messages.filter((m) => m.type === 'error').length;c.send({ v: 1, seq: 3, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: [{ name: 'adeept', driver: 'adeept_adr036', state: 'up' }, { name: 'dryrun', state: 'down' }], faults: [] });let named;for (let i = 0; i < 40; i++) { named = await deviceRow(hello.device_id); if (named.drivers.length && named.drivers[0] === 'adeept_adr036') break; await t.wait(25); }assert.deepStrictEqual(named.drivers, ['adeept_adr036', 'dryrun']);-- assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Node\'s own status');++ assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Link\'s own status');c.close();});@@@ -142,7 +142,7 @@ const { boot, check, done } = require('./helpers/app');await new Promise((r) => setTimeout(r, 100));state = (await get('?kind=bot.machine')).json.resources[0].state;}-- assert.strictEqual(state, 'offline', 'offline once its Node goes');++ assert.strictEqual(state, 'offline', 'offline once its Link goes');await t.db.query('UPDATE devices SET revoked_at = now() WHERE id = $1', [deviceId]);assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources, [], 'a removed machine leaves the index');});@@@ -1,6 +1,6 @@'use strict';// Jobs on the device link (plan T14 L1, platform.job-frame@1): server/jobs/dispatch.js hands a platform.job@1 job--// to a paired Node, only for a class it advertises, resends it until acked, and meters it into run_usage_outbox++// to a paired Link, only for a class it advertises, resends it until acked, and meters it into run_usage_outbox// one reading per wall-clock second (run:<job id>:<n>), backfilled and deduplicated from job_exit; the relay// delivers each reading to Billing once, and keeps it queued while Billing is unset or down.const assert = require('assert');@@@ -28,7 +28,7 @@ const PROJECT = `prj_${ids.ulid()}`;const send = (deviceId, job, opts = {}) => dispatch(t.db, deviceId, job, { link: t.hub, project: PROJECT, subject: SUBJECT, ...opts });const sawJob = (c, id) => c.messages.some((m) => m.type === 'job' && m.job.id === id);-- /** A Network-paired Node advertising `classes`, connected; its capabilities are persisted before it returns. */++ /** A Network-paired Link advertising `classes`, connected; its capabilities are persisted before it returns. */async function node(classes = ['function']) {const { robot } = await t.robot(alex);const principal = t.network.addNode({ owner: alex.subject, ref: robot.id });@@@ -62,7 +62,7 @@ const PROJECT = `prj_${ids.ulid()}`;return n;}-- await check('a job reaches its connected Node as a valid job frame and the ack places it', async () => {++ await check('a job reaches its connected Link as a valid job frame and the ack places it', async () => {const n = await node();const job = newJob();const r = await send(n.deviceId, job);@@@ -80,12 +80,12 @@ const PROJECT = `prj_${ids.ulid()}`;await assert.rejects(send(n.deviceId, newJob(), { project: 'not-a-project' }), (e) => e.code === 'bot.invalid_job');});-- await check('a class the Node does not advertise is never sent, at dispatch or on a reconnect', async () => {++ await check('a class the Link does not advertise is never sent, at dispatch or on a reconnect', async () => {const n = await node(['function']);const code = newJob({ class: 'code' });await assert.rejects(send(n.deviceId, code), (e) => e.code === 'bot.class_unadvertised');assert.strictEqual(await row(code.id), null);-- // Stored while the Node is away; it comes back advertising another class only.++ // Stored while the Link is away; it comes back advertising another class only.n.c.close(); await n.c.waitForClose();const fn = newJob();assert.strictEqual((await send(n.deviceId, fn)).sent, false);@@@ -93,7 +93,7 @@ const PROJECT = `prj_${ids.ulid()}`;await n.connect();await poll(async () => (await row(fn.id)).state === 'failed');assert.strictEqual((await row(fn.id)).fault_code, 'bot.class_unadvertised');-- assert.ok(!sawJob(n.c, code.id) && !sawJob(n.c, fn.id), 'no job frame for a class the Node lacks');++ assert.ok(!sawJob(n.c, code.id) && !sawJob(n.c, fn.id), 'no job frame for a class the Link lacks');});await check('an unacked job is resent on every reconnect until acked, then never again', async () => {@@@ -189,7 +189,7 @@ const PROJECT = `prj_${ids.ulid()}`;assert.strictEqual(j.state, 'cancelled');assert.strictEqual(j.started_ms, null);assert.deepStrictEqual(await readings(job.id), []);-- // Cancelled while the Node is away and never sent: it is not sent on the reconnect either.++ // Cancelled while the Link is away and never sent: it is not sent on the reconnect either.n.c.close(); await n.c.waitForClose();const away = newJob();await send(n.deviceId, away);@@@ -213,7 +213,7 @@ const PROJECT = `prj_${ids.ulid()}`;assert.deepStrictEqual((await readings(job.id)).map((r) => r.idempotency_key), [0, 1, 2, 3, 4].map((k) => `run:${job.id}:${k}`));});-- await check('a Node never meters another device\'s job, nor seconds past limits.wall_ms', async () => {++ await check('a Link never meters another device\'s job, nor seconds past limits.wall_ms', async () => {const a = await node();const b = await node();const S = 1790935600000;@@@ -279,7 +279,7 @@ const PROJECT = `prj_${ids.ulid()}`;refused(await call('POST', '/api/v1/jobs', { token: null, body }), 401, 'bot.sign_in');// project_id is required: Run is the payer.refused(await call('POST', '/api/v1/jobs', { body: { ...body, project_id: undefined } }), 422, 'bot.invalid_input');-- // The dispatch reaches the Node once; the same job again is idempotent.++ // The dispatch reaches the Link once; the same job again is idempotent.const r = await call('POST', '/api/v1/jobs', { body });assert.strictEqual(r.status, 201, r.text);assert.strictEqual(r.json.sent, true);@@@ -307,7 +307,7 @@ const PROJECT = `prj_${ids.ulid()}`;assert.deepStrictEqual(t.app.locals.jobs.stdout(job.id), got.json.stdout);refused(await call('GET', '/api/v1/jobs/job_00000000000000000000000000'), 404, 'bot.job_not_found');refused(await call('POST', '/api/v1/jobs/job_00000000000000000000000000/cancel'), 404, 'bot.job_not_found');-- // Cancel asks the Node to stop it.++ // Cancel asks the Link to stop it.const c = await call('POST', `/api/v1/jobs/${job.id}/cancel`);assert.strictEqual(c.status, 200, c.text);assert.strictEqual(c.json.job.state, 'cancelled');@@@ -101,7 +101,7 @@ const { loadProfiles } = require('../server/profiles');const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sim);const tel = s.state.telemetry;assert.deepStrictEqual(Object.keys(tel.sim).sort(), ['bumped', 'drive', 'headlights', 'horn', 'pose']);-- assert.deepStrictEqual(Object.keys(tel.sensors).sort(), ['distance_cm', 'line'], 'the range finder and line readouts under the keys the Node reports');++ assert.deepStrictEqual(Object.keys(tel.sensors).sort(), ['distance_cm', 'line'], 'the range finder and line readouts under the keys the Link reports');assert.ok(!('drive' in tel) && !('pose' in tel), 'nothing else of the telemetry');w.close();t.app.locals.sim.stop(robot.id);
@@ -70,8 +70,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -70,8 +70,8 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_JOBS=on
#BOT_JOBS=on
#BOT_JOBS_INTERVAL_MS=5000
#BOT_JOBS_INTERVAL_MS=5000
#BOT_INSTALLER_URL=https://openvibe.bot/install
#BOT_INSTALLER_URL=https://openvibe.bot/install
-# Where GET /install redirects (302): OpenVibe.Node's installer script. Defaults to the checksummed asset on
-# OpenVibe.Node's latest release (install.sh, pinned there by Node's CI cmp guard so it never drifts with main).
+# Where GET /install redirects (302): OpenVibe Link's installer script. Defaults to the checksummed asset on
+# OpenVibe Link's latest release (install.sh, pinned there by its CI cmp guard so it never drifts with main).
# https on raw.githubusercontent.com, github.com, objects.githubusercontent.com or openvibe.bot only; anything
# https on raw.githubusercontent.com, github.com, objects.githubusercontent.com or openvibe.bot only; anything
# else refuses to boot.
# else refuses to boot.
#BOT_INSTALLER_SOURCE_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh
#BOT_INSTALLER_SOURCE_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh
@@ -98,7 +98,7 @@ OV_OAUTH_CLIENT_SECRET=
@@ -98,7 +98,7 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_ONVIF_TIMEOUT_MS=5000
#BOT_ONVIF_TIMEOUT_MS=5000
# ── OpenVibe.Billing: job usage (plan T14 L1) ───────────────
# ── OpenVibe.Billing: job usage (plan T14 L1) ───────────────
-# Where the per-second readings of jobs run on paired Nodes go: billing.usage.record, POST <url>/api/v1/usage.
+# Where the per-second readings of jobs run on paired Links go: billing.usage.record, POST <url>/api/v1/usage.
# Bot mints its own Network service token (audience openvibe.billing, capability billing.usage.record) from
# Bot mints its own Network service token (audience openvibe.billing, capability billing.usage.record) from
# OV_OAUTH_CLIENT_ID/SECRET and OV_NETWORK_INTERNAL_URL; BOT_BILLING_TOKEN, an operator-minted service token of
# OV_OAUTH_CLIENT_ID/SECRET and OV_NETWORK_INTERNAL_URL; BOT_BILLING_TOKEN, an operator-minted service token of
# the same grant, overrides it. BOT_BILLING_URL unset: readings wait in run_usage_outbox (never dropped) and a
# the same grant, overrides it. BOT_BILLING_URL unset: readings wait in run_usage_outbox (never dropped) and a
@@ -19,16 +19,16 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -19,16 +19,16 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.
- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.
- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.
- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.
-- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
+- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Link plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
-- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
+- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Link — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRestream stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRestream stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
-- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
+- Dispatching `platform.job@1` jobs to a paired Link over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
Sitemap entries use the release commit time for these code-backed pages.
Sitemap entries use the release commit time for these code-backed pages.
@@ -40,28 +40,28 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -40,28 +40,28 @@ Sitemap entries use the release commit time for these code-backed pages.
## Devices
## Devices
-- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
-- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
+- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe Link `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe Link `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
+- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe Link `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
-- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.network/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Node turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.
+- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.network/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Link turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.
- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).
- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).
- `GET /api/v1/desk-hosts` lists where a desk may go for that person:
- `GET /api/v1/desk-hosts` lists where a desk may go for that person:
- their own machines where "Let my agent use this computer" is on (trust `user-owned`);
- their own machines where "Let my agent use this computer" is on (trust `user-owned`);
- - desk hosts whose owner offers them to other people (`devices.shared_desks`, the "Offer my desks to other people" button on `/machines`), only where the Node sandboxes every desk (`desk.isolation: sandbox`, OpenVibe.Node v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.
+ - desk hosts whose owner offers them to other people (`devices.shared_desks`, the "Offer my desks to other people" button on `/machines`), only where the Link sandboxes every desk (`desk.isolation: sandbox`, OpenVibe Link v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.
- Each host comes with its room: a browser desk counts as half of `max_desks`.
- Each host comes with its room: a browser desk counts as half of `max_desks`.
- `POST /api/v1/machines/:id/desk-leases` starts the desk. Then `POST …/desk-leases/:desk/commands` carries `screen`, `input` and `browser` (`bot.command@1` without `desk`) to that desk alone, and `DELETE …/desk-leases/:desk` stops it.
- `POST /api/v1/machines/:id/desk-leases` starts the desk. Then `POST …/desk-leases/:desk/commands` carries `screen`, `input` and `browser` (`bot.command@1` without `desk`) to that desk alone, and `DELETE …/desk-leases/:desk` stops it.
- A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.
- A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.
- A full machine answers `409 bot.machine_busy`; anyone else gets 404.
- A full machine answers `409 bot.machine_busy`; anyone else gets 404.
- Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).
- Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).
-- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
-- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
+- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
+- **The live screen** (plan T14, contracts 0.182.0, OpenVibe Link v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.
- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.
- **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.
- **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.
- **The audit** keeps the action, seconds and fps, never the URL or key.
- **The audit** keeps the action, seconds and fps, never the URL or key.
- **The viewer.** `GET` answers the session's viewer `signaling_url` once the machine publishes, and the page plays it with the panel's WebRTC viewer.
- **The viewer.** `GET` answers the session's viewer `signaling_url` once the machine publishes, and the page plays it with the panel's WebRTC viewer.
- **Stop.** `DELETE` (or Stop) tells the machine to stop and retires the key. Removing a machine archives its stream.
- **Stop.** `DELETE` (or Stop) tells the machine to stop and retires the key. Removing a machine archives its stream.
- **Refused** with `bot.computer_off` when the machine does not offer it (`screen.stream`), and no key is made.
- **Refused** with `bot.computer_off` when the machine does not offer it (`screen.stream`), and no key is made.
-- **The private desk** (contracts 0.214.0 `bot.command@1` kind `desk`, OpenVibe.Node v0.15.0 on Linux): a screen of its
+- **The private desk** (contracts 0.214.0 `bot.command@1` kind `desk`, OpenVibe Link v0.15.0 on Linux): a screen of its
own on the machine (Xvfb with its own bus and window manager) where an agent works without touching the owner's.
own on the machine (Xvfb with its own bus and window manager) where an agent works without touching the owner's.
- **Commands.** `desk` `{ action: start | stop | status, width?, height? }` reaches a machine whose status lists
- **Commands.** `desk` `{ action: start | stop | status, width?, height? }` reaches a machine whose status lists
`computer.desk`. `screen`, `input` and `browser` with `desk: "private"` act there. A machine without one answers
`computer.desk`. `screen`, `input` and `browser` with `desk: "private"` act there. A machine without one answers
@@ -73,7 +73,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -73,7 +73,7 @@ Sitemap entries use the release commit time for these code-backed pages.
- **The live screen** of the desk: `POST /machines/:id/screen` with `desk: "private"` (or the page's "Show the
- **The live screen** of the desk: `POST /machines/:id/screen` with `desk: "private"` (or the page's "Show the
private desk" button) sends `screen_stream` with `desk: private`.
private desk" button) sends `screen_stream` with `desk: private`.
- **Vibe by voice** (`server/voice.js`, migration `0024_machine_voice.sql`, contracts 0.214.0 `voice_ask` /
- **Vibe by voice** (`server/voice.js`, migration `0024_machine_voice.sql`, contracts 0.214.0 `voice_ask` /
- `voice_answer`; consumer plan §8). On a machine whose Node runs the voice plugin (`capabilities.voice`), the owner
+ `voice_answer`; consumer plan §8). On a machine whose Link runs the voice plugin (`capabilities.voice`), the owner
turns voice on and chooses a call word ("hey vibe" by default) on /machines or `POST /api/v1/machines/:id/voice`.
turns voice on and chooses a call word ("hey vibe" by default) on /machines or `POST /api/v1/machines/:id/voice`.
- **Config** then carries `voice: true` and `call_word`, and the machine listens for the word on its own. Only what
- **Config** then carries `voice: true` and `call_word`, and the machine listens for the word on its own. Only what
the person says after it comes as `voice_ask`.
the person says after it comes as `voice_ask`.
@@ -106,7 +106,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -106,7 +106,7 @@ Sitemap entries use the release commit time for these code-backed pages.
The audit keeps the action and position (typed text only as a character count) under the machine. Nothing else
The audit keeps the action and position (typed text only as a character count) under the machine. Nothing else
passes: no programs, no files, no choice of where the screen goes.
passes: no programs, no files, no choice of where the screen goes.
- **Desks for agents** (the Desks plan, `OpenVibe_Agent_Desks_Plan_2026-10-27.md`; contracts 0.215.0–0.218.0,
- **Desks for agents** (the Desks plan, `OpenVibe_Agent_Desks_Plan_2026-10-27.md`; contracts 0.215.0–0.218.0,
- OpenVibe.Node v0.19.0–v0.26.0). Each agent task gets a desk of its own on a machine.
+ OpenVibe Link v0.19.0–v0.26.0). Each agent task gets a desk of its own on a machine.
- **Named desks** (`desk-<name>`): each has its own display, browser profile and files. **Browser desks**
- **Named desks** (`desk-<name>`): each has its own display, browser profile and files. **Browser desks**
(`kind: browser`) are a headless browser alone; a desk host or a Mac runs browser desks only.
(`kind: browser`) are a headless browser alone; a desk host or a Mac runs browser desks only.
- **Programs on a desk:** `exec` with `desk`, and `detach` for an app that lasts until the desk stops.
- **Programs on a desk:** `exec` with `desk`, and `detach` for an app that lasts until the desk stops.
@@ -119,8 +119,8 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -119,8 +119,8 @@ Sitemap entries use the release commit time for these code-backed pages.
machine's (`bot:machine_live:<id>`) whose `mirror_to_live` OpenRestream links to their channel. It is never the
machine's (`bot:machine_live:<id>`) whose `mirror_to_live` OpenRestream links to their channel. It is never the
person's own screen and never the private screen stream, and it lasts up to 30 minutes.
person's own screen and never the private screen stream, and it lasts up to 30 minutes.
- **Your own models** (AI on your own machines; contracts 0.219.0 `bot.command@1` kind `ai`, capability
- **Your own models** (AI on your own machines; contracts 0.219.0 `bot.command@1` kind `ai`, capability
- `bot.machine.ai`; OpenVibe.Node v0.27.0+ `ai` plugin, `install.sh --ai`).
- - **Reporting:** a machine's Node reports its model server's models (`capabilities.ai.ai`, read by
+ `bot.machine.ai`; OpenVibe Link v0.27.0+ `ai` plugin, `install.sh --ai`).
+ - **Reporting:** a machine's Link reports its model server's models (`capabilities.ai.ai`, read by
`server/machine-ai.js`) and, from v0.29.0, how fast the last one answered there (`ai_tokens_per_s`). The API has it
`server/machine-ai.js`) and, from v0.29.0, how fast the last one answered there (`ai_tokens_per_s`). The API has it
as `computer.ai` and `computer.ai_speed`.
as `computer.ai` and `computer.ai_speed`.
- **Who may ask:** the owner, or OpenVibe.AI (`svc:ai` with `bot.machine.ai`, acting for the owner) once the owner
- **Who may ask:** the owner, or OpenVibe.AI (`svc:ai` with `bot.machine.ai`, acting for the owner) once the owner
@@ -134,7 +134,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -134,7 +134,7 @@ Sitemap entries use the release commit time for these code-backed pages.
(`bot.machine`, online or offline) on one cursor. Each names its page here (`links.console`), so OpenVibe.Services
(`bot.machine`, online or offline) on one cursor. Each names its page here (`links.console`), so OpenVibe.Services
lists and links them.
lists and links them.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
-- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
+- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe Link's `docs/hardware-adeept.md` (T15 step 1).
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
- Profiles declare `button` commands by name and `point` commands for normalised video coordinates in `[0, 1]`. A hold button sends `down` while pressed and `up` on release; each `down` has the device deadline used for held controls. The command gate checks declarations, roles, robot limits and control specific cooldowns. The panel and read-only embed render the profile's labels, with buttons disabled for anyone who cannot drive.
- Profiles declare `button` commands by name and `point` commands for normalised video coordinates in `[0, 1]`. A hold button sends `down` while pressed and `up` on release; each `down` has the device deadline used for held controls. The command gate checks declarations, roles, robot limits and control specific cooldowns. The panel and read-only embed render the profile's labels, with buttons disabled for anyone who cannot drive.
@@ -225,7 +225,7 @@ Set `BOT_BILLING_URL` in `/etc/openvibe/bot.env` to OpenVibe.Billing's base URL
@@ -225,7 +225,7 @@ Set `BOT_BILLING_URL` in `/etc/openvibe/bot.env` to OpenVibe.Billing's base URL
Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.
Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.
-`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe.Node's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with Node's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
+`/install` is proxied to the app, which answers a 302 to OpenVibe Link's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe Link's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with the repo's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
## Deploy files
## Deploy files
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
"catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, looi, phone.camera, camera.onvif, sim.rover, relay.generic (the bare relay robot; OpenVibe.Live's old controls were converted to it and deleted from Live) and ros2.diffdrive / ros2.holonomic, seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",
"catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, looi, phone.camera, camera.onvif, sim.rover, relay.generic (the bare relay robot; OpenVibe.Live's old controls were converted to it and deleted from Live) and ros2.diffdrive / ros2.holonomic, seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",
- "ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe.Node's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)",
+ "ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe Link's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)",
"curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
@@ -57,8 +57,8 @@
@@ -57,8 +57,8 @@
"webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks",
"webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks",
"the owner's agent (migration 0012 robots.agent_control): panel 'Your agent' switch; svc:actor acting for the owner only reads GET /api/v1/robots/:id/steps (state + step words) and runs POST …/steps (<= 20 steps, <= 30 s, through hub.operatorCommand, audited as the service) or POST …/commands; otherwise 403 bot.agent_not_allowed",
"the owner's agent (migration 0012 robots.agent_control): panel 'Your agent' switch; svc:actor acting for the owner only reads GET /api/v1/robots/:id/steps (state + step words) and runs POST …/steps (<= 20 steps, <= 30 s, through hub.operatorCommand, audited as the service) or POST …/commands; otherwise 403 bot.agent_not_allowed",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
- "panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
- "platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
+ "panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Link's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
+ "platform.job@1 jobs dispatched to a paired Link over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# Community AI offers through Bot
# Community AI offers through Bot
-OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bot is the transport and the reporter:
+OpenVibe Link's `ai` plugin can offer a person's local models to the network. Bot is the transport and the reporter:
- **Report** — `server/jobs/offers.js` `buildAi()` reads every live Network-paired device whose
- **Report** — `server/jobs/offers.js` `buildAi()` reads every live Network-paired device whose
`capabilities.ai.ai.offer.enabled` is true and publishes one offer per machine to Network's
`capabilities.ai.ai.offer.enabled` is true and publishes one offer per machine to Network's
@@ -10,7 +10,7 @@ OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bo
@@ -10,7 +10,7 @@ OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bo
- **Dispatch** — `POST /api/v1/machines/:id/commands` with `kind: ai` reaches a machine only from OpenVibe.AI
- **Dispatch** — `POST /api/v1/machines/:id/commands` with `kind: ai` reaches a machine only from OpenVibe.AI
(`svc:ai`, `bot.machine.ai`). A machine's own models need `ai_models` (the owner's "Let my AI use this computer's
(`svc:ai`, `bot.machine.ai`). A machine's own models need `ai_models` (the owner's "Let my AI use this computer's
models" switch). A **community** run is marked with the `community/<model>` model prefix and is allowed when the
models" switch). A **community** run is marked with the `community/<model>` model prefix and is allowed when the
- machine's own offer is enabled: the offer is the opt-in, and the Node plugin enforces hours, concurrency and its
+ machine's own offer is enabled: the offer is the opt-in, and the Link plugin enforces hours, concurrency and its
model list. The prefix never reaches the local model server.
model list. The prefix never reaches the local model server.
The command's value is `bot.command@1`, which is closed to extra fields, so the prefix carries the community flag
The command's value is `bot.command@1`, which is closed to extra fields, so the prefix carries the community flag
@@ -6,7 +6,7 @@ is its runbook: what runs in which order, what is backed up, how the result is v
@@ -6,7 +6,7 @@ is its runbook: what runs in which order, what is backed up, how the result is v
## Scope — what this PR actually changes
## Scope — what this PR actually changes
-Bot becomes the dispatcher of plan T14: it hands `platform.job@1` jobs to a paired OpenVibe.Node over the
+Bot becomes the dispatcher of plan T14: it hands `platform.job@1` jobs to a paired OpenVibe Link over the
`/device` socket (`platform.job-frame@1`) and meters them per wall-clock second for OpenVibe.Billing.
`/device` socket (`platform.job-frame@1`) and meters them per wall-clock second for OpenVibe.Billing.
- `migrations/0004_run_jobs.sql` (`-- phase: expand`) — two **new** tables and their indexes:
- `migrations/0004_run_jobs.sql` (`-- phase: expand`) — two **new** tables and their indexes:
@@ -94,7 +94,7 @@ node test/run-jobs.test.js
@@ -94,7 +94,7 @@ node test/run-jobs.test.js
1. The harness starts a scratch database, applies the base branch's migrations and the repository's fixtures,
1. The harness starts a scratch database, applies the base branch's migrations and the repository's fixtures,
then this PR's migrations: exactly `0004_run_jobs.sql` applies, and a second run applies nothing.
then this PR's migrations: exactly `0004_run_jobs.sql` applies, and a second run applies nothing.
-2. It runs `node test/run-jobs.test.js`: a job reaches a connected Node and only for an advertised class; it is
+2. It runs `node test/run-jobs.test.js`: a job reaches a connected Link and only for an advertised class; it is
resent until acked; exited 0 writes one reading per second plus the partial last second; a lost `job_usage`
resent until acked; exited 0 writes one reading per second plus the partial last second; a lost `job_usage`
is backfilled; a second `job_exit` writes nothing; a cancel before `job_started` bills nothing;
is backfilled; a second `job_exit` writes nothing; a cancel before `job_started` bills nothing;
`usage_read` never regresses; and with Billing unset or down readings stay queued until a later sweep
`usage_read` never regresses; and with Billing unset or down readings stay queued until a later sweep
@@ -14,6 +14,6 @@ Use an isolated copy of Bot's PostgreSQL data and the same deployment configurat
@@ -14,6 +14,6 @@ Use an isolated copy of Bot's PostgreSQL data and the same deployment configurat
## Production order and way back
## Production order and way back
-1. Confirm the Node installer source is reachable and that the Node release consumed by its script is published. Record the current Bot revision and take a fresh, restorable PostgreSQL backup before the guarded Bot deployment.
+1. Confirm the Link installer source is reachable and that the Link release consumed by its script is published. Record the current Bot revision and take a fresh, restorable PostgreSQL backup before the guarded Bot deployment.
2. Deploy the reviewed revision through the normal merge pipeline. Confirm `/api/ready` and `/api/health`, then repeat the 302, query-parameter and pairing-command checks above using a controlled test owner. Keep the installer command and pairing code out of logs and tickets.
2. Deploy the reviewed revision through the normal merge pipeline. Confirm `/api/ready` and `/api/health`, then repeat the 302, query-parameter and pairing-command checks above using a controlled test owner. Keep the installer command and pairing code out of logs and tickets.
3. If the new route or pairing command is wrong, roll Bot back to the recorded prior revision through the deployment pipeline and repeat the health and pairing checks. There is no schema reversal or data restore in the ordinary rollback because this PR changes neither schema nor stored data. If an unrelated data incident requires restoring the backup, coordinate it separately with the database owner because a restore can discard writes made after the snapshot.
3. If the new route or pairing command is wrong, roll Bot back to the recorded prior revision through the deployment pipeline and repeat the health and pairing checks. There is no schema reversal or data restore in the ordinary rollback because this PR changes neither schema nor stored data. If an unrelated data incident requires restoring the backup, coordinate it separately with the database owner because a restore can discard writes made after the snapshot.
@@ -6,8 +6,8 @@ runbook for PR #18: what runs in which order, what is backed up, how the result
@@ -6,8 +6,8 @@ runbook for PR #18: what runs in which order, what is backed up, how the result
## Scope — what this PR actually changes
## Scope — what this PR actually changes
-PR #18 changes the default target of the `GET /install` redirect so it points at OpenVibe.Node's **released**
-installer asset instead of a path on OpenVibe.Node's `main` branch:
+PR #18 changes the default target of the `GET /install` redirect so it points at OpenVibe Link's **released**
+installer asset instead of a path on OpenVibe Link's `main` branch:
- `server/config.js` — the `BOT_INSTALLER_SOURCE_URL` fallback becomes
- `server/config.js` — the `BOT_INSTALLER_SOURCE_URL` fallback becomes
`https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh` (was
`https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh` (was
@@ -84,10 +84,10 @@ adds it from this file at the verified head; it resolves to:
@@ -84,10 +84,10 @@ adds it from this file at the verified head; it resolves to:
node token → `403 bot.forbidden`; with a service token lacking the capability → `403 capability.denied`;
node token → `403 bot.forbidden`; with a service token lacking the capability → `403 capability.denied`;
without `project_id` → `422 bot.invalid_input`.
without `project_id` → `422 bot.invalid_input`.
- A valid dispatch of a job whose class the target Node advertises → `201`, `sent: true`, a `run_jobs` row in
- A valid dispatch of a job whose class the target Node advertises → `201`, `sent: true`, a `run_jobs` row in
- state `queued`, and the Node receives one `job` frame; the same body again → the same row, `sent: false`,
+ state `queued`, and the Link receives one `job` frame; the same body again → the same row, `sent: false`,
no second frame; a class the device does not advertise → `409 bot.class_unadvertised` with **no** row.
no second frame; a class the device does not advertise → `409 bot.class_unadvertised` with **no** row.
- `GET /api/v1/jobs/:id` → `200` with the row and any captured stdout; an unknown id → `404 bot.job_not_found`.
- `GET /api/v1/jobs/:id` → `200` with the row and any captured stdout; an unknown id → `404 bot.job_not_found`.
- - `POST /api/v1/jobs/:id/cancel` → `200`, the row `cancelled`, the Node receives one `job_cancel` frame.
+ - `POST /api/v1/jobs/:id/cancel` → `200`, the row `cancelled`, the Link receives one `job_cancel` frame.
4. Behaviour elsewhere is unchanged: the person and node routes still answer exactly as before, and the
4. Behaviour elsewhere is unchanged: the person and node routes still answer exactly as before, and the
`/device` and `/control` sockets still gate the same way.
`/device` and `/control` sockets still gate the same way.
5. `docs/protocol.md` §1.2 no longer says "no HTTP route", §3 lists the three routes and `bot.job.dispatch`,
5. `docs/protocol.md` §1.2 no longer says "no HTTP route", §3 lists the three routes and `bot.job.dispatch`,
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
Status: **done** (updated 2026-10-27). Every step below is built and in production:
Status: **done** (updated 2026-10-27). Every step below is built and in production:
- steps 1–3 and 5: Contracts 0.109.0 and Bot, 2026-10-07;
- steps 1–3 and 5: Contracts 0.109.0 and Bot, 2026-10-07;
-- step 4: OpenVibe.Node's `relay` plugin (`plugins/relay`);
+- step 4: OpenVibe Link's `relay` plugin (`plugins/relay`);
- step 6: Live deleted its controls (`0011_drop_live_controls`). Its old hardware socket `/ws/control` answers
- step 6: Live deleted its controls (`0011_drop_live_controls`). Its old hardware socket `/ws/control` answers
**410 Gone**, naming OpenVibe.Bot, so a client that still connects there learns where to go
**410 Gone**, naming OpenVibe.Bot, so a client that still connects there learns where to go
(Live `server/net/ws-paths.js`).
(Live `server/net/ws-paths.js`).
@@ -17,7 +17,7 @@ one priority"):
@@ -17,7 +17,7 @@ one priority"):
- **Chat commands reach a button robot.** `!forward`, `!left` … press the robot's buttons of the same name when it
- **Chat commands reach a button robot.** `!forward`, `!left` … press the robot's buttons of the same name when it
has no drive, and any other `!word` that is not a channel sound presses the button of that name (Live#383,
has no drive, and any other `!word` that is not a channel sound presses the button of that name (Live#383,
Chat#194).
Chat#194).
-- **The Node's lifecycle:** `openvibe-node quit` ends a running Node cleanly with nothing latched (Node 0.16.0), for
+- **The Link's lifecycle:** `openvibe-node quit` ends a running Link cleanly with nothing latched (Link 0.16.0), for
launchers that start and stop it with the rest of a stream setup. `stop` stays the kill switch.
launchers that start and stop it with the rest of a stream setup. `stop` stays the kill switch.
## What Live has today
## What Live has today
@@ -48,7 +48,7 @@ lines) let viewers drive a streamer's hardware from the channel page:
@@ -48,7 +48,7 @@ lines) let viewers drive a streamer's hardware from the channel page:
| Controls on/off, login required | The robot's access policy (`private` / `invite` / `queue`); controls are never anonymous |
| Controls on/off, login required | The robot's access policy (`private` / `invite` / `queue`); controls are never anonymous |
| Whitelist | Operators added by @username (built) |
| Whitelist | Operators added by @username (built) |
| Per-viewer rate limit, cooldowns | The command gate's per-robot limits and cooldowns (built), plus the per-button `cooldown_ms` |
| Per-viewer rate limit, cooldowns | The command gate's per-robot limits and cooldowns (built), plus the per-button `cooldown_ms` |
-| The hardware client and its stream-key socket | The OpenVibe **Node** paired to the robot, running a **relay** plugin (below); the stream key stops being a robot credential |
+| The hardware client and its stream-key socket | The OpenVibe **Link** paired to the robot, running a **relay** plugin (below); the stream key stops being a robot credential |
| The controls panel under the video | Bot's panel embedded on the Live channel page (`/panel/:id/embed`, `LIVE_BOT_EMBED`), already built |
| The controls panel under the video | Bot's panel embedded on the Live channel page (`/panel/:id/embed`, `LIVE_BOT_EMBED`), already built |
## What has to be built
## What has to be built
@@ -121,7 +121,7 @@ if anything, needs redoing by hand.
@@ -121,7 +121,7 @@ if anything, needs redoing by hand.
`point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Live
`point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Live
command behind each Bot button name is in the robot's audit (`live_conversions.detail` too), so the mapping
command behind each Bot button name is in the robot's audit (`live_conversions.detail` too), so the mapping
is one line.
is one line.
-3. Point the Pi script at the paired Node instead of Live's stream-key socket. The stream key is no longer a
+3. Point the Pi script at the paired Link instead of Live's stream-key socket. The stream key is no longer a
robot credential.
robot credential.
4. Robot page → **On your stream** → **Put the buttons under my OpenVibe.Live stream**, and choose who may drive.
4. Robot page → **On your stream** → **Put the buttons under my OpenVibe.Live stream**, and choose who may drive.
@@ -42,7 +42,7 @@ keep connecting with it either way.
@@ -42,7 +42,7 @@ keep connecting with it either way.
**The installer command** is `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX` (or the
**The installer command** is `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX` (or the
Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the `adeept.adr036`,
Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the `adeept.adr036`,
`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run
`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run
-`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe.Node's `install/install.sh`).
+`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe Link's `install/install.sh`).
**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its
**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
@@ -71,7 +71,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -71,7 +71,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |
| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
-| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe.Node reads. The envelope `seq` is Bot's own counter and never echoes the device's |
+| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe Link reads. The envelope `seq` is Bot's own counter and never echoes the device's |
| `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself |
| `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself |
| `error` | `code, detail` | a frame the server refused |
| `error` | `code, detail` | a frame the server refused |
@@ -81,7 +81,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -81,7 +81,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
|---|---|---|
|---|---|---|
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` |
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` |
| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
-| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe.Node sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |
+| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |
| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |
| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |
| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `ack` | `id` | a command ran (or, keyed by a job id, a job was accepted: §1.2) |
| `ack` | `id` | a command ran (or, keyed by a job id, a job was accepted: §1.2) |
@@ -102,13 +102,13 @@ deadline or firmware that keeps going on its own never carries into the next tur
@@ -102,13 +102,13 @@ deadline or firmware that keeps going on its own never carries into the next tur
sends none. The same halt goes out when the owner or an operator takes over a turn (below).
sends none. The same halt goes out when the owner or an operator takes over a turn (below).
**What the order guarantees, and what it does not.** Frames on one device connection arrive in the order
**What the order guarantees, and what it does not.** Frames on one device connection arrive in the order
-Bot sent them (one WebSocket), and OpenVibe.Node handles them in that order. Two things follow:
+Bot sent them (one WebSocket), and OpenVibe Link handles them in that order. Two things follow:
- **Nothing decided under a turn follows that turn's halt.** Bot ends a turn and sends its halt in one
- **Nothing decided under a turn follows that turn's halt.** Bot ends a turn and sends its halt in one
synchronous step that also moves the robot's control epoch. A queue driver's command whose gate decision
synchronous step that also moves the robot's control epoch. A queue driver's command whose gate decision
began before that step is refused (`bot.not_your_turn`) at the moment it would be sent, with no await between
began before that step is refused (`bot.not_your_turn`) at the moment it would be sent, with no await between
the check and the send. So on the wire, everything authorised under turn N precedes N's halt.
the check and the send. So on the wire, everything authorised under turn N precedes N's halt.
- **Lateness is bounded by the deadline.** Every motion command carries `deadline_ms` on Bot's clock, at
- **Lateness is bounded by the deadline.** Every motion command carries `deadline_ms` on Bot's clock, at
- most the robot's `max_command_ms` away. Node compares it with its estimate of Bot's clock on arrival, nacks
+ most the robot's `max_command_ms` away. Link compares it with its estimate of Bot's clock on arrival, nacks
a late one (`expired`) before any driver sees it, and gives a driver only the time left.
a late one (`expired`) before any driver sees it, and gives a driver only the time left.
Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never
Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never
@@ -121,7 +121,7 @@ across a Bot restart:
@@ -121,7 +121,7 @@ across a Bot restart:
- It moves when a turn ends, is left or is taken over. The halt for that change carries the new value, and every
- It moves when a turn ends, is left or is taken over. The halt for that change carries the new value, and every
command carries the epoch current when it is sent. An owner's command right after a takeover carries the epoch its
command carries the epoch current when it is sent. An owner's command right after a takeover carries the epoch its
halt moved to.
halt moved to.
-- OpenVibe.Node (Node#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,
+- OpenVibe Link (Node#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,
and passes the epoch to drivers (`cmd.turn_epoch`), so a driver can check it too.
and passes the epoch to drivers (`cmd.turn_epoch`), so a driver can check it too.
- It is absent until a robot's first turn change since Bot started. A command without it runs as before.
- It is absent until a robot's first turn change since Bot started. A command without it runs as before.
@@ -194,9 +194,9 @@ against these):
@@ -194,9 +194,9 @@ against these):
| profile | device | commands |
| profile | device | commands |
|---|---|---|
|---|---|---|
-| `adeept.adr036` | Node plugin `adeept_adr036`, ordinary wheels | `drive {throttle, steer}`; `actuator` `pan`/`tilt` −1..1, `buzzer` tone 220–880 Hz, `lights` rgb (8 LEDs) |
-| `adeept.adr036.mecanum` | Node plugin `adeept_adr036`, mecanum wheels | `drive {x, y, rotation}`; the same actuators |
-| `cozmo` | Node plugin `cozmo` | `drive {throttle, steer}`; `actuator` `head` −1..1, `lift` 0..1, `backpack_lights`/`cube_lights` rgb; `say`; `display` text, face (`neutral happy sad surprised sleepy angry`) or image |
+| `adeept.adr036` | Link plugin `adeept_adr036`, ordinary wheels | `drive {throttle, steer}`; `actuator` `pan`/`tilt` −1..1, `buzzer` tone 220–880 Hz, `lights` rgb (8 LEDs) |
+| `adeept.adr036.mecanum` | Link plugin `adeept_adr036`, mecanum wheels | `drive {x, y, rotation}`; the same actuators |
+| `cozmo` | Link plugin `cozmo` | `drive {throttle, steer}`; `actuator` `head` −1..1, `lift` 0..1, `backpack_lights`/`cube_lights` rgb; `say`; `display` text, face (`neutral happy sad surprised sleepy angry`) or image |
| `camera.onvif` | server driver `onvif` | `ptz {pan, tilt, zoom}` −1..1 |
| `camera.onvif` | server driver `onvif` | `ptz {pan, tilt, zoom}` −1..1 |
| `sim.rover` | server driver `sim` | `drive {throttle, steer}` |
| `sim.rover` | server driver `sim` | `drive {throttle, steer}` |
@@ -215,7 +215,7 @@ and audited (`kind: estop_state`, `result: refused`); nothing changes on that ro
@@ -215,7 +215,7 @@ and audited (`kind: estop_state`, `result: refused`); nothing changes on that ro
**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine
**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine
itself: `openvibe-node stop`, or a driver's own stop button through it. Only a release on that machine
itself: `openvibe-node stop`, or a driver's own stop button through it. Only a release on that machine
-(`openvibe-node resume`) lifts it; the owner's clear does not. The Node keeps refusing motion (`nack`
+(`openvibe-node resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack`
`local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps
`local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps
`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot is
`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot is
stopped on the robot and holds back its Clear button until the device reports the release. After the release
stopped on the robot and holds back its Clear button until the device reports the release. After the release
@@ -303,7 +303,7 @@ heartbeat or any frame brings it back. Commands are never replayed after a recon
@@ -303,7 +303,7 @@ heartbeat or any frame brings it back. Commands are never replayed after a recon
### 1.2 Jobs (platform.job-frame@1)
### 1.2 Jobs (platform.job-frame@1)
-Bot is the dispatcher of plan T14: it hands a `platform.job@1` job to a paired OpenVibe.Node over this socket
+Bot is the dispatcher of plan T14: it hands a `platform.job@1` job to a paired OpenVibe Link over this socket
and meters it. The Run service (which owns `run.job.*`) calls it over the internal HTTP API under the
and meters it. The Run service (which owns `run.job.*`) calls it over the internal HTTP API under the
`bot.job.dispatch` capability: `POST /api/v1/jobs`, `POST /api/v1/jobs/:id/cancel` and `GET /api/v1/jobs/:id`
`bot.job.dispatch` capability: `POST /api/v1/jobs`, `POST /api/v1/jobs/:id/cancel` and `GET /api/v1/jobs/:id`
(`server/api/v1.js`) call the jobs service `server/jobs/index.js` `createJobs({ db, hub, usage, log, now })`, which
(`server/api/v1.js`) call the jobs service `server/jobs/index.js` `createJobs({ db, hub, usage, log, now })`, which
@@ -317,8 +317,8 @@ against `platform.job-frame@1` from the pinned openvibe-contracts; an invalid on
@@ -317,8 +317,8 @@ against `platform.job-frame@1` from the pinned openvibe-contracts; an invalid on
|---|---|---|---|
|---|---|---|---|
| server → device | `job` | `job` (platform.job@1) | sent by `dispatch` when the device is connected, and resent on every reconnect while unacked. Never sent for a `class` missing from the device's stored `capabilities.worker.runtime_classes` (dispatch answers `409 bot.class_unadvertised`; a queued job whose class is no longer advertised fails with `fault_code` `bot.class_unadvertised`) |
| server → device | `job` | `job` (platform.job@1) | sent by `dispatch` when the device is connected, and resent on every reconnect while unacked. Never sent for a `class` missing from the device's stored `capabilities.worker.runtime_classes` (dispatch answers `409 bot.class_unadvertised`; a queued job whose class is no longer advertised fails with `fault_code` `bot.class_unadvertised`) |
| server → device | `job_cancel` | `id` | sent by `cancel` if the job was sent and has not ended; resent on a reconnect and when a `job_started` or `ack` crosses it. A job not yet started is `cancelled` at once; a cancelled job is never sent again |
| server → device | `job_cancel` | `id` | sent by `cancel` if the job was sent and has not ended; resent on a reconnect and when a `job_started` or `ack` crosses it. A job not yet started is `cancelled` at once; a cancelled job is never sent again |
-| server → device | `job_exit_ack` | `id` | after every reading of the job is committed to `run_usage_outbox`; the Node then forgets the job and stops resending its `job_exit` |
-| device → server | `ack` / `nack` | `id`, `fault_code` | the Node accepted (`placed`) or refused (`failed`, `fault_code` kept) the job |
+| server → device | `job_exit_ack` | `id` | after every reading of the job is committed to `run_usage_outbox`; the Link then forgets the job and stops resending its `job_exit` |
+| device → server | `ack` / `nack` | `id`, `fault_code` | the Link accepted (`placed`) or refused (`failed`, `fault_code` kept) the job |
| device → server | `job_started` | `id, started_ms` | `running`; the first `started_ms` anchors every second and never changes |
| device → server | `job_started` | `id, started_ms` | `running`; the first `started_ms` anchors every second and never changes |
| device → server | `job_stdout` | `id, chunk_seq, chunk` | the last 1 MiB of each job, in memory; a `chunk_seq` already held is dropped. Never metered |
| device → server | `job_stdout` | `id, chunk_seq, chunk` | the last 1 MiB of each job, in memory; a `chunk_seq` already held is dropped. Never metered |
| device → server | `job_usage` | `id, started_ms, second, cpu_ms?` | one reading for `second` (quantity 1); ignored once `job_exit` was taken |
| device → server | `job_usage` | `id, started_ms, second, cpu_ms?` | one reading for `second` (quantity 1); ignored once `job_exit` was taken |
@@ -1,17 +1,17 @@
@@ -1,17 +1,17 @@
-- phase: expand
-- phase: expand
--
--
-- Jobs on the device link (plan T14 step L1; platform.job@1 inside platform.job-frame@1): Bot is the dispatcher
-- Jobs on the device link (plan T14 step L1; platform.job@1 inside platform.job-frame@1): Bot is the dispatcher
+-- that hands a job to a paired Link and meters it per wall-clock second for Billing. Additive only: two new
-- tables, nothing existing changes, nothing to backfill.
-- tables, nothing existing changes, nothing to backfill.
--
--
-- run_jobs one row per job id: what was sent, to which device, its state and how far it is metered
-- run_jobs one row per job id: what was sent, to which device, its state and how far it is metered
-- run_usage_outbox platform.usage-sample@1 readings waiting for Billing (the openvibe-sdk outboxSchema shape).
-- run_usage_outbox platform.usage-sample@1 readings waiting for Billing (the openvibe-sdk outboxSchema shape).
-- event_id is the reading's idempotency_key run:<job id>:<second>, so a second is queued once
-- event_id is the reading's idempotency_key run:<job id>:<second>, so a second is queued once
+-- however often the Link reports it.
CREATE TABLE IF NOT EXISTS run_jobs (
CREATE TABLE IF NOT EXISTS run_jobs (
id text PRIMARY KEY CHECK (id ~ '^job_[0-9A-HJKMNP-TV-Z]{26}$'), -- platform.job@1 id
id text PRIMARY KEY CHECK (id ~ '^job_[0-9A-HJKMNP-TV-Z]{26}$'), -- platform.job@1 id
- node_id text NOT NULL REFERENCES devices (id), -- dev_…: the device whose Node runs it
+ node_id text NOT NULL REFERENCES devices (id), -- dev_…: the device whose Link runs it
project_id text, -- prj_… of the payer; no FK, Bot holds no projects
project_id text, -- prj_… of the payer; no FK, Bot holds no projects
subject text, -- who the readings are for (user:usr_…)
subject text, -- who the readings are for (user:usr_…)
provider text,
provider text,
@@ -19,9 +19,9 @@ CREATE TABLE IF NOT EXISTS run_jobs (
@@ -19,9 +19,9 @@ CREATE TABLE IF NOT EXISTS run_jobs (
job jsonb NOT NULL, -- the platform.job@1 body, resent verbatim until acked
job jsonb NOT NULL, -- the platform.job@1 body, resent verbatim until acked
state text NOT NULL CHECK (state IN ('queued', 'placed', 'running', 'succeeded', 'failed', 'cancelled', 'expired')),
state text NOT NULL CHECK (state IN ('queued', 'placed', 'running', 'succeeded', 'failed', 'cancelled', 'expired')),
cancel_requested boolean NOT NULL DEFAULT false,
cancel_requested boolean NOT NULL DEFAULT false,
- fault_code text, -- the Node's nack, or why Bot stopped sending it
+ fault_code text, -- the Link's nack, or why Bot stopped sending it
sent_at bigint, -- first time the job frame went out (ms)
sent_at bigint, -- first time the job frame went out (ms)
- started_ms bigint, -- the Node's started_ms: anchors every usage second
+ started_ms bigint, -- the Link's started_ms: anchors every usage second
wall_ms bigint, -- job_exit.usage.wall_ms as billed (capped at limits.wall_ms)
wall_ms bigint, -- job_exit.usage.wall_ms as billed (capped at limits.wall_ms)
usage_read integer NOT NULL DEFAULT 0 CHECK (usage_read >= 0), -- seconds queued for Billing; never moves back
usage_read integer NOT NULL DEFAULT 0 CHECK (usage_read >= 0), -- seconds queued for Billing; never moves back
exit_reason text, -- job_exit.reason; set once, by the first job_exit
exit_reason text, -- job_exit.reason; set once, by the first job_exit
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
-- phase: expand
-- phase: expand
-- A screen share link shows the owner's own screen (main) or the machine's private desk (contracts 0.214.0 bot.command@1
-- A screen share link shows the owner's own screen (main) or the machine's private desk (contracts 0.214.0 bot.command@1
+-- `desk: private`, OpenVibe Link v0.15.0): the agent works on the desk, and a room watches it there, never the owner's
-- screen. Links made before this show the owner's screen, as they did.
-- screen. Links made before this show the owner's screen, as they did.
ALTER TABLE machine_screen_shares ADD COLUMN IF NOT EXISTS desk text NOT NULL DEFAULT 'main' CHECK (desk IN ('main', 'private'));
ALTER TABLE machine_screen_shares ADD COLUMN IF NOT EXISTS desk text NOT NULL DEFAULT 'main' CHECK (desk IN ('main', 'private'));
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
-- phase: expand
-- phase: expand
-- Vibe by voice (consumer plan §8, contracts 0.214.0 bot.device-message@1 voice_ask / voice_answer): the owner lets
-- Vibe by voice (consumer plan §8, contracts 0.214.0 bot.device-message@1 voice_ask / voice_answer): the owner lets
-- Vibe answer their voice on this machine, and chooses its call word. Off until they turn it on on openvibe.bot/machines;
-- Vibe answer their voice on this machine, and chooses its call word. Off until they turn it on on openvibe.bot/machines;
+-- the machine (OpenVibe Link's voice plugin) listens for the word only while it is on (config `voice`).
ALTER TABLE devices ADD COLUMN IF NOT EXISTS voice_on boolean NOT NULL DEFAULT false;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS voice_on boolean NOT NULL DEFAULT false;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS call_word text;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS call_word text;
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
-- phase: expand
-- phase: expand
-- AI on your own machines (contracts 0.219.0, bot.command@1 kind ai; capability bot.machine.ai): the owner lets their
-- AI on your own machines (contracts 0.219.0, bot.command@1 kind ai; capability bot.machine.ai): the owner lets their
+-- AI (OpenVibe.AI, acting for them) run a model on this machine's own model server through OpenVibe Link's ai plugin.
-- Off until they turn it on on openvibe.bot/machines.
-- Off until they turn it on on openvibe.bot/machines.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS ai_models boolean NOT NULL DEFAULT false;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS ai_models boolean NOT NULL DEFAULT false;
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
-- phase: expand
-- phase: expand
-- Run sessions (contracts 0.223.0, the Desks plan's D6). A machine's owner may offer its desks to other people
-- Run sessions (contracts 0.223.0, the Desks plan's D6). A machine's owner may offer its desks to other people
+-- (shared_desks, off until they turn it on on openvibe.bot/machines; only where its OpenVibe Link runs every desk in its
+-- own sandbox, desk_isolation sandbox, Link v0.32.0). OpenVibe.Run leases one named desk per session there, or on the
-- person's own machine (bot.desk-lease@1): only that session's commands reach the desk, and the lease ends with it.
-- person's own machine (bot.desk-lease@1): only that session's commands reach the desk, and the lease ends with it.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS shared_desks boolean NOT NULL DEFAULT false;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS shared_desks boolean NOT NULL DEFAULT false;
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
-- phase: expand
-- phase: expand
-- What a machine's coding agents have left (Codes C9c, bot.device-message@1's heartbeat `fleet`): each agent's id,
-- What a machine's coding agents have left (Codes C9c, bot.device-message@1's heartbeat `fleet`): each agent's id,
-- whether it is installed and signed in, and each limit window's label, what it has used of its limit, and when it
-- whether it is installed and signed in, and each limit window's label, what it has used of its limit, and when it
+-- frees. Its OpenVibe Link reads it with OpenVibe.Codes' own logins probe as the machine's user and reports labels,
-- numbers and times only: no key, token or account name. Bot keeps the latest reading per machine and when it stored
-- numbers and times only: no key, token or account name. Bot keeps the latest reading per machine and when it stored
-- it; a machine that stops beating keeps its last reading, and fleet_at says how old it is.
-- it; a machine that stops beating keeps its last reading, and fleet_at says how old it is.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS fleet jsonb;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS fleet jsonb;
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
-- phase: expand
-- phase: expand
-- Plan L34 (the machines hub): the roles a machine has for the network (the catalogue is Contracts 0.257.0's
-- Plan L34 (the machines hub): the roles a machine has for the network (the catalogue is Contracts 0.257.0's
-- machineRoles: codes.worker, desks.host, ai.host, bot.robot). The owner sets them on openvibe.network/machines and
-- machineRoles: codes.worker, desks.host, ai.host, bot.robot). The owner sets them on openvibe.network/machines and
+-- the Link reads them on its next check-in (the config frame carries them). A machine that never had a role has the
-- empty list, which is what the column starts with.
-- empty list, which is what the column starts with.
ALTER TABLE devices ADD COLUMN roles jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE devices ADD COLUMN roles jsonb NOT NULL DEFAULT '[]'::jsonb;
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
-- phase: expand
-- phase: expand
+-- The guided first run (OpenVibe Link's `openvibe-node`, docs/install.md#first-run-guided-and-measured): one row per
+-- device and step, from the `node.first_run` telemetry events the Link sends over its existing device link. It powers
-- the machines page's figures for a machine's first job and the admin funnel (how many installs reach each step, and
-- the machines page's figures for a machine's first job and the admin funnel (how many installs reach each step, and
-- how long each took). The fields are an allowlist: no path, username or address is stored.
-- how long each took). The fields are an allowlist: no path, username or address is stored.
CREATE TABLE IF NOT EXISTS device_first_run (
CREATE TABLE IF NOT EXISTS device_first_run (
@@ -254,7 +254,7 @@
@@ -254,7 +254,7 @@
return;
return;
} else if (!playing) {
} else if (!playing) {
misses += 1;
misses += 1;
- say(s && s.online === false ? 'The machine is offline.' : misses > 20 ? 'Still waiting for the machine. Is the Node running in your desktop session?' : 'Waiting for the machine…');
+ say(s && s.online === false ? 'The machine is offline.' : misses > 20 ? 'Still waiting for the machine. Is the Link running in your desktop session?' : 'Waiting for the machine…');
}
}
setTimeout(poll, playing ? 5000 : 1500);
setTimeout(poll, playing ? 5000 : 1500);
};
};
@@ -890,7 +890,7 @@
@@ -890,7 +890,7 @@
el.textContent = text ? `Device fault: ${text}` : '';
el.textContent = text ? `Device fault: ${text}` : '';
el.hidden = !text;
el.hidden = !text;
}
}
- // A readout's sensor keys by the widget's capability, as the Node's drivers report them (Adeept: distance_cm and
+ // A readout's sensor keys by the widget's capability, as the Link's drivers report them (Adeept: distance_cm and
// line {left, middle, right}; Cozmo: cliff and picked_up), else the capability's own name. One key found shows its
// line {left, middle, right}; Cozmo: cliff and picked_up), else the capability's own name. One key found shows its
// value; several show each by name.
// value; several show each by name.
const SENSOR_KEYS = { ultrasonic: ['distance_cm', 'ultrasonic'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };
const SENSOR_KEYS = { ultrasonic: ['distance_cm', 'ultrasonic'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };
@@ -14,8 +14,8 @@
@@ -14,8 +14,8 @@
* DELETE /api/v1/machines/:id/desk-leases/:desk the desk is stopped and the lease ends
* DELETE /api/v1/machines/:id/desk-leases/:desk the desk is stopped and the lease ends
*
*
* A machine takes a lease when it is the person's own and its owner turned on "Let my agent use this computer", or when
* A machine takes a lease when it is the person's own and its owner turned on "Let my agent use this computer", or when
- * its owner offers its desks to other people (shared_desks) and its Node runs every desk in its own sandbox (describe
- * desk.isolation "sandbox", Node v0.32.0). Anything else is 404, never confirmed to exist. A leased desk is the
+ * its owner offers its desks to other people (shared_desks) and its Link runs every desk in its own sandbox (describe
+ * desk.isolation "sandbox", Link v0.32.0). Anything else is 404, never confirmed to exist. A leased desk is the
* session's alone: the machine's own routes refuse it (api/v1.js), and on a shared host nothing but browser desks runs.
* session's alone: the machine's own routes refuse it (api/v1.js), and on a shared host nothing but browser desks runs.
*/
*/
const express = require('express');
const express = require('express');
@@ -61,7 +61,7 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
@@ -61,7 +61,7 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
/**
/**
* How a person may use a machine for a session, or null: { device, shared, trust }. Their own machine where agents
* How a person may use a machine for a session, or null: { device, shared, trust }. Their own machine where agents
- * may use it; another's only when its owner shares its desks and its Node sandboxes every one.
+ * may use it; another's only when its owner shares its desks and its Link sandboxes every one.
*/
*/
async function usable(device, subject) {
async function usable(device, subject) {
if (!device || device.revoked_at) return null;
if (!device || device.revoked_at) return null;
@@ -41,7 +41,7 @@ function robotSummary(row, baseUrl = null) {
@@ -41,7 +41,7 @@ function robotSummary(row, baseUrl = null) {
return { ...summary, ...consoleOf(baseUrl, `/r/${encodeURIComponent(row.id)}`) };
return { ...summary, ...consoleOf(baseUrl, `/r/${encodeURIComponent(row.id)}`) };
}
}
-/** A person's own machine (devices.owner_subject): online while its Node is connected. */
+/** A person's own machine (devices.owner_subject): online while its Link is connected. */
function machineSummary(row, online, baseUrl = null) {
function machineSummary(row, online, baseUrl = null) {
const summary = {
const summary = {
id: row.id, kind: MACHINE_KIND, service: SERVICE,
id: row.id, kind: MACHINE_KIND, service: SERVICE,
@@ -82,12 +82,12 @@ const MACHINE_SERVICES = {
@@ -82,12 +82,12 @@ const MACHINE_SERVICES = {
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
// AI (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server for the owner, or — only for a
// AI (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server for the owner, or — only for a
// machine whose ai plugin offers its models to the network — a community request that opted in (below).
// machine whose ai plugin offers its models to the network — a community request that opted in (below).
- // A command that says `community: true` runs on the machine's offer to the network instead, so the Node ai plugin's
+ // A command that says `community: true` runs on the machine's offer to the network instead, so the Link ai plugin's
// own opt-in (capabilities.ai.offer.enabled) stands in for the "Let my AI use this computer's models" switch.
// own opt-in (capabilities.ai.offer.enabled) stands in for the "Let my AI use this computer's models" switch.
'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai', community: true },
'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai', community: true },
};
};
/**
/**
- * A machine offering its models to the network (OpenVibe.Node ai plugin 0.3.0): the owner turned on "Offer to the
+ * A machine offering its models to the network (OpenVibe Link ai plugin 0.3.0): the owner turned on "Offer to the
* network" and the offer names its models. Hours, concurrency and price are the plugin's to enforce; Bot only decides
* network" and the offer names its models. Hours, concurrency and price are the plugin's to enforce; Bot only decides
* whether AI may send a community run here at all.
* whether AI may send a community run here at all.
*/
*/
@@ -512,7 +512,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -512,7 +512,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ robot_id: req.params.id, ...result });
res.json({ robot_id: req.params.id, ...result });
}));
}));
- // ── A person's own computer (the Node's computer plugin, plan T14/T17) ─────────────────────────────
+ // ── A person's own computer (the Link's computer plugin, plan T14/T17) ─────────────────────────────
// The machine's owner, or their agent (an agent service with bot.computer.control, acting for that owner) where the
// The machine's owner, or their agent (an agent service with bot.computer.control, acting for that owner) where the
// owner turned "Let my agent use this computer" on. What may happen there is the owner's level on the machine.
// owner turned "Let my agent use this computer" on. What may happen there is the owner's level on the machine.
async function computerMachine(req, { agentNeedsOptIn = true, kind = null } = {}) {
async function computerMachine(req, { agentNeedsOptIn = true, kind = null } = {}) {
@@ -545,17 +545,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -545,17 +545,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,
shared_desks: !!device.shared_desks,
shared_desks: !!device.shared_desks,
// What the machine does for the network (plan L34): the roles the owner gave it. The catalogue is
// What the machine does for the network (plan L34): the roles the owner gave it. The catalogue is
- // server/machine-roles.js; the Node reads them on its next check-in.
+ // server/machine-roles.js; the Link reads them on its next check-in.
roles: json(device.roles, []),
roles: json(device.roles, []),
- // The machine's kind, its operating system and the OpenVibe.Node's version, as the Node last said.
+ // The machine's kind, its operating system and the OpenVibe Link's version, as the Link last said.
kind: device.kind || null,
kind: device.kind || null,
os: (st.status && typeof st.status.os === 'string' && st.status.os) || null,
os: (st.status && typeof st.status.os === 'string' && st.status.os) || null,
node_version: device.agent_version || null,
node_version: device.agent_version || null,
last_seen_at: device.last_seen ? new Date(device.last_seen).toISOString() : null,
last_seen_at: device.last_seen ? new Date(device.last_seen).toISOString() : null,
- // The machine's id on Network (`nod_…`) and the OpenVibe.Node's name for it, so a caller that knows the
+ // The machine's id on Network (`nod_…`) and the OpenVibe Link's name for it, so a caller that knows the
// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).
// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).
node_id: device.node_principal || null,
node_id: device.node_principal || null,
- // What its coding agents have left, as its Node last reported it, and when Bot stored that reading
+ // What its coding agents have left, as its Link last reported it, and when Bot stored that reading
// (Codes C9c). null: the machine has reported none (no OpenVibe.Codes runner, or no reading yet).
// (Codes C9c). null: the machine has reported none (no OpenVibe.Codes runner, or no reading yet).
fleet: json(device.fleet, null),
fleet: json(device.fleet, null),
fleet_at: device.fleet_at ? new Date(device.fleet_at).toISOString() : null,
fleet_at: device.fleet_at ? new Date(device.fleet_at).toISOString() : null,
@@ -564,9 +564,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -564,9 +564,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'), desk: pick('desk'), ai: pick('ai'),
terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'), desk: pick('desk'), ai: pick('ai'),
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
- // Desks running now (the Node's telemetry): where an agent's next desk has room (Desks plan D6, placement).
+ // Desks running now (the Link's telemetry): where an agent's next desk has room (Desks plan D6, placement).
desks_running: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
desks_running: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
- // How fast its last model answered there (Node v0.29.0's ai plugin): for choosing where a model runs.
+ // How fast its last model answered there (Link v0.29.0's ai plugin): for choosing where a model runs.
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
} : null,
} : null,
@@ -859,7 +859,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -859,7 +859,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ audit: rows.map(auditPresent), next_before: rows.length ? rows[rows.length - 1].id : null });
res.json({ audit: rows.map(auditPresent), next_before: rows.length ? rows[rows.length - 1].id : null });
}));
}));
- // ── Jobs (Run → Bot: hand a platform.job@1 to a paired Node, server/jobs/index.js) ────────────
+ // ── Jobs (Run → Bot: hand a platform.job@1 to a paired Link, server/jobs/index.js) ────────────
// Internal and service-to-service only: OpenVibe.Run's Network service token, never a person's.
// Internal and service-to-service only: OpenVibe.Run's Network service token, never a person's.
r.post('/jobs', jobs, wrap(async (req, res) => {
r.post('/jobs', jobs, wrap(async (req, res) => {
requireJobDispatch(req);
requireJobDispatch(req);
@@ -16,7 +16,7 @@ const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes',
@@ -16,7 +16,7 @@ const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes',
const trim = (u) => String(u || '').replace(/\/+$/, '');
const trim = (u) => String(u || '').replace(/\/+$/, '');
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
const PAIRING_AUTHORITIES = ['bot', 'network'];
const PAIRING_AUTHORITIES = ['bot', 'network'];
-// Where GET /install may send the client: OpenVibe.Node's canonical script lives on GitHub (raw, a release
+// Where GET /install may send the client: OpenVibe Link's canonical script lives on GitHub (raw, a release
// asset) or on openvibe.bot itself. Anything else refuses to boot.
// asset) or on openvibe.bot itself. Anything else refuses to boot.
const INSTALLER_SOURCE_HOSTS = ['raw.githubusercontent.com', 'github.com', 'objects.githubusercontent.com', 'openvibe.bot'];
const INSTALLER_SOURCE_HOSTS = ['raw.githubusercontent.com', 'github.com', 'objects.githubusercontent.com', 'openvibe.bot'];
@@ -269,7 +269,7 @@ function loadConfig(env = process.env) {
@@ -269,7 +269,7 @@ function loadConfig(env = process.env) {
},
},
// The one-line installer the owner copies next to the pairing code. scriptUrl is what the command
// The one-line installer the owner copies next to the pairing code. scriptUrl is what the command
// prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:
// prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:
- // it redirects to OpenVibe.Node's canonical one, and only this config (never a query parameter)
+ // it redirects to OpenVibe Link's canonical one, and only this config (never a query parameter)
// chooses the target.
// chooses the target.
// Embeddable panel (plan T15 R9): the origins whose pages may frame a robot's read-only panel.
// Embeddable panel (plan T15 R9): the origins whose pages may frame a robot's read-only panel.
embed: { origins: embedOrigins },
embed: { origins: embedOrigins },
@@ -51,7 +51,7 @@ function newCode() {
@@ -51,7 +51,7 @@ function newCode() {
const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
-// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,
+// The installer driver a robot's profile needs (OpenVibe Link install/install.sh --driver). Simulated,
// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.
// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.
const DRIVER_BY_PROFILE = Object.freeze({
const DRIVER_BY_PROFILE = Object.freeze({
'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo', looi: 'looi',
'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo', looi: 'looi',
@@ -720,7 +720,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -720,7 +720,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
/**
/**
* A one-time code for `ownerSubject` to add a machine that runs their own jobs (Network pairing, ref
* A one-time code for `ownerSubject` to add a machine that runs their own jobs (Network pairing, ref
* `worker:<owner>`; Network keeps one live code per ref, so a new code replaces an unused one). The installer
* `worker:<owner>`; Network keeps one live code per ref, so a new code replaces an unused one). The installer
- * command turns the Node's job worker on (`--worker`). → { code, expires_at, pairing_id, installer }
+ * command turns the Link's job worker on (`--worker`). → { code, expires_at, pairing_id, installer }
*/
*/
async function mintMachine(ownerSubject, installerUrl) {
async function mintMachine(ownerSubject, installerUrl) {
if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
@@ -859,7 +859,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -859,7 +859,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function setWatchProbe(deviceId, value) {
async function setWatchProbe(deviceId, value) {
return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
}
}
- /** Let the owner's AI run models on this machine (OpenVibe Node's ai plugin), or stop it (the owner check is the caller's). */
+ /** Let the owner's AI run models on this machine (OpenVibe Link's ai plugin), or stop it (the owner check is the caller's). */
async function setAiModels(deviceId, value) {
async function setAiModels(deviceId, value) {
return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
}
}
@@ -873,7 +873,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -873,7 +873,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
/**
/**
* The roles a machine has for the network (plan L34, step M4): the owner's choice, checked against the
* The roles a machine has for the network (plan L34, step M4): the owner's choice, checked against the
- * catalogue (server/machine-roles.js) and stored whole. The Node reads them on its next check-in.
+ * catalogue (server/machine-roles.js) and stored whole. The Link reads them on its next check-in.
* The owner's check is the caller's.
* The owner's check is the caller's.
*/
*/
async function setMachineRoles(deviceId, roles) {
async function setMachineRoles(deviceId, roles) {
@@ -993,8 +993,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -993,8 +993,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
[id, JSON.stringify(fleet), iso(now())]);
[id, JSON.stringify(fleet), iso(now())]);
}
}
- // ── The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run) ──────────────
- // One row per device and step, from the `node.first_run` telemetry events the Node already sends. The fields are
+ // ── The guided first run (OpenVibe Link's `openvibe-node`, docs/install.md#first-run) ──────────────
+ // One row per device and step, from the `node.first_run` telemetry events the Link already sends. The fields are
// an allowlist: a path, a username or an address is never stored even if a buggy client sends one.
// an allowlist: a path, a username or an address is never stored even if a buggy client sends one.
const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);
const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);
const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);
const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);
@@ -1,14 +1,14 @@
@@ -1,14 +1,14 @@
'use strict';
'use strict';
/**
/**
- * A machine's coding agents, as its OpenVibe.Node reports them (Codes C9c).
+ * A machine's coding agents, as its OpenVibe Link reports them (Codes C9c).
*
*
- * The Node reads them with OpenVibe.Codes' own logins probe (`openvibe-codes logins --json`, as the machine's user)
+ * The Link reads them with OpenVibe.Codes' own logins probe (`openvibe-codes logins --json`, as the machine's user)
* and sends the reading with every heartbeat (`bot.device-message@1`'s `fleet`): each agent's id, whether it is
* and sends the reading with every heartbeat (`bot.device-message@1`'s `fleet`): each agent's id, whether it is
* installed and signed in, and each limit window's label, what it has used of its limit, and when it frees. Bot keeps
* installed and signed in, and each limit window's label, what it has used of its limit, and when it frees. Bot keeps
* the latest reading per machine (`devices.fleet`, `devices.fleet_at`) for Codes' Agents page, its router and its MCP.
* the latest reading per machine (`devices.fleet`, `devices.fleet_at`) for Codes' Agents page, its router and its MCP.
*
*
- * What arrives is bounded here exactly as the Node bounds it before sending, because a frame is whatever the socket
+ * What arrives is bounded here exactly as the Link bounds it before sending, because a frame is whatever the socket
* says: an agent id shaped like an id, at most 16 agents, at most 10 windows each, a short label and reset time, two
* says: an agent id shaped like an id, at most 16 agents, at most 10 windows each, a short label and reset time, two
* finite numbers. A reading never carries a key, a token or an account name, and Bot adds none: it stores what it is
* finite numbers. A reading never carries a key, a token or an account name, and Bot adds none: it stores what it is
* sent, and nothing else. `null` means the frame was not a reading at all (not an array) and the last one stands; an
* sent, and nothing else. `null` means the frame was not a reading at all (not an array) and the last one stands; an
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * The dispatcher (plan T14 step L1): Bot hands platform.job@1 jobs to a paired Node over the device link and
+ * The dispatcher (plan T14 step L1): Bot hands platform.job@1 jobs to a paired Link over the device link and
* meters them (platform.job-frame@1, docs/protocol.md "Jobs"). The Run service, which owns run.job.*, calls it
* meters them (platform.job-frame@1, docs/protocol.md "Jobs"). The Run service, which owns run.job.*, calls it
* over the internal HTTP API (bot.job.dispatch): POST /api/v1/jobs, POST /api/v1/jobs/:id/cancel and
* over the internal HTTP API (bot.job.dispatch): POST /api/v1/jobs, POST /api/v1/jobs/:id/cancel and
* GET /api/v1/jobs/:id (server/api/v1.js), through the jobs service server/jobs/index.js, which also holds the one
* GET /api/v1/jobs/:id (server/api/v1.js), through the jobs service server/jobs/index.js, which also holds the one
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
* on reconnect
* on reconnect
*
*
* `link` is the realtime hub (sendToDevice). A job is sent only to a device whose stored
* `link` is the realtime hub (sendToDevice). A job is sent only to a device whose stored
- * capabilities.worker.runtime_classes lists its class. An unacked job is resent on every reconnect (the Node acks
+ * capabilities.worker.runtime_classes lists its class. An unacked job is resent on every reconnect (the Link acks
* a job it already holds again, or answers with its job_exit, and never runs one twice). job_exit_ack goes out
* a job it already holds again, or answers with its job_exit, and never runs one twice). job_exit_ack goes out
* once every reading of the job is committed to run_usage_outbox, from which the relay delivers it to Billing.
* once every reading of the job is committed to run_usage_outbox, from which the relay delivers it to Billing.
*/
*/
@@ -31,7 +31,7 @@ const STDOUT_RING_BYTES = 1024 * 1024; // the last 1 MiB of each job's stdout
@@ -31,7 +31,7 @@ const STDOUT_RING_BYTES = 1024 * 1024; // the last 1 MiB of each job's stdout
const STDOUT_JOBS = 1024; // rings kept, oldest dropped first
const STDOUT_JOBS = 1024; // rings kept, oldest dropped first
const brief = (errors) => (errors || []).slice(0, 3).map((e) => `${e.path} ${e.message}`).join('; ');
const brief = (errors) => (errors || []).slice(0, 3).map((e) => `${e.path} ${e.message}`).join('; ');
-/** The classes a device's stored capabilities advertise (OpenVibe.Node status.capabilities.worker). */
+/** The classes a device's stored capabilities advertise (OpenVibe Link status.capabilities.worker). */
function advertisedClasses(capabilities) {
function advertisedClasses(capabilities) {
const worker = json(capabilities, {}).worker;
const worker = json(capabilities, {}).worker;
return worker && Array.isArray(worker.runtime_classes) ? worker.runtime_classes.filter((c) => typeof c === 'string') : [];
return worker && Array.isArray(worker.runtime_classes) ? worker.runtime_classes.filter((c) => typeof c === 'string') : [];
@@ -173,7 +173,7 @@ function createJobFrames({ db: getDb, usage = null, log = console, now = () => D
@@ -173,7 +173,7 @@ function createJobFrames({ db: getDb, usage = null, log = console, now = () => D
if (!row) return false;
if (!row) return false;
if (row.exit_reason != null) return true;
if (row.exit_reason != null) return true;
const job = json(row.job, {});
const job = json(row.job, {});
- // Never past the wall-clock cap the payer set: the Node kills the job there.
+ // Never past the wall-clock cap the payer set: the Link kills the job there.
if ((msg.second + 1) * 1000 > job.limits.wall_ms) { log.warn(`[Bot] job ${row.id}: job_usage second ${msg.second} beyond limits.wall_ms, not metered`); return true; }
if ((msg.second + 1) * 1000 > job.limits.wall_ms) { log.warn(`[Bot] job ${row.id}: job_usage second ${msg.second} beyond limits.wall_ms, not metered`); return true; }
const startedMs = row.started_ms != null ? Number(row.started_ms) : msg.started_ms;
const startedMs = row.started_ms != null ? Number(row.started_ms) : msg.started_ms;
if (row.started_ms == null) await store.markStarted(t, row.id, startedMs, now());
if (row.started_ms == null) await store.markStarted(t, row.id, startedMs, now());
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* The worker offers Bot reports to Network (plan T14/T16, contracts 0.161.0, ADR-046). Every live device that runs a
* The worker offers Bot reports to Network (plan T14/T16, contracts 0.161.0, ADR-046). Every live device that runs a
- * job worker (OpenVibe.Node status.capabilities.worker.runtime_classes) is a person's own machine: one
+ * job worker (OpenVibe Link status.capabilities.worker.runtime_classes) is a person's own machine: one
* platform.resource-offer@1 per device, kind `node`, trust `user-owned`, naming its owner, advertising
* platform.resource-offer@1 per device, kind `node`, trust `user-owned`, naming its owner, advertising
* `worker:<class>` for each class, `up` while the device is connected and `down` otherwise. The set is reported whole
* `worker:<class>` for each class, `up` while the device is connected and `down` otherwise. The set is reported whole
* as source `bot` (Network marks an offer missing from a later report down), once a minute and a moment after a device
* as source `bot` (Network marks an offer missing from a later report down), once a minute and a moment after a device
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
*
*
* createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }
* createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }
*
*
- * The same set carries a community AI offer (the design's "Offer to the network"): a machine whose OpenVibe.Node ai
+ * The same set carries a community AI offer (the design's "Offer to the network"): a machine whose OpenVibe Link ai
* plugin advertises `capabilities.ai.offer.enabled` with models, hours, concurrency, a credit price and an audience is
* plugin advertises `capabilities.ai.offer.enabled` with models, hours, concurrency, a credit price and an audience is
* one user-owned provider offer, `ai:<device>`, whose detail.kind is `ai` and whose health follows its connection and
* one user-owned provider offer, `ai:<device>`, whose detail.kind is `ai` and whose health follows its connection and
* its own open/ready flags. Network validates the detail; OpenVibe.AI reads the internal list and places only requests
* its own open/ready flags. Network validates the detail; OpenVibe.AI reads the internal list and places only requests
@@ -26,7 +26,7 @@ const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu
@@ -26,7 +26,7 @@ const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu
/** The ai plugin's offer, or null when it is off or incomplete (an incomplete one is left out, never half reported). */
/** The ai plugin's offer, or null when it is off or incomplete (an incomplete one is left out, never half reported). */
function aiOfferOf(capabilities) {
function aiOfferOf(capabilities) {
- // The Node reports each plugin's describe under its name, so the ai plugin's {ai: {models, offer}} arrives as
+ // The Link reports each plugin's describe under its name, so the ai plugin's {ai: {models, offer}} arrives as
// capabilities.ai.ai; a direct capabilities.ai.offer is accepted too (an older/other reporter).
// capabilities.ai.ai; a direct capabilities.ai.offer is accepted too (an older/other reporter).
let ai = json(capabilities, {}).ai;
let ai = json(capabilities, {}).ai;
if (ai && ai.ai) ai = ai.ai;
if (ai && ai.ai) ai = ai.ai;
@@ -104,7 +104,7 @@ function createOfferPublisher({ db, hub, reporter = null, aiReporter = null, log
@@ -104,7 +104,7 @@ function createOfferPublisher({ db, hub, reporter = null, aiReporter = null, log
return offers;
return offers;
}
}
- /** The community AI offers: one per machine whose Node ai plugin advertises an enabled offer. */
+ /** The community AI offers: one per machine whose Link ai plugin advertises an enabled offer. */
async function buildAi() {
async function buildAi() {
const rows = await db.many(
const rows = await db.many(
`SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner
`SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner
@@ -3,11 +3,11 @@
@@ -3,11 +3,11 @@
/**
/**
* run_jobs (migrations/0004_run_jobs.sql): the dispatcher's record of every job it handed to a device.
* run_jobs (migrations/0004_run_jobs.sql): the dispatcher's record of every job it handed to a device.
*
*
- * queued stored; sent (again on every reconnect) until the Node acks it
- * placed the Node acked it
+ * queued stored; sent (again on every reconnect) until the Link acks it
+ * placed the Link acked it
* running job_started
* running job_started
* succeeded / failed / cancelled / expired from job_exit (exited 0 / exited ≠ 0, limit, stopped, failed /
* succeeded / failed / cancelled / expired from job_exit (exited 0 / exited ≠ 0, limit, stopped, failed /
- * cancelled / ttl); failed also when the Node nacked it
+ * cancelled / ttl); failed also when the Link nacked it
*
*
* A cancel before job_started marks the row cancelled at once; job_exit, which is authoritative, still sets the
* A cancel before job_started marks the row cancelled at once; job_exit, which is authoritative, still sets the
* final state and is still metered. exit_reason is written once, by the first job_exit: after it no usage frame
* final state and is still metered. exit_reason is written once, by the first job_exit: after it no usage frame
@@ -27,7 +27,7 @@ async function put(db, { job, nodeId, project = null, subject = null, provider =
@@ -27,7 +27,7 @@ async function put(db, { job, nodeId, project = null, subject = null, provider =
}
}
const get = (db, id) => db.maybe('SELECT * FROM run_jobs WHERE id = $1', [id]);
const get = (db, id) => db.maybe('SELECT * FROM run_jobs WHERE id = $1', [id]);
-/** The job, only if it belongs to this device: a Node never reads or meters another device's job. */
+/** The job, only if it belongs to this device: a Link never reads or meters another device's job. */
const ofNode = (db, id, nodeId, { lock = false } = {}) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1 AND node_id = $2${lock ? ' FOR UPDATE' : ''}`, [id, nodeId]);
const ofNode = (db, id, nodeId, { lock = false } = {}) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1 AND node_id = $2${lock ? ' FOR UPDATE' : ''}`, [id, nodeId]);
/** Unacked, uncancelled jobs of a device, oldest first: what a reconnect resends. */
/** Unacked, uncancelled jobs of a device, oldest first: what a reconnect resends. */
@@ -37,7 +37,7 @@ const cancelPending = (db, nodeId) => db.many(`SELECT id FROM run_jobs WHERE nod
@@ -37,7 +37,7 @@ const cancelPending = (db, nodeId) => db.many(`SELECT id FROM run_jobs WHERE nod
const markSent = (db, id, at) => db.exec('UPDATE run_jobs SET sent_at = COALESCE(sent_at, $2), updated_at = $2 WHERE id = $1', [id, at]);
const markSent = (db, id, at) => db.exec('UPDATE run_jobs SET sent_at = COALESCE(sent_at, $2), updated_at = $2 WHERE id = $1', [id, at]);
const markPlaced = (db, id, at) => db.exec(`UPDATE run_jobs SET state = 'placed', updated_at = $2 WHERE id = $1 AND state = 'queued'`, [id, at]);
const markPlaced = (db, id, at) => db.exec(`UPDATE run_jobs SET state = 'placed', updated_at = $2 WHERE id = $1 AND state = 'queued'`, [id, at]);
-/** The Node refused it (nack), or Bot stopped sending it: failed, ended, nothing to meter. */
+/** The Link refused it (nack), or Bot stopped sending it: failed, ended, nothing to meter. */
const markRefused = (db, id, faultCode, at) => db.exec(
const markRefused = (db, id, faultCode, at) => db.exec(
`UPDATE run_jobs SET state = CASE WHEN cancel_requested THEN 'cancelled' ELSE 'failed' END, fault_code = $2, finished_at = $3, updated_at = $3
`UPDATE run_jobs SET state = CASE WHEN cancel_requested THEN 'cancelled' ELSE 'failed' END, fault_code = $2, finished_at = $3, updated_at = $3
WHERE id = $1 AND state IN ('queued', 'placed', 'cancelled') AND exit_reason IS NULL AND finished_at IS NULL`, [id, faultCode, at]);
WHERE id = $1 AND state IN ('queued', 'placed', 'cancelled') AND exit_reason IS NULL AND finished_at IS NULL`, [id, faultCode, at]);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"id": "adeept.adr036",
"id": "adeept.adr036",
"name": "Adeept ADR036 4WD Smart Car Kit for Raspberry Pi",
"name": "Adeept ADR036 4WD Smart Car Kit for Raspberry Pi",
"vendor": "Adeept",
"vendor": "Adeept",
- "description": "The first curated OpenVibe robot: the Adeept 4WD Smart Car with the Robot HAT — four DC motors and pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The OpenVibe Node agent drives the HAT directly, so the kit's stock control server is never exposed.",
+ "description": "The first curated OpenVibe robot: the Adeept 4WD Smart Car with the Robot HAT — four DC motors and pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The OpenVibe Link agent drives the HAT directly, so the kit's stock control server is never exposed.",
"profile_id": "adeept.adr036",
"profile_id": "adeept.adr036",
"build_guide_url": "https://www.adeept.com/learn/detail-97.html",
"build_guide_url": "https://www.adeept.com/learn/detail-97.html",
"parts": [
"parts": [
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * A machine's own models (AI on your own machines, contracts 0.219.0 kind ai), from the capabilities its OpenVibe Node
- * declares. The Node reports each plugin's describe under the plugin's name, so the ai plugin (Node v0.27.0) arrives as
+ * A machine's own models (AI on your own machines, contracts 0.219.0 kind ai), from the capabilities its OpenVibe Link
+ * declares. The Link reports each plugin's describe under the plugin's name, so the ai plugin (Link v0.27.0) arrives as
* capabilities.ai = { ai: { models, ready, max_tokens } }; a computer plugin that offered ai itself would put it in
* capabilities.ai = { ai: { models, ready, max_tokens } }; a computer plugin that offered ai itself would put it in
* capabilities.computer.ai. → { models, ready, max_tokens } or null
* capabilities.computer.ai. → { models, ready, max_tokens } or null
*/
*/
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
'use strict';
'use strict';
/**
/**
* The catalogue of machine roles (plan L34, step M4: a product declares a machine role — a name, what it installs
* The catalogue of machine roles (plan L34, step M4: a product declares a machine role — a name, what it installs
- * and what the hub shows — and the Node turns it on at its next check-in).
+ * and what the hub shows — and the Link turns it on at its next check-in).
*
*
* The catalogue lives in the Contracts release: each product's service manifest carries its own roles
* The catalogue lives in the Contracts release: each product's service manifest carries its own roles
* (`machineRoles`, openvibe-contracts 0.261.0), and the hub's catalogue is the union of them. This service pins
* (`machineRoles`, openvibe-contracts 0.261.0), and the hub's catalogue is the union of them. This service pins
* that release, so the manifests are the catalogue; the copy below is the fallback for an older pin, and goes
* that release, so the manifests are the catalogue; the copy below is the fallback for an older pin, and goes
- * with it. Bot only ever refuses a role outside the catalogue: a role is a promise the Node has to keep, so an
+ * with it. Bot only ever refuses a role outside the catalogue: a role is a promise the Link has to keep, so an
* unknown one is refused rather than stored.
* unknown one is refused rather than stored.
*
*
* catalogue() the roles, as [{ id, name, installs, shows }]
* catalogue() the roles, as [{ id, name, installs, shows }]
@@ -19,7 +19,7 @@ const contracts = require('openvibe-contracts');
@@ -19,7 +19,7 @@ const contracts = require('openvibe-contracts');
const LOCAL = [
const LOCAL = [
{ id: 'codes.worker', name: 'Codes worker', installs: 'the Codes job runner (the coding agent and the job sandbox)', shows: 'Runs your coding jobs, such as a task you start on openvibe.codes.' },
{ id: 'codes.worker', name: 'Codes worker', installs: 'the Codes job runner (the coding agent and the job sandbox)', shows: 'Runs your coding jobs, such as a task you start on openvibe.codes.' },
{ id: 'desks.host', name: 'Desk host', installs: 'the desk sandbox (a private browser desk for every session)', shows: "Hosts desks for agents, yours and other people's." },
{ id: 'desks.host', name: 'Desk host', installs: 'the desk sandbox (a private browser desk for every session)', shows: "Hosts desks for agents, yours and other people's." },
- { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Node's ai plugin", shows: 'Serves your own AI models, on your own machine.' },
+ { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Link's ai plugin", shows: 'Serves your own AI models, on your own machine.' },
{ id: 'bot.robot', name: 'Robot', installs: 'the robot drivers the profile asks for', shows: 'Drives a robot, and takes its commands.' },
{ id: 'bot.robot', name: 'Robot', installs: 'the robot drivers the profile asks for', shows: 'Drives a robot, and takes its commands.' },
];
];
@@ -8,14 +8,14 @@
@@ -8,14 +8,14 @@
* then screen_stream start to the machine with the WHIP URL
* then screen_stream start to the machine with the WHIP URL
* and key → { streaming, seconds, fps, until, desk }; desk
* and key → { streaming, seconds, fps, until, desk }; desk
* `private` shows the machine's private desk (contracts
* `private` shows the machine's private desk (contracts
- * 0.214.0, Node v0.15.0) instead of the owner's own screen
+ * 0.214.0, Link v0.15.0) instead of the owner's own screen
* state(device, subject) → { live, signaling_url, state, stream } (the session's viewer
* state(device, subject) → { live, signaling_url, state, stream } (the session's viewer
* URL once the machine is publishing; `state` the machine's own
* URL once the machine is publishing; `state` the machine's own
* telemetry: live or off)
* telemetry: live or off)
* stop(device, subject, principal) screen_stream stop to the machine (when online) and the
* stop(device, subject, principal) screen_stream stop to the machine (when online) and the
* stream's key retired with its session → { stopped }
* stream's key retired with its session → { stopped }
*
*
- * OpenVibe.Node's computer plugin captures the frames and its core publishes them (Node v0.12.0, docs/computer.md).
+ * OpenVibe Link's computer plugin captures the frames and its core publishes them (Link v0.12.0, docs/computer.md).
*/
*/
const { BotError } = require('./util');
const { BotError } = require('./util');
@@ -65,18 +65,18 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
@@ -65,18 +65,18 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
const desk = opts.desk == null || opts.desk === '' ? 'main' : String(opts.desk);
const desk = opts.desk == null || opts.desk === '' ? 'main' : String(opts.desk);
if (!DESK.test(desk)) throw new BotError(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');
if (!DESK.test(desk)) throw new BotError(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');
if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
- // Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).
+ // Before any key is made: the machine says whether it can (Link v0.12.0's computer plugin, a capture tool).
const offered = hub.computerOf(device.id);
const offered = hub.computerOf(device.id);
// The person's own screen needs a live capture of it; a desk's is the desk's own (desk.screen, below): a desk
// The person's own screen needs a live capture of it; a desk's is the desk's own (desk.screen, below): a desk
// host has no screen of its own to show, only its desks.
// host has no screen of its own to show, only its desks.
if (desk === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {
if (desk === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {
- throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session');
+ throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your graphical session');
}
}
if (desk !== 'main' && !(offered && offered.desk && offered.desk.screen)) {
if (desk !== 'main' && !(offered && offered.desk && offered.desk.screen)) {
- throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on');
+ throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on');
}
}
if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {
if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {
- throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');
+ throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer');
}
}
// On the owner's OpenVibe Live channel (the Desks plan's D8): a desk only, never their own screen, on a public
// On the owner's OpenVibe Live channel (the Desks plan's D8): a desk only, never their own screen, on a public
// stream of its own that OpenRestream links to their channel; refused with Live's reason when it cannot be.
// stream of its own that OpenRestream links to their channel; refused with Live's reason when it cannot be.
@@ -48,10 +48,10 @@ const CAPABILITIES = new Set([
@@ -48,10 +48,10 @@ const CAPABILITIES = new Set([
/** Panel widget types (open in the profile, closed in code: a new widget is a new entry here). */
/** Panel widget types (open in the profile, closed in code: a new widget is a new entry here). */
const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);
const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);
-/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */
-// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe.Node docs/hardware-looi.md).
+/** Device drivers a mapping may name. `relay` is a Link plugin with no hardware map of its own. */
+// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe Link docs/hardware-looi.md).
// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.
// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.
-// `plugin`: a community OpenVibe Node plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;
+// `plugin`: a community OpenVibe Link plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;
// community profiles are reviewed and signed before robots can use them, server/submissions.js).
// community profiles are reviewed and signed before robots can use them, server/submissions.js).
const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);
const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);
const PLUGIN_NAME = /^[a-z][a-z0-9_-]{1,40}$/;
const PLUGIN_NAME = /^[a-z][a-z0-9_-]{1,40}$/;
@@ -194,7 +194,7 @@ function validateProfile(p) {
@@ -194,7 +194,7 @@ function validateProfile(p) {
return w.command != null ? { ...w, command: validateWidgetCommand(p.id, w, commands) } : { ...w };
return w.command != null ? { ...w, command: validateWidgetCommand(p.id, w, commands) } : { ...w };
});
});
if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);
if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);
- if (p.mapping.driver === 'plugin' && !PLUGIN_NAME.test(String(p.mapping.plugin || ''))) bad(`${p.id}: a plugin driver names its Node plugin in mapping.plugin (lower case letters, digits, - and _)`);
+ if (p.mapping.driver === 'plugin' && !PLUGIN_NAME.test(String(p.mapping.plugin || ''))) bad(`${p.id}: a plugin driver names its Link plugin in mapping.plugin (lower case letters, digits, - and _)`);
const l = p.limits && typeof p.limits === 'object' ? p.limits : {};
const l = p.limits && typeof p.limits === 'object' ? p.limits : {};
const limits = {
const limits = {
max_speed: number(l.max_speed) != null ? number(l.max_speed) : 1,
max_speed: number(l.max_speed) != null ? number(l.max_speed) : 1,
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"name": "LOOI desktop robot",
"name": "LOOI desktop robot",
"vendor": "LOOI",
"vendor": "LOOI",
"kind": "bridge",
"kind": "bridge",
- "description": "LOOI, the tracked desk robot that uses a phone as its face: differential drive and head tilt over Bluetooth from a bridge computer (Windows, macOS or Linux) running the OpenVibe Node with the Generic LOOI driver. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, where the robot's panel shows under the stream.",
+ "description": "LOOI, the tracked desk robot that uses a phone as its face: differential drive and head tilt over Bluetooth from a bridge computer (Windows, macOS or Linux) running the OpenVibe Link with the Generic LOOI driver. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, where the robot's panel shows under the stream.",
"capabilities": ["drive.differential", "head", "battery"],
"capabilities": ["drive.differential", "head", "battery"],
"mapping": { "driver": "looi", "plugin": "looi" },
"mapping": { "driver": "looi", "plugin": "looi" },
"commands": {
"commands": {
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"name": "ROS 2 robot",
"name": "ROS 2 robot",
"vendor": "ROS 2",
"vendor": "ROS 2",
"kind": "onboard",
"kind": "onboard",
- "description": "Any ROS 2 robot that drives on cmd_vel (geometry_msgs/Twist): TurtleBot 3 or 4, a Jackal, a home-built base. The OpenVibe Node's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery. Mecanum and omni bases are ros2.holonomic.",
+ "description": "Any ROS 2 robot that drives on cmd_vel (geometry_msgs/Twist): TurtleBot 3 or 4, a Jackal, a home-built base. The OpenVibe Link's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery. Mecanum and omni bases are ros2.holonomic.",
"capabilities": ["drive.differential", "camera", "sensor.ultrasonic", "battery"],
"capabilities": ["drive.differential", "camera", "sensor.ultrasonic", "battery"],
"mapping": { "driver": "plugin", "plugin": "ros2" },
"mapping": { "driver": "plugin", "plugin": "ros2" },
"commands": {
"commands": {
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"name": "ROS 2 robot (mecanum or omni)",
"name": "ROS 2 robot (mecanum or omni)",
"vendor": "ROS 2",
"vendor": "ROS 2",
"kind": "onboard",
"kind": "onboard",
- "description": "A ROS 2 robot with mecanum or omni wheels that moves in any direction on cmd_vel (linear x and y, angular z). The OpenVibe Node's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery.",
+ "description": "A ROS 2 robot with mecanum or omni wheels that moves in any direction on cmd_vel (linear x and y, angular z). The OpenVibe Link's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery.",
"capabilities": ["drive.mecanum", "camera", "sensor.ultrasonic", "battery"],
"capabilities": ["drive.mecanum", "camera", "sensor.ultrasonic", "battery"],
"mapping": { "driver": "plugin", "plugin": "ros2" },
"mapping": { "driver": "plugin", "plugin": "ros2" },
"commands": {
"commands": {
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are
* use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are
* never queued for an offline device and never replayed after a reconnect. Jobs
* never queued for an offline device and never replayed after a reconnect. Jobs
* (platform.job-frame@1, server/jobs/dispatch.js) are the exception: an unacked `job`
* (platform.job-frame@1, server/jobs/dispatch.js) are the exception: an unacked `job`
- * is resent on every reconnect, and the Node never runs one id twice.
+ * is resent on every reconnect, and the Link never runs one id twice.
* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding
* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding
* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the
* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the
* gate lives in the domain and every decision is audited.
* gate lives in the domain and every decision is audited.
@@ -36,32 +36,32 @@ const MAX_BACKLOG = 64; // frames a device may send before its authentication
@@ -36,32 +36,32 @@ const MAX_BACKLOG = 64; // frames a device may send before its authentication
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);
const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);
const MAX_AGENT_VERSION = 40;
const MAX_AGENT_VERSION = 40;
-// How often a machine's unchanged fleet reading is stored again (Codes C9c): its Node re-reads its agents' logins
+// How often a machine's unchanged fleet reading is stored again (Codes C9c): its Link re-reads its agents' logins
// every minute, so a minute is as fresh as the reading can be, and a beat every second would be a write a second.
// every minute, so a minute is as fresh as the reading can be, and a beat every second would be a write a second.
const FLEET_REFRESH_MS = 60_000;
const FLEET_REFRESH_MS = 60_000;
const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
// A machine's answer to a computer command (exec output, a file, a screen) is at most 512 KB of JSON in its ack
// A machine's answer to a computer command (exec output, a file, a screen) is at most 512 KB of JSON in its ack
-// (OpenVibe.Node protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.
+// (OpenVibe Link protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.
const DEVICE_MAX_PAYLOAD = 640 * 1024;
const DEVICE_MAX_PAYLOAD = 640 * 1024;
-// The Node's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its
+// The Link's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its
// status, and the reply window (the owner's approval on the machine plus the program's own run). `desk` (contracts
// status, and the reply window (the owner's approval on the machine plus the program's own run). `desk` (contracts
-// 0.214.0, Node v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live
+// 0.214.0, Link v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live
// screen) act there with `desk: private`, never on the owner's own screen.
// screen) act there with `desk: private`, never on the owner's own screen.
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };
-const NO_DESK = 'this machine has no private desk: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on';
-const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer';
-const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Node v0.26.0 or newer (an older one would run it on the person\'s own screen)';
-const NO_BROWSER_DESKS = 'this machine runs no browser desks: they need OpenVibe Node v0.23.0 or newer, with Chromium, Chrome, Edge or Brave';
+const NO_DESK = 'this machine has no private desk: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on';
+const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer';
+const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Link v0.26.0 or newer (an older one would run it on the person\'s own screen)';
+const NO_BROWSER_DESKS = 'this machine runs no browser desks: they need OpenVibe Link v0.23.0 or newer, with Chromium, Chrome, Edge or Brave';
const NO_WHOLE_DESKS = 'this machine runs browser desks only (a whole desk needs Linux with Xvfb): start it with kind browser';
const NO_WHOLE_DESKS = 'this machine runs browser desks only (a whole desk needs Linux with Xvfb): start it with kind browser';
-/** The kinds of desk a machine runs (Node v0.23.0: desk.kinds); an older Node's desks are all whole desks. */
+/** The kinds of desk a machine runs (Link v0.23.0: desk.kinds); an older Link's desks are all whole desks. */
const deskKinds = (offered) => (offered && offered.desk ? (Array.isArray(offered.desk.kinds) ? offered.desk.kinds : ['desktop']) : []);
const deskKinds = (offered) => (offered && offered.desk ? (Array.isArray(offered.desk.kinds) ? offered.desk.kinds : ['desktop']) : []);
/** The desk a command names: main (the person's own screen, the default), private or desk-<name> (contracts 0.215.0). */
/** The desk a command names: main (the person's own screen, the default), private or desk-<name> (contracts 0.215.0). */
const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.desk : 'main');
const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.desk : 'main');
// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with
// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with
-// no screen of its own (no one signed in, Node v0.20.0 --desk-host) offers them on its desks only.
+// no screen of its own (no one signed in, Link v0.20.0 --desk-host) offers them on its desks only.
const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';
const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';
// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a
// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a
-// browser of its own (a desk host for other people offers none, Node v0.32.0; its core routes it to the desks, v0.32.1).
+// browser of its own (a desk host for other people offers none, Link v0.32.0; its core routes it to the desks, v0.32.1).
const onDeskBrowser = (kind, value, offered) => kind === 'browser' && deskOf(value) !== 'main' && !!(offered && offered.desk);
const onDeskBrowser = (kind, value, offered) => kind === 'browser' && deskOf(value) !== 'main' && !!(offered && offered.desk);
/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */
/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */
const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));
const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));
@@ -113,7 +113,7 @@ function deviceCredentialFromProtocols(header) {
@@ -113,7 +113,7 @@ function deviceCredentialFromProtocols(header) {
const value = named ? named.slice(DEVICE_PROTOCOL.length + 1) : '';
const value = named ? named.slice(DEVICE_PROTOCOL.length + 1) : '';
return CREDENTIAL_RE.test(value) ? value : null;
return CREDENTIAL_RE.test(value) ? value : null;
}
}
-// The keys the Node's drivers report a sensor capability under (the Adeept and Cozmo plugins: distance_cm, line).
+// The keys the Link's drivers report a sensor capability under (the Adeept and Cozmo plugins: distance_cm, line).
const SENSOR_KEYS = { ultrasonic: ['distance_cm'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };
const SENSOR_KEYS = { ultrasonic: ['distance_cm'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };
const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const rgbOrNull = (c) => (c && typeof c === 'object' ? { r: finite(c.r), g: finite(c.g), b: finite(c.b) } : null);
const rgbOrNull = (c) => (c && typeof c === 'object' ? { r: finite(c.r), g: finite(c.g), b: finite(c.b) } : null);
@@ -288,7 +288,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -288,7 +288,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
*/
*/
async function persistDeclared(conn, frame) {
async function persistDeclared(conn, frame) {
const d = conn.device;
const d = conn.device;
- // OpenVibe.Node's status lists its drivers as objects ({ name, driver, state, … }, internal/protocol Status)
+ // OpenVibe Link's status lists its drivers as objects ({ name, driver, state, … }, internal/protocol Status)
// where its pairing request lists their names: the row keeps the names either way.
// where its pairing request lists their names: the row keeps the names either way.
const driverName = (x) => (typeof x === 'string' ? x : (x && typeof x === 'object' && typeof (x.driver || x.name) === 'string' ? x.driver || x.name : null));
const driverName = (x) => (typeof x === 'string' ? x : (x && typeof x === 'object' && typeof (x.driver || x.name) === 'string' ? x.driver || x.name : null));
const msg = Array.isArray(frame.drivers) ? { ...frame, drivers: frame.drivers.map(driverName) } : frame;
const msg = Array.isArray(frame.drivers) ? { ...frame, drivers: frame.drivers.map(driverName) } : frame;
@@ -414,7 +414,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -414,7 +414,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (msg.fleet !== undefined) noteFleet(conn, msg.fleet);
if (msg.fleet !== undefined) noteFleet(conn, msg.fleet);
// `echo` carries the device's `t` back for its RTT; the envelope `seq` stays Bot's own counter.
// `echo` carries the device's `t` back for its RTT; the envelope `seq` stays Bot's own counter.
const ack = { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) };
const ack = { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) };
- // OpenVibe.Node reads `heartbeat_ack.t` (its RTT is now - t), so echo it as `t` as well.
+ // OpenVibe Link reads `heartbeat_ack.t` (its RTT is now - t), so echo it as `t` as well.
if (Number.isFinite(msg.t)) ack.t = msg.t;
if (Number.isFinite(msg.t)) ack.t = msg.t;
return sendFrame(conn, 'heartbeat_ack', ack);
return sendFrame(conn, 'heartbeat_ack', ack);
}
}
@@ -493,7 +493,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -493,7 +493,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
conn.telemetry = msg;
conn.telemetry = msg;
if (urgent && !conn.sim) {
if (urgent && !conn.sim) {
- // The guided first run's steps ride these events (OpenVibe.Node's docs/install.md#first-run): store the
+ // The guided first run's steps ride these events (OpenVibe Link's docs/install.md#first-run): store the
// step times for the machines page and the admin funnel. Not awaited: the sensor broadcast below must
// step times for the machines page and the admin funnel. Not awaited: the sensor broadcast below must
// keep its frame order (two telemetry frames are handled concurrently), and a storage failure must never
// keep its frame order (two telemetry frames are handled concurrently), and a storage failure must never
// break a robot's telemetry. The domain ignores an event that is not a known step.
// break a robot's telemetry. The domain ignores an event that is not a known step.
@@ -814,20 +814,20 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -814,20 +814,20 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (screen) {
if (screen) {
// A desk's live view is the desk's own screen (desk.screen, checked below), not the person's.
// A desk's live view is the desk's own screen (desk.screen, checked below), not the person's.
if (value.action === 'start' && deskOf(value) === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {
if (value.action === 'start' && deskOf(value) === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {
- return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
+ return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
}
}
} else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {
} else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {
- return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Node v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
+ return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Link v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
}
}
// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
- // one per agent task) needs a machine that runs several (Node v0.19.0: desk.named). Kind desk names its desk in
+ // one per agent task) needs a machine that runs several (Link v0.19.0: desk.named). Kind desk names its desk in
// value.desk too, private by default.
// value.desk too, private by default.
const desk = kind === 'desk' ? (typeof value.desk === 'string' ? value.desk : 'private') : deskOf(value);
const desk = kind === 'desk' ? (typeof value.desk === 'string' ? value.desk : 'private') : deskOf(value);
if (desk !== 'main' && !(offered && offered.desk)) return refuse('bot.computer_off', NO_DESK, summary);
if (desk !== 'main' && !(offered && offered.desk)) return refuse('bot.computer_off', NO_DESK, summary);
if (desk !== 'main' && desk !== 'private' && !(offered.desk && offered.desk.named === true)) return refuse('bot.computer_off', NO_NAMED_DESKS, summary);
if (desk !== 'main' && desk !== 'private' && !(offered.desk && offered.desk.named === true)) return refuse('bot.computer_off', NO_NAMED_DESKS, summary);
- // A program on a desk (Node v0.26.0: terminal.desks): an older Node would run it in the person's own session.
+ // A program on a desk (Link v0.26.0: terminal.desks): an older Link would run it in the person's own session.
if (kind === 'exec' && desk !== 'main' && !(offered.terminal && offered.terminal.desks === true)) return refuse('bot.computer_off', NO_DESK_PROGRAMS, summary);
if (kind === 'exec' && desk !== 'main' && !(offered.terminal && offered.terminal.desks === true)) return refuse('bot.computer_off', NO_DESK_PROGRAMS, summary);
- // A desk starts as a kind the machine runs (Desks plan D2): an older Node would quietly start a whole desk
+ // A desk starts as a kind the machine runs (Desks plan D2): an older Link would quietly start a whole desk
// for a browser desk, and a Mac runs browser desks only.
// for a browser desk, and a Mac runs browser desks only.
if (kind === 'desk' && value.action === 'start') {
if (kind === 'desk' && value.action === 'start') {
const wanted = typeof value.kind === 'string' ? value.kind : 'desktop';
const wanted = typeof value.kind === 'string' ? value.kind : 'desktop';
@@ -988,7 +988,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -988,7 +988,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }
function closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
- // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe.Node does ({volts, percent}).
+ // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe Link does ({volts, percent}).
function batteryFraction(telemetry) {
function batteryFraction(telemetry) {
const b = telemetry ? telemetry.battery : null;
const b = telemetry ? telemetry.battery : null;
if (Number.isFinite(b)) return b;
if (Number.isFinite(b)) return b;
@@ -1021,7 +1021,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1021,7 +1021,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
/**
/**
* The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities): the
* The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities): the
- * capability's name, and the keys the Node's drivers report it under (public/panel.js SENSOR_KEYS).
+ * capability's name, and the keys the Link's drivers report it under (public/panel.js SENSOR_KEYS).
*/
*/
function readoutKeys(profile) {
function readoutKeys(profile) {
const keys = new Set();
const keys = new Set();
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the
* `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the
* hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers
* hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers
* `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself
* `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself
- * at a motion command's `deadline_ms` unless a newer command arrived first (the Node's deadman).
+ * at a motion command's `deadline_ms` unless a newer command arrived first (the Link's deadman).
*
*
* attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent; pass the profile if loaded) → true/false
* attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent; pass the profile if loaded) → true/false
* startAll() every existing sim robot (boot)
* startAll() every existing sim robot (boot)
@@ -64,7 +64,7 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -64,7 +64,7 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
if (f.kind === 'drive') {
if (f.kind === 'drive') {
sim.drive = { ...f.value };
sim.drive = { ...f.value };
if (sim.stopTimer) clearTimeout(sim.stopTimer);
if (sim.stopTimer) clearTimeout(sim.stopTimer);
- // The deadman: no newer command by the deadline → stop, as the Node does.
+ // The deadman: no newer command by the deadline → stop, as the Link does.
sim.stopTimer = Number.isFinite(f.deadline_ms) ? setTimeout(() => halt(sim), Math.max(0, f.deadline_ms - now())) : null;
sim.stopTimer = Number.isFinite(f.deadline_ms) ? setTimeout(() => halt(sim), Math.max(0, f.deadline_ms - now())) : null;
if (sim.stopTimer && sim.stopTimer.unref) sim.stopTimer.unref();
if (sim.stopTimer && sim.stopTimer.unref) sim.stopTimer.unref();
} else if (f.kind === 'actuator' && f.value && f.value.name === 'headlights') {
} else if (f.kind === 'actuator' && f.value && f.value.name === 'headlights') {
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* and signed before they run on anyone's Node). Anyone signed in submits a profile whose id is under `community.`,
* and signed before they run on anyone's Node). Anyone signed in submits a profile whose id is under `community.`,
* with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the
* with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the
* profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so
* profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so
- * robots can be added with it; the signature and Bot's public key are published so a Node or anyone can check it.
+ * robots can be added with it; the signature and Bot's public key are published so a Link or anyone can check it.
*
*
* createSubmissions({ db, config, now })
* createSubmissions({ db, config, now })
* submit(person, { profile, driver_url, guide_url, license, notes }) → the submission row (pending)
* submit(person, { profile, driver_url, guide_url, license, notes }) → the submission row (pending)
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Vibe by voice on a person's own machine (consumer plan §8; openvibe-contracts 0.214.0 bot.device-message@1
* Vibe by voice on a person's own machine (consumer plan §8; openvibe-contracts 0.214.0 bot.device-message@1
* voice_ask / voice_answer, chat.vibe.ask). The owner turns voice on for a machine and chooses its call word
* voice_ask / voice_answer, chat.vibe.ask). The owner turns voice on for a machine and chooses its call word
- * (migrations/0024_machine_voice.sql); the machine (OpenVibe Node's voice plugin) listens for the word, transcribes what
+ * (migrations/0024_machine_voice.sql); the machine (OpenVibe Link's voice plugin) listens for the word, transcribes what
* follows on the machine itself, and sends voice_ask. realtime.js answers `working` at once and hands the question here:
* follows on the machine itself, and sends voice_ask. realtime.js answers `working` at once and hands the question here:
* Bot asks Vibe as the owner through OpenVibe.Chat (POST /internal/chat/vibe/ask with its own service token, audience
* Bot asks Vibe as the owner through OpenVibe.Chat (POST /internal/chat/vibe/ask with its own service token, audience
* openvibe.chat, chat.vibe.ask), which keeps it in the owner's "Voice on <machine>" chat, and the answer goes back to the
* openvibe.chat, chat.vibe.ask), which keeps it in the owner's "Voice on <machine>" chat, and the answer goes back to the
@@ -22,8 +22,8 @@ const { framePage } = require('./home');
@@ -22,8 +22,8 @@ const { framePage } = require('./home');
const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main';
const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main';
const CONNECT = {
const CONNECT = {
browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },
browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },
- onboard: { label: 'On-board', text: 'The OpenVibe Node runs on the robot\'s own computer and drives its hardware.' },
- bridge: { label: 'Bridge', text: 'The OpenVibe Node runs on a computer or phone next to the robot and joins the robot\'s own link. Nothing on the robot changes.' },
+ onboard: { label: 'On-board', text: 'The OpenVibe Link runs on the robot\'s own computer and drives its hardware.' },
+ bridge: { label: 'Bridge', text: 'The OpenVibe Link runs on a computer or phone next to the robot and joins the robot\'s own link. Nothing on the robot changes.' },
server: { label: 'Server-side', text: 'Nothing to install: Bot connects to the device itself.' },
server: { label: 'Server-side', text: 'Nothing to install: Bot connects to the device itself.' },
phone: { label: 'Phone', text: 'The phone\'s browser is the device: scan a code, allow the camera, keep the page open. Nothing to install.' },
phone: { label: 'Phone', text: 'The phone\'s browser is the device: scan a code, allow the camera, keep the page open. Nothing to install.' },
};
};
@@ -36,7 +36,7 @@ const MODELS = {
@@ -36,7 +36,7 @@ const MODELS = {
'adeept.adr036': {
'adeept.adr036': {
connect: 'onboard', order: 1,
connect: 'onboard', order: 1,
setup: ['Build the kit and flash Raspberry Pi OS (64-bit, Lite is enough) on its card.', 'Add the robot here and paste the one pairing command on the Pi.',
setup: ['Build the kit and flash Raspberry Pi OS (64-bit, Lite is enough) on its card.', 'Add the robot here and paste the one pairing command on the Pi.',
- 'The OpenVibe Node drives the Robot HAT itself, publishes the Pi camera, and stops the motors the moment commands stop. The kit\'s stock control server is never run.'],
+ 'The OpenVibe Link drives the Robot HAT itself, publishes the Pi camera, and stops the motors the moment commands stop. The kit\'s stock control server is never run.'],
guide: { href: `${NODE_BLOB}/docs/hardware-adeept.md`, label: 'Adeept setup guide' },
guide: { href: `${NODE_BLOB}/docs/hardware-adeept.md`, label: 'Adeept setup guide' },
},
},
'adeept.adr036.mecanum': {
'adeept.adr036.mecanum': {
@@ -52,7 +52,7 @@ const MODELS = {
@@ -52,7 +52,7 @@ const MODELS = {
},
},
looi: {
looi: {
connect: 'bridge', order: 4,
connect: 'bridge', order: 4,
- setup: ['The community\'s Generic LOOI driver bridges to the desk robot over Bluetooth from a Windows PC.', 'Install the Node with --driver looi; the phone in LOOI keeps doing what it does.'],
+ setup: ['The community\'s Generic LOOI driver bridges to the desk robot over Bluetooth from a Windows PC.', 'Install the Link with --driver looi; the phone in LOOI keeps doing what it does.'],
guide: { href: `${NODE_BLOB}/docs/hardware-looi.md`, label: 'LOOI driver contract' },
guide: { href: `${NODE_BLOB}/docs/hardware-looi.md`, label: 'LOOI driver contract' },
},
},
'phone.camera': {
'phone.camera': {
@@ -79,7 +79,7 @@ const MODELS = {
@@ -79,7 +79,7 @@ const MODELS = {
},
},
'relay.generic': {
'relay.generic': {
connect: 'onboard', order: 6,
connect: 'onboard', order: 6,
- setup: ['Any Raspberry Pi or Linux machine running the OpenVibe Node.', 'Each button on the panel is passed to a script of yours by the relay driver: lights, a door, a motor board.'],
+ setup: ['Any Raspberry Pi or Linux machine running the OpenVibe Link.', 'Each button on the panel is passed to a script of yours by the relay driver: lights, a door, a motor board.'],
guide: { href: '/docs/drivers', label: 'Write a driver' },
guide: { href: '/docs/drivers', label: 'Write a driver' },
},
},
};
};
@@ -179,7 +179,7 @@ ${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}
@@ -179,7 +179,7 @@ ${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}
<ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}
<ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}
${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p>
${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p>
<ul class="dir-grid">${community.map(modelCard).join('')}</ul></section>` : ''}
<ul class="dir-grid">${community.map(modelCard).join('')}</ul></section>` : ''}
-<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator, then submit it: once it is reviewed, anyone can add a robot with it. A driver for its hardware runs under the OpenVibe Node.</p>
+<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator, then submit it: once it is reviewed, anyone can add a robot with it. A driver for its hardware runs under the OpenVibe Link.</p>
<p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;
<p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;
return shell({ config, path: '/models', title: 'Models', description: 'The robots OpenVibe.Bot drives: kits, bridged robots like Cozmo and LOOI, ONVIF cameras and a simulated rover.', body });
return shell({ config, path: '/models', title: 'Models', description: 'The robots OpenVibe.Bot drives: kits, bridged robots like Cozmo and LOOI, ONVIF cameras and a simulated rover.', body });
}
}
@@ -204,7 +204,7 @@ ${parts}
@@ -204,7 +204,7 @@ ${parts}
${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p>
${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p>
<ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}
<ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}
<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>
<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>
-<li>Check it on your machine before you install the driver (OpenVibe Node v0.6.0 or later): <code>openvibe-node profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>
+<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-node profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>
<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}
<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}
<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;
<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;
return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });
return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });
@@ -264,7 +264,7 @@ function renderSubmit({ config, licenses = [], values = {}, error = null }) {
@@ -264,7 +264,7 @@ function renderSubmit({ config, licenses = [], values = {}, error = null }) {
<form class="dir-form" method="post" action="/robots/submit">
<form class="dir-form" method="post" action="/robots/submit">
${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>
<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>
-<span class="muted dir-note">Its id is <code>community.<name></code>. A robot run by your own Node plugin uses <code>"driver": "plugin"</code> and names it in <code>"plugin"</code>.</span></label>
+<span class="muted dir-note">Its id is <code>community.<name></code>. A robot run by your own Link plugin uses <code>"driver": "plugin"</code> and names it in <code>"plugin"</code>.</span></label>
<label class="dir-field"><span class="dir-field-label">Driver source (https)</span><input type="url" name="driver_url" maxlength="300" placeholder="https://github.com/you/your-driver" value="${esc(values.driver_url || '')}"></label>
<label class="dir-field"><span class="dir-field-label">Driver source (https)</span><input type="url" name="driver_url" maxlength="300" placeholder="https://github.com/you/your-driver" value="${esc(values.driver_url || '')}"></label>
<label class="dir-field"><span class="dir-field-label">Setup guide (https)</span><input type="url" name="guide_url" maxlength="300" placeholder="https://openvibe.wiki/…" value="${esc(values.guide_url || '')}"></label>
<label class="dir-field"><span class="dir-field-label">Setup guide (https)</span><input type="url" name="guide_url" maxlength="300" placeholder="https://openvibe.wiki/…" value="${esc(values.guide_url || '')}"></label>
<label class="dir-field"><span class="dir-field-label">Driver licence</span><select name="license"><option value="">Choose…</option>${licenses.map((l) => `<option value="${esc(l)}"${l === values.license ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
<label class="dir-field"><span class="dir-field-label">Driver licence</span><select name="license"><option value="">Choose…</option>${licenses.map((l) => `<option value="${esc(l)}"${l === values.license ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Node, the
+ * The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Link, the
* robot-profile format, and a validator for a pasted profile.
* robot-profile format, and a validator for a pasted profile.
*
*
* They are rendered with the app's own page helper (server/web/render.js, the one the robots page uses), which
* They are rendered with the app's own page helper (server/web/render.js, the one the robots page uses), which
@@ -12,14 +12,14 @@
@@ -12,14 +12,14 @@
* renderDriversPage({ config }) GET /docs/drivers
* renderDriversPage({ config }) GET /docs/drivers
* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate
* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate
* renderAutomationPage({ config }) GET /docs/automation (routines, webhooks, the agent switch)
* renderAutomationPage({ config }) GET /docs/automation (routines, webhooks, the agent switch)
- * renderComputersPage({ config }) GET /docs/computers (the Node's computer plugin for your agents)
+ * renderComputersPage({ config }) GET /docs/computers (the Link's computer plugin for your agents)
* formatProblems(problems) / validationResult(result)
* formatProblems(problems) / validationResult(result)
*/
*/
const { esc } = require('./render');
const { esc } = require('./render');
const { framePage } = require('./home');
const { framePage } = require('./home');
const simRover = require('../profiles/sim.rover.json');
const simRover = require('../profiles/sim.rover.json');
-// The driver protocol and the bundled drivers live in OpenVibe.Node (the Node core and its plugins/ tree).
+// The driver protocol and the bundled drivers live in OpenVibe Link (the Link core and its plugins/ tree).
const NODE = 'https://github.com/OpenVibers/OpenVibe.Node';
const NODE = 'https://github.com/OpenVibers/OpenVibe.Node';
const TREE = `${NODE}/tree/main`;
const TREE = `${NODE}/tree/main`;
const BLOB = `${NODE}/blob/main`;
const BLOB = `${NODE}/blob/main`;
@@ -40,7 +40,7 @@ function docsPage({ config, path, title, description, content }) {
@@ -40,7 +40,7 @@ function docsPage({ config, path, title, description, content }) {
<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav>
<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav>
<main id="main" class="docs-main">
<main id="main" class="docs-main">
${content}
${content}
-<p class="docs-foot muted">The Node and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>
+<p class="docs-foot muted">The Link and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>
</main>
</main>
</div>`;
</div>`;
return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });
return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });
@@ -54,9 +54,9 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}
@@ -54,9 +54,9 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}
function renderDocsIndex({ config }) {
function renderDocsIndex({ config }) {
const content = `
const content = `
<h1>Build for OpenVibe.Bot</h1>
<h1>Build for OpenVibe.Bot</h1>
-<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Node runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>
+<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Link runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>
<section class="card"><h2>Write a driver</h2>
<section class="card"><h2>Write a driver</h2>
-<p>A driver is a separate program the OpenVibe Node starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p>
+<p>A driver is a separate program the OpenVibe Link starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p>
<p>${inLink('/docs/drivers', 'Write a driver')} covers the lifecycle, every message, the safety rules, a minimal Python driver, and how to test and contribute it.</p></section>
<p>${inLink('/docs/drivers', 'Write a driver')} covers the lifecycle, every message, the safety rules, a minimal Python driver, and how to test and contribute it.</p></section>
<section class="card"><h2>Robot profiles</h2>
<section class="card"><h2>Robot profiles</h2>
<p>A profile (<code>bot.robot-profile@1</code>) says what the robot can do, which commands its driver accepts, and which widgets the panel shows. The panel is a function of the profile: a new robot needs a profile, not code.</p>
<p>A profile (<code>bot.robot-profile@1</code>) says what the robot can do, which commands its driver accepts, and which widgets the panel shows. The panel is a function of the profile: a new robot needs a profile, not code.</p>
@@ -64,13 +64,13 @@ function renderDocsIndex({ config }) {
@@ -64,13 +64,13 @@ function renderDocsIndex({ config }) {
<section class="card"><h2>Automate a robot</h2>
<section class="card"><h2>Automate a robot</h2>
<p>${inLink('/docs/automation', 'Routines, webhooks and your agent')}: steps on a schedule, signed event deliveries to your server, and letting your OpenVibe.Actor agent drive.</p></section>
<p>${inLink('/docs/automation', 'Routines, webhooks and your agent')}: steps on a schedule, signed event deliveries to your server, and letting your OpenVibe.Actor agent drive.</p></section>
<section class="card"><h2>Your computer for your agents</h2>
<section class="card"><h2>Your computer for your agents</h2>
-<p>${inLink('/docs/computers', 'Your computer')}: the Node's computer plugin, its levels, approvals on the computer, desks, your own models, and the API.</p></section>
+<p>${inLink('/docs/computers', 'Your computer')}: the Link's computer plugin, its levels, approvals on the computer, desks, your own models, and the API.</p></section>
<section class="card"><h2>Robots</h2>
<section class="card"><h2>Robots</h2>
<p>Add a robot and drive it from any browser. The <b>Simulated rover</b> runs inside Bot with no hardware, so you can see your profile's panel before anything is wired up.</p>
<p>Add a robot and drive it from any browser. The <b>Simulated rover</b> runs inside Bot with no hardware, so you can see your profile's panel before anything is wired up.</p>
<p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section>
<p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section>
<h2>Where things live</h2>
<h2>Where things live</h2>
<ul>
<ul>
-<li>The <b>OpenVibe Node</b> (${out(NODE, 'OpenVibers/OpenVibe.Node')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>
+<li>The <b>OpenVibe Link</b> (${out(NODE, 'OpenVibers/OpenVibe.Node')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>
<li>Drivers are Node <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li>
<li>Drivers are Node <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li>
<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')}, ${out(`${TREE}/plugins/relay`, 'relay')} and ${out(`${TREE}/plugins/ros2`, 'ros2')} (any ROS 2 robot on <code>cmd_vel</code>, through rosbridge or rclpy; profiles <code>ros2.diffdrive</code> and <code>ros2.holonomic</code>, installer <code>--driver ros2</code>). LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</li>
<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')}, ${out(`${TREE}/plugins/relay`, 'relay')} and ${out(`${TREE}/plugins/ros2`, 'ros2')} (any ROS 2 robot on <code>cmd_vel</code>, through rosbridge or rclpy; profiles <code>ros2.diffdrive</code> and <code>ros2.holonomic</code>, installer <code>--driver ros2</code>). LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</li>
<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
@@ -84,18 +84,18 @@ function renderDocsIndex({ config }) {
@@ -84,18 +84,18 @@ function renderDocsIndex({ config }) {
function renderDriversPage({ config }) {
function renderDriversPage({ config }) {
const content = `
const content = `
<h1>Write a driver</h1>
<h1>Write a driver</h1>
-<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe.Node.</p>
+<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe Link.</p>
<section class="card"><h2>What a driver is</h2>
<section class="card"><h2>What a driver is</h2>
-<p>A driver is a <b>separate executable</b> the OpenVibe Node core starts. It can be written in any language. The two speak <b>JSON lines over stdin/stdout</b>: one JSON object per line, UTF-8, in each direction. The core never links driver code; the robot drivers are Python 3 because their hardware libraries are.</p>
+<p>A driver is a <b>separate executable</b> the OpenVibe Link core starts. It can be written in any language. The two speak <b>JSON lines over stdin/stdout</b>: one JSON object per line, UTF-8, in each direction. The core never links driver code; the robot drivers are Python 3 because their hardware libraries are.</p>
<p>Nothing but protocol lines may go to stdout. Send diagnostics to <b>stderr</b> (the Python runtime points file descriptor 1 at stderr and keeps a private copy for the protocol, because hardware libraries print).</p>
<p>Nothing but protocol lines may go to stdout. Send diagnostics to <b>stderr</b> (the Python runtime points file descriptor 1 at stderr and keeps a private copy for the protocol, because hardware libraries print).</p>
-<p>The Node core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the e-stop and the deadman, and forwards already-validated commands to a driver.</p></section>
+<p>The Link core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the e-stop and the deadman, and forwards already-validated commands to a driver.</p></section>
<section class="card"><h2>The three connection kinds</h2>
<section class="card"><h2>The three connection kinds</h2>
-<p>The robot's profile carries a <code>kind</code>, which says where the Node runs relative to the robot:</p>
+<p>The robot's profile carries a <code>kind</code>, which says where the Link runs relative to the robot:</p>
<ul>
<ul>
-<li><b>on-board</b> (<code>kind: "onboard"</code>) — the Node runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li>
-<li><b>bridge</b> (<code>kind: "bridge"</code>) — the Node runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li>
+<li><b>on-board</b> (<code>kind: "onboard"</code>) — the Link runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li>
+<li><b>bridge</b> (<code>kind: "bridge"</code>) — the Link runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li>
<li><b>server-side</b> (<code>kind: "server"</code>) — there is no Node on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li>
<li><b>server-side</b> (<code>kind: "server"</code>) — there is no Node on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li>
</ul>
</ul>
<p>The pairing's <code>device_kind</code> is <code>onboard</code> or <code>bridge</code>; a server-side robot is not installed on a machine.</p></section>
<p>The pairing's <code>device_kind</code> is <code>onboard</code> or <code>bridge</code>; a server-side robot is not installed on a machine.</p></section>
@@ -140,7 +140,7 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
@@ -140,7 +140,7 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
<li>when its own sensors say so (for example a cliff or a pick-up).</li>
<li>when its own sensors say so (for example a cliff or a pick-up).</li>
</ul>
</ul>
<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>
<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>
-<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Node's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Node is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
+<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
<section class="card"><h2>A minimal driver in Python</h2>
<section class="card"><h2>A minimal driver in Python</h2>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
@@ -224,9 +224,9 @@ ${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.js
@@ -224,9 +224,9 @@ ${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.js
<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Node. Use it to see exactly what your profile shows before wiring a driver.</p></section>
<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Node. Use it to see exactly what your profile shows before wiring a driver.</p></section>
<section class="card"><h2>Contribute it</h2>
<section class="card"><h2>Contribute it</h2>
-<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Node. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
+<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Link. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
`;
`;
- return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Node: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });
+ return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Link: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });
}
}
// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────
// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────
@@ -369,7 +369,7 @@ ${code(COMMISSION_CALL)}
@@ -369,7 +369,7 @@ ${code(COMMISSION_CALL)}
// ── GET /docs/computers ────────────────────────────────────────────────────────────────────────────
// ── GET /docs/computers ────────────────────────────────────────────────────────────────────────────
-const COMPUTER_INSTALL = `curl -fsSL https://node.openvibe.network/install | sh -s -- \\
+const COMPUTER_INSTALL = `curl -fsSL https://openvibe.link/install | sh -s -- \\
--network https://openvibe.network --pairing pair_… --code ABCD-1234 --computer ask`;
--network https://openvibe.network --pairing pair_… --code ABCD-1234 --computer ask`;
const COMPUTER_APPROVE = `openvibe-node approvals # what waits: id, kind, what it would do
const COMPUTER_APPROVE = `openvibe-node approvals # what waits: id, kind, what it would do
openvibe-node approve cmd_01J… # let it run
openvibe-node approve cmd_01J… # let it run
@@ -384,13 +384,13 @@ const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…
@@ -384,13 +384,13 @@ const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…
function renderComputersPage({ config }) {
function renderComputersPage({ config }) {
const content = `
const content = `
<h1>Your computer for your agents</h1>
<h1>Your computer for your agents</h1>
-<p class="muted">The OpenVibe Node's <b>computer</b> plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.</p>
+<p class="muted">The OpenVibe Link's <b>computer</b> plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.</p>
<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2>
<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2>
<p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
<p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
${code(COMPUTER_INSTALL)}
${code(COMPUTER_INSTALL)}
-<p class="muted">The installer is also at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working. What the Node is and what it does is on <a href="https://node.openvibe.network">node.openvibe.network</a>.</p>
-<p>The Node then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Node in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section>
+<p class="muted">The installer is also at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working. What the Link is and what it does is on <a href="https://openvibe.link">node.openvibe.network</a>.</p>
+<p>The Link then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Link in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section>
<section class="card" aria-labelledby="levels-h"><h2 id="levels-h">Levels, set on the computer</h2>
<section class="card" aria-labelledby="levels-h"><h2 id="levels-h">Levels, set on the computer</h2>
<ul>
<ul>
@@ -405,7 +405,7 @@ ${code(COMPUTER_INSTALL)}
@@ -405,7 +405,7 @@ ${code(COMPUTER_INSTALL)}
<p>At level <b>ask</b>, a request waits up to two minutes for your answer. You can answer in three places:</p>
<p>At level <b>ask</b>, a request waits up to two minutes for your answer. You can answer in three places:</p>
<ul>
<ul>
<li><b>The tray</b> (Linux and Windows). <code>openvibe-node tray</code> lists what waits, each with <b>Approve</b> and <b>Deny</b>, beside the stop and resume. It raises a notification with the same two buttons, and its icon turns amber while something waits. The installer starts it when you sign in.</li>
<li><b>The tray</b> (Linux and Windows). <code>openvibe-node tray</code> lists what waits, each with <b>Approve</b> and <b>Deny</b>, beside the stop and resume. It raises a notification with the same two buttons, and its icon turns amber while something waits. The installer starts it when you sign in.</li>
-<li><b>A notification or dialog</b> from the Node itself when it runs in your session: buttons on Linux, a dialog whose default is Deny on macOS.</li>
+<li><b>A notification or dialog</b> from the Link itself when it runs in your session: buttons on Linux, a dialog whose default is Deny on macOS.</li>
<li><b>The command line</b>:</li>
<li><b>The command line</b>:</li>
</ul>
</ul>
${code(COMPUTER_APPROVE)}
${code(COMPUTER_APPROVE)}
@@ -419,11 +419,11 @@ ${code(COMPUTER_APPROVE)}
@@ -419,11 +419,11 @@ ${code(COMPUTER_APPROVE)}
<section class="card" aria-labelledby="desks-h"><h2 id="desks-h">Desks: a computer of its own for each task</h2>
<section class="card" aria-labelledby="desks-h"><h2 id="desks-h">Desks: a computer of its own for each task</h2>
<p>Your agent does not use your screen. Each task gets a <b>desk</b> on one of your machines: a screen, browser, files and sign-ins of its own, so several tasks work at once without touching each other or you.</p>
<p>Your agent does not use your screen. Each task gets a <b>desk</b> on one of your machines: a screen, browser, files and sign-ins of its own, so several tasks work at once without touching each other or you.</p>
<ul>
<ul>
-<li><b>Two kinds.</b> A <b>browser desk</b> is a browser alone, with no screen to draw. It costs about half as much, so it is what most web work gets, and it runs on a Mac too. A <b>whole desk</b> is a full screen where the agent can also open programs, such as an office suite or a terminal (OpenVibe Node v0.26.0; Linux).</li>
+<li><b>Two kinds.</b> A <b>browser desk</b> is a browser alone, with no screen to draw. It costs about half as much, so it is what most web work gets, and it runs on a Mac too. A <b>whole desk</b> is a full screen where the agent can also open programs, such as an office suite or a terminal (OpenVibe Link v0.26.0; Linux).</li>
<li><b>Watch it, take it over.</b> A running task shows <b>Watch and take over</b> on its page in ${out('https://openvibe.actor', 'OpenVibe.Actor')}, and Vibe offers the same link in your chat. While you use the mouse and keyboard there, the agent waits; when you stop, it goes on.</li>
<li><b>Watch it, take it over.</b> A running task shows <b>Watch and take over</b> on its page in ${out('https://openvibe.actor', 'OpenVibe.Actor')}, and Vibe offers the same link in your chat. While you use the mouse and keyboard there, the agent waits; when you stop, it goes on.</li>
<li><b>It hands you the steps that are yours.</b> For a sign-in, a code sent to your phone, a CAPTCHA or a payment, the agent gets the page ready and asks you to do that step on its desk ("Needs you"). It never types a password itself.</li>
<li><b>It hands you the steps that are yours.</b> For a sign-in, a code sent to your phone, a CAPTCHA or a payment, the agent gets the page ready and asks you to do that step on its desk ("Needs you"). It never types a password itself.</li>
-<li><b>Sign in once.</b> On OpenVibe Node v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li>
-<li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Node v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li>
+<li><b>Sign in once.</b> On OpenVibe Link v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li>
+<li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Link v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li>
<li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li>
<li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li>
<li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-node stop</code>) stops them all.</li>
<li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-node stop</code>) stops them all.</li>
</ul>
</ul>
@@ -432,7 +432,7 @@ ${code(COMPUTER_APPROVE)}
@@ -432,7 +432,7 @@ ${code(COMPUTER_APPROVE)}
<section class="card" aria-labelledby="models-c-h"><h2 id="models-c-h">Your own models</h2>
<section class="card" aria-labelledby="models-c-h"><h2 id="models-c-h">Your own models</h2>
<p>If you run a model on your computer (Ollama, LM Studio or llama.cpp's server), your OpenVibe AI can think with it: free, private to you, and with no port opened.</p>
<p>If you run a model on your computer (Ollama, LM Studio or llama.cpp's server), your OpenVibe AI can think with it: free, private to you, and with no port opened.</p>
<ul>
<ul>
-<li><b>Set it up.</b> On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Use the models you run on it</b>, or add <code>--ai</code> to any other one (OpenVibe Node v0.28.0 or newer). The installer says which model server it finds.</li>
+<li><b>Set it up.</b> On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Use the models you run on it</b>, or add <code>--ai</code> to any other one (OpenVibe Link v0.28.0 or newer). The installer says which model server it finds.</li>
<li><b>Turn it on.</b> The machine's card lists its models and how fast they answer there. <b>Let my AI use this computer's models</b> lets OpenVibe ask them for you; nothing reaches them before that.</li>
<li><b>Turn it on.</b> The machine's card lists its models and how fast they answer there. <b>Let my AI use this computer's models</b> lets OpenVibe ask them for you; nothing reaches them before that.</li>
<li><b>Use it.</b> <b>Use these models in OpenVibe.Chat</b> fills in "Your AI key" on OpenVibe.Chat. Your Vibe and the AI members you set up can then think with your computer. On OpenVibe.Live, a streamer's AI viewers can too: choose <b>A model on my own computer</b> as their AI source.</li>
<li><b>Use it.</b> <b>Use these models in OpenVibe.Chat</b> fills in "Your AI key" on OpenVibe.Chat. Your Vibe and the AI members you set up can then think with your computer. On OpenVibe.Live, a streamer's AI viewers can too: choose <b>A model on my own computer</b> as their AI source.</li>
<li><b>Any of your computers.</b> Choose <b>any of my computers</b> instead of one, and OpenVibe asks the fastest of yours that is on and has the model, then the next when one is busy.</li>
<li><b>Any of your computers.</b> Choose <b>any of my computers</b> instead of one, and OpenVibe asks the fastest of yours that is on and has the model, then the next when one is busy.</li>
@@ -444,7 +444,7 @@ ${code(COMPUTER_APPROVE)}
@@ -444,7 +444,7 @@ ${code(COMPUTER_APPROVE)}
<p>On ${inLink('/machines', 'Your machines')}, <b>Show my screen</b> shows the computer's screen live, to you only, for 10 minutes (30 seconds to 30 minutes through the API).</p>
<p>On ${inLink('/machines', 'Your machines')}, <b>Show my screen</b> shows the computer's screen live, to you only, for 10 minutes (30 seconds to 30 minutes through the API).</p>
<ul>
<ul>
<li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>
<li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>
-<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your desktop session.</li>
+<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your desktop session.</li>
<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-node status</code> shows it while it runs.</li>
<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-node status</code> shows it while it runs.</li>
<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-node stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li>
<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-node stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li>
</ul>
</ul>
@@ -471,9 +471,9 @@ The page answers with its URL, title, text and links. The browser is the agent's
@@ -471,9 +471,9 @@ The page answers with its URL, title, text and links. The browser is the agent's
<li><code>ai</code>: <code>model</code> (one of its models), <code>messages</code> (<code>role</code> and <code>content</code>), <code>max_tokens</code>, <code>temperature</code> → <code>message</code>, <code>usage</code> and <code>ms</code>, from the computer's own model server. <code>POST /api/v1/machines/:id/ai-models</code> with <code>on</code> is the switch above; OpenVibe's own AI uses the models only while it is on.</li>
<li><code>ai</code>: <code>model</code> (one of its models), <code>messages</code> (<code>role</code> and <code>content</code>), <code>max_tokens</code>, <code>temperature</code> → <code>message</code>, <code>usage</code> and <code>ms</code>, from the computer's own model server. <code>POST /api/v1/machines/:id/ai-models</code> with <code>on</code> is the switch above; OpenVibe's own AI uses the models only while it is on.</li>
<li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li>
<li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li>
</ul>
</ul>
-<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>nod_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Node last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
+<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>nod_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Link last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
`;
`;
- return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Node: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });
+ return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Link: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });
}
}
module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage, validationResult };
module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage, validationResult };
@@ -52,8 +52,8 @@ const HOME_CSP = [
@@ -52,8 +52,8 @@ const HOME_CSP = [
const KINDS = [
const KINDS = [
['browser', 'Simulated', 'No hardware at all: it runs inside Bot and in your browser.'],
['browser', 'Simulated', 'No hardware at all: it runs inside Bot and in your browser.'],
['phone', 'A phone', 'The phone\'s browser is the robot: scan a code, allow the camera, keep the page open.'],
['phone', 'A phone', 'The phone\'s browser is the robot: scan a code, allow the camera, keep the page open.'],
- ['onboard', 'On-board', 'The OpenVibe Node runs on the robot\'s own computer, such as a Raspberry Pi, and drives its hardware.'],
- ['bridge', 'Bridge', 'The Node runs on a computer next to the robot and joins the robot\'s own link. Nothing on the robot changes.'],
+ ['onboard', 'On-board', 'The OpenVibe Link runs on the robot\'s own computer, such as a Raspberry Pi, and drives its hardware.'],
+ ['bridge', 'Bridge', 'The Link runs on a computer next to the robot and joins the robot\'s own link. Nothing on the robot changes.'],
['server', 'Server-side', 'Nothing to install: Bot connects to the device itself, like an ONVIF camera.'],
['server', 'Server-side', 'Nothing to install: Bot connects to the device itself, like an ONVIF camera.'],
];
];
// directory.js requires this module for framePage, so its words are read when a page renders, not at load.
// directory.js requires this module for framePage, so its words are read when a page renders, not at load.
@@ -147,7 +147,7 @@ function startSection() {
@@ -147,7 +147,7 @@ function startSection() {
title: 'Online in a minute',
title: 'Online in a minute',
lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router and no stock control server left exposed.',
lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router and no stock control server left exposed.',
items: [
items: [
- { title: 'Run one command on the robot', text: 'Paste the installer line on its Raspberry Pi or Linux computer. It installs the OpenVibe Node, finds the kit and shows a short code.' },
+ { title: 'Run one command on the robot', text: 'Paste the installer line on its Raspberry Pi or Linux computer. It installs the OpenVibe Link, finds the kit and shows a short code.' },
{ title: 'Approve it', text: 'Sign in at openvibe.bot/link and enter the code. The robot\'s credential is stored hashed, and you can revoke it any time.' },
{ title: 'Approve it', text: 'Sign in at openvibe.bot/link and enter the code. The robot\'s credential is stored hashed, and you can revoke it any time.' },
{ title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included. Share it, list it, or bind it to your OpenVibe.Live channel.' },
{ title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included. Share it, list it, or bind it to your OpenVibe.Live channel.' },
],
],
@@ -186,14 +186,14 @@ function moreSection({ config, kit: k, models }) {
@@ -186,14 +186,14 @@ function moreSection({ config, kit: k, models }) {
});
});
const model = k ? models.find((m) => m.id === k.profile_id) : null;
const model = k ? models.find((m) => m.id === k.profile_id) : null;
const kitBody = k && k.parts && k.parts.length
const kitBody = k && k.parts && k.parts.length
- ? `<p class="hm-fold-lede">What to get for the robot OpenVibe.Bot was built around. The OpenVibe Node drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.</p><ul class="hm-parts">${k.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('')}</ul>
+ ? `<p class="hm-fold-lede">What to get for the robot OpenVibe.Bot was built around. The OpenVibe Link drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.</p><ul class="hm-parts">${k.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('')}</ul>
<p class="hm-kit-actions">${link(k.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/hardware-adeept.md', 'Setup guide for the Node', 'sc-btn')}</p>`
<p class="hm-kit-actions">${link(k.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/hardware-adeept.md', 'Setup guide for the Node', 'sc-btn')}</p>`
: '';
: '';
const build = rows([
const build = rows([
- ['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Node: every message and the safety rules.'],
+ ['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Link: every message and the safety rules.'],
['/docs/profiles', 'Robot profiles', 'The JSON that says what a robot can do and which controls its panel shows, with a validator.'],
['/docs/profiles', 'Robot profiles', 'The JSON that says what a robot can do and which controls its panel shows, with a validator.'],
['/robots/submit', 'Submit a robot', 'Describe your robot with a profile and point at its driver. Once reviewed, anyone can add one.'],
['/robots/submit', 'Submit a robot', 'Describe your robot with a profile and point at its driver. Once reviewed, anyone can add one.'],
- ['https://github.com/OpenVibers/OpenVibe.Bot', 'The source', 'OpenVibe.Bot and the OpenVibe Node are open source under the AGPL-3.0.'],
+ ['https://github.com/OpenVibers/OpenVibe.Bot', 'The source', 'OpenVibe.Bot and the OpenVibe Link are open source under the AGPL-3.0.'],
]);
]);
return foldBlock({
return foldBlock({
id: 'more',
id: 'more',
@@ -201,7 +201,7 @@ function moreSection({ config, kit: k, models }) {
@@ -201,7 +201,7 @@ function moreSection({ config, kit: k, models }) {
html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="hm-h">More on OpenVibe.Bot</h2>
html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="hm-h">More on OpenVibe.Bot</h2>
${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}
${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}
${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}
${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}
-${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Node', body: `<p class="hm-fold-lede">The same OpenVibe Node runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}
+${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Node', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}
${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}
${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}
${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}
${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}
${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${link('/docs', 'All build pages', 'hm-more')}</p>` })}</section>`,
${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${link('/docs', 'All build pages', 'hm-more')}</p>` })}</section>`,
@@ -229,9 +229,9 @@ function faqItems({ models }) {
@@ -229,9 +229,9 @@ function faqItems({ models }) {
{ q: 'Can other people drive my robot?', a: 'Only if you say so. Add people by @username as operators or viewers, or open a queue where signed-in visitors take timed turns. Listing a robot in the directory lets anyone watch it.' },
{ q: 'Can other people drive my robot?', a: 'Only if you say so. Add people by @username as operators or viewers, or open a queue where signed-in visitors take timed turns. Listing a robot in the directory lets anyone watch it.' },
{ q: 'Can I show it on my stream?', a: 'Yes. Bind the robot to your OpenVibe.Live channel and its panel shows under your stream, where anyone you allow can take the controls.' },
{ q: 'Can I show it on my stream?', a: 'Yes. Bind the robot to your OpenVibe.Live channel and its panel shows under your stream, where anyone you allow can take the controls.' },
{ q: 'Can my AI agent drive it?', a: 'Only when you turn on Your agent for that robot. Then your OpenVibe.Actor agent can read its state and run steps as you, through the same gate, and for nobody else.' },
{ q: 'Can my AI agent drive it?', a: 'Only when you turn on Your agent for that robot. Then your OpenVibe.Actor agent can read its state and run steps as you, through the same gate, and for nobody else.' },
- { q: 'Can my agents use my computer?', a: 'Only if you install the OpenVibe Node there and say so. Each agent task gets a desk of its own, a browser or a whole screen, that you can watch and take over, at the level you set on the computer, with a kill switch there. A spare computer can host the desks instead.' },
- { q: 'Can my AI use the models on my computer?', a: 'Yes. Install the Node with --ai where Ollama, LM Studio or llama.cpp runs and turn on "Let my AI use this computer\'s models": your Vibe, your AI members and your stream\'s AI viewers can then think with them, free, and only for you.' },
- { q: 'Is it open source?', a: 'Yes. OpenVibe.Bot and the OpenVibe Node that runs on the robot are on GitHub under the AGPL-3.0, and anyone can write a driver or submit a robot profile.' },
+ { q: 'Can my agents use my computer?', a: 'Only if you install the OpenVibe Link there and say so. Each agent task gets a desk of its own, a browser or a whole screen, that you can watch and take over, at the level you set on the computer, with a kill switch there. A spare computer can host the desks instead.' },
+ { q: 'Can my AI use the models on my computer?', a: 'Yes. Install the Link with --ai where Ollama, LM Studio or llama.cpp runs and turn on "Let my AI use this computer\'s models": your Vibe, your AI members and your stream\'s AI viewers can then think with them, free, and only for you.' },
+ { q: 'Is it open source?', a: 'Yes. OpenVibe.Bot and the OpenVibe Link that runs on the robot are on GitHub under the AGPL-3.0, and anyone can write a driver or submit a robot profile.' },
].filter(Boolean);
].filter(Boolean);
}
}
@@ -824,11 +824,11 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
@@ -824,11 +824,11 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
};
};
- // The computer plugin (Node docs/computer.md): the level its owner set on the machine, and whether their agent may use it.
+ // The computer plugin (Link docs/computer.md): the level its owner set on the machine, and whether their agent may use it.
const LEVELS = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
const LEVELS = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
const computerOf = (m) => {
const computerOf = (m) => {
const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
- // Its own models (OpenVibe Node's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.
+ // Its own models (OpenVibe Link's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.
const models = aiOf(caps);
const models = aiOf(caps);
const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true,
const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true,
offer: models.offer && typeof models.offer === 'object' ? models.offer : null,
offer: models.offer && typeof models.offer === 'object' ? models.offer : null,
@@ -841,11 +841,11 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
@@ -841,11 +841,11 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
screenStream: !!(c.screen && c.screen.stream === true),
screenStream: !!(c.screen && c.screen.stream === true),
// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true,
desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true,
- // Each desk in its own sandbox (Node v0.32.0, --desk-host --shared): it may serve other people's sessions.
+ // Each desk in its own sandbox (Link v0.32.0, --desk-host --shared): it may serve other people's sessions.
sandboxed: d.isolation === 'sandbox' } : null,
sandboxed: d.isolation === 'sandbox' } : null,
ai };
ai };
};
};
- // Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
+ // Vibe by voice: shown for a machine whose Link runs the voice plugin (capabilities.voice).
const voiceRow = (m) => {
const voiceRow = (m) => {
const v = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).voice;
const v = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).voice;
if (!v || typeof v !== 'object') return '';
if (!v || typeof v !== 'object') return '';
@@ -900,7 +900,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
@@ -900,7 +900,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;
if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
- // The desks running now (the Node's telemetry), and the page that lists them, to watch or stop one.
+ // The desks running now (the Link's telemetry), and the page that lists them, to watch or stop one.
const running = Number.isInteger(m.computer_desks) && m.computer_desks > 0
const running = Number.isInteger(m.computer_desks) && m.computer_desks > 0
? ` <span class="pill">${m.computer_desks === 1 ? '1 desk running' : `${m.computer_desks} desks running`}</span>` : '';
? ` <span class="pill">${m.computer_desks === 1 ? '1 desk running' : `${m.computer_desks} desks running`}</span>` : '';
const deskLink = c.desks && m.online ? `<a class="button quiet" href="/machines/${esc(m.id)}/desks">See its desks</a>` : '';
const deskLink = c.desks && m.online ? `<a class="button quiet" href="/machines/${esc(m.id)}/desks">See its desks</a>` : '';
@@ -916,7 +916,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
@@ -916,7 +916,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ '</div>';
+ '</div>';
};
};
- // The guided first run's one job (Node v0.39.0): what this machine is, from the first_job step Bot stored.
+ // The guided first run's one job (Link v0.39.0): what this machine is, from the first_job step Bot stored.
const bytesText = (n) => {
const bytesText = (n) => {
if (!Number.isFinite(n) || n < 0) return '';
if (!Number.isFinite(n) || n < 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -950,12 +950,12 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
@@ -950,12 +950,12 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
<p class="muted">${text}</p></li>`;
<p class="muted">${text}</p></li>`;
const add = pairing
const add = pairing
? `<h2 id="add-h">What should this computer do?</h2>
? `<h2 id="add-h">What should this computer do?</h2>
-<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Node and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p>
+<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Link and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p>
<ul class="choices" aria-labelledby="add-h">
<ul class="choices" aria-labelledby="add-h">
${choice('Run your coding jobs', ' --worker', 'Linux with systemd. It turns the Node\'s job worker on: jobs run as a separate user, in a sandbox, with the limits you set. For coding jobs the computer also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.')}
${choice('Run your coding jobs', ' --worker', 'Linux with systemd. It turns the Node\'s job worker on: jobs run as a separate user, in a sandbox, with the limits you set. For coding jobs the computer also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.')}
-${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Node runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.')}
+${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Link runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.')}
${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}
${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}
-${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Node v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/ai.md">How it works</a>.')}
+${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Link v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/ai.md">How it works</a>.')}
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}" data-local-time>${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>. It works once.</p></li></ul>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}" data-local-time>${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>. It works once.</p></li></ul>
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
@@ -1145,7 +1145,7 @@ function renderPhonePage() {
@@ -1145,7 +1145,7 @@ function renderPhonePage() {
}
}
/**
/**
- * /admin/first-run: the measured first run, reviewers only. One row per step the Node reported (node.first_run), so
+ * /admin/first-run: the measured first run, reviewers only. One row per step the Link reported (node.first_run), so
* the number that reach each step and the median time between them answer whether installs get to a working machine.
* the number that reach each step and the median time between them answer whether installs get to a working machine.
*/
*/
function renderFirstRunPage({ rows = [] }) {
function renderFirstRunPage({ rows = [] }) {
@@ -1198,14 +1198,14 @@ function renderFirstRunPage({ rows = [] }) {
@@ -1198,14 +1198,14 @@ function renderFirstRunPage({ rows = [] }) {
}).join('')}</ul>`
}).join('')}</ul>`
: '<p class="muted">No machine has reported a first run yet.</p>';
: '<p class="muted">No machine has reported a first run yet.</p>';
const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
-<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Node's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>
+<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>
<section class="dir-sec"><h2>The funnel</h2>
<section class="dir-sec"><h2>The funnel</h2>
<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
<tbody>${rowsHtml}</tbody></table>
<tbody>${rowsHtml}</tbody></table>
<p class="muted">${count('self_check') - failed} self-check${count('self_check') - failed === 1 ? '' : 's'} passed${failed ? `, ${failed} failed` : ''}. A failed check is still a machine that got that far; its reason is in the machine's own record.</p></section>
<p class="muted">${count('self_check') - failed} self-check${count('self_check') - failed === 1 ? '' : 's'} passed${failed ? `, ${failed} failed` : ''}. A failed check is still a machine that got that far; its reason is in the machine's own record.</p></section>
<section class="dir-sec"><h2>Most recent</h2>${recent}</section>`;
<section class="dir-sec"><h2>Most recent</h2>${recent}</section>`;
- return page('First run', body, { description: 'The guided first run of OpenVibe Node, measured step by step.', canonical: '/admin/first-run' });
+ return page('First run', body, { description: 'The guided first run of OpenVibe Link, measured step by step.', canonical: '/admin/first-run' });
}
}
module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* The non-API surface: the front page at `/` (web/home.js), `/robots.txt`, `/sitemap.xml`, `/llms.txt`,
* The non-API surface: the front page at `/` (web/home.js), `/robots.txt`, `/sitemap.xml`, `/llms.txt`,
- * `/llms-full.txt`, `/manifest.webmanifest`, `/install`, a 302 to OpenVibe.Node's installer script (the one-paste command,
+ * `/llms-full.txt`, `/manifest.webmanifest`, `/install`, a 302 to OpenVibe Link's installer script (the one-paste command,
* ADR-043), and the signed-in pages (the five-minute path and the profile-rendered panel, plan T15 step 3):
* ADR-043), and the signed-in pages (the five-minute path and the profile-rendered panel, plan T15 step 3):
*
*
* GET /robots the signed-in person's robots and the "add a robot" form
* GET /robots the signed-in person's robots and the "add a robot" form
@@ -103,17 +103,17 @@ const LLMS_PAGES = [
@@ -103,17 +103,17 @@ const LLMS_PAGES = [
{
{
path: '/models', title: 'Models',
path: '/models', title: 'Models',
note: 'Every robot model Bot drives, by how it connects.',
note: 'Every robot model Bot drives, by how it connects.',
- text: 'The robots OpenVibe.Bot drives, grouped by how they connect: simulated (the rover that runs in Bot and in the browser), on-board (the OpenVibe Node on the robot\'s own computer: the Adeept ADR036 4WD car and its mecanum build, the relay robot for any Linux board), bridge (the Node on a computer or phone next to the robot: Cozmo through PyCozmo, the LOOI desk robot over Bluetooth) and server-side (ONVIF pan-tilt-zoom cameras, nothing to install). Each model has a page with how it connects, its setup steps, its guide and the controls its panel gets.',
+ text: 'The robots OpenVibe.Bot drives, grouped by how they connect: simulated (the rover that runs in Bot and in the browser), on-board (the OpenVibe Link on the robot\'s own computer: the Adeept ADR036 4WD car and its mecanum build, the relay robot for any Linux board), bridge (the Link on a computer or phone next to the robot: Cozmo through PyCozmo, the LOOI desk robot over Bluetooth) and server-side (ONVIF pan-tilt-zoom cameras, nothing to install). Each model has a page with how it connects, its setup steps, its guide and the controls its panel gets.',
},
},
{
{
path: '/docs', title: 'Build for OpenVibe.Bot',
path: '/docs', title: 'Build for OpenVibe.Bot',
note: 'The build index: drivers and robot profiles.',
note: 'The build index: drivers and robot profiles.',
- text: 'The build index for OpenVibe.Bot. Two things are open to build against: a driver, a separate program the OpenVibe Node runs and talks to in JSON lines over stdin/stdout, and a robot profile (bot.robot-profile@1), the JSON that says what a robot can do and which widgets its panel shows. The page links the driver guide and the profile guide, and points to the OpenVibe Node plugins tree, the protocol document and the plugin contract.',
+ text: 'The build index for OpenVibe.Bot. Two things are open to build against: a driver, a separate program the OpenVibe Link runs and talks to in JSON lines over stdin/stdout, and a robot profile (bot.robot-profile@1), the JSON that says what a robot can do and which widgets its panel shows. The page links the driver guide and the profile guide, and points to the OpenVibe Link plugins tree, the protocol document and the plugin contract.',
},
},
{
{
path: '/docs/drivers', title: 'Write a driver',
path: '/docs/drivers', title: 'Write a driver',
note: 'The driver protocol: lifecycle, every message and the safety rules.',
note: 'The driver protocol: lifecycle, every message and the safety rules.',
- text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Node core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop, estop, resume; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',
+ text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Link core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop, estop, resume; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',
},
},
{
{
path: '/docs/profiles', title: 'Robot profiles',
path: '/docs/profiles', title: 'Robot profiles',
@@ -128,7 +128,7 @@ const LLMS_PAGES = [
@@ -128,7 +128,7 @@ const LLMS_PAGES = [
{
{
path: '/docs/computers', title: 'Your computer for your agents',
path: '/docs/computers', title: 'Your computer for your agents',
note: 'The Node\'s computer plugin: levels set on the computer, approvals there, the agent switch and the API.',
note: 'The Node\'s computer plugin: levels set on the computer, approvals there, the agent switch and the API.',
- text: 'How to let your agents use your own computer through the OpenVibe Node\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-node approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.bot/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',
+ text: 'How to let your agents use your own computer through the OpenVibe Link\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-node approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.bot/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',
},
},
{
{
path: '/updates', title: 'What shipped on OpenVibe.Bot',
path: '/updates', title: 'What shipped on OpenVibe.Bot',
@@ -474,7 +474,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -474,7 +474,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.redirect(303, '/robots/submissions');
return res.redirect(303, '/robots/submissions');
}));
}));
- // The guided first run's funnel, reviewers only (OpenVibe.Node docs/install.md): the Node reports one
+ // The guided first run's funnel, reviewers only (OpenVibe Link docs/install.md): the Link reports one
// node.first_run telemetry event per step, and Bot stores one row per device and step.
// node.first_run telemetry event per step, and Bot stores one row per device and step.
r.get('/admin/first-run', page(async (req, res) => {
r.get('/admin/first-run', page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
@@ -626,9 +626,9 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -626,9 +626,9 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,
desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,
ai_models: !!d.ai_models,
ai_models: !!d.ai_models,
- // The Node ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).
+ // The Link ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).
ai_offer: (() => { const ai = aiOf(d.capabilities); return ai && ai.offer && typeof ai.offer === 'object' ? ai.offer : null; })(),
ai_offer: (() => { const ai = aiOf(d.capabilities); return ai && ai.offer && typeof ai.offer === 'object' ? ai.offer : null; })(),
- // How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.
+ // How fast its last model answered there (Link v0.29.0's ai plugin), and whether one is answering now.
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
ai_busy: sensors.ai_busy === 1,
ai_busy: sensors.ai_busy === 1,
@@ -642,8 +642,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -642,8 +642,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return mine;
return mine;
};
};
const asUser = (me) => ({ kind: 'user', sub: me.subject });
const asUser = (me) => ({ kind: 'user', sub: me.subject });
- // The machine has a private desk to show (Node v0.15.0 on Linux): its owner may show it, or share it, instead.
- // The private desk is a whole desk: a machine that runs browser desks only (a Mac, Node v0.23.0) has none to show.
+ // The machine has a private desk to show (Link v0.15.0 on Linux): its owner may show it, or share it, instead.
+ // The private desk is a whole desk: a machine that runs browser desks only (a Mac, Link v0.23.0) has none to show.
const hasDesk = (mine) => {
const hasDesk = (mine) => {
const d = hub && (hub.computerOf(mine.id) || {}).desk;
const d = hub && (hub.computerOf(mine.id) || {}).desk;
return !!d && (!Array.isArray(d.kinds) || d.kinds.includes('desktop'));
return !!d && (!Array.isArray(d.kinds) || d.kinds.includes('desktop'));
@@ -713,7 +713,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -713,7 +713,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
let maxDesks = null;
let maxDesks = null;
let error = null;
let error = null;
if (!hub || !hub.isOnline(mine.id)) error = 'The machine is offline: its desks stopped with it, or show here when it is back.';
if (!hub || !hub.isOnline(mine.id)) error = 'The machine is offline: its desks stopped with it, or show here when it is back.';
- else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Node with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';
+ else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Link with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';
else {
else {
const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });
const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });
if (r && r.result === 'ack') {
if (r && r.result === 'ack') {
@@ -856,7 +856,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -856,7 +856,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.redirect(303, '/machines');
res.redirect(303, '/machines');
}));
}));
// The owner offers this desk host's desks to other people's sessions (OpenVibe.Run, contracts 0.223.0), or stops: only
// The owner offers this desk host's desks to other people's sessions (OpenVibe.Run, contracts 0.223.0), or stops: only
- // where its Node sandboxes every desk (Node v0.32.0, install.sh --desk-host --shared).
+ // where its Link sandboxes every desk (Link v0.32.0, install.sh --desk-host --shared).
r.post('/machines/:id/shared-desks', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/machines/:id/shared-desks', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
@@ -865,12 +865,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -865,12 +865,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const on = req.body && req.body.on === '1';
const on = req.body && req.body.on === '1';
const desk = ((hub && hub.computerOf(mine.id)) || {}).desk;
const desk = ((hub && hub.computerOf(mine.id)) || {}).desk;
if (on && !(desk && desk.isolation === 'sandbox')) {
if (on && !(desk && desk.isolation === 'sandbox')) {
- throw new BotError(409, 'bot.computer_off', 'this machine does not sandbox its desks: install it with --desk-host --shared (OpenVibe Node v0.32.0 or newer), then try again');
+ throw new BotError(409, 'bot.computer_off', 'this machine does not sandbox its desks: install it with --desk-host --shared (OpenVibe Link v0.32.0 or newer), then try again');
}
}
await domain.machines.setSharedDesks(mine.id, on);
await domain.machines.setSharedDesks(mine.id, on);
res.redirect(303, '/machines');
res.redirect(303, '/machines');
}));
}));
- // The owner lets Vibe answer their voice on this machine (OpenVibe Node's voice plugin), with a call word, or stops it.
+ // The owner lets Vibe answer their voice on this machine (OpenVibe Link's voice plugin), with a call word, or stops it.
r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// The T15 lane H proving test: one end-to-end Adeept dry-run through Bot, using the frames OpenVibe.Node
+// The T15 lane H proving test: one end-to-end Adeept dry-run through Bot, using the frames OpenVibe Link
// actually sends (internal/protocol, internal/link, testdata/bot). An owner pairs a fake ADR036, drives
// actually sends (internal/protocol, internal/link, testdata/bot). An owner pairs a fake ADR036, drives
// and actuates it over the /control socket, reads battery and latency back, latches and clears the e-stop,
// and actuates it over the /control socket, reads battery and latency back, latches and clears the e-stop,
// and never sees the device's secrets in a read. BOT_WHIP_BASE is set, so pairing must answer a whip_url.
// and never sees the device's secrets in a read. BOT_WHIP_BASE is set, so pairing must answer a whip_url.
@@ -30,7 +30,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -30,7 +30,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
await check('step 1: an owner robot pairs over HTTP with --robot/--name and gets a whip_url', async () => {
await check('step 1: an owner robot pairs over HTTP with --robot/--name and gets a whip_url', async () => {
const created = await t.robot(alex, { profile_id: 'adeept.adr036' });
const created = await t.robot(alex, { profile_id: 'adeept.adr036' });
robot = created.robot;
robot = created.robot;
- // OpenVibe.Node's cmdPair sends Robot, Code, AgentVersion, DeviceKind, Drivers, Capabilities and Name.
+ // OpenVibe Link's cmdPair sends Robot, Code, AgentVersion, DeviceKind, Drivers, Capabilities and Name.
paired = await t.call('POST', '/api/v1/pair', { token: null, body: {
paired = await t.call('POST', '/api/v1/pair', { token: null, body: {
robot: robot.id, code: created.pairing.code, agent_version: '0.1.0', device_kind: 'onboard',
robot: robot.id, code: created.pairing.code, agent_version: '0.1.0', device_kind: 'onboard',
drivers: ['pca9685', 'ads7830'], capabilities: { pca9685: { pan: true, tilt: true } }, name: 'adeept-dry-run',
drivers: ['pca9685', 'ads7830'], capabilities: { pca9685: { pan: true, tilt: true } }, name: 'adeept-dry-run',
@@ -60,7 +60,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -60,7 +60,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
assert.strictEqual(config.estop_latched, false);
assert.strictEqual(config.estop_latched, false);
assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');
assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');
assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);
assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);
- // Only now does the Node report: its link holds every frame until hello.
+ // Only now does the Link report: its link holds every frame until hello.
dev.send({ type: 'status', firmware: 'openvibe-node-0.1.0', capabilities: {}, agent_version: '0.1.0',
dev.send({ type: 'status', firmware: 'openvibe-node-0.1.0', capabilities: {}, agent_version: '0.1.0',
device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });
device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });
dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });
dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });
@@ -102,7 +102,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -102,7 +102,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
});
});
// ── 4. a Node-shaped battery object becomes a numeric fraction on the robot state ────────────────
// ── 4. a Node-shaped battery object becomes a numeric fraction on the robot state ────────────────
- await check("step 4: a Node {volts, percent} battery shows a non-null numeric battery", async () => {
+ await check("step 4: a Link {volts, percent} battery shows a non-null numeric battery", async () => {
dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, voltage: 7.42, sensors: { ultrasonic: 118, line: [0, 1, 1, 0] } });
dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, voltage: 7.42, sensors: { ultrasonic: 118, line: [0, 1, 1, 0] } });
const frame = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery
const frame = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery
&& m.state.telemetry.battery.percent === 59);
&& m.state.telemetry.battery.percent === 59);
@@ -118,7 +118,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -118,7 +118,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
dev.send({ type: 'heartbeat', t: sentAt });
dev.send({ type: 'heartbeat', t: sentAt });
const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack' && m.t === sentAt);
const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack' && m.t === sentAt);
assert.strictEqual(ack.echo, sentAt, 'echo carries the device t');
assert.strictEqual(ack.echo, sentAt, 'echo carries the device t');
- assert.strictEqual(ack.t, sentAt, 't is the field OpenVibe.Node reads for its RTT');
+ assert.strictEqual(ack.t, sentAt, 't is the field OpenVibe Link reads for its RTT');
assert.ok(!Number.isNaN(Date.parse(ack.server_time)), `a server_time: ${ack.server_time}`);
assert.ok(!Number.isNaN(Date.parse(ack.server_time)), `a server_time: ${ack.server_time}`);
// A later beat carrying the measured round trip: Bot keeps it as the connection's latency.
// A later beat carrying the measured round trip: Bot keeps it as the connection's latency.
dev.send({ type: 'heartbeat', t: sentAt + 1000, rtt_ms: 42 });
dev.send({ type: 'heartbeat', t: sentAt + 1000, rtt_ms: 42 });
@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/app');
const start = (body) => t.call('POST', '/api/v1/claims', { token: null, body });
const start = (body) => t.call('POST', '/api/v1/claims', { token: null, body });
const poll = (c, secret = c.secret) => t.call('GET', `/api/v1/claims/${c.id}`, { token: null, headers: { 'X-OV-Claim-Secret': secret } });
const poll = (c, secret = c.secret) => t.call('GET', `/api/v1/claims/${c.id}`, { token: null, headers: { 'X-OV-Claim-Secret': secret } });
const streamOf = (robotId) => [...t.openre.streams.values()].find((s) => s.refs.some((r) => r.type === 'robot' && r.id === robotId));
const streamOf = (robotId) => [...t.openre.streams.values()].find((s) => s.refs.some((r) => r.type === 'robot' && r.id === robotId));
- // What OpenVibe.Node's installer sends after looking at an Adeept ADR036 on a Pi 4.
+ // What OpenVibe Link's installer sends after looking at an Adeept ADR036 on a Pi 4.
const pi = {
const pi = {
hostname: 'raspi',
hostname: 'raspi',
hardware: { model: 'Raspberry Pi 4 Model B Rev 1.4', os: 'Debian GNU/Linux 12 (bookworm)', arch: 'aarch64', kit: 'adeept.adr036',
hardware: { model: 'Raspberry Pi 4 Model B Rev 1.4', os: 'Debian GNU/Linux 12 (bookworm)', arch: 'aarch64', kit: 'adeept.adr036',
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// "Offer to the network" (the design's community machines; plan E4): a Node ai plugin advertises its owner's models,
+// "Offer to the network" (the design's community machines; plan E4): a Link ai plugin advertises its owner's models,
// hours, concurrency, price and audience in status.capabilities.ai.offer; Bot reports it as a user-owned provider offer
// hours, concurrency, price and audience in status.capabilities.ai.offer; Bot reports it as a user-owned provider offer
// whose detail.kind is `ai`, and AI may send a community run to that machine even with "Let my AI use this computer's
// whose detail.kind is `ai`, and AI may send a community run to that machine even with "Let my AI use this computer's
// models" off, because the offer is the opt-in. Without the offer the same run is refused. This is Bot's half: the
// models" off, because the offer is the opt-in. Without the offer the same run is refused. This is Bot's half: the
@@ -13,7 +13,7 @@ const KEY = Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' })).toS
@@ -13,7 +13,7 @@ const KEY = Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' })).toS
const PROFILE = {
const PROFILE = {
id: 'community.ballbot', version: 1, name: 'Ballbot', vendor: 'Mia', kind: 'onboard',
id: 'community.ballbot', version: 1, name: 'Ballbot', vendor: 'Mia', kind: 'onboard',
- description: 'A self-balancing ball robot on an ESP32, driven by a community Node plugin.',
+ description: 'A self-balancing ball robot on an ESP32, driven by a community Link plugin.',
capabilities: ['drive.differential', 'battery'],
capabilities: ['drive.differential', 'battery'],
mapping: { driver: 'plugin', plugin: 'ballbot' },
mapping: { driver: 'plugin', plugin: 'ballbot' },
commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } },
commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } },
@@ -141,7 +141,7 @@ const plugins = require('./helpers/plugins');
@@ -141,7 +141,7 @@ const plugins = require('./helpers/plugins');
});
});
// Over the socket, every Adeept (ordinary and mecanum) and Cozmo control reaches the device in the exact
// Over the socket, every Adeept (ordinary and mecanum) and Cozmo control reaches the device in the exact
- // shape its Node plugin takes (test/helpers/plugins.js transcribes the plugins).
+ // shape its Link plugin takes (test/helpers/plugins.js transcribes the plugins).
const PLUGIN_COMMANDS = {
const PLUGIN_COMMANDS = {
'adeept.adr036': [
'adeept.adr036': [
['drive', { throttle: 0.5, steer: -2 }, { throttle: 0.5, steer: -1 }],
['drive', { throttle: 0.5, steer: -2 }, { throttle: 0.5, steer: -1 }],
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, bot.desk.session; the Desks plan's D6). Only Run, with the
// Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, bot.desk.session; the Desks plan's D6). Only Run, with the
// capability, acting for a person: GET /api/v1/desk-hosts lists that person's own machines (agents allowed) and desk
// capability, acting for a person: GET /api/v1/desk-hosts lists that person's own machines (agents allowed) and desk
-// hosts whose owner offers them to other people and whose Node sandboxes every desk; a lease starts a browser desk,
+// hosts whose owner offers them to other people and whose Link sandboxes every desk; a lease starts a browser desk,
// takes commands for that desk alone and ends with DELETE; the machine's own routes never reach a leased desk; a full
// takes commands for that desk alone and ends with DELETE; the machine's own routes never reach a leased desk; a full
// machine is busy; anyone else gets 404.
// machine is busy; anyone else gets 404.
const assert = require('assert');
const assert = require('assert');
@@ -56,7 +56,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -56,7 +56,7 @@ const { boot, check, done } = require('./helpers/app');
const { dev } = await pairOverWs(robot.id, pairing.code);
const { dev } = await pairOverWs(robot.id, pairing.code);
dev.send({ type: 'heartbeat', t: 1738065600500 });
dev.send({ type: 'heartbeat', t: 1738065600500 });
const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack');
const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack');
- assert.strictEqual(ack.t, 1738065600500, 'OpenVibe.Node reads ack.t for its RTT');
+ assert.strictEqual(ack.t, 1738065600500, 'OpenVibe Link reads ack.t for its RTT');
assert.strictEqual(ack.echo, 1738065600500, 'the existing echo field is unchanged');
assert.strictEqual(ack.echo, 1738065600500, 'the existing echo field is unchanged');
assert.ok(ack.server_time);
assert.ok(ack.server_time);
dev.close();
dev.close();
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// The guided first run, measured (OpenVibe.Node's `openvibe-node`): the Node sends one `node.first_run` telemetry
+// The guided first run, measured (OpenVibe Link's `openvibe-node`): the Link sends one `node.first_run` telemetry
// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores
// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores
// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.
// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.
const assert = require('assert');
const assert = require('assert');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * What each device plugin accepts, transcribed from OpenVibe.Node (2479ef1) so Bot's profiles are tested
+ * What each device plugin accepts, transcribed from OpenVibe Link (2479ef1) so Bot's profiles are tested
* against the device side. A value passes only when the plugin takes it as sent and inside its ranges —
* against the device side. A value passes only when the plugin takes it as sent and inside its ranges —
* never by leaning on the plugin's own clamp. accepts(kind, value) returns null, or why it would be refused.
* never by leaning on the plugin's own clamp. accepts(kind, value) returns null, or why it would be refused.
*
*
@@ -8,12 +8,12 @@
@@ -8,12 +8,12 @@
* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
* SDK = plugins/sdk/openvibe_plugin/__init__.py
* SDK = plugins/sdk/openvibe_plugin/__init__.py
*
*
- * LOOI = OpenVibe.Node docs/hardware-looi.md (a community driver; the contract it implements).
+ * LOOI = OpenVibe Link docs/hardware-looi.md (a community driver; the contract it implements).
* RELAY = the relay plugin (plan T15 R9 step 4), a bridge with no hardware of its own.
* RELAY = the relay plugin (plan T15 R9 step 4), a bridge with no hardware of its own.
* ROS2 = plugins/ros2/openvibe_ros2/__init__.py (Ros2.handle): drive only (say needs say_topic, which no shipped
* ROS2 = plugins/ros2/openvibe_ros2/__init__.py (Ros2.handle): drive only (say needs say_topic, which no shipped
* profile assumes); throttle/steer, or x/y/rotation when the profile's base is holonomic.
* profile assumes); throttle/steer, or x/y/rotation when the profile's base is holonomic.
*
*
- * camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
+ * camera.onvif and sim.rover are `kind: "server"` profiles with no Link plugin; their contract is the
* value table in docs/protocol.md.
* value table in docs/protocol.md.
*/
*/
const num = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
const num = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
@@ -133,12 +133,12 @@ function cozmo({ maxSay = 200 } = {}) {
@@ -133,12 +133,12 @@ function cozmo({ maxSay = 200 } = {}) {
}
}
/**
/**
- * RELAY (relay.generic): the Node plugin that forwards a robot's button and video-click commands to the
+ * RELAY (relay.generic): the Link plugin that forwards a robot's button and video-click commands to the
* owner's local script (plan T15 R9 step 4). It takes every declared button name and the point layer as the
* owner's local script (plan T15 R9 step 4). It takes every declared button name and the point layer as the
* gate builds them (server/domain buildValue); it holds no hardware of its own.
* gate builds them (server/domain buildValue); it holds no hardware of its own.
*/
*/
const relay = {
const relay = {
- name: 'relay (Node plugin)',
+ name: 'relay (Link plugin)',
accepts(kind, v) {
accepts(kind, v) {
if (kind === 'halt') return halt(v);
if (kind === 'halt') return halt(v);
if (!isObj(v)) return `${kind} value must be an object`;
if (!isObj(v)) return `${kind} value must be an object`;
@@ -198,7 +198,7 @@ const server = {
@@ -198,7 +198,7 @@ const server = {
};
};
/**
/**
- * LOOI = OpenVibe.Node docs/hardware-looi.md, the contract the community's Generic LOOI driver implements: drive is
+ * LOOI = OpenVibe Link docs/hardware-looi.md, the contract the community's Generic LOOI driver implements: drive is
* throttle/steer -1..1, the one actuator is head -1..1, and halt.
* throttle/steer -1..1, the one actuator is head -1..1, and halt.
*/
*/
const looi = {
const looi = {
@@ -218,7 +218,7 @@ const looi = {
@@ -218,7 +218,7 @@ const looi = {
},
},
};
};
-/** The contract a profile's commands must meet: its Node plugin, or the server driver. */
+/** The contract a profile's commands must meet: its Link plugin, or the server driver. */
/**
/**
* The phone driver = public/phone-device.js onCommand: say {text}; display {face} (its FACES) or {text}; actuator
* The phone driver = public/phone-device.js onCommand: say {text}; display {face} (its FACES) or {text}; actuator
* torch (true/false) and chime ({hz} or null); halt. Anything else is nacked unsupported.
* torch (true/false) and chime ({hz} or null); halt. Anything else is nacked unsupported.
@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
await check('GET /: no promise the service cannot keep (video waits for OpenRestream; no store, no unsupported boards)', async () => {
await check('GET /: no promise the service cannot keep (video waits for OpenRestream; no store, no unsupported boards)', async () => {
const html = await (await get('/')).text();
const html = await (await get('/')).text();
assert.match(html, /sends its camera over WebRTC to OpenRestream and the panel plays it live/);
assert.match(html, /sends its camera over WebRTC to OpenRestream and the panel plays it live/);
- // ROS 2 left this list with Bot#95 (ros2.diffdrive, ros2.holonomic, the Node's plugins/ros2 driver).
+ // ROS 2 left this list with Bot#95 (ros2.diffdrive, ros2.holonomic, the Link's plugins/ros2 driver).
// Webhooks left this list when they shipped (routines and webhooks, migration 0011, /docs/automation).
// Webhooks left this list when they shipped (routines and webhooks, migration 0011, /docs/automation).
for (const claim of [/ESP32/, /Android/, /arrive paired/i, /\bbuy\b/i]) assert.ok(!claim.test(html), `no ${claim}`);
for (const claim of [/ESP32/, /Android/, /arrive paired/i, /\bbuy\b/i]) assert.ok(!claim.test(html), `no ${claim}`);
});
});
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// GET /install (plan T15 step 1): a 302 to OpenVibe.Node's installer script, chosen by config only and checked at
+// GET /install (plan T15 step 1): a 302 to OpenVibe Link's installer script, chosen by config only and checked at
// boot (https, allow-listed host). The pairing's installer command carries `--driver <kind>` from the profile,
// boot (https, allow-listed host). The pairing's installer command carries `--driver <kind>` from the profile,
// and nothing for a profile whose driver is `none`.
// and nothing for a profile whose driver is `none`.
const assert = require('assert');
const assert = require('assert');
@@ -162,7 +162,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -162,7 +162,7 @@ const { boot, check, done } = require('./helpers/app');
m.c.close();
m.c.close();
});
});
- await check('named desks (contracts 0.215.0, Node v0.19.0): one per agent task, each its own screen; an older Node runs one', async () => {
+ await check('named desks (contracts 0.215.0, Link v0.19.0): one per agent task, each its own screen; an older Link runs one', async () => {
const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });
const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });
const start = await cmd(multi.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });
const start = await cmd(multi.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });
@@ -177,11 +177,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -177,11 +177,11 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(rows.map((r) => [r.kind, r.value]), [
assert.deepStrictEqual(rows.map((r) => [r.kind, r.value]), [
['desk', { action: 'start', desk: 'desk-task-a' }], ['browser', { action: 'open', host: 'example.org', desk: 'desk-task-a' }], ['desk', { action: 'list' }],
['desk', { action: 'start', desk: 'desk-task-a' }], ['browser', { action: 'open', host: 'example.org', desk: 'desk-task-a' }], ['desk', { action: 'list' }],
], 'the audit names the desk');
], 'the audit names the desk');
- // A machine on an older Node runs its private desk only: a named one is refused there, never sent.
+ // A machine on an older Link runs its private desk only: a named one is refused there, never sent.
const sent = seen.length;
const sent = seen.length;
const old = await cmd(single.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });
const old = await cmd(single.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });
assert.deepStrictEqual([old.status, old.json.code], [409, 'bot.computer_off']);
assert.deepStrictEqual([old.status, old.json.code], [409, 'bot.computer_off']);
- assert.match(old.json.detail || old.json.reason || '', /Node v0\.19\.0/);
+ assert.match(old.json.detail || old.json.reason || '', /Link v0\.19\.0/);
assert.strictEqual((await cmd(single.deviceId, { kind: 'screen', value: { desk: 'desk-task-a' } }, { user: alex })).json.code, 'bot.computer_off');
assert.strictEqual((await cmd(single.deviceId, { kind: 'screen', value: { desk: 'desk-task-a' } }, { user: alex })).json.code, 'bot.computer_off');
assert.strictEqual(seen.length, sent, 'nothing reached the older machine');
assert.strictEqual(seen.length, sent, 'nothing reached the older machine');
// Names follow the contract.
// Names follow the contract.
@@ -200,7 +200,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -200,7 +200,7 @@ const { boot, check, done } = require('./helpers/app');
single.c.close();
single.c.close();
});
});
- await check('browser desks (contracts 0.216.0, Node v0.23.0): a desk starts as a kind the machine runs; the audit names it; an older Node is never sent one', async () => {
+ await check('browser desks (contracts 0.216.0, Link v0.23.0): a desk starts as a kind the machine runs; the audit names it; an older Link is never sent one', async () => {
const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 };
const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 };
const both = await machine(alex, { ...COMPUTER, desk: { ...desk, kinds: ['desktop', 'browser'] } });
const both = await machine(alex, { ...COMPUTER, desk: { ...desk, kinds: ['desktop', 'browser'] } });
const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, kinds: ['browser'] } });
const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, kinds: ['browser'] } });
@@ -215,16 +215,16 @@ const { boot, check, done } = require('./helpers/app');
@@ -215,16 +215,16 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual([whole.status, whole.json.code], [409, 'bot.computer_off']);
assert.deepStrictEqual([whole.status, whole.json.code], [409, 'bot.computer_off']);
assert.match(whole.json.detail || whole.json.reason || '', /browser desks only/);
assert.match(whole.json.detail || whole.json.reason || '', /browser desks only/);
const older = await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a', kind: 'browser' } }, { user: alex });
const older = await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a', kind: 'browser' } }, { user: alex });
- assert.deepStrictEqual([older.status, older.json.code], [409, 'bot.computer_off'], 'an older Node would start a whole desk instead');
- assert.match(older.json.detail || older.json.reason || '', /Node v0\.23\.0/);
+ assert.deepStrictEqual([older.status, older.json.code], [409, 'bot.computer_off'], 'an older Link would start a whole desk instead');
+ assert.match(older.json.detail || older.json.reason || '', /Link v0\.23\.0/);
const bad = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-c', kind: 'vm' } }, { user: alex });
const bad = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-c', kind: 'vm' } }, { user: alex });
assert.strictEqual(bad.status, 422);
assert.strictEqual(bad.status, 422);
assert.strictEqual(seen.length, before, 'nothing refused reached a machine');
assert.strictEqual(seen.length, before, 'nothing refused reached a machine');
- // An older Node still starts its whole desks as before (no kind).
+ // An older Link still starts its whole desks as before (no kind).
assert.strictEqual((await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');
assert.strictEqual((await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');
const audit = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3 ORDER BY id', [both.deviceId, 'desk', 'ack']);
const audit = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3 ORDER BY id', [both.deviceId, 'desk', 'ack']);
assert.deepStrictEqual(audit.map((r) => r.value.kind), ['browser', 'desktop'], 'the audit names the kind');
assert.deepStrictEqual(audit.map((r) => r.value.kind), ['browser', 'desktop'], 'the audit names the kind');
- // A kept profile (contracts 0.217.0, Node v0.24.0) reaches the machine and the audit names it.
+ // A kept profile (contracts 0.217.0, Link v0.24.0) reaches the machine and the audit names it.
const kept = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-k', kind: 'browser', profile: 'agent' } }, { user: alex });
const kept = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-k', kind: 'browser', profile: 'agent' } }, { user: alex });
assert.strictEqual(kept.json.result, 'ack', kept.text);
assert.strictEqual(kept.json.result, 'ack', kept.text);
assert.strictEqual(seen.at(-1).value.profile, 'agent');
assert.strictEqual(seen.at(-1).value.profile, 'agent');
@@ -236,7 +236,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -236,7 +236,7 @@ const { boot, check, done } = require('./helpers/app');
old.c.close();
old.c.close();
});
});
- await check('programs on a desk (contracts 0.218.0, Node v0.26.0): sent to a Node that runs them, audited with the desk; an older Node never gets one', async () => {
+ await check('programs on a desk (contracts 0.218.0, Link v0.26.0): sent to a Link that runs them, audited with the desk; an older Link never gets one', async () => {
const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] };
const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] };
const now = await machine(alex, { ...COMPUTER, terminal: { ...COMPUTER.terminal, desks: true }, desk });
const now = await machine(alex, { ...COMPUTER, terminal: { ...COMPUTER.terminal, desks: true }, desk });
const old = await machine(alex, { ...COMPUTER, desk });
const old = await machine(alex, { ...COMPUTER, desk });
@@ -248,14 +248,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -248,14 +248,14 @@ const { boot, check, done } = require('./helpers/app');
const before = seen.length;
const before = seen.length;
const refused = await cmd(old.deviceId, { kind: 'exec', value: { argv: ['xterm'], desk: 'desk-task-a' } }, { user: alex });
const refused = await cmd(old.deviceId, { kind: 'exec', value: { argv: ['xterm'], desk: 'desk-task-a' } }, { user: alex });
assert.deepStrictEqual([refused.status, refused.json.code], [409, 'bot.computer_off']);
assert.deepStrictEqual([refused.status, refused.json.code], [409, 'bot.computer_off']);
- assert.match(refused.json.detail || refused.json.reason || '', /Node v0\.26\.0/);
+ assert.match(refused.json.detail || refused.json.reason || '', /Link v0\.26\.0/);
assert.strictEqual(seen.length, before, 'never sent');
assert.strictEqual(seen.length, before, 'never sent');
assert.strictEqual((await cmd(old.deviceId, { kind: 'exec', value: { argv: ['echo', 'hi'] } }, { user: alex })).json.result, 'ack', 'its own session as before');
assert.strictEqual((await cmd(old.deviceId, { kind: 'exec', value: { argv: ['echo', 'hi'] } }, { user: alex })).json.result, 'ack', 'its own session as before');
now.c.close();
now.c.close();
old.c.close();
old.c.close();
});
});
- await check('a desk host (Node v0.20.0 --desk-host): no screen of its own, yet its desks are seen and driven', async () => {
+ await check('a desk host (Link v0.20.0 --desk-host): no screen of its own, yet its desks are seen and driven', async () => {
// Nobody is signed in to a desk host: it offers no screen or input of its own, only its desks' (desk.screen, desk.input).
// Nobody is signed in to a desk host: it offers no screen or input of its own, only its desks' (desk.screen, desk.input).
const { screen: _s, input: _i, ...headless } = COMPUTER;
const { screen: _s, input: _i, ...headless } = COMPUTER;
const host = await machine(alex, { ...headless, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
const host = await machine(alex, { ...headless, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
@@ -362,7 +362,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -362,7 +362,7 @@ const { boot, check, done } = require('./helpers/app');
const hostCmd = /<code[^>]*>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);
const hostCmd = /<code[^>]*>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);
assert.ok(hostCmd, 'the desk host install command');
assert.ok(hostCmd, 'the desk host install command');
assert.ok(!hostCmd[1].includes('--worker'), 'a desk host never runs the job worker too');
assert.ok(!hostCmd[1].includes('--worker'), 'a desk host never runs the job worker too');
- assert.ok(page.includes('Use the models you run on it'), 'AI on your own machines (Node v0.28.0 --ai)');
+ assert.ok(page.includes('Use the models you run on it'), 'AI on your own machines (Link v0.28.0 --ai)');
const aiCmd = /<code[^>]*>(curl -fsSL [^<]+ --ai)<\/code>/.exec(page);
const aiCmd = /<code[^>]*>(curl -fsSL [^<]+ --ai)<\/code>/.exec(page);
assert.ok(aiCmd && !aiCmd[1].includes('--worker'), 'the models install command');
assert.ok(aiCmd && !aiCmd[1].includes('--worker'), 'the models install command');
host.c.close();
host.c.close();
@@ -446,7 +446,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -446,7 +446,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!/secret (query|result)|web_search/.test(JSON.stringify(toolRow)), 'no tool names, arguments or results in the audit');
assert.ok(!/secret (query|result)|web_search/.test(JSON.stringify(toolRow)), 'no tool names, arguments or results in the audit');
const orphan = await cmd(gpu.deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'tool', content: 'x' }] } }, asAi(alex.subject));
const orphan = await cmd(gpu.deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'tool', content: 'x' }] } }, asAi(alex.subject));
assert.strictEqual(orphan.status, 422, 'a tool message needs the call it answers');
assert.strictEqual(orphan.status, 422, 'a tool message needs the call it answers');
- // A real Node reports the ai plugin under its own name, and a machine may run it with no computer plugin.
+ // A real Link reports the ai plugin under its own name, and a machine may run it with no computer plugin.
const own = await machine(alex, null);
const own = await machine(alex, null);
own.c.send(frame('status', { capabilities: { ai: { ai: { models: ['qwen2.5-1.5b-instruct'], ready: true, max_tokens: 256 } } }, drivers: [], faults: [] }));
own.c.send(frame('status', { capabilities: { ai: { ai: { models: ['qwen2.5-1.5b-instruct'], ready: true, max_tokens: 256 } } }, drivers: [], faults: [] }));
await t.wait(300);
await t.wait(300);
@@ -456,7 +456,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -456,7 +456,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await cmd(own.deviceId, { kind: 'ai', value: { model: 'qwen2.5-1.5b-instruct', messages: [{ role: 'user', content: 'hi' }] } }, { user: alex })).json.result, 'ack');
assert.strictEqual((await cmd(own.deviceId, { kind: 'ai', value: { model: 'qwen2.5-1.5b-instruct', messages: [{ role: 'user', content: 'hi' }] } }, { user: alex })).json.result, 'ack');
const noExec = await cmd(own.deviceId, { kind: 'exec', value: { argv: ['id'] } }, { user: alex });
const noExec = await cmd(own.deviceId, { kind: 'exec', value: { argv: ['id'] } }, { user: alex });
assert.strictEqual(noExec.json.code, 'bot.computer_off', 'no terminal without the computer plugin');
assert.strictEqual(noExec.json.code, 'bot.computer_off', 'no terminal without the computer plugin');
- // Node v0.29.0's ai plugin says how fast its last model answered there (after the 2 Hz telemetry window).
+ // Link v0.29.0's ai plugin says how fast its last model answered there (after the 2 Hz telemetry window).
await t.wait(600);
await t.wait(600);
own.c.send(frame('telemetry', { sensors: { ai_busy: 0, ai_model: 'qwen2.5-1.5b-instruct', ai_tokens_per_s: 14.6 } }));
own.c.send(frame('telemetry', { sensors: { ai_busy: 0, ai_model: 'qwen2.5-1.5b-instruct', ai_tokens_per_s: 14.6 } }));
await t.wait(600);
await t.wait(600);
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
'use strict';
'use strict';
-// The fleet (Codes C9c): a machine's OpenVibe.Node reports what its coding agents have left with each heartbeat
+// The fleet (Codes C9c): a machine's OpenVibe Link reports what its coding agents have left with each heartbeat
// (bot.device-message@1's `fleet`), and Bot keeps the latest reading per machine for Codes' Agents page, its router and
// (bot.device-message@1's `fleet`), and Bot keeps the latest reading per machine for Codes' Agents page, its router and
// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, nod_…), which is the same machine
// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, nod_…), which is the same machine
// Run pins a job to by its device id (dev_…): one row, both names.
// Run pins a job to by its device id (dev_…): one row, both names.
//
//
-// What this checks: a reading is stored and answered; it is bounded exactly as the Node bounds it (16 agents, 10 windows,
+// What this checks: a reading is stored and answered; it is bounded exactly as the Link bounds it (16 agents, 10 windows,
// an id shaped like an id, short labels, numbers only), and a frame that is not a reading changes nothing; the owner
// an id shaped like an id, short labels, numbers only), and a frame that is not a reading changes nothing; the owner
// sees their own reading and nobody else does; a machine that has reported nothing answers null; the reading is stored
// sees their own reading and nobody else does; a machine that has reported nothing answers null; the reading is stored
// again when it changes or a minute has passed, never on every beat.
// again when it changes or a minute has passed, never on every beat.
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };
const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };
const seen = [];
const seen = [];
- /** A machine of `owner` whose fake Node answers screen_stream like Node v0.12.0. */
+ /** A machine of `owner` whose fake Node answers screen_stream like Link v0.12.0. */
async function machine(owner, computer = SCREEN) {
async function machine(owner, computer = SCREEN) {
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
@@ -159,7 +159,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -159,7 +159,7 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });
assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });
});
});
- await check('a desk on the live screen: private, or a named desk on a Node that runs several (contracts 0.215.0); never a bad name', async () => {
+ await check('a desk on the live screen: private, or a named desk on a Link that runs several (contracts 0.215.0); never a bad name', async () => {
const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true };
const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true };
const multi = await machine(alex, { ...SCREEN, desk: { ...desk, named: true, max_desks: 4 } });
const multi = await machine(alex, { ...SCREEN, desk: { ...desk, named: true, max_desks: 4 } });
const r = await screen('POST', multi.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-task-a' } });
const r = await screen('POST', multi.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-task-a' } });
@@ -174,7 +174,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -174,7 +174,7 @@ const { boot, check, done } = require('./helpers/app');
single.c.close();
single.c.close();
});
});
- await check('a desk host (Node v0.20.0 --desk-host) shows its desks live though it has no screen of its own', async () => {
+ await check('a desk host (Link v0.20.0 --desk-host) shows its desks live though it has no screen of its own', async () => {
const host = await machine(alex, { files: SCREEN.files, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
const host = await machine(alex, { files: SCREEN.files, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
// Its page offers its desks, never a screen it does not have.
// Its page offers its desks, never a screen it does not have.
const idle = await (await fetch(`${t.base}/machines/${host.deviceId}/screen?desk=desk-task-a`, { headers: cookie(alex) })).text();
const idle = await (await fetch(`${t.base}/machines/${host.deviceId}/screen?desk=desk-task-a`, { headers: cookie(alex) })).text();
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// The machines hub (plan L34, step M2): Bot stays the registry of the machines. A person sees their own
// The machines hub (plan L34, step M2): Bot stays the registry of the machines. A person sees their own
// machines and nobody else's; the network's hub (a service holding bot.machine.manage) acts for a person it
// machines and nobody else's; the network's hub (a service holding bot.machine.manage) acts for a person it
// names in X-OV-Subject; a role outside the catalogue is refused; a pairing code is one code for the person;
// names in X-OV-Subject; a role outside the catalogue is refused; a pairing code is one code for the person;
-// the roles reach the Node in its config frame; and a machine's online state is reported to Network once per
+// the roles reach the Link in its config frame; and a machine's online state is reported to Network once per
// change.
// change.
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -120,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -120,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(own.status, 201, own.text);
assert.strictEqual(own.status, 201, own.text);
});
});
- await check('the roles reach the Node in its config frame', async () => {
+ await check('the roles reach the Link in its config frame', async () => {
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(alexMachine.principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(alexMachine.principal)}` } });
const config = await c.waitFor((m) => m.type === 'config');
const config = await c.waitFor((m) => m.type === 'config');
assert.ok(config, 'the machine gets its config');
assert.ok(config, 'the machine gets its config');
@@ -115,13 +115,13 @@ const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;
@@ -115,13 +115,13 @@ const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;
assert.strictEqual(after.kind, 'bridge');
assert.strictEqual(after.kind, 'bridge');
assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);
assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);
assert.strictEqual(after.agent_version, '0.3.1');
assert.strictEqual(after.agent_version, '0.3.1');
- // The Node's own status frame lists drivers as objects (OpenVibe.Node internal/protocol Status): the names stay.
+ // The Link's own status frame lists drivers as objects (OpenVibe Link internal/protocol Status): the names stay.
const errors = c.messages.filter((m) => m.type === 'error').length;
const errors = c.messages.filter((m) => m.type === 'error').length;
c.send({ v: 1, seq: 3, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: [{ name: 'adeept', driver: 'adeept_adr036', state: 'up' }, { name: 'dryrun', state: 'down' }], faults: [] });
c.send({ v: 1, seq: 3, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: [{ name: 'adeept', driver: 'adeept_adr036', state: 'up' }, { name: 'dryrun', state: 'down' }], faults: [] });
let named;
let named;
for (let i = 0; i < 40; i++) { named = await deviceRow(hello.device_id); if (named.drivers.length && named.drivers[0] === 'adeept_adr036') break; await t.wait(25); }
for (let i = 0; i < 40; i++) { named = await deviceRow(hello.device_id); if (named.drivers.length && named.drivers[0] === 'adeept_adr036') break; await t.wait(25); }
assert.deepStrictEqual(named.drivers, ['adeept_adr036', 'dryrun']);
assert.deepStrictEqual(named.drivers, ['adeept_adr036', 'dryrun']);
- assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Node\'s own status');
+ assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Link\'s own status');
c.close();
c.close();
});
});
@@ -142,7 +142,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -142,7 +142,7 @@ const { boot, check, done } = require('./helpers/app');
await new Promise((r) => setTimeout(r, 100));
await new Promise((r) => setTimeout(r, 100));
state = (await get('?kind=bot.machine')).json.resources[0].state;
state = (await get('?kind=bot.machine')).json.resources[0].state;
}
}
- assert.strictEqual(state, 'offline', 'offline once its Node goes');
+ assert.strictEqual(state, 'offline', 'offline once its Link goes');
await t.db.query('UPDATE devices SET revoked_at = now() WHERE id = $1', [deviceId]);
await t.db.query('UPDATE devices SET revoked_at = now() WHERE id = $1', [deviceId]);
assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources, [], 'a removed machine leaves the index');
assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources, [], 'a removed machine leaves the index');
});
});
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// Jobs on the device link (plan T14 L1, platform.job-frame@1): server/jobs/dispatch.js hands a platform.job@1 job
// Jobs on the device link (plan T14 L1, platform.job-frame@1): server/jobs/dispatch.js hands a platform.job@1 job
-// to a paired Node, only for a class it advertises, resends it until acked, and meters it into run_usage_outbox
+// to a paired Link, only for a class it advertises, resends it until acked, and meters it into run_usage_outbox
// one reading per wall-clock second (run:<job id>:<n>), backfilled and deduplicated from job_exit; the relay
// one reading per wall-clock second (run:<job id>:<n>), backfilled and deduplicated from job_exit; the relay
// delivers each reading to Billing once, and keeps it queued while Billing is unset or down.
// delivers each reading to Billing once, and keeps it queued while Billing is unset or down.
const assert = require('assert');
const assert = require('assert');
@@ -28,7 +28,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -28,7 +28,7 @@ const PROJECT = `prj_${ids.ulid()}`;
const send = (deviceId, job, opts = {}) => dispatch(t.db, deviceId, job, { link: t.hub, project: PROJECT, subject: SUBJECT, ...opts });
const send = (deviceId, job, opts = {}) => dispatch(t.db, deviceId, job, { link: t.hub, project: PROJECT, subject: SUBJECT, ...opts });
const sawJob = (c, id) => c.messages.some((m) => m.type === 'job' && m.job.id === id);
const sawJob = (c, id) => c.messages.some((m) => m.type === 'job' && m.job.id === id);
- /** A Network-paired Node advertising `classes`, connected; its capabilities are persisted before it returns. */
+ /** A Network-paired Link advertising `classes`, connected; its capabilities are persisted before it returns. */
async function node(classes = ['function']) {
async function node(classes = ['function']) {
const { robot } = await t.robot(alex);
const { robot } = await t.robot(alex);
const principal = t.network.addNode({ owner: alex.subject, ref: robot.id });
const principal = t.network.addNode({ owner: alex.subject, ref: robot.id });
@@ -62,7 +62,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -62,7 +62,7 @@ const PROJECT = `prj_${ids.ulid()}`;
return n;
return n;
}
}
- await check('a job reaches its connected Node as a valid job frame and the ack places it', async () => {
+ await check('a job reaches its connected Link as a valid job frame and the ack places it', async () => {
const n = await node();
const n = await node();
const job = newJob();
const job = newJob();
const r = await send(n.deviceId, job);
const r = await send(n.deviceId, job);
@@ -80,12 +80,12 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -80,12 +80,12 @@ const PROJECT = `prj_${ids.ulid()}`;
await assert.rejects(send(n.deviceId, newJob(), { project: 'not-a-project' }), (e) => e.code === 'bot.invalid_job');
await assert.rejects(send(n.deviceId, newJob(), { project: 'not-a-project' }), (e) => e.code === 'bot.invalid_job');
});
});
- await check('a class the Node does not advertise is never sent, at dispatch or on a reconnect', async () => {
+ await check('a class the Link does not advertise is never sent, at dispatch or on a reconnect', async () => {
const n = await node(['function']);
const n = await node(['function']);
const code = newJob({ class: 'code' });
const code = newJob({ class: 'code' });
await assert.rejects(send(n.deviceId, code), (e) => e.code === 'bot.class_unadvertised');
await assert.rejects(send(n.deviceId, code), (e) => e.code === 'bot.class_unadvertised');
assert.strictEqual(await row(code.id), null);
assert.strictEqual(await row(code.id), null);
- // Stored while the Node is away; it comes back advertising another class only.
+ // Stored while the Link is away; it comes back advertising another class only.
n.c.close(); await n.c.waitForClose();
n.c.close(); await n.c.waitForClose();
const fn = newJob();
const fn = newJob();
assert.strictEqual((await send(n.deviceId, fn)).sent, false);
assert.strictEqual((await send(n.deviceId, fn)).sent, false);
@@ -93,7 +93,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -93,7 +93,7 @@ const PROJECT = `prj_${ids.ulid()}`;
await n.connect();
await n.connect();
await poll(async () => (await row(fn.id)).state === 'failed');
await poll(async () => (await row(fn.id)).state === 'failed');
assert.strictEqual((await row(fn.id)).fault_code, 'bot.class_unadvertised');
assert.strictEqual((await row(fn.id)).fault_code, 'bot.class_unadvertised');
- assert.ok(!sawJob(n.c, code.id) && !sawJob(n.c, fn.id), 'no job frame for a class the Node lacks');
+ assert.ok(!sawJob(n.c, code.id) && !sawJob(n.c, fn.id), 'no job frame for a class the Link lacks');
});
});
await check('an unacked job is resent on every reconnect until acked, then never again', async () => {
await check('an unacked job is resent on every reconnect until acked, then never again', async () => {
@@ -189,7 +189,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -189,7 +189,7 @@ const PROJECT = `prj_${ids.ulid()}`;
assert.strictEqual(j.state, 'cancelled');
assert.strictEqual(j.state, 'cancelled');
assert.strictEqual(j.started_ms, null);
assert.strictEqual(j.started_ms, null);
assert.deepStrictEqual(await readings(job.id), []);
assert.deepStrictEqual(await readings(job.id), []);
- // Cancelled while the Node is away and never sent: it is not sent on the reconnect either.
+ // Cancelled while the Link is away and never sent: it is not sent on the reconnect either.
n.c.close(); await n.c.waitForClose();
n.c.close(); await n.c.waitForClose();
const away = newJob();
const away = newJob();
await send(n.deviceId, away);
await send(n.deviceId, away);
@@ -213,7 +213,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -213,7 +213,7 @@ const PROJECT = `prj_${ids.ulid()}`;
assert.deepStrictEqual((await readings(job.id)).map((r) => r.idempotency_key), [0, 1, 2, 3, 4].map((k) => `run:${job.id}:${k}`));
assert.deepStrictEqual((await readings(job.id)).map((r) => r.idempotency_key), [0, 1, 2, 3, 4].map((k) => `run:${job.id}:${k}`));
});
});
- await check('a Node never meters another device\'s job, nor seconds past limits.wall_ms', async () => {
+ await check('a Link never meters another device\'s job, nor seconds past limits.wall_ms', async () => {
const a = await node();
const a = await node();
const b = await node();
const b = await node();
const S = 1790935600000;
const S = 1790935600000;
@@ -279,7 +279,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -279,7 +279,7 @@ const PROJECT = `prj_${ids.ulid()}`;
refused(await call('POST', '/api/v1/jobs', { token: null, body }), 401, 'bot.sign_in');
refused(await call('POST', '/api/v1/jobs', { token: null, body }), 401, 'bot.sign_in');
// project_id is required: Run is the payer.
// project_id is required: Run is the payer.
refused(await call('POST', '/api/v1/jobs', { body: { ...body, project_id: undefined } }), 422, 'bot.invalid_input');
refused(await call('POST', '/api/v1/jobs', { body: { ...body, project_id: undefined } }), 422, 'bot.invalid_input');
- // The dispatch reaches the Node once; the same job again is idempotent.
+ // The dispatch reaches the Link once; the same job again is idempotent.
const r = await call('POST', '/api/v1/jobs', { body });
const r = await call('POST', '/api/v1/jobs', { body });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.sent, true);
assert.strictEqual(r.json.sent, true);
@@ -307,7 +307,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -307,7 +307,7 @@ const PROJECT = `prj_${ids.ulid()}`;
assert.deepStrictEqual(t.app.locals.jobs.stdout(job.id), got.json.stdout);
assert.deepStrictEqual(t.app.locals.jobs.stdout(job.id), got.json.stdout);
refused(await call('GET', '/api/v1/jobs/job_00000000000000000000000000'), 404, 'bot.job_not_found');
refused(await call('GET', '/api/v1/jobs/job_00000000000000000000000000'), 404, 'bot.job_not_found');
refused(await call('POST', '/api/v1/jobs/job_00000000000000000000000000/cancel'), 404, 'bot.job_not_found');
refused(await call('POST', '/api/v1/jobs/job_00000000000000000000000000/cancel'), 404, 'bot.job_not_found');
- // Cancel asks the Node to stop it.
+ // Cancel asks the Link to stop it.
const c = await call('POST', `/api/v1/jobs/${job.id}/cancel`);
const c = await call('POST', `/api/v1/jobs/${job.id}/cancel`);
assert.strictEqual(c.status, 200, c.text);
assert.strictEqual(c.status, 200, c.text);
assert.strictEqual(c.json.job.state, 'cancelled');
assert.strictEqual(c.json.job.state, 'cancelled');
@@ -101,7 +101,7 @@ const { loadProfiles } = require('../server/profiles');
@@ -101,7 +101,7 @@ const { loadProfiles } = require('../server/profiles');
const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sim);
const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sim);
const tel = s.state.telemetry;
const tel = s.state.telemetry;
assert.deepStrictEqual(Object.keys(tel.sim).sort(), ['bumped', 'drive', 'headlights', 'horn', 'pose']);
assert.deepStrictEqual(Object.keys(tel.sim).sort(), ['bumped', 'drive', 'headlights', 'horn', 'pose']);
- assert.deepStrictEqual(Object.keys(tel.sensors).sort(), ['distance_cm', 'line'], 'the range finder and line readouts under the keys the Node reports');
+ assert.deepStrictEqual(Object.keys(tel.sensors).sort(), ['distance_cm', 'line'], 'the range finder and line readouts under the keys the Link reports');
assert.ok(!('drive' in tel) && !('pose' in tel), 'nothing else of the telemetry');
assert.ok(!('drive' in tel) && !('pose' in tel), 'nothing else of the telemetry');
w.close();
w.close();
t.app.locals.sim.stop(robot.id);
t.app.locals.sim.stop(robot.id);