openvibers/openvibe.sdk Commits

0.66.0: openvibe-sdk/bot machines and desks (a desk of your own on a machine: start, browser, screen, input, exec, turn, stop) (#90)

OP OpenVibers committed 17 days ago · 0266036afd · 10 files, +209 −11

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2178d4d..3e5fcc7 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,19 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.66.0 (2026-10-27)
++
++- **`openvibe-sdk/bot`: machines and desks** (the Desks plan; the roadmap's SDK section). `bot.machines.list()`,
++ `.computer(id)`, `.command(id, { kind, value })` (bot.command@1's computer kinds) and `.deskTurn(id, desk)`.
++- **`bot.machines.desk(id, { name?, kind?, profile? })`** is a desk of your own on a machine: a browser alone or a whole
++ screen, with its own files and sign-ins (OpenVibe.Node v0.19.0 to v0.26.0).
++ - `start`, `stop` and `status`.
++ - `browser`, `screen`, `input` and `exec` on that desk only, never on the person's own screen. `exec` takes
++ `detach` for an app that keeps running until the desk stops.
++ - `turn()` says whether a person is using the desk now.
++ - Each method answers the machine's `data`. A command the machine does not do throws an `OpenVibeError` with code
++ `bot.desk_nack`.
++
## 0.65.0 (2026-10-26)
- **`openvibe-sdk/sso`: switch account and use another account** (the owner, 2026-10-26: sign-in "like the Google
ddiff --git a/README.md b/README.md
iindex b1a3213..072328d 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.65.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.66.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.66.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
ddiff --git a/STATUS.json b/STATUS.json
iindex c163f43..5941919 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.65.0",
++ "version": "0.66.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex fb73a12..88e30b0 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.65.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.66.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -43,7 +43,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.65.0';
++const SDK_VERSION = '0.66.0';
module.exports = {
createClient,
@@@ -1774,17 +1774,25 @@ function (module, exports) {
* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
* Every other POST carries an Idempotency-Key (the client generates one when retries are on).
*
++ * Machines (the OpenVibe Node's computer plugin): `machines.list`, `machines.computer`, `machines.command` (bot.command@1's
++ * computer kinds) and `machines.deskTurn`; `machines.desk(id, { name?, kind?, profile? })` is a desk of your own on a
++ * machine (the Desks plan: its own screen or browser, files and sign-ins), with start/stop/status and browser, screen,
++ * input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`
++ * (the machine's own fault code is `fault`), so a desk reads as plain awaits.
++ *
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
* `devices.rotate` / `devices.revoke`.
*/
--const { isOpenVibeError } = __load(3);
++const { isOpenVibeError, OpenVibeError } = __load(3);
const { isActingSubjectId } = __load(5);
const { paginate } = __load(7);
const enc = encodeURIComponent;
++const DESK_NAME = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
++const randomDesk = () => `desk-${Math.random().toString(36).slice(2, 12).padEnd(10, '0')}`;
/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
function actingHeaders({ actingSubject } = {}) {
@@@ -1862,8 +1870,64 @@ function createBotClient(client, defaults = {}) {
},
};
++ // ── Machines (the OpenVibe Node's computer plugin) and desks of your own on them ──
++ const machines = {
++ /** { machines } — the person's machines with their computer capabilities (a service sees the ones opened to it). */
++ list: (o = {}) => call({ path: '/api/v1/machines' }, o),
++ /** { machine } — one machine's computer capabilities, or null. */
++ computer: (id, o = {}) => orNull(call({ path: `/api/v1/machines/${enc(id)}/computer` }, o)),
++ /**
++ * { id?, kind, value, ms? } -> { machine_id, result, data?, reason? } (bot.command@1's computer kinds: exec, fs,
++ * screen, input, browser, probe, desk). A refusal by Bot is an OpenVibeError (bot.computer_off, bot.person_driving …).
++ */
++ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),
++ /** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */
++ deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),
++ /**
++ * A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.
++ * const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });
++ * await desk.start();
++ * const page = await desk.browser({ action: 'open', url: 'https://example.org/' });
++ * await desk.stop();
++ * Each method answers the machine's `data`; a nack throws (code bot.desk_nack, `fault` the machine's own).
++ */
++ desk(id, { name = randomDesk(), kind, profile, width, height } = {}, o = {}) {
++ if (!DESK_NAME.test(name)) throw new TypeError('name is desk-<name>: lower-case letters, digits and dashes, at most 24 after desk-');
++ const ack = (r) => {
++ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;
++ throw new OpenVibeError({ code: 'bot.desk_nack', status: 409, message: `the machine did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });
++ };
++ const on = (cmdKind, value, ms) => machines.command(id, { kind: cmdKind, value: { ...value, desk: name }, ...(ms ? { ms } : {}) }, o).then(ack);
++ const start = {
++ action: 'start', desk: name,
++ ...(kind ? { kind } : {}), ...(profile ? { profile } : {}),
++ ...(Number.isInteger(width) && Number.isInteger(height) ? { width, height } : {}),
++ };
++ return {
++ name,
++ machine: id,
++ /** Start it (a no-op when it runs) -> { running, kind?, width?, height?, profile? }. */
++ start: (ms) => machines.command(id, { kind: 'desk', value: start, ...(ms ? { ms } : {}) }, o).then(ack),
++ /** Stop it and everything on it -> { running: false, stopped, profile_saved? }. */
++ stop: () => machines.command(id, { kind: 'desk', value: { action: 'stop', desk: name } }, o).then(ack),
++ status: () => machines.command(id, { kind: 'desk', value: { action: 'status', desk: name } }, o).then(ack),
++ /** The agent's browser there: { action: open|read|click|fill|press|back|screenshot|close, … }. */
++ browser: (value, ms) => on('browser', value, ms),
++ /** A picture of the desk -> { format, width, height, content_b64 }. */
++ screen: (value = {}, ms) => on('screen', value, ms),
++ /** The desk's mouse and keyboard: { action: click|double_click|right_click|move|scroll|type|key, … }. */
++ input: (value, ms) => on('input', value, ms),
++ /** A program in the desk's session; { detach: true } leaves it running until the desk stops (a whole desk). */
++ exec: (value, ms) => on('exec', value, ms),
++ /** Whether a person is using it now. */
++ turn: () => machines.deskTurn(id, name, o),
++ };
++ },
++ };
++
return {
robots,
++ machines,
devices: {
/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
ddiff --git a/package-lock.json b/package-lock.json
iindex cdd5a85..1b583d7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.65.0",
++ "version": "0.66.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.65.0",
++ "version": "0.66.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 6868448..1adcfce 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.65.0",
++ "version": "0.66.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/bot.js b/src/bot.js
iindex bc3ab3d..c0c4c7e 100644
---- a/src/bot.js
++++ b/src/bot.js
@@@ -15,17 +15,25 @@
* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
* Every other POST carries an Idempotency-Key (the client generates one when retries are on).
*
++ * Machines (the OpenVibe Node's computer plugin): `machines.list`, `machines.computer`, `machines.command` (bot.command@1's
++ * computer kinds) and `machines.deskTurn`; `machines.desk(id, { name?, kind?, profile? })` is a desk of your own on a
++ * machine (the Desks plan: its own screen or browser, files and sign-ins), with start/stop/status and browser, screen,
++ * input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`
++ * (the machine's own fault code is `fault`), so a desk reads as plain awaits.
++ *
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
* `devices.rotate` / `devices.revoke`.
*/
--const { isOpenVibeError } = require('./core/errors');
++const { isOpenVibeError, OpenVibeError } = require('./core/errors');
const { isActingSubjectId } = require('./core/ids');
const { paginate } = require('./core/paginate');
const enc = encodeURIComponent;
++const DESK_NAME = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
++const randomDesk = () => `desk-${Math.random().toString(36).slice(2, 12).padEnd(10, '0')}`;
/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
function actingHeaders({ actingSubject } = {}) {
@@@ -103,8 +111,64 @@ function createBotClient(client, defaults = {}) {
},
};
++ // ── Machines (the OpenVibe Node's computer plugin) and desks of your own on them ──
++ const machines = {
++ /** { machines } — the person's machines with their computer capabilities (a service sees the ones opened to it). */
++ list: (o = {}) => call({ path: '/api/v1/machines' }, o),
++ /** { machine } — one machine's computer capabilities, or null. */
++ computer: (id, o = {}) => orNull(call({ path: `/api/v1/machines/${enc(id)}/computer` }, o)),
++ /**
++ * { id?, kind, value, ms? } -> { machine_id, result, data?, reason? } (bot.command@1's computer kinds: exec, fs,
++ * screen, input, browser, probe, desk). A refusal by Bot is an OpenVibeError (bot.computer_off, bot.person_driving …).
++ */
++ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),
++ /** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */
++ deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),
++ /**
++ * A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.
++ * const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });
++ * await desk.start();
++ * const page = await desk.browser({ action: 'open', url: 'https://example.org/' });
++ * await desk.stop();
++ * Each method answers the machine's `data`; a nack throws (code bot.desk_nack, `fault` the machine's own).
++ */
++ desk(id, { name = randomDesk(), kind, profile, width, height } = {}, o = {}) {
++ if (!DESK_NAME.test(name)) throw new TypeError('name is desk-<name>: lower-case letters, digits and dashes, at most 24 after desk-');
++ const ack = (r) => {
++ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;
++ throw new OpenVibeError({ code: 'bot.desk_nack', status: 409, message: `the machine did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });
++ };
++ const on = (cmdKind, value, ms) => machines.command(id, { kind: cmdKind, value: { ...value, desk: name }, ...(ms ? { ms } : {}) }, o).then(ack);
++ const start = {
++ action: 'start', desk: name,
++ ...(kind ? { kind } : {}), ...(profile ? { profile } : {}),
++ ...(Number.isInteger(width) && Number.isInteger(height) ? { width, height } : {}),
++ };
++ return {
++ name,
++ machine: id,
++ /** Start it (a no-op when it runs) -> { running, kind?, width?, height?, profile? }. */
++ start: (ms) => machines.command(id, { kind: 'desk', value: start, ...(ms ? { ms } : {}) }, o).then(ack),
++ /** Stop it and everything on it -> { running: false, stopped, profile_saved? }. */
++ stop: () => machines.command(id, { kind: 'desk', value: { action: 'stop', desk: name } }, o).then(ack),
++ status: () => machines.command(id, { kind: 'desk', value: { action: 'status', desk: name } }, o).then(ack),
++ /** The agent's browser there: { action: open|read|click|fill|press|back|screenshot|close, … }. */
++ browser: (value, ms) => on('browser', value, ms),
++ /** A picture of the desk -> { format, width, height, content_b64 }. */
++ screen: (value = {}, ms) => on('screen', value, ms),
++ /** The desk's mouse and keyboard: { action: click|double_click|right_click|move|scroll|type|key, … }. */
++ input: (value, ms) => on('input', value, ms),
++ /** A program in the desk's session; { detach: true } leaves it running until the desk stops (a whole desk). */
++ exec: (value, ms) => on('exec', value, ms),
++ /** Whether a person is using it now. */
++ turn: () => machines.deskTurn(id, name, o),
++ };
++ },
++ };
++
return {
robots,
++ machines,
devices: {
/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 371ccdd..155e247 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.65.0';
++const SDK_VERSION = '0.66.0';
module.exports = {
createClient,
ddiff --git a/test/bot.test.js b/test/bot.test.js
iindex 300f239..6cfc99a 100644
---- a/test/bot.test.js
++++ b/test/bot.test.js
@@@ -20,6 +20,8 @@ let contracts = null;
try { contracts = require('openvibe-contracts'); } catch { /* the fixture case skips */ }
const loadFixture = (dir, file) => JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', file), 'utf8'));
++const seenCommands = [];
++
/** A stub Bot: fixtures for the contract-shaped answers, plus the routes the SDK turns into calls. */
function server() {
return stubServer((req, res, body) => {
@@@ -63,6 +65,17 @@ function server() {
if (m === 'GET' && p === '/api/v1/profiles/adeept.adr036') return send(res, 200, { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });
if (m === 'GET' && p === '/api/v1/kits') return send(res, 200, { kits: [{ id: 'starter' }] });
if (m === 'GET' && p === '/api/v1/kits/starter') return send(res, 200, { kit: { id: 'starter' } });
++ if (m === 'GET' && p === '/api/v1/machines') return send(res, 200, { machines: [{ id: DEV, name: 'desk-host', online: true, computer: { desk: { named: true, kinds: ['browser'] } } }] });
++ if (m === 'GET' && p === `/api/v1/machines/${DEV}/computer`) return send(res, 200, { machine: { id: DEV } });
++ if (m === 'GET' && p === `/api/v1/machines/${DEV}/desk-turn`) return send(res, 200, { desk: u.searchParams.get('desk'), person_driving: false, last_person_at: null });
++ if (m === 'POST' && p === `/api/v1/machines/${DEV}/commands`) {
++ const input = JSON.parse(body.toString() || '{}');
++ seenCommands.push(input);
++ if (input.kind === 'browser' && input.value.action === 'click') return send(res, 200, { machine_id: DEV, result: 'nack', reason: 'nothing on the page matches #missing' });
++ if (input.kind === 'desk') return send(res, 200, { machine_id: DEV, result: 'ack', data: { running: input.value.action === 'start', desk: input.value.desk, kind: input.value.kind || 'desktop' } });
++ return send(res, 200, { machine_id: DEV, result: 'ack', data: { url: 'https://example.org/', title: 'Example' } });
++ }
++ if (p.startsWith('/api/v1/machines/')) return problem(res, 404, 'bot.device_not_found', 'no such machine of yours');
if (p.startsWith('/api/v1/profiles/')) return problem(res, 404, 'bot.profile_not_found', 'no such profile');
if (p.startsWith('/api/v1/kits/')) return problem(res, 404, 'bot.kit_not_found', 'no such kit');
if (p.startsWith('/api/v1/robots/')) return problem(res, 404, 'bot.robot_not_found', 'no such robot');
@@@ -73,6 +86,27 @@ function server() {
const hdr = (r, k) => r.headers[k.toLowerCase()];
run([
++ ['machines and a desk of your own: start, the browser there, a nack thrown, the person\'s turn, stop', async () => {
++ const srv = await server();
++ try {
++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });
++ assert.strictEqual((await bot.machines.list()).machines[0].id, DEV);
++ assert.deepStrictEqual(await bot.machines.computer(DEV), { machine: { id: DEV } });
++ assert.strictEqual(await bot.machines.computer('dev_01J8Z4N0A1B2C3D4E5F6G7H8J0'), null, 'an unknown machine is null');
++ const desk = bot.machines.desk(DEV, { kind: 'browser', profile: 'agent' });
++ assert.match(desk.name, /^desk-[a-z0-9]{10}$/);
++ assert.deepStrictEqual(await desk.start(), { running: true, desk: desk.name, kind: 'browser' });
++ assert.deepStrictEqual(seenCommands.at(-1), { kind: 'desk', value: { action: 'start', desk: desk.name, kind: 'browser', profile: 'agent' } });
++ assert.deepStrictEqual(await desk.browser({ action: 'open', url: 'https://example.org/' }), { url: 'https://example.org/', title: 'Example' });
++ assert.deepStrictEqual(seenCommands.at(-1).value, { action: 'open', url: 'https://example.org/', desk: desk.name }, 'on the desk, never the person\'s screen');
++ await assert.rejects(() => desk.browser({ action: 'click', selector: '#missing' }), (e) => e.code === 'bot.desk_nack' && /nothing on the page/.test(e.message));
++ assert.deepStrictEqual(await desk.turn(), { desk: desk.name, person_driving: false, last_person_at: null });
++ await desk.exec({ argv: ['xterm'], detach: true });
++ assert.deepStrictEqual(seenCommands.at(-1), { kind: 'exec', value: { argv: ['xterm'], detach: true, desk: desk.name } });
++ assert.strictEqual((await desk.stop()).running, false);
++ assert.throws(() => bot.machines.desk(DEV, { name: 'private' }), TypeError, 'only a named desk of your own');
++ } finally { await srv.close(); }
++ }],
['a service acting for a person sends X-OV-Subject, its token and an Idempotency-Key', async () => {
const srv = await server();
const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc-token' }), { actingSubject: USR });
ddiff --git a/types/bot.d.ts b/types/bot.d.ts
iindex 72ea486..713d9ab 100644
---- a/types/bot.d.ts
++++ b/types/bot.d.ts
@@@ -81,8 +81,31 @@ export interface PairInput {
capabilities?: Record<string, unknown>;
name?: string;
}
++export interface MachineCommandInput { id?: string; kind: 'exec' | 'fs' | 'screen' | 'input' | 'browser' | 'probe' | 'desk'; value: Record<string, unknown>; ms?: number; }
++export interface MachineCommandResult { machine_id?: string; id?: string; result: 'ack' | 'nack' | 'expired' | 'refused' | string; data?: Record<string, unknown>; code?: string; reason?: string | null; [field: string]: unknown; }
++export interface DeskOptions { name?: string; kind?: 'browser' | 'desktop'; profile?: string; width?: number; height?: number; }
++export interface Desk {
++ readonly name: string;
++ readonly machine: string;
++ start(ms?: number): Promise<Record<string, unknown>>;
++ stop(): Promise<Record<string, unknown>>;
++ status(): Promise<Record<string, unknown>>;
++ browser(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
++ screen(value?: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
++ input(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
++ exec(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
++ turn(): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;
++}
++export interface MachinesApi {
++ list(opts?: CallOptions): Promise<{ machines: Array<Record<string, unknown>> }>;
++ computer(id: string, opts?: CallOptions): Promise<{ machine: Record<string, unknown> } | null>;
++ command(id: string, input: MachineCommandInput, opts?: CallOptions): Promise<MachineCommandResult>;
++ deskTurn(id: string, desk: string, opts?: CallOptions): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;
++ desk(id: string, options?: DeskOptions, opts?: CallOptions): Desk;
++}
export interface BotClient {
robots: RobotsApi;
++ machines: MachinesApi;
devices: DevicesApi;
profiles: ProfilesApi;
kits: KitsApi;