@desklink/axi 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <a href="https://www.npmjs.com/package/@desklink/axi"><img alt="npm" src="https://img.shields.io/npm/v/@desklink/axi?style=flat" /></a>
5
5
  <a href="https://github.com/umeranjum17/desklink/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/desklink/ci.yml?style=flat&branch=main" /></a>
6
6
  <a href="https://github.com/umeranjum17/desklink/blob/main/LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
7
- <img alt="Linux and macOS" src="https://img.shields.io/badge/Linux%20%7C%20macOS-111?style=flat" />
7
+ <img alt="Linux and macOS (preview)" src="https://img.shields.io/badge/Linux%20%7C%20macOS%20%28preview%29-111?style=flat" />
8
8
  </p>
9
9
 
10
10
  <p align="center">
@@ -21,13 +21,14 @@ npm install -g @desklink/axi
21
21
  ```
22
22
 
23
23
  Or build from the repository root with `npm install && npm run build`.
24
+ Delete copied `*.tsbuildinfo` before rebuilding a moved worktree.
24
25
  The engine is resolved by `@desklink/host`; see its [installation guide](../desktop-host/README.md#install-and-run)
25
- for supported platforms and [macOS setup](../desktop-host/README.md#macos).
26
+ for supported platforms and [macOS setup](../desktop-host/README.md#macos-preview).
26
27
  Set `DESKLINK_AXI_ENGINE` for a source build.
27
28
 
28
- A Linux/macOS desktop AXI. It runs a private bridge per session, captures read-only frames through `@desklink/host` protocol v3, and sends input only through a WebRTC `control` channel. The bridge's Unix socket lives in `$XDG_RUNTIME_DIR/desklink-axi` (or the OS temp directory), with `DESKLINK_AXI_SESSION` selecting an isolated socket. It opens a separate engine session; don't drive the desktop while someone else is controlling it.
29
+ A Linux desktop AXI with macOS in preview. It runs a private bridge per session, captures read-only frames through `@desklink/host` protocol v3, and sends input only through a WebRTC `control` channel. The bridge's Unix socket lives in `$XDG_RUNTIME_DIR/desklink-axi` (or the OS temp directory), with `DESKLINK_AXI_SESSION` selecting an isolated socket. It opens a separate engine session; don't drive the desktop while someone else is controlling it.
29
30
 
30
- The engine is resolved by `@desklink/host`; set `DESKLINK_AXI_ENGINE` to an executable built from source if no packaged binary is installed. On macOS use `PATH=/opt/homebrew/bin:$PATH`, Rust >= 1.97.1, delete copied `*.tsbuildinfo` before rebuilding a moved worktree, and `DESKLINK_VPX_STATIC_DIR=/opt/homebrew` (the install **prefix**, not `/opt/homebrew/lib`). For macOS start the bridge from a stable signed app, not `desklink-axi start` in Terminal/SSH: `open -n /absolute/DesklinkHost.app --args axi-bridge /absolute/path/to/packages/axi/bin/desklink-axi.js --control`. Install or update that app using `packages/desktop-host/release/install-mac-app.sh`; grant Screen Recording and Accessibility to it once. An SSH-launched Node helper does not inherit those grants. OCR needs the optional `tesseract` executable (`brew install tesseract`, `sudo pacman -S tesseract` or `sudo apt install tesseract-ocr`); on macOS AXI also finds Homebrew's standard install paths when LaunchServices omits them from PATH. `look` works without it.
31
+ For macOS source prerequisites, signing and permission attribution, follow the [host setup guide](../desktop-host/README.md#macos-preview). To launch AXI through its signed local harness, use `open -n /absolute/DesklinkHost.app --args axi-bridge /absolute/path/to/packages/axi/bin/desklink-axi.js --control` from the logged-in Mac session. OCR needs the optional `tesseract` executable (`brew install tesseract`, `sudo pacman -S tesseract` or `sudo apt install tesseract-ocr`); on macOS AXI also finds Homebrew's standard install paths when LaunchServices omits them from PATH. `look` works without it.
31
32
 
32
33
  ```
33
34
  desklink-axi start --control --source x11 --display :97
@@ -44,7 +45,11 @@ desklink-axi look @r1
44
45
  desklink-axi stop
