@desklink/axi 0.3.0 → 0.4.0

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,16 +21,17 @@ 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
- Set `DESKLINK_AXI_ENGINE` for a source build.
26
+ for supported platforms and [macOS setup](../desktop-host/README.md#macos-preview).
27
+ Set `DESKLINK_AXI_ENGINE` for a matching source build. An override pointing new AXI at a 0.3.x engine is unsupported; host and engine versions must match.
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` 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
- desklink-axi start --control --source x11 --display :97
34
+ desklink-axi start --source x11 --display :97
34
35
  desklink-axi screen --query "Budget"
35
36
  desklink-axi tree --query "Save"
36
37
  desklink-axi type "hello" --into @a3.10
@@ -44,7 +45,11 @@ desklink-axi look @r1
44
45
  desklink-axi stop
45
46
  ```
46
47
 
47
- `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.
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
+
50
+ `health` reports capture state, latest frame sequence, and a stream-stop reason without OCR. `start` grants all available access to its same-user paired consumer; 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 need no app grants; unavailable OS backends fail per action. Legacy `start --control` is accepted and ignored. See the [clipboard protocol](../desktop-host/docs/PROTOCOL.md#clipboard-over-the-local-protocol) for X11 targets, transfer limits and selection lifetime. `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
 
@@ -99,7 +111,7 @@ Measured on a private 1280×720 Xvfb client: `diff → click 100,100 → look @r
99
111
 
100
112
  ## Accessibility tree (`tree`) — Linux AT-SPI
101
113
 
102
- `tree` reads the desktop's AT-SPI accessibility tree over D-Bus and returns bounded TOON rows of `ref,role,name,value,x,y,w,h,states`. It needs no `--control`; acting on refs does. Refs are generation-tagged (`@a<gen>.<n>`), stay usable across newer snapshots, and are revalidated at use time: a ref whose role, name, or screen extents changed — or whose object is gone — returns `stale-ref` instead of acting blind.
114
+ `tree` reads the desktop's AT-SPI accessibility tree over D-Bus and returns bounded TOON rows of `ref,role,name,value,x,y,w,h,states`. Read and input actions share the same paired session. Refs are generation-tagged (`@a<gen>.<n>`), stay usable across newer snapshots, and are revalidated at use time: a ref whose role, name, or screen extents changed — or whose object is gone — returns `stale-ref` instead of acting blind.
103
115
 
104
116
  - Capability probe: the bus is resolved from `AT_SPI_BUS_ADDRESS`, else `org.a11y.Bus.GetAddress` on the session bus. Unavailable → structured `a11y-unavailable` error; every other command keeps working. macOS AX is **not implemented** and labeled as such.
105
117
  - Values: text and value widgets expose their content in the `value` column (Text/Value interfaces), so a typed entry can be verified without pixels.
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))
@@ -106,7 +107,9 @@ export async function serve(args) {
106
107
  throw new Error('session already running');
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
- const control = args.includes('--control');
110
+ const cursor = args.includes('--cursor') ? args[args.indexOf('--cursor') + 1] : 'embedded';
111
+ if (cursor !== 'embedded' && cursor !== 'hidden')
112
+ throw new Error('cursor: use embedded or hidden');
110
113
  const timeout = args.includes('--timeout') ? Number(args[args.indexOf('--timeout') + 1]) : 120000;
111
114
  if (process.platform !== 'linux' && process.platform !== 'darwin')
112
115
  throw new Error('Linux or macOS only');
@@ -119,6 +122,7 @@ export async function serve(args) {
119
122
  : 'desktop engine unavailable; set DESKLINK_AXI_ENGINE');
120
123
  mkdirSync(join(socketPath, '..'), { recursive: true, mode: 0o700 });
121
124
  const events = [];
125
+ const cursorSamples = new CursorEventBuffer();
122
126
  const tokenFailure = {};
123
127
  const checkToken = () => { if (tokenFailure.current)
124
128
  throw new Error(`cannot persist portal restore token: ${tokenFailure.current.message}`); };
@@ -128,7 +132,9 @@ export async function serve(args) {
128
132
  let peer;
129
133
  let offerReady = false;
130
134
  const engine = await EngineClient.start(executable.command, executable.args, { requestTimeoutMs: 125000, onEvent: event => {
131
- if (event.event !== 'session.frame.changed')
135
+ if (event.event === 'session.cursor')
136
+ cursorSamples.push(event);
137
+ else if (event.event !== 'session.frame.changed')
132
138
  events.push(event);
133
139
  if (event.event === 'session.restoreToken' && tokenFile) {
134
140
  try {
@@ -157,12 +163,13 @@ export async function serve(args) {
157
163
  source: process.platform === 'darwin'
158
164
  ? { kind: 'display', ...(display === undefined ? {} : { display_id: Number(display) }) }
159
165
  : source === 'x11' || (source === 'auto' && display) ? { kind: 'x11', display } : { kind: 'portal' },
160
- permissions: control ? ['view', 'control', 'clipboard'] : ['view'], loopbackTcp: true,
161
- agentIndicator: control,
166
+ loopbackTcp: true,
167
+ agentIndicator: true, // serve rejects platforms other than Linux/macOS above.
168
+ cursor,
162
169
  ...(tokenFile ? { restoreToken: takeToken(tokenFile) } : {}),
163
170
  }, timeout);
164
171
  checkToken();
165
- peer = control ? new PeerConnection('desklink-axi', { iceServers: [], bindAddress: '127.0.0.1', enableIceTcp: true }) : undefined;
172
+ peer = new PeerConnection('desklink-axi', { iceServers: [], bindAddress: '127.0.0.1', enableIceTcp: true });
166
173
  let channel;
167
174
  let open = false;
168
175
  let nextSeq = 1;
@@ -267,8 +274,6 @@ export async function serve(args) {
267
274
  return [...lines.values()];
268
275
  };
269
276
  const act = async (message) => {
270
- if (!control)
271
- throw new Error('input-unavailable: start --control');
272
277
  for (let i = 0; i < 100 && !open; i++)
273
278
  await new Promise(r => setTimeout(r, 50));
274
279
  if (!open || !channel)
@@ -313,7 +318,11 @@ export async function serve(args) {
313
318
  let waitSeen = baseline;
314
319
  routeNote = undefined;
315
320
  if (command === 'start')
316
- return `session: open source=${source} ${display ?? ''} size=${opened.geometry.encoded.width}x${opened.geometry.encoded.height} permissions=view${control ? ',control' : ''}`;
321
+ return `session: open source=${source} ${display ?? ''} size=${opened.geometry.encoded.width}x${opened.geometry.encoded.height}`;
322
+ if (command === 'cursor') {
323
+ const batch = cursorSamples.take(opened.sessionId);
324
+ return formatCursorSamples(batch.samples, batch.dropped);
325
+ }
317
326
  if (command === 'health') {
318
327
  const state = events.filter(event => event.event === 'session.state' || event.event === 'session.capture.stopped').at(-1);
319
328
  if (state?.event === 'session.capture.stopped')
@@ -424,8 +433,6 @@ export async function serve(args) {
424
433
  return 'session: stopped';
425
434
  }
426
435
  if (command === 'clipboard') {
427
- if (!control)
428
- throw new Error('input-unavailable: clipboard requires start --control');
429
436
  if (args[0] === 'read') {
430
437
  const result = await engine.readClipboard(opened.sessionId);
431
438
  return `clipboard: ${args.includes('--full') ? result.text : result.text.slice(0, 1000)} (${result.text.length} chars)`;
@@ -435,6 +442,21 @@ export async function serve(args) {
435
442
  return 'clipboard: written';
436
443
  }
437
444
  }
445
+ if (command === 'point') {
446
+ if (args.includes('--clear')) {
447
+ if (args.length !== 1)
448
+ throw new Error('point: --clear accepts no other arguments');
449
+ await engine.point(opened.sessionId, { clear: true });
450
+ return 'point: cleared';
451
+ }
452
+ if (!/^\d+,\d+$/.test(args[0] ?? ''))
453
+ throw new Error('point: expected x,y desktop pixels, or --clear');
454
+ const [x, y] = args[0].split(',').map(Number);
455
+ const timeout = args.includes('--timeout') ? Number(args[args.indexOf('--timeout') + 1]) : 3000;
456
+ const label = args.includes('--label') ? args[args.indexOf('--label') + 1] : '';
457
+ await engine.point(opened.sessionId, { x: x, y: y, label, timeoutMs: timeout });
458
+ return `point: shown at=${x},${y} timeout=${timeout}ms`;
459
+ }
438
460
  const action = ['click', 'drag', 'type', 'press', 'scroll'].includes(command);
439
461
  const actionWait = action && args.includes('--wait') ? args[args.indexOf('--wait') + 1] : 'none';
440
462
  const actionDelay = action && /^\d+$/.test(actionWait) ? boundedDelay(actionWait) : undefined;
@@ -629,6 +651,15 @@ export async function serve(args) {
629
651
  if (fields.some(field => !['ref', 'text', 'x', 'y', 'w', 'h', 'conf', 'line'].includes(field)))
630
652
  throw new Error('fields: valid fields are ref,text,x,y,w,h,conf,line');
631
653
  const frame = await capture(pendingDamage ? waitSeen : baseline);
654
+ // A frame reply can already be older than the latest change notice. Its
655
+ // stillness must not erase motion from a newer frame. Classify at capture
656
+ // time too: synchronous OCR can outlast the motion freshness window.
657
+ const moving = [...motion].filter(([, state]) => state.count >= 3 && Date.now() - state.at < 600 && (frame.still_ms < 150 || state.seq > frame.seq));
658
+ if (frame.still_ms >= 150)
659
+ for (const [region, state] of motion) {
660
+ if (state.seq <= frame.seq)
661
+ motion.delete(region);
662
+ }
632
663
  if (pendingDamage)
633
664
  frame.damage = [...new Set([...pendingDamage, ...frame.damage])];
634
665
  if (command === 'look') {
@@ -717,7 +748,7 @@ export async function serve(args) {
717
748
  }
718
749
  }
719
750
  if (command === 'home')
720
- return `session: open source=${source} ${display ?? ''} size=${frame.width}x${frame.height} permissions=view${control ? ',control' : ''}\nframe: ${frame.seq} settled=${frame.still_ms >= 150} unseen=${frame.changed ? frame.damage.length : 0} region\nwindows: unavailable on this compositor\ntext[${Math.min(text.length, 12)} of ${text.length}]{ref,text,x,y}:\n${text.slice(0, 12).map(t => ` ${t.ref},${JSON.stringify(t.text)},${t.x},${t.y}`).join('\n')}\nhelp[2]:\n desklink-axi diff\n desklink-axi screen --query "<words>"`;
751
+ return `session: open source=${source} ${display ?? ''} size=${frame.width}x${frame.height}\nframe: ${frame.seq} settled=${frame.still_ms >= 150} unseen=${frame.changed ? frame.damage.length : 0} region\nwindows: unavailable on this compositor\ntext[${Math.min(text.length, 12)} of ${text.length}]{ref,text,x,y}:\n${text.slice(0, 12).map(t => ` ${t.ref},${JSON.stringify(t.text)},${t.x},${t.y}`).join('\n')}\nhelp[2]:\n desklink-axi diff\n desklink-axi screen --query "<words>"`;
721
752
  if (command === 'screen') {
722
753
  const query = args.includes('--query') ? args[args.indexOf('--query') + 1] : undefined;
723
754
  const items = query ? text.filter(t => t.text.toLowerCase().includes(query.toLowerCase())) : text;
@@ -747,11 +778,8 @@ export async function serve(args) {
747
778
  return false;
748
779
  });
749
780
  const gone = unmatched.length;
750
- if (frame.still_ms >= 150)
751
- motion.clear();
752
781
  const animating = [];
753
782
  const visible = [];
754
- const moving = [...motion].filter(([, state]) => state.count >= 3 && Date.now() - state.at < 600);
755
783
  for (const region of [...frame.damage, ...moving.map(([key]) => key).filter(key => !frame.damage.some(region => overlaps(key, region)))]) {
756
784
  const state = moving.find(([key]) => overlaps(key, region))?.[1];
757
785
  const [x, y, w, h] = region.split(',').map(Number);
@@ -770,8 +798,6 @@ export async function serve(args) {
770
798
  const changed = visible.length ? `${visible.length} region since frame ${frame.previous}` : `none since frame ${frame.previous}`;
771
799
  return `changed: ${changed}\nregions[${visible.length}]{ref,box}:\n${visible.map((d, i) => ` @r${i + 1},"${d}"`).join('\n')}\nappeared[${Math.min(appeared.length, 20)} of ${appeared.length}]{${fields.join(',')}}:\n${appeared.slice(0, 20).map(t => ` ${fields.map(field => JSON.stringify(field === 'w' ? t.words.at(-1).x + t.words.at(-1).w - t.x : field === 'h' ? Math.max(...t.words.map(w => w.y + w.h)) - t.y : t[field])).join(',')}`).join('\n')}\ngone: ${gone} text items${animating.length ? `\nanimating[${animating.length}]{box}:\n${animating.map(box => ` "${box}"`).join('\n')}\nanimating: ${animating.join('; ')}` : ''}\nhelp[2]:\n desklink-axi look @r1\n desklink-axi screen --query "<words>"`;
772
800
  }
773
- if (frame.still_ms >= 150)
774
- motion.clear();
775
801
  return `changed: none since frame ${frame.seq} (still ${frame.still_ms}ms)\nhelp[1]:\n desklink-axi screen --query "<words>"`;
776
802
  };
777
803
  let commands = Promise.resolve();
@@ -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', [], ['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: ['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');
@@ -67,5 +68,8 @@ else {
67
68
  },
68
69
  };
69
70
  const registry = { tool: 'desklink-axi', root: homeCommand, rootHelp, commands };
70
- process.exit(await dispatch(registry, process.argv.slice(2)));
71
+ // 0.3 scripts may still pass --control; paired sessions always have access.
72
+ const argv = process.argv.slice(2);
73
+ const flagsEnd = argv.indexOf('--');
74
+ process.exit(await dispatch(registry, argv.filter((arg, index) => !(argv[0] === 'start' && arg === '--control' && (flagsEnd < 0 || index < flagsEnd)))));
71
75
  }
@@ -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'];
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
- 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`; }
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
+ export function renderHome(binPath) { return `desklink-axi: ${binPath.replace(homedir(), '~')} — ${DESCRIPTION}\nhelp[5]:\n desklink-axi start --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
+ 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 --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\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.4.0",
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.4.0",
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
  }
@@ -10,7 +10,7 @@ See and drive a live Linux or macOS desktop and an opted-in browser through desk
10
10
  ```
11
11
  desklink-axi: desklink-axi — See and drive a live Linux or macOS desktop and an opted-in browser through desklink; text and changes by default, pixels on request
12
12
  help[5]:
13
- desklink-axi start --control --source x11 --display :97
13
+ desklink-axi start --source x11 --display :97
14
14
  desklink-axi screen --query "<words>"
15
15
  desklink-axi tree --query "<words>"
16
16
  desklink-axi look @r1
@@ -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.