@desklink/axi 0.5.0 → 0.5.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
@@ -45,9 +45,9 @@ desklink-axi look @r1
45
45
  desklink-axi stop
46
46
  ```
47
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).
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`, plus `hotspot` where the compositor reports it) from the live session; `dropped:N` reports older samples overwritten after its 10,000-sample buffer fills. On Wayland a hidden session still runs when positions are unavailable and says why in `cursor.limitation`; see the [cursor protocol](../desktop-host/docs/PROTOCOL.md#cursor-free-capture).
49
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.
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/<session>.portal-token` (otherwise `~/.local/state/desklink-axi/<session>.portal-token`), atomically replaced with mode 0600 after each grant; `<session>` is `DESKLINK_AXI_SESSION`, the same name the session's socket and browser state use, so two lanes never hand each other's grant to their own consumer. 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
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
52
 
53
53
  ## Browser lane (opt-in, loopback-only)
@@ -105,6 +105,8 @@ For a remote signed Mac bridge, start it once **inside the permission-granted ap
105
105
 
106
106
  For an isolated integration proof run `DESKLINK_AXI_ENGINE=/path/to/engine CARGO_TARGET_DIR=/tmp/task-owned-target node packages/axi/test/flow.mjs` from the repository root. It launches and kills its own Xvfb process by exact PID. `test/a11y-flow.mjs` is the accessibility + marks proof: it also starts a private D-Bus session bus, a private AT-SPI bus, and a GTK3 fixture app (all task-owned, private `XDG_RUNTIME_DIR`), asserts semantic-ref driving of form controls and a file dialog, and prints per-command p50/p95 with `--measure 20`. It skips itself when `busctl`/`dbus-daemon`/`at-spi2-core`/GTK are absent and never touches the user's accessibility bus.
107
107
 
108
+ For the 200-word OCR and query proof, add `DESKLINK_OCR_SCALE=1` or `DESKLINK_OCR_SCALE=1.5` and `DESKLINK_OCR_EVIDENCE=/absolute/output/directory` to that command. The latter uses a private 3840×2160 display at 1.5 scale. Each run saves the ground truth, OCR, word diff, screenshot and every visible-label query. Add `DESKLINK_OCR_UPDATE_README=1` to the 1x run to replace the README transcript directly with CLI output; no transcript lines are edited by hand. Use `DESKLINK_OCR_SHOWCASE_ONLY=1` with the 1x command to replay the edit and regenerate the transcript without repeating the label-query checks. This proof needs Chromium and Tesseract. Known limitation: a text insertion caret visible in the captured pixels may be read as part of nearby item text (for example a trailing `|`), because frames carry pixels without caret identity. Full-frame OCR upscales 3x in software before Tesseract, so a 3840x2160 frame transiently needs a 11520x6480 (~74.6M px, ~298MB RGBA) buffer plus PNG encode plus Tesseract on the enlarged image.
109
+
108
110
  To check the CLI against the AXI spec, run `axi-axi validate "node bin/desklink-axi.js" --dir .` from `packages/axi`.
109
111
 
110
112
  Measured on a private 1280×720 Xvfb client: `diff → click 100,100 → look @r1 → type abc` returned 79 + 79 + 39 + 30 = **227 text tokens** (`@anthropic-ai/tokenizer` 0.0.4), plus ~5 tokens for the 64×64 crop. Four full screenshots would cost ~4×1,229 = **4,916 image tokens** under the same published width×height/750 estimate (~21× difference). This is a short synthetic task, not a production-workload benchmark; the tokenizer is legacy, so absolute counts are approximate.
package/dist/bridge.js CHANGED
@@ -249,21 +249,57 @@ export async function serve(args) {
249
249
  };
250
250
  const ocr = async (frame, box = [0, 0, frame.width, frame.height]) => {
251
251
  const imagePath = join(dir, 'ocr.png');
252
- await image(frame, box, imagePath);
253
- const { spawnSync } = await import('node:child_process');
252
+ // UI glyphs at native size are often below Tesseract's useful x-height.
253
+ // Enlarge the captured pixels, rather than correcting recognized words.
254
+ const scale = 3;
255
+ const input = await cropBuffer(frame, box);
256
+ const { PNG } = await import('pngjs');
257
+ const enlarged = new PNG({ width: input.width * scale, height: input.height * scale });
258
+ for (let y = 0; y < enlarged.height; y++) {
259
+ const sy = Math.max(0, Math.min(input.height - 1, (y + 0.5) / scale - 0.5));
260
+ const top = Math.floor(sy), bottom = Math.min(input.height - 1, top + 1), fy = sy - top;
261
+ for (let x = 0; x < enlarged.width; x++) {
262
+ const sx = Math.max(0, Math.min(input.width - 1, (x + 0.5) / scale - 0.5));
263
+ const left = Math.floor(sx), right = Math.min(input.width - 1, left + 1), fx = sx - left;
264
+ const to = (y * enlarged.width + x) * 4;
265
+ for (let c = 0; c < 3; c++) {
266
+ const a = input.data[(top * input.width + left) * 4 + c];
267
+ const b = input.data[(top * input.width + right) * 4 + c];
268
+ const d = input.data[(bottom * input.width + left) * 4 + c];
269
+ const e = input.data[(bottom * input.width + right) * 4 + c];
270
+ enlarged.data[to + c] = Math.round((a * (1 - fx) + b * fx) * (1 - fy) + (d * (1 - fx) + e * fx) * fy);
271
+ }
272
+ enlarged.data[to + 3] = 255;
273
+ }
274
+ }
275
+ writeFileSync(imagePath, PNG.sync.write(enlarged));
254
276
  const homebrew = ['/opt/homebrew/bin/tesseract', '/usr/local/bin/tesseract'].find(existsSync);
255
- const result = spawnSync(process.platform === 'darwin' && homebrew ? homebrew : 'tesseract', [imagePath, 'stdout', 'tsv'], { encoding: 'utf8' });
256
- if (result.error || result.status !== 0)
257
- throw new Error('ocr-unavailable: install tesseract (brew install tesseract, sudo pacman -S tesseract or sudo apt install tesseract-ocr)');
277
+ // Keep receiving capture and input events while recognition is running.
278
+ const output = await new Promise((resolve, reject) => {
279
+ const child = spawn(process.platform === 'darwin' && homebrew ? homebrew : 'tesseract', [imagePath, 'stdout', 'tsv'], { stdio: ['ignore', 'pipe', 'ignore'] });
280
+ let result = '';
281
+ child.stdout.setEncoding('utf8');
282
+ child.stdout.on('data', chunk => { result += chunk; });
283
+ const unavailable = () => reject(new Error('ocr-unavailable: install tesseract (brew install tesseract, sudo pacman -S tesseract or sudo apt install tesseract-ocr)'));
284
+ child.once('error', unavailable);
285
+ child.once('close', code => { if (code === 0)
286
+ resolve(result);
287
+ else
288
+ unavailable(); });
289
+ });
258
290
  const lines = new Map();
259
- for (const cols of result.stdout.split('\n').slice(1).map(line => line.split('\t'))) {
291
+ const rows = output.split('\n').slice(1).map(line => line.split('\t'));
292
+ for (const cols of rows) {
260
293
  if (cols.length < 12 || Number(cols[10]) < 0 || !cols[11]?.trim())
261
294
  continue;
295
+ const token = cols[11].trim();
296
+ if (!token)
297
+ continue;
262
298
  const key = cols.slice(1, 5).join(':');
263
299
  if (!lines.has(key))
264
300
  lines.set(key, { ref: `@${frame.seq}.${lines.size + 1}`, text: '', x: Number(cols[6]), y: Number(cols[7]), conf: Number(cols[10]), line: key, words: [] });
265
301
  const item = lines.get(key);
266
- const word = { text: cols[11].trim(), x: box[0] + Number(cols[6]), y: box[1] + Number(cols[7]), w: Number(cols[8]), h: Number(cols[9]) };
302
+ const word = { text: token, x: box[0] + Math.floor(Number(cols[6]) / scale), y: box[1] + Math.floor(Number(cols[7]) / scale), w: Math.ceil(Number(cols[8]) / scale), h: Math.ceil(Number(cols[9]) / scale) };
267
303
  if (!item.text) {
268
304
  item.x = word.x;
269
305
  item.y = word.y;
@@ -464,14 +500,14 @@ export async function serve(args) {
464
500
  const current = await capture(baseline);
465
501
  observed = undefined; // Validation must not consume the diff baseline.
466
502
  if (current.seq !== baseline) {
503
+ // Cropping through a widget border or a caret changes segmentation.
504
+ // Validate against the same full-screen context that produced the ref.
505
+ const currentText = await ocr(current);
467
506
  for (const ref of args.filter(arg => /^@\d+\.\d+$/.test(arg))) {
468
507
  const item = text.find(t => t.ref === ref);
469
508
  if (!item)
470
509
  throw new Error(`stale-ref: ${ref}; run screen --query`);
471
- const x = Math.max(0, item.x - 32), y = Math.max(0, item.y - 32);
472
- const right = Math.min(current.width, item.words.at(-1).x + item.words.at(-1).w + 32);
473
- const bottom = Math.min(current.height, Math.max(...item.words.map(w => w.y + w.h)) + 32);
474
- if (!(await ocr(current, [x, y, right - x, bottom - y])).some(t => t.text === item.text && Math.abs(t.x - item.x) < 12 && Math.abs(t.y - item.y) < 12))
510
+ if (!currentText.some(t => t.text === item.text && Math.abs(t.x - item.x) < 12 && Math.abs(t.y - item.y) < 12))
475
511
  throw new Error(`stale-ref: ${ref} moved or changed; run screen --query`);
476
512
  }
477
513
  }
@@ -760,12 +796,15 @@ export async function serve(args) {
760
796
  const prev = text;
761
797
  let kept = [...prev];
762
798
  const dirty = [];
799
+ // Recognize complete lines in full context before selecting damage. A
800
+ // cropped caret or widget border must not turn unchanged words into fragments.
801
+ const currentText = await ocr(frame);
763
802
  for (const region of frame.damage) {
764
803
  const [rx, ry, rw, rh] = region.split(',').map(Number);
765
804
  const x = Math.max(0, rx - 32), y = Math.max(0, ry - 32);
766
805
  const right = Math.min(frame.width, rx + rw + 32), bottom = Math.min(frame.height, ry + rh + 32);
767
806
  kept = kept.filter(t => !t.words.some(w => w.x < right && w.x + w.w > x && w.y < bottom && w.y + w.h > y));
768
- dirty.push(...await ocr(frame, [x, y, right - x, bottom - y]));
807
+ dirty.push(...currentText.filter(t => t.words.some(w => w.x < right && w.x + w.w > x && w.y < bottom && w.y + w.h > y)));
769
808
  }
770
809
  text = [...kept, ...dirty.filter((t, i) => !dirty.slice(0, i).some(p => p.text === t.text && Math.abs(p.x - t.x) < 12 && Math.abs(p.y - t.y) < 12))]
771
810
  .map((t, i) => ({ ...t, ref: `@${frame.seq}.${i + 1}` }));
@@ -795,7 +834,7 @@ export async function serve(args) {
795
834
  }
796
835
  if (observed)
797
836
  observed.damage = visible;
798
- const changed = visible.length ? `${visible.length} region since frame ${frame.previous}` : `none since frame ${frame.previous}`;
837
+ const changed = visible.length ? `${visible.length} region${visible.length === 1 ? '' : 's'} since frame ${frame.previous}` : `none since frame ${frame.previous}`;
799
838
  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>"`;
800
839
  }