45
46
  ```
46
47
 
48
+ `start --cursor hidden` opts into cursor-free capture; omit it for existing behavior. Use `desklink-axi cursor` to drain source-coordinate cursor samples (`x`, `y`, `visible`, `timestamp_us`) from the live session; `dropped:N` reports older samples overwritten after its 10,000-sample buffer fills. Linux portals require cursor metadata and refuse hidden capture when it is unavailable; see the [cursor protocol](../desktop-host/docs/PROTOCOL.md#cursor-free-capture).
49
+
47
50
  `health` reports capture state, latest frame sequence, and a stream-stop reason without OCR. `start` defaults to view-only; Linux uses the portal unless `--source x11 --display :N` is given, and macOS uses the main display unless `--source display --display <id>` is given. The portal can require a desktop consent prompt; its single-use restore token is kept in `$XDG_STATE_HOME/desklink-axi/portal-token` (otherwise `~/.local/state/desklink-axi/portal-token`), atomically replaced with mode 0600 after each grant. Set `DESKLINK_AXI_RESTORE_TOKEN_FILE` to use a different path. macOS Screen Recording and Accessibility belong to the app that launches the bridge and its engine child. Do not run it unattended against someone else's display. Input and clipboard read/write require `start --control`; view-only clipboard calls return a structured error naming that flag. `stop` releases held input. Actions acknowledge `input: applied` immediately, without waiting for a frame. Every action (`click`, `type`, `press`, `scroll`, `drag`) accepts `--wait settle|change|none|<milliseconds>`; frame timeout after applied input returns `input: applied; frame: timed out` with success status. `batch` takes a JSON array of 1–30 `[verb, ...args]` steps (click/type/press/scroll/wait/snapshot/assert/tree/marks), runs them in order in one socket round trip, stops on the first failed step and returns a compact per-step result. OCR refs survive advancing frame numbers only while their text and position still match a fresh crop; moved or ambiguous text requires `screen --query`. `screen --query` reuses a prior matching text location for a targeted crop before falling back to full OCR, and reports estimated text tokens. Start with `screen --query` or `--region` before `--full`. For web pages prefer DOM/browser automation when available; AXI is for native apps or whole-desktop tasks. `wait change` and `wait settle` block on engine frame signals rather than polling. `diff` reports repeatedly changing regions separately as `animating`; `diff --include-animating` includes them among ordinary changes. `look` writes a PNG crop. `--help` lists the commands and their flags. Errors are structured and the CLI does not prompt.
51
+ To guide the person without input, use `desklink-axi point 400,300 --label "Here" --timeout 3000`, or `desklink-axi point --clear`. See the [host protocol reference](../desktop-host/docs/PROTOCOL.md#point-on-the-host-desktop) for supported sources, coordinates, and limits.
52
+
48
53
  ## Browser lane (opt-in, loopback-only)
49
54
 
50
55
  For web pages the browser lane gives semantic control through [playwright-core](https://www.npmjs.com/package/playwright-core) over CDP; the desktop lane stays for native apps and whole-desktop tasks. Nothing is attached implicitly:
@@ -60,16 +65,23 @@ desklink-axi browser select 1 / open <url> / navigate <url> / close 1 / detach
60
65
 
61
66
  Non-loopback endpoints are refused; `launch` renders on `$DISPLAY` (X11) so it never follows `WAYLAND_DISPLAY` to another desktop, and `--arg <chromium flag>` (repeatable) passes extra flags such as `--arg=--window-size=1280,640`. Snapshot refs are re-derived on every command and rejected as `stale-ref` when the page changed. `upload` only sets files on `<input type=file>`; for OS-level dialogs it returns `not-supported` and names the desktop lane (`desklink-axi click/type`), which is also the route for real native-dialog navigation — a DOM upload is not equivalent to completing a native file dialog. `detach` stops the browser only if this lane launched it; a passed `--profile` directory is kept, a lane-created temp profile is removed.
62
67
 
68
+ On Linux, a lane-launched browser's recorded PID and start time must match
69
+ before connection or cleanup. Cached sessions from older versions without a
70
+ recorded start time are refused; use a fresh `DESKLINK_AXI_SESSION` and profile.
71
+ Cleanup verifies helper PID/start-time pairs against the private launch marker
72
+ before signalling them. Browsers attached through `--cdp` remain caller-owned.
73
+
63
74
  ## Batch outcomes
64
75
 
65
76
  `batch` steps are `[verb, ...args]` arrays or `{verb, args?, name?, wait?}` objects with verbs `click, type, press, scroll, wait, snapshot, assert, tree, marks`. A named snapshot step binds its OCR items so later steps can target `@name.n`; `assert` steps make success depend on observed screen text; per-step lines separate `input: applied` (transport acknowledgement) from `effect: change observed | none within timeout`, and the footer says `effects: asserted[n]` or `effects: unverified (input acknowledgements only)` — an input acknowledgement is never a verified outcome, so wrong-focus typing or a no-op click fails the batch instead of reporting success.
66
77
 
67
78
  ## Proofs and measurements
68
79
 
69
- From the repository root with a task-built engine (`DESKLINK_AXI_ENGINE`) on Linux:
80
+ From the repository root with a task-built engine (`DESKLINK_AXI_ENGINE`) on
81
+ Linux, following the [private-lab guidance](../../README.md#develop):
70
82
 
71
83
  - `node packages/axi/test/smoke.mjs` — clean-tree rebuild of host+axi, then `batch` and `click` on a private Xvfb; asserts no task-owned survivors.
72
- - `node packages/axi/test/flow.mjs` — full desktop proof (frame/damage, input, chords, batch outcomes, injected-failure cleanup).
84
+ - `node packages/axi/test/flow.mjs` — full desktop proof (frame/damage, point coordinates/timeout/clear without pointer or focus changes, click-through, input, chords, batch outcomes, injected-failure cleanup).
73
85
  - `node packages/axi/test/browser-flow.mjs` — the open/form/two-tab fixture through the browser lane with semantic refs on a private Xvfb; also proves selected-vs-front, stale-ref rejection, DOM upload and the not-supported fallback.
74
86
  - `node packages/axi/test/bench.mjs [runs]` — comparative p50/p95 (default 20 measured runs after 3 warmups) for the browser lane vs the AXI fallback over the open/form/tabs/native fixtures; text-token proxy is `ceil(stdout chars/4)`, a proxy, not a tokenizer.
75
87
 
package/dist/bridge.js CHANGED
@@ -8,6 +8,7 @@ import { PeerConnection } from 'node-datachannel';
8
8
  import { saveToken, takeToken, tokenPath } from './token.js';
9
9
  import { A11yClient, A11yError, axRefPattern, isActionable } from './atspi.js';
10
10
  import { dedupeCandidates, drawMarks, estimateMarksCost } from './marks.js';
11
+ import { CursorEventBuffer, formatCursorSamples } from './cursor-events.js';
11
12
  export const socketPath = join(process.env.XDG_RUNTIME_DIR ?? join(tmpdir(), `desklink-axi-${process.getuid?.() ?? 'user'}`), 'desklink-axi', `${process.env.DESKLINK_AXI_SESSION ?? 'default'}.sock`);
12
13
  export async function call(command, args = []) {
13
14
  if (command === 'stop' && !existsSync(socketPath))
@@ -107,6 +108,9 @@ export async function serve(args) {
107
108
  const source = args.includes('--source') ? args[args.indexOf('--source') + 1] : 'auto';
108
109
  const display = args.includes('--display') ? args[args.indexOf('--display') + 1] : undefined;
109
110
  const control = args.includes('--control');
111
+ const cursor = args.includes('--cursor') ? args[args.indexOf('--cursor') + 1] : 'embedded';
112
+ if (cursor !== 'embedded' && cursor !== 'hidden')
113
+ throw new Error('cursor: use embedded or hidden');
110
114
  const timeout = args.includes('--timeout') ? Number(args[args.indexOf('--timeout') + 1]) : 120000;
111
115
  if (process.platform !== 'linux' && process.platform !== 'darwin')
112
116
  throw new Error('Linux or macOS only');
@@ -119,6 +123,7 @@ export async function serve(args) {
119
123
  : 'desktop engine unavailable; set DESKLINK_AXI_ENGINE');
120
124
  mkdirSync(join(socketPath, '..'), { recursive: true, mode: 0o700 });
121
125
  const events = [];
126
+ const cursorSamples = new CursorEventBuffer();
122
127
  const tokenFailure = {};
123
128
  const checkToken = () => { if (tokenFailure.current)
124
129
  throw new Error(`cannot persist portal restore token: ${tokenFailure.current.message}`); };
@@ -128,7 +133,9 @@ export async function serve(args) {
128
133
  let peer;
129
134
  let offerReady = false;
130
135
  const engine = await EngineClient.start(executable.command, executable.args, { requestTimeoutMs: 125000, onEvent: event => {
131
- if (event.event !== 'session.frame.changed')
136
+ if (event.event === 'session.cursor')
137
+ cursorSamples.push(event);
138
+ else if (event.event !== 'session.frame.changed')
132
139
  events.push(event);
133
140
  if (event.event === 'session.restoreToken' && tokenFile) {
134
141
  try {
@@ -159,6 +166,7 @@ export async function serve(args) {
159
166
  : source === 'x11' || (source === 'auto' && display) ? { kind: 'x11', display } : { kind: 'portal' },
160
167
  permissions: control ? ['view', 'control', 'clipboard'] : ['view'], loopbackTcp: true,
161
168
  agentIndicator: control,
169
+ cursor,
162
170
  ...(tokenFile ? { restoreToken: takeToken(tokenFile) } : {}),
163
171
  }, timeout);
164
172
  checkToken();
@@ -314,6 +322,10 @@ export async function serve(args) {
314
322
  routeNote = undefined;
315
323
  if (command === 'start')
316
324
  return `session: open source=${source} ${display ?? ''} size=${opened.geometry.encoded.width}x${opened.geometry.encoded.height} permissions=view${control ? ',control' : ''}`;
325
+ if (command === 'cursor') {
326
+ const batch = cursorSamples.take(opened.sessionId);
327
+ return formatCursorSamples(batch.samples, batch.dropped);
328
+ }
317
329
  if (command === 'health') {
318
330
  const state = events.filter(event => event.event === 'session.state' || event.event === 'session.capture.stopped').at(-1);
319
331
  if (state?.event === 'session.capture.stopped')
@@ -435,6 +447,21 @@ export async function serve(args) {
435
447
  return 'clipboard: written';
436
448
  }
437
449
  }
450
+ if (command === 'point') {
451
+ if (args.includes('--clear')) {
452
+ if (args.length !== 1)
453
+ throw new Error('point: --clear accepts no other arguments');
454
+ await engine.point(opened.sessionId, { clear: true });
455
+ return 'point: cleared';
456
+ }
457
+ if (!/^\d+,\d+$/.test(args[0] ?? ''))
458
+ throw new Error('point: expected x,y desktop pixels, or --clear');
459
+ const [x, y] = args[0].split(',').map(Number);
460
+ const timeout = args.includes('--timeout') ? Number(args[args.indexOf('--timeout') + 1]) : 3000;
461
+ const label = args.includes('--label') ? args[args.indexOf('--label') + 1] : '';
462
+ await engine.point(opened.sessionId, { x: x, y: y, label, timeoutMs: timeout });
463
+ return `point: shown at=${x},${y} timeout=${timeout}ms`;
464
+ }
438
465
  const action = ['click', 'drag', 'type', 'press', 'scroll'].includes(command);
439
466
  const actionWait = action && args.includes('--wait') ? args[args.indexOf('--wait') + 1] : 'none';
440
467
  const actionDelay = action && /^\d+$/.test(actionWait) ? boundedDelay(actionWait) : undefined;
@@ -70,6 +70,8 @@ async function orderedPages(state, context) {
70
70
  }
71
71
  async function connect() {
72
72
  const state = readState();
73
+ if (state.launched)
74
+ assertOwnedBrowser(state);
73
75
  const { chromium } = await import('playwright-core');
74
76
  const browser = await chromium.connectOverCDP(`http://${state.endpoint}`).catch((error) => {
