@desklink/axi 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/dist/bridge.js +28 -1
- package/dist/browser-lane.js +48 -38
- package/dist/cursor-events.js +37 -0
- package/dist/index.js +3 -2
- package/dist/skill/content.js +2 -2
- package/package.json +7 -7
- package/skills/desklink-axi/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<a href="https://www.npmjs.com/package/@desklink/axi"><img alt="npm" src="https://img.shields.io/npm/v/@desklink/axi?style=flat" /></a>
|
|
5
5
|
<a href="https://github.com/umeranjum17/desklink/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/desklink/ci.yml?style=flat&branch=main" /></a>
|
|
6
6
|
<a href="https://github.com/umeranjum17/desklink/blob/main/LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
|
|
7
|
-
<img alt="Linux and macOS" src="https://img.shields.io/badge/Linux%20%7C%20macOS-111?style=flat" />
|
|
7
|
+
<img alt="Linux and macOS (preview)" src="https://img.shields.io/badge/Linux%20%7C%20macOS%20%28preview%29-111?style=flat" />
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
@@ -21,13 +21,14 @@ npm install -g @desklink/axi
|
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Or build from the repository root with `npm install && npm run build`.
|
|
24
|
+
Delete copied `*.tsbuildinfo` before rebuilding a moved worktree.
|
|
24
25
|
The engine is resolved by `@desklink/host`; see its [installation guide](../desktop-host/README.md#install-and-run)
|
|
25
|
-
for supported platforms and [macOS setup](../desktop-host/README.md#macos).
|
|
26
|
+
for supported platforms and [macOS setup](../desktop-host/README.md#macos-preview).
|
|
26
27
|
Set `DESKLINK_AXI_ENGINE` for a source build.
|
|
27
28
|
|
|
28
|
-
A Linux
|
|
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 --control` from the logged-in Mac session. OCR needs the optional `tesseract` executable (`brew install tesseract`, `sudo pacman -S tesseract` or `sudo apt install tesseract-ocr`); on macOS AXI also finds Homebrew's standard install paths when LaunchServices omits them from PATH. `look` works without it.
|
|
31
32
|
|
|
32
33
|
```
|
|
33
34
|
desklink-axi start --control --source x11 --display :97
|
|
@@ -44,7 +45,11 @@ desklink-axi look @r1
|
|
|
44
45
|
desklink-axi stop
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
`start --cursor hidden` opts into cursor-free capture; omit it for existing behavior. Use `desklink-axi cursor` to drain source-coordinate cursor samples (`x`, `y`, `visible`, `timestamp_us`) from the live session; `dropped:N` reports older samples overwritten after its 10,000-sample buffer fills. Linux portals require cursor metadata and refuse hidden capture when it is unavailable; see the [cursor protocol](../desktop-host/docs/PROTOCOL.md#cursor-free-capture).
|
|
49
|
+
|
|
47
50
|
`health` reports capture state, latest frame sequence, and a stream-stop reason without OCR. `start` defaults to view-only; Linux uses the portal unless `--source x11 --display :N` is given, and macOS uses the main display unless `--source display --display <id>` is given. The portal can require a desktop consent prompt; its single-use restore token is kept in `$XDG_STATE_HOME/desklink-axi/portal-token` (otherwise `~/.local/state/desklink-axi/portal-token`), atomically replaced with mode 0600 after each grant. Set `DESKLINK_AXI_RESTORE_TOKEN_FILE` to use a different path. macOS Screen Recording and Accessibility belong to the app that launches the bridge and its engine child. Do not run it unattended against someone else's display. Input and clipboard read/write require `start --control`; view-only clipboard calls return a structured error naming that flag. `stop` releases held input. Actions acknowledge `input: applied` immediately, without waiting for a frame. Every action (`click`, `type`, `press`, `scroll`, `drag`) accepts `--wait settle|change|none|<milliseconds>`; frame timeout after applied input returns `input: applied; frame: timed out` with success status. `batch` takes a JSON array of 1–30 `[verb, ...args]` steps (click/type/press/scroll/wait/snapshot/assert/tree/marks), runs them in order in one socket round trip, stops on the first failed step and returns a compact per-step result. OCR refs survive advancing frame numbers only while their text and position still match a fresh crop; moved or ambiguous text requires `screen --query`. `screen --query` reuses a prior matching text location for a targeted crop before falling back to full OCR, and reports estimated text tokens. Start with `screen --query` or `--region` before `--full`. For web pages prefer DOM/browser automation when available; AXI is for native apps or whole-desktop tasks. `wait change` and `wait settle` block on engine frame signals rather than polling. `diff` reports repeatedly changing regions separately as `animating`; `diff --include-animating` includes them among ordinary changes. `look` writes a PNG crop. `--help` lists the commands and their flags. Errors are structured and the CLI does not prompt.
|
|
51
|
+
To guide the person without input, use `desklink-axi point 400,300 --label "Here" --timeout 3000`, or `desklink-axi point --clear`. See the [host protocol reference](../desktop-host/docs/PROTOCOL.md#point-on-the-host-desktop) for supported sources, coordinates, and limits.
|
|
52
|
+
|
|
48
53
|
## Browser lane (opt-in, loopback-only)
|
|
49
54
|
|
|
50
55
|
For web pages the browser lane gives semantic control through [playwright-core](https://www.npmjs.com/package/playwright-core) over CDP; the desktop lane stays for native apps and whole-desktop tasks. Nothing is attached implicitly:
|
|
@@ -60,16 +65,23 @@ desklink-axi browser select 1 / open <url> / navigate <url> / close 1 / detach
|
|
|
60
65
|
|
|
61
66
|
Non-loopback endpoints are refused; `launch` renders on `$DISPLAY` (X11) so it never follows `WAYLAND_DISPLAY` to another desktop, and `--arg <chromium flag>` (repeatable) passes extra flags such as `--arg=--window-size=1280,640`. Snapshot refs are re-derived on every command and rejected as `stale-ref` when the page changed. `upload` only sets files on `<input type=file>`; for OS-level dialogs it returns `not-supported` and names the desktop lane (`desklink-axi click/type`), which is also the route for real native-dialog navigation — a DOM upload is not equivalent to completing a native file dialog. `detach` stops the browser only if this lane launched it; a passed `--profile` directory is kept, a lane-created temp profile is removed.
|
|
62
67
|
|
|
68
|
+
On Linux, a lane-launched browser's recorded PID and start time must match
|
|
69
|
+
before connection or cleanup. Cached sessions from older versions without a
|
|
70
|
+
recorded start time are refused; use a fresh `DESKLINK_AXI_SESSION` and profile.
|
|
71
|
+
Cleanup verifies helper PID/start-time pairs against the private launch marker
|
|
72
|
+
before signalling them. Browsers attached through `--cdp` remain caller-owned.
|
|
73
|
+
|
|
63
74
|
## Batch outcomes
|
|
64
75
|
|
|
65
76
|
`batch` steps are `[verb, ...args]` arrays or `{verb, args?, name?, wait?}` objects with verbs `click, type, press, scroll, wait, snapshot, assert, tree, marks`. A named snapshot step binds its OCR items so later steps can target `@name.n`; `assert` steps make success depend on observed screen text; per-step lines separate `input: applied` (transport acknowledgement) from `effect: change observed | none within timeout`, and the footer says `effects: asserted[n]` or `effects: unverified (input acknowledgements only)` — an input acknowledgement is never a verified outcome, so wrong-focus typing or a no-op click fails the batch instead of reporting success.
|
|
66
77
|
|
|
67
78
|
## Proofs and measurements
|
|
68
79
|
|
|
69
|
-
From the repository root with a task-built engine (`DESKLINK_AXI_ENGINE`) on
|
|
80
|
+
From the repository root with a task-built engine (`DESKLINK_AXI_ENGINE`) on
|
|
81
|
+
Linux, following the [private-lab guidance](../../README.md#develop):
|
|
70
82
|
|
|
71
83
|
- `node packages/axi/test/smoke.mjs` — clean-tree rebuild of host+axi, then `batch` and `click` on a private Xvfb; asserts no task-owned survivors.
|
|
72
|
-
- `node packages/axi/test/flow.mjs` — full desktop proof (frame/damage, input, chords, batch outcomes, injected-failure cleanup).
|
|
84
|
+
- `node packages/axi/test/flow.mjs` — full desktop proof (frame/damage, point coordinates/timeout/clear without pointer or focus changes, click-through, input, chords, batch outcomes, injected-failure cleanup).
|
|
73
85
|
- `node packages/axi/test/browser-flow.mjs` — the open/form/two-tab fixture through the browser lane with semantic refs on a private Xvfb; also proves selected-vs-front, stale-ref rejection, DOM upload and the not-supported fallback.
|
|
74
86
|
- `node packages/axi/test/bench.mjs [runs]` — comparative p50/p95 (default 20 measured runs after 3 warmups) for the browser lane vs the AXI fallback over the open/form/tabs/native fixtures; text-token proxy is `ceil(stdout chars/4)`, a proxy, not a tokenizer.
|
|
75
87
|
|
package/dist/bridge.js
CHANGED
|
@@ -8,6 +8,7 @@ import { PeerConnection } from 'node-datachannel';
|
|
|
8
8
|
import { saveToken, takeToken, tokenPath } from './token.js';
|
|
9
9
|
import { A11yClient, A11yError, axRefPattern, isActionable } from './atspi.js';
|
|
10
10
|
import { dedupeCandidates, drawMarks, estimateMarksCost } from './marks.js';
|
|
11
|
+
import { CursorEventBuffer, formatCursorSamples } from './cursor-events.js';
|
|
11
12
|
export const socketPath = join(process.env.XDG_RUNTIME_DIR ?? join(tmpdir(), `desklink-axi-${process.getuid?.() ?? 'user'}`), 'desklink-axi', `${process.env.DESKLINK_AXI_SESSION ?? 'default'}.sock`);
|
|
12
13
|
export async function call(command, args = []) {
|
|
13
14
|
if (command === 'stop' && !existsSync(socketPath))
|
|
@@ -107,6 +108,9 @@ export async function serve(args) {
|
|
|
107
108
|
const source = args.includes('--source') ? args[args.indexOf('--source') + 1] : 'auto';
|
|
108
109
|
const display = args.includes('--display') ? args[args.indexOf('--display') + 1] : undefined;
|
|
109
110
|
const control = args.includes('--control');
|
|
111
|
+
const cursor = args.includes('--cursor') ? args[args.indexOf('--cursor') + 1] : 'embedded';
|
|
112
|
+
if (cursor !== 'embedded' && cursor !== 'hidden')
|
|
113
|
+
throw new Error('cursor: use embedded or hidden');
|
|
110
114
|
const timeout = args.includes('--timeout') ? Number(args[args.indexOf('--timeout') + 1]) : 120000;
|
|
111
115
|
if (process.platform !== 'linux' && process.platform !== 'darwin')
|
|
112
116
|
throw new Error('Linux or macOS only');
|
|
@@ -119,6 +123,7 @@ export async function serve(args) {
|
|
|
119
123
|
: 'desktop engine unavailable; set DESKLINK_AXI_ENGINE');
|
|
120
124
|
mkdirSync(join(socketPath, '..'), { recursive: true, mode: 0o700 });
|
|
121
125
|
const events = [];
|
|
126
|
+
const cursorSamples = new CursorEventBuffer();
|
|
122
127
|
const tokenFailure = {};
|
|
123
128
|
const checkToken = () => { if (tokenFailure.current)
|
|
124
129
|
throw new Error(`cannot persist portal restore token: ${tokenFailure.current.message}`); };
|
|
@@ -128,7 +133,9 @@ export async function serve(args) {
|
|
|
128
133
|
let peer;
|
|
129
134
|
let offerReady = false;
|
|
130
135
|
const engine = await EngineClient.start(executable.command, executable.args, { requestTimeoutMs: 125000, onEvent: event => {
|
|
131
|
-
if (event.event
|
|
136
|
+
if (event.event === 'session.cursor')
|
|
137
|
+
cursorSamples.push(event);
|
|
138
|
+
else if (event.event !== 'session.frame.changed')
|
|
132
139
|
events.push(event);
|
|
133
140
|
if (event.event === 'session.restoreToken' && tokenFile) {
|
|
134
141
|
try {
|
|
@@ -159,6 +166,7 @@ export async function serve(args) {
|
|
|
159
166
|
: source === 'x11' || (source === 'auto' && display) ? { kind: 'x11', display } : { kind: 'portal' },
|
|
160
167
|
permissions: control ? ['view', 'control', 'clipboard'] : ['view'], loopbackTcp: true,
|
|
161
168
|
agentIndicator: control,
|
|
169
|
+
cursor,
|
|
162
170
|
...(tokenFile ? { restoreToken: takeToken(tokenFile) } : {}),
|
|
163
171
|
}, timeout);
|
|
164
172
|
checkToken();
|
|
@@ -314,6 +322,10 @@ export async function serve(args) {
|
|
|
314
322
|
routeNote = undefined;
|
|
315
323
|
if (command === 'start')
|
|
316
324
|
return `session: open source=${source} ${display ?? ''} size=${opened.geometry.encoded.width}x${opened.geometry.encoded.height} permissions=view${control ? ',control' : ''}`;
|
|
325
|
+
if (command === 'cursor') {
|
|
326
|
+
const batch = cursorSamples.take(opened.sessionId);
|
|
327
|
+
return formatCursorSamples(batch.samples, batch.dropped);
|
|
328
|
+
}
|
|
317
329
|
if (command === 'health') {
|
|
318
330
|
const state = events.filter(event => event.event === 'session.state' || event.event === 'session.capture.stopped').at(-1);
|
|
319
331
|
if (state?.event === 'session.capture.stopped')
|
|
@@ -435,6 +447,21 @@ export async function serve(args) {
|
|
|
435
447
|
return 'clipboard: written';
|
|
436
448
|
}
|
|
437
449
|
}
|
|
450
|
+
if (command === 'point') {
|
|
451
|
+
if (args.includes('--clear')) {
|
|
452
|
+
if (args.length !== 1)
|
|
453
|
+
throw new Error('point: --clear accepts no other arguments');
|
|
454
|
+
await engine.point(opened.sessionId, { clear: true });
|
|
455
|
+
return 'point: cleared';
|
|
456
|
+
}
|
|
457
|
+
if (!/^\d+,\d+$/.test(args[0] ?? ''))
|
|
458
|
+
throw new Error('point: expected x,y desktop pixels, or --clear');
|
|
459
|
+
const [x, y] = args[0].split(',').map(Number);
|
|
460
|
+
const timeout = args.includes('--timeout') ? Number(args[args.indexOf('--timeout') + 1]) : 3000;
|
|
461
|
+
const label = args.includes('--label') ? args[args.indexOf('--label') + 1] : '';
|
|
462
|
+
await engine.point(opened.sessionId, { x: x, y: y, label, timeoutMs: timeout });
|
|
463
|
+
return `point: shown at=${x},${y} timeout=${timeout}ms`;
|
|
464
|
+
}
|
|
438
465
|
const action = ['click', 'drag', 'type', 'press', 'scroll'].includes(command);
|
|
439
466
|
const actionWait = action && args.includes('--wait') ? args[args.indexOf('--wait') + 1] : 'none';
|
|
440
467
|
const actionDelay = action && /^\d+$/.test(actionWait) ? boundedDelay(actionWait) : undefined;
|
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', [], ['control', 'source', 'display', 'timeout']], ['stop', [], []], ['health', [], []],
|
|
23
|
+
['start', [], ['control', 'source', 'display', 'timeout', 'cursor']], ['stop', [], []], ['health', [], []], ['cursor', [], []],
|
|
24
24
|
['screen', [], ['query', 'region', 'full', 'fields']], ['diff', [], ['include-animating', 'fields']],
|
|
25
25
|
['tree', [], ['query', 'full', 'fields']], ['marks', [], ['region', 'out']],
|
|
26
|
+
['point', [], ['label', 'timeout', 'clear']],
|
|
26
27
|
['look', [], ['region', 'out']], ['click', ['target'], ['button', 'double', 'wait']],
|
|
27
28
|
['type', ['text'], ['into', 'submit', 'wait']], ['press', ['key'], ['wait']],
|
|
28
29
|
['scroll', ['direction'], ['at', 'amount', 'wait']], ['drag', ['from', 'to'], ['wait']],
|
|
@@ -34,7 +35,7 @@ else {
|
|
|
34
35
|
Object.assign(commands, browserCommands());
|
|
35
36
|
for (const [name, required, flags] of definitions)
|
|
36
37
|
commands[name] = {
|
|
37
|
-
spec: { name, summary: `${name} on the live desktop`, args: required.map(arg => ({ name: arg, required: true, description: arg })), flags: flags.map(flag => ({ name: flag, type: ['control', 'full', 'double', 'submit', 'include-animating'].includes(flag) ? 'boolean' : 'string', values: flag === 'source' ? ['auto', 'portal', 'x11', 'display'] : flag === 'button' ? ['left', 'right'] : undefined, description: flag })), examples: [`desklink-axi ${name} ${required.map(arg => `<${arg}>`).join(' ')}`] },
|
|
38
|
+
spec: { name, summary: `${name} on the live desktop`, args: name === 'point' ? [{ name: 'x,y', required: false, description: 'selected desktop pixels; omit for --clear' }] : required.map(arg => ({ name: arg, required: true, description: arg })), flags: flags.map(flag => ({ name: flag, type: ['control', 'full', 'double', 'submit', 'include-animating', 'clear'].includes(flag) ? 'boolean' : 'string', values: flag === 'source' ? ['auto', 'portal', 'x11', 'display'] : flag === 'button' ? ['left', 'right'] : undefined, description: flag })), examples: name === 'point' ? ['desklink-axi point 400,300 --label "Here" --timeout 3000', 'desklink-axi point --clear'] : [`desklink-axi ${name} ${required.map(arg => `<${arg}>`).join(' ')}`] },
|
|
38
39
|
async run(parsed) {
|
|
39
40
|
if (name === 'scroll' && !['up', 'down'].includes(parsed.positionals[0]))
|
|
40
41
|
throw new UsageError('scroll direction must be up or down');
|
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'];
|
|
5
|
+
const commands = ['start', 'stop', 'health', 'screen', 'tree', 'marks', 'diff', 'look', 'point', 'click', 'type', 'press', 'scroll', 'drag', 'batch', 'wait', 'clipboard read', 'clipboard write', 'setup hooks', 'browser attach', 'browser launch', 'browser tabs', 'browser select', 'browser open', 'browser navigate', 'browser close', 'browser snapshot', 'browser click', 'browser fill', 'browser press', 'browser upload', 'browser detach'];
|
|
6
6
|
export function renderHome(binPath) { return `desklink-axi: ${binPath.replace(homedir(), '~')} — ${DESCRIPTION}\nhelp[5]:\n desklink-axi start --control --source x11 --display :97\n desklink-axi screen --query "<words>"\n desklink-axi tree --query "<words>"\n desklink-axi look @r1\n desklink-axi browser snapshot`; }
|
|
7
7
|
export function rootHelpText() { return [`desklink-axi: ${DESCRIPTION}`, emitList('commands', commands.map(command => ({ command, summary: command.startsWith('browser') ? `${command} on the attached browser` : `${command} on the live desktop` })), ['command', 'summary']), 'flags[1]{flag,description}:\n --help,show help for any command', 'examples[2]:\n desklink-axi start --control --source x11 --display :97\n desklink-axi screen --query "<words>"'].join('\n'); }
|
|
8
|
-
export function renderSkill() { return `---\nname: desklink-axi\ndescription: "${DESCRIPTION}"\n---\n\n# desklink-axi\n\n${DESCRIPTION} (${SPEC_VERSION}).\n\n\`\`\`\n${renderHome('desklink-axi')}\n\`\`\`\n\nTwo lanes: the desktop lane (capture, OCR refs, AT-SPI tree, input) and the browser lane (semantic web control through an opted-in Chromium). On the desktop, \`tree --query "<words>"\` reads the AT-SPI accessibility tree into generation-tagged \`@a<gen>.<n>\` refs (values included), and \`marks\` overlays numbered candidates for icon-only controls. For web pages use the browser lane: \`desklink-axi browser attach --cdp 127.0.0.1:<port>\` for a browser you started with \`--remote-debugging-port\` (loopback only), or \`desklink-axi browser launch\` for an isolated task-owned profile. Then \`browser tabs\`, \`browser snapshot\` (compact accessibility refs, no screenshots), \`browser click/fill/upload @e<N>\`, \`browser select <tab>\`. Page selection (selected) and the browser's actually activated tab (front) are reported separately. OS-level dialogs are not-supported in the browser lane: fall back to the desktop lane (click/type on the dialog).\n\nRead with screen --query or --region before screen --full. Actions acknowledge input immediately; add --wait change|settle|<milliseconds> when needed. Batch sequential steps in one socket round trip: desklink-axi batch '[["press","Meta+l"],["type","https://example.com"],["press","Return"],["wait","change"]]'. Steps may be {verb,args?,name?,wait?} objects; a named snapshot binds OCR refs for later @name.n steps, and assert steps (\`["assert","text"]\`, \`["assert","--absent","text"]\`) make the batch outcome depend on observed screen text. The footer reports effects: asserted[n] or effects: unverified (input acknowledgements only) — an input ack is never a verified outcome.\n\
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Agent-facing desktop control CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"skill:check": "node scripts/gen-skill.mjs --check"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@types/node": "
|
|
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.3.
|
|
33
|
-
"node-datachannel": "
|
|
32
|
+
"@desklink/host": "0.3.1",
|
|
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
|
}
|
|
@@ -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.
|