801
840
  return `changed: none since frame ${frame.seq} (still ${frame.still_ms}ms)\nhelp[1]:\n desklink-axi screen --query "<words>"`;
@@ -32,6 +32,6 @@ export class CursorEventBuffer {
32
32
  }
33
33
  export function formatCursorSamples(samples, dropped) {
34
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')}`
35
+ ? `cursor[${samples.length}] dropped:${dropped} {sessionId,x,y,visible,timestamp_us,hotspot?}:\n${samples.map(sample => ` ${JSON.stringify(sample)}`).join('\n')}`
36
36
  : `cursor: no pending samples; dropped:${dropped}`;
37
37
  }
package/dist/token.js CHANGED
@@ -2,8 +2,12 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, write
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
+ // Scoped by the session, exactly like the socket and the browser state beside
6
+ // it: one shared name per user let a second lane's grant overwrite the first
7
+ // lane's single-use token, and each lane's consumer then read the other's.
5
8
  export function tokenPath() {
6
- return process.env.DESKLINK_AXI_RESTORE_TOKEN_FILE ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'desklink-axi', 'portal-token');
9
+ return process.env.DESKLINK_AXI_RESTORE_TOKEN_FILE
10
+ ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'desklink-axi', `${process.env.DESKLINK_AXI_SESSION ?? 'default'}.portal-token`);
7
11
  }
8
12
  // A portal token is single-use. Remove it from the reusable name before open;
9
13
  // a failed open must prompt again rather than replay a possibly consumed grant.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/axi",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Agent-facing desktop control CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "typescript": "5.9.3"
30
30
  },
31
31
  "dependencies": {
32
- "@desklink/host": "0.5.0",
32
+ "@desklink/host": "0.5.1",
33
33
  "node-datachannel": "0.33.4",
34
34
  "playwright-core": "1.62.1",
35
35
  "pngjs": "7.0.0"