75
77
  throw new AxiError(`browser: cannot reach ${state.endpoint}: ${error.message.split('\n')[0]}`, 'check that the browser is still running; re-attach with browser attach --cdp');
@@ -152,12 +154,23 @@ const attach = {
152
154
  return 0;
153
155
  },
154
156
  };
155
- function childrenOf(pid) {
157
+ function processStarted(pid) {
156
158
  try {
157
- return readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim().split(/\s+/).filter(Boolean).map(Number);
159
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
160
+ const fields = stat.slice(stat.lastIndexOf(') ') + 2).split(' ');
161
+ return fields[0] === 'Z' ? undefined : fields[19];
158
162
  }
159
163
  catch {
160
- return [];
164
+ return undefined;
165
+ }
166
+ }
167
+ function assertOwnedBrowser(state) {
168
+ if (process.platform !== 'linux')
169
+ return; // /proc identity is Linux-only.
170
+ // The fingerprint was recorded from our ChildProcess at spawn. Chromium may
171
+ // hide/scrub /proc/environ after startup; it is not the root PID's identity.
172
+ if (!state.pid || !state.pidStarted || processStarted(state.pid) !== state.pidStarted) {
173
+ throw new AxiError('browser: refusing stale or unowned process identity', 'use a fresh task session; no process was signalled or contacted');
161
174
  }
162
175
  }
163
176
  // Chromium helpers (crashpad handlers double-fork, xdg-settings spawns during
@@ -180,22 +193,31 @@ function markerStrays(marker) {
180
193
  if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid)
181
194
  continue;
182
195
  try {
183
- if (uid !== undefined && statSync(`/proc/${pid}`).uid !== uid)
196
+ const started = processStarted(pid);
197
+ if (!started || (uid !== undefined && statSync(`/proc/${pid}`).uid !== uid))
184
198
  continue;
185
- if (readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').includes(needle))
186
- found.push(pid);
199
+ if (readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').includes(needle) && processStarted(pid) === started)
200
+ found.push({ pid, started });
187
201
  }
188
202
  catch { /* vanished or unreadable */ }
189
203
  }
190
204
  return found;
191
205
  }
192
206
  async function stopLaunched(state) {
193
- const stop = async (pid) => {
207
+ const signal = (pid, started, value) => {
208
+ if (process.platform === 'linux' && (!started || processStarted(pid) !== started))
209
+ return;
194
210
  try {
195
- process.kill(pid, 'SIGTERM');
211
+ process.kill(pid, value);
196
212
  }
197
213
  catch { /* already gone */ }
198
- for (let i = 0; i < 20; i++) {
214
+ };
215
+ const stop = async (pid, started = processStarted(pid)) => {
216
+ const same = () => process.platform !== 'linux' || (started !== undefined && processStarted(pid) === started);
217
+ if (!same())
218
+ return;
219
+ signal(pid, started, 'SIGTERM');
220
+ for (let i = 0; i < 20 && same(); i++) {
199
221
  try {
200
222
  process.kill(pid, 0);
201
223
  }
@@ -204,17 +226,17 @@ async function stopLaunched(state) {
204
226
  }
205
227
  await new Promise(r => setTimeout(r, 50));
206
228
  }
207
- try {
208
- process.kill(pid, 'SIGKILL');
209
- }
210
- catch { /* already gone */ }
229
+ if (same())
230
+ signal(pid, started, 'SIGKILL');
211
231
  };
212
- if (state.pid) {
213
- // Record the child tree before any signal; kill only these exact PIDs.
214
- const kids = [...new Set([...childrenOf(state.pid), ...childrenOf(state.pid).flatMap(childrenOf)])];
215
- await stop(state.pid);
232
+ if (state.pid && (process.platform !== 'linux' || processStarted(state.pid))) {
233
+ assertOwnedBrowser(state);
234
+ // This launch supplied the private marker at spawn. Snapshot matching
235
+ // PID/start-time pairs before signalling, never descendants of a reused PID.
236
+ const kids = state.browserSession ? markerStrays(state.browserSession) : [];
237
+ await stop(state.pid, state.pidStarted);
216
238
  for (const kid of kids)
217
- await stop(kid);
239
+ await stop(kid.pid, kid.started);
218
240
  }
219
241
  // Helpers that reparented out of the recorded tree (crashpad handlers,
220
242
  // xdg-settings spawned during shutdown) still carry the launch marker:
@@ -227,20 +249,12 @@ async function stopLaunched(state) {
227
249
  if (!strays.length)
228
250
  break;
229
251
  if (Date.now() >= deadline) {
230
- for (const pid of strays) {
231
- try {
232
- process.kill(pid, 'SIGKILL');
233
- }
234
- catch { /* gone */ }
235
- }
252
+ for (const child of strays)
253
+ signal(child.pid, child.started, 'SIGKILL');
236
254
  break;
237
255
  }
238
- for (const pid of strays) {
239
- try {
240
- process.kill(pid, termed ? 'SIGKILL' : 'SIGTERM');
241
- }
242
- catch { /* gone */ }
243
- }
256
+ for (const child of strays)
257
+ signal(child.pid, child.started, termed ? 'SIGKILL' : 'SIGTERM');
244
258
  termed = true;
245
259
  await new Promise(r => setTimeout(r, 100));
246
260
  }
@@ -295,10 +309,11 @@ const launch = {
295
309
  ...(parsed.flags.headless ? ['--headless=new'] : []),
296
310
  ...(Array.isArray(parsed.flags.arg) ? parsed.flags.arg.map(String) : typeof parsed.flags.arg === 'string' ? [parsed.flags.arg] : []),
297
311
  ];
298
- const browserSession = randomBytes(8).toString('hex');
312
+ const browserSession = randomBytes(16).toString('hex');
299
313
  const log = openSync(join(profile, 'chromium.log'), 'a');
300
314
  const child = spawn(executable, args, { detached: true, stdio: ['ignore', 'ignore', log], env: { ...process.env, DESKLINK_AXI_BROWSER_SESSION: browserSession } });
301
315
  closeSync(log);
316
+ const pidStarted = child.pid ? processStarted(child.pid) : undefined;
302
317
  const portFile = join(profile, 'DevToolsActivePort');
303
318
  for (let i = 0; i < 300 && (!existsSync(portFile) || !readFileSync(portFile, 'utf8').trim()) && child.pid; i++)
304
319
  await new Promise(r => setTimeout(r, 100));
@@ -307,17 +322,12 @@ const launch = {
307
322
  child.kill('SIGKILL');
308
323
  }
309
324
  catch { /* already gone */ }
310
- for (const pid of markerStrays(browserSession)) {
311
- try {
312
- process.kill(pid, 'SIGKILL');
313
- }
314
- catch { /* gone */ }
315
- }
325
+ await stopLaunched({ endpoint: '', launched: true, pid: child.pid, pidStarted, browserSession });
316
326
  const tail = existsSync(join(profile, 'chromium.log')) ? readFileSync(join(profile, 'chromium.log'), 'utf8').split('\n').filter(Boolean).at(-1) ?? '' : '';
317
327
  throw new AxiError(`browser: Chromium did not open its debugging port${tail ? `: ${tail.slice(0, 200)}` : ''}`, 'check the executable; see chromium.log in the profile; try --headless on displays without a compositor');
318
328
  }
319
329
  const port = readFileSync(portFile, 'utf8').split('\n')[0].trim();
320
- writeState({ endpoint: `127.0.0.1:${port}`, launched: true, pid: child.pid, profile, profileCreated: typeof parsed.flags.profile !== 'string', browserSession });
330
+ writeState({ endpoint: `127.0.0.1:${port}`, launched: true, pid: child.pid, pidStarted, profile, profileCreated: typeof parsed.flags.profile !== 'string', browserSession });
321
331
  child.unref();
322
332
  print(`browser: launched 127.0.0.1:${port} pid=${child.pid} profile=${profile}`);
323
333
  return 0;
@@ -0,0 +1,37 @@
1
+ export const CURSOR_EVENT_BUFFER_CAPACITY = 10_000;
2
+ export class CursorEventBuffer {
3
+ samples = new Array(CURSOR_EVENT_BUFFER_CAPACITY);
4
+ head = 0;
5
+ size = 0;
6
+ dropped = 0;
7
+ push(event) {
8
+ if (this.size === CURSOR_EVENT_BUFFER_CAPACITY) {
9
+ this.samples[this.head] = event.params;
10
+ this.head = (this.head + 1) % CURSOR_EVENT_BUFFER_CAPACITY;
11
+ this.dropped++;
12
+ return;
13
+ }
14
+ this.samples[(this.head + this.size) % CURSOR_EVENT_BUFFER_CAPACITY] = event.params;
15
+ this.size++;
16
+ }
17
+ take(sessionId) {
18
+ const samples = [];
19
+ for (let index = 0; index < this.size; index++) {
20
+ const slot = (this.head + index) % CURSOR_EVENT_BUFFER_CAPACITY;
21
+ const sample = this.samples[slot];
22
+ if (sample?.sessionId === sessionId)
23
+ samples.push(sample);
24
+ this.samples[slot] = undefined;
25
+ }
26
+ const dropped = this.dropped;
27
+ this.head = 0;
28
+ this.size = 0;
29
+ this.dropped = 0;
30
+ return { samples, dropped };
31
+ }
32
+ }
33
+ export function formatCursorSamples(samples, dropped) {
34
+ return samples.length
35
+ ? `cursor[${samples.length}] dropped:${dropped} {sessionId,x,y,visible,timestamp_us}:\n${samples.map(sample => ` ${JSON.stringify(sample)}`).join('\n')}`
36
+ : `cursor: no pending samples; dropped:${dropped}`;
37
+ }
package/dist/index.js CHANGED
@@ -20,9 +20,10 @@ else if (process.argv[2] === '--session-hook') {
20
20
  }
21
21
  else {
22
22
  const definitions = [
23
- ['start', [], ['control', 'source', 'display', 'timeout']], ['stop', [], []], ['health', [], []],
23
+ ['start', [], ['control', 'source', 'display', 'timeout', 'cursor']], ['stop', [], []], ['health', [], []], ['cursor', [], []],
24
24
  ['screen', [], ['query', 'region', 'full', 'fields']], ['diff', [], ['include-animating', 'fields']],
25
25
  ['tree', [], ['query', 'full', 'fields']], ['marks', [], ['region', 'out']],
26
+ ['point', [], ['label', 'timeout', 'clear']],
26
27
  ['look', [], ['region', 'out']], ['click', ['target'], ['button', 'double', 'wait']],
27
28
  ['type', ['text'], ['into', 'submit', 'wait']], ['press', ['key'], ['wait']],
28
29
  ['scroll', ['direction'], ['at', 'amount', 'wait']], ['drag', ['from', 'to'], ['wait']],
@@ -34,7 +35,7 @@ else {
34
35
  Object.assign(commands, browserCommands());
35
36
  for (const [name, required, flags] of definitions)
36
37
  commands[name] = {
37
- spec: { name, summary: `${name} on the live desktop`, args: required.map(arg => ({ name: arg, required: true, description: arg })), flags: flags.map(flag => ({ name: flag, type: ['control', 'full', 'double', 'submit', 'include-animating'].includes(flag) ? 'boolean' : 'string', values: flag === 'source' ? ['auto', 'portal', 'x11', 'display'] : flag === 'button' ? ['left', 'right'] : undefined, description: flag })), examples: [`desklink-axi ${name} ${required.map(arg => `<${arg}>`).join(' ')}`] },
38
+ spec: { name, summary: `${name} on the live desktop`, args: name === 'point' ? [{ name: 'x,y', required: false, description: 'selected desktop pixels; omit for --clear' }] : required.map(arg => ({ name: arg, required: true, description: arg })), flags: flags.map(flag => ({ name: flag, type: ['control', 'full', 'double', 'submit', 'include-animating', 'clear'].includes(flag) ? 'boolean' : 'string', values: flag === 'source' ? ['auto', 'portal', 'x11', 'display'] : flag === 'button' ? ['left', 'right'] : undefined, description: flag })), examples: name === 'point' ? ['desklink-axi point 400,300 --label "Here" --timeout 3000', 'desklink-axi point --clear'] : [`desklink-axi ${name} ${required.map(arg => `<${arg}>`).join(' ')}`] },
38
39
  async run(parsed) {
39
40
  if (name === 'scroll' && !['up', 'down'].includes(parsed.positionals[0]))
40
41
  throw new UsageError('scroll direction must be up or down');
@@ -2,7 +2,7 @@ import { homedir } from 'node:os';
2
2
  import { emitList } from '../output/toon.js';
3
3
  export const DESCRIPTION = "See and drive a live Linux or macOS desktop and an opted-in browser through desklink; text and changes by default, pixels on request";
4
4
  export const SPEC_VERSION = 'axi/1.0-2026-07';
5
- const commands = ['start', 'stop', 'health', 'screen', 'tree', 'marks', 'diff', 'look', 'click', 'type', 'press', 'scroll', 'drag', 'batch', 'wait', 'clipboard read', 'clipboard write', 'setup hooks', 'browser attach', 'browser launch', 'browser tabs', 'browser select', 'browser open', 'browser navigate', 'browser close', 'browser snapshot', 'browser click', 'browser fill', 'browser press', 'browser upload', 'browser detach'];
5
+ const commands = ['start', 'stop', 'health', 'screen', 'tree', 'marks', 'diff', 'look', 'point', 'click', 'type', 'press', 'scroll', 'drag', 'batch', 'wait', 'clipboard read', 'clipboard write', 'setup hooks', 'browser attach', 'browser launch', 'browser tabs', 'browser select', 'browser open', 'browser navigate', 'browser close', 'browser snapshot', 'browser click', 'browser fill', 'browser press', 'browser upload', 'browser detach'];
6
6
  export function renderHome(binPath) { return `desklink-axi: ${binPath.replace(homedir(), '~')} — ${DESCRIPTION}\nhelp[5]:\n desklink-axi start --control --source x11 --display :97\n desklink-axi screen --query "<words>"\n desklink-axi tree --query "<words>"\n desklink-axi look @r1\n desklink-axi browser snapshot`; }
7
7
  export function rootHelpText() { return [`desklink-axi: ${DESCRIPTION}`, emitList('commands', commands.map(command => ({ command, summary: command.startsWith('browser') ? `${command} on the attached browser` : `${command} on the live desktop` })), ['command', 'summary']), 'flags[1]{flag,description}:\n --help,show help for any command', 'examples[2]:\n desklink-axi start --control --source x11 --display :97\n desklink-axi screen --query "<words>"'].join('\n'); }
8
- export function renderSkill() { return `---\nname: desklink-axi\ndescription: "${DESCRIPTION}"\n---\n\n# desklink-axi\n\n${DESCRIPTION} (${SPEC_VERSION}).\n\n\`\`\`\n${renderHome('desklink-axi')}\n\`\`\`\n\nTwo lanes: the desktop lane (capture, OCR refs, AT-SPI tree, input) and the browser lane (semantic web control through an opted-in Chromium). On the desktop, \`tree --query "<words>"\` reads the AT-SPI accessibility tree into generation-tagged \`@a<gen>.<n>\` refs (values included), and \`marks\` overlays numbered candidates for icon-only controls. For web pages use the browser lane: \`desklink-axi browser attach --cdp 127.0.0.1:<port>\` for a browser you started with \`--remote-debugging-port\` (loopback only), or \`desklink-axi browser launch\` for an isolated task-owned profile. Then \`browser tabs\`, \`browser snapshot\` (compact accessibility refs, no screenshots), \`browser click/fill/upload @e<N>\`, \`browser select <tab>\`. Page selection (selected) and the browser's actually activated tab (front) are reported separately. OS-level dialogs are not-supported in the browser lane: fall back to the desktop lane (click/type on the dialog).\n\nRead with screen --query or --region before screen --full. Actions acknowledge input immediately; add --wait change|settle|<milliseconds> when needed. Batch sequential steps in one socket round trip: desklink-axi batch '[["press","Meta+l"],["type","https://example.com"],["press","Return"],["wait","change"]]'. Steps may be {verb,args?,name?,wait?} objects; a named snapshot binds OCR refs for later @name.n steps, and assert steps (\`["assert","text"]\`, \`["assert","--absent","text"]\`) make the batch outcome depend on observed screen text. The footer reports effects: asserted[n] or effects: unverified (input acknowledgements only) — an input ack is never a verified outcome.\n\nEvery command supports --help. Exit 0 success, 1 operational error, 2 usage error. No screen text at startup unless requested.\n`; }
8
+ export function renderSkill() { return `---\nname: desklink-axi\ndescription: "${DESCRIPTION}"\n---\n\n# desklink-axi\n\n${DESCRIPTION} (${SPEC_VERSION}).\n\n\`\`\`\n${renderHome('desklink-axi')}\n\`\`\`\n\nTwo lanes: the desktop lane (capture, OCR refs, AT-SPI tree, input) and the browser lane (semantic web control through an opted-in Chromium). On the desktop, \`tree --query "<words>"\` reads the AT-SPI accessibility tree into generation-tagged \`@a<gen>.<n>\` refs (values included), and \`marks\` overlays numbered candidates for icon-only controls. For web pages use the browser lane: \`desklink-axi browser attach --cdp 127.0.0.1:<port>\` for a browser you started with \`--remote-debugging-port\` (loopback only), or \`desklink-axi browser launch\` for an isolated task-owned profile. Then \`browser tabs\`, \`browser snapshot\` (compact accessibility refs, no screenshots), \`browser click/fill/upload @e<N>\`, \`browser select <tab>\`. Page selection (selected) and the browser's actually activated tab (front) are reported separately. OS-level dialogs are not-supported in the browser lane: fall back to the desktop lane (click/type on the dialog).\n\nRead with screen --query or --region before screen --full. Actions acknowledge input immediately; add --wait change|settle|<milliseconds> when needed. Batch sequential steps in one socket round trip: desklink-axi batch '[["press","Meta+l"],["type","https://example.com"],["press","Return"],["wait","change"]]'. Steps may be {verb,args?,name?,wait?} objects; a named snapshot binds OCR refs for later @name.n steps, and assert steps (\`["assert","text"]\`, \`["assert","--absent","text"]\`) make the batch outcome depend on observed screen text. The footer reports effects: asserted[n] or effects: unverified (input acknowledgements only) — an input ack is never a verified outcome.\n\nGuide without input using \`point 400,300 --label "Here" --timeout 3000\`; use \`point --clear\` to remove the cue. See the [host protocol reference](../../../desktop-host/docs/PROTOCOL.md#point-on-the-host-desktop) for supported sources and limits. Every command supports --help. Exit 0 success, 1 operational error, 2 usage error. No screen text at startup unless requested.\n`; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/axi",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Agent-facing desktop control CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,15 +24,15 @@
24
24
  "skill:check": "node scripts/gen-skill.mjs --check"
25
25
  },
26
26
  "devDependencies": {
27
- "@types/node": "^22.10.0",
28
- "@types/pngjs": "^6.0.5",
29
- "typescript": "^5.6.0"
27
+ "@types/node": "22.20.4",
28
+ "@types/pngjs": "6.0.5",
29
+ "typescript": "5.9.3"
30
30
  },
31
31
  "dependencies": {
32
- "@desklink/host": "0.3.0",
33
- "node-datachannel": "^0.33.4",
32
+ "@desklink/host": "0.3.1",
33
+ "node-datachannel": "0.33.4",
34
34
  "playwright-core": "1.62.1",
35
- "pngjs": "^7.0.0"
35
+ "pngjs": "7.0.0"
36
36
  },
37
37
  "license": "Apache-2.0"
38
38
  }
@@ -21,4 +21,4 @@ Two lanes: the desktop lane (capture, OCR refs, AT-SPI tree, input) and the brow
21
21
 
22
22
  Read with screen --query or --region before screen --full. Actions acknowledge input immediately; add --wait change|settle|<milliseconds> when needed. Batch sequential steps in one socket round trip: desklink-axi batch '[["press","Meta+l"],["type","https://example.com"],["press","Return"],["wait","change"]]'. Steps may be {verb,args?,name?,wait?} objects; a named snapshot binds OCR refs for later @name.n steps, and assert steps (`["assert","text"]`, `["assert","--absent","text"]`) make the batch outcome depend on observed screen text. The footer reports effects: asserted[n] or effects: unverified (input acknowledgements only) — an input ack is never a verified outcome.
23
23
 
24
- Every command supports --help. Exit 0 success, 1 operational error, 2 usage error. No screen text at startup unless requested.
24
+ Guide without input using `point 400,300 --label "Here" --timeout 3000`; use `point --clear` to remove the cue. See the [host protocol reference](../../../desktop-host/docs/PROTOCOL.md#point-on-the-host-desktop) for supported sources and limits. Every command supports --help. Exit 0 success, 1 operational error, 2 usage error. No screen text at startup unless requested.