@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 +22 -10
- package/dist/bridge.js +42 -16
- package/dist/browser-lane.js +48 -38
- package/dist/cursor-events.js +37 -0
- package/dist/index.js +7 -3
- package/dist/skill/content.js +4 -4
- package/package.json +7 -7
- package/skills/desklink-axi/SKILL.md +2 -2
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
|
|
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
|
-
|
|
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 --
|
|
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
|
-
`
|
|
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
|
|
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`.
|
|
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
|
|
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
|
|
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
|
-
|
|
161
|
-
agentIndicator:
|
|
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 =
|
|
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}
|
|
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}
|
|
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();
|
package/dist/browser-lane.js
CHANGED
|
@@ -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
|
|
157
|
+
function processStarted(pid) {
|
|
156
158
|
try {
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
207
|
+
const signal = (pid, started, value) => {
|
|
208
|
+
if (process.platform === 'linux' && (!started || processStarted(pid) !== started))
|
|
209
|
+
return;
|
|
194
210
|
try {
|
|
195
|
-
process.kill(pid,
|
|
211
|
+
process.kill(pid, value);
|
|
196
212
|
}
|
|
197
213
|
catch { /* already gone */ }
|
|
198
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
catch { /* already gone */ }
|
|
229
|
+
if (same())
|
|
230
|
+
signal(pid, started, 'SIGKILL');
|
|
211
231
|
};
|
|
212
|
-
if (state.pid) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
231
|
-
|
|
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
|
|
239
|
-
|
|
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(
|
|
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
|
-
|
|
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', [], ['
|
|
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: ['
|
|
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
|
-
|
|
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
|
}
|
package/dist/skill/content.js
CHANGED
|
@@ -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 --
|
|
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 --
|
|
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\
|
|
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
|
+
"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": "
|
|
28
|
-
"@types/pngjs": "
|
|
29
|
-
"typescript": "
|
|
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.
|
|
33
|
-
"node-datachannel": "
|
|
32
|
+
"@desklink/host": "0.4.0",
|
|
33
|
+
"node-datachannel": "0.33.4",
|
|
34
34
|
"playwright-core": "1.62.1",
|
|
35
|
-
"pngjs": "
|
|
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 --
|
|
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.
|