@desklink/axi 0.3.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/dist/bridge.js ADDED
@@ -0,0 +1,818 @@
1
+ import { createServer, connect } from 'node:net';
2
+ import { mkdirSync, existsSync, unlinkSync, mkdtempSync, rmSync, chmodSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { spawn } from 'node:child_process';
6
+ import { EngineClient, resolveEngine } from '@desklink/host';
7
+ import { PeerConnection } from 'node-datachannel';
8
+ import { saveToken, takeToken, tokenPath } from './token.js';
9
+ import { A11yClient, A11yError, axRefPattern, isActionable } from './atspi.js';
10
+ import { dedupeCandidates, drawMarks, estimateMarksCost } from './marks.js';
11
+ 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
+ export async function call(command, args = []) {
13
+ if (command === 'stop' && !existsSync(socketPath))
14
+ return 'session: stopped (already closed)';
15
+ if (command === 'start' && existsSync(socketPath)) {
16
+ const stale = await new Promise(resolve => { const probe = connect(socketPath); probe.on('connect', () => { probe.end(); resolve(false); }); probe.on('error', () => resolve(true)); });
17
+ if (stale)
18
+ unlinkSync(socketPath);
19
+ }
20
+ if (command === 'start' && !existsSync(socketPath)) {
21
+ if (process.platform === 'darwin')
22
+ throw new Error('On macOS launch the signed DesklinkHost.app with --args axi-bridge <absolute desklink-axi.js> [start flags]; do not start the Node helper from Terminal or SSH.');
23
+ mkdirSync(join(socketPath, '..'), { recursive: true, mode: 0o700 });
24
+ const errorPath = `${socketPath}.error`;
25
+ if (existsSync(errorPath))
26
+ unlinkSync(errorPath);
27
+ const child = spawn(process.execPath, [process.argv[1], '--bridge', ...args], { detached: true, stdio: 'ignore', env: process.env });
28
+ if (process.env.DESKLINK_AXI_PID_FILE && child.pid) {
29
+ try {
30
+ appendFileSync(process.env.DESKLINK_AXI_PID_FILE, `${child.pid}\n`);
31
+ }
32
+ catch (error) {
33
+ child.kill('SIGKILL');
34
+ throw error;
35
+ }
36
+ }
37
+ child.unref();
38
+ const deadline = Date.now() + Number(args.includes('--timeout') ? args[args.indexOf('--timeout') + 1] : 120000) + 5000;
39
+ while (Date.now() < deadline && !existsSync(socketPath) && !existsSync(errorPath))
40
+ await new Promise(r => setTimeout(r, 50));
41
+ if (existsSync(errorPath)) {
42
+ const message = readFileSync(errorPath, 'utf8');
43
+ unlinkSync(errorPath);
44
+ throw new Error(message);
45
+ }
46
+ }
47
+ if (!existsSync(socketPath))
48
+ throw new Error('no-session: run desklink-axi start first');
49
+ return new Promise((resolve, reject) => {
50
+ const socket = connect(socketPath);
51
+ let data = '';
52
+ socket.on('connect', () => socket.write(JSON.stringify({ command, args }) + '\n'));
53
+ socket.on('data', chunk => data += chunk);
54
+ socket.on('end', () => resolve(data.trim()));
55
+ socket.on('error', reject);
56
+ });
57
+ }
58
+ function tileDamage(before, after, width, height) {
59
+ const columns = Math.ceil(width / 32), rows = Math.ceil(height / 32);
60
+ const dirty = new Set();
61
+ for (let row = 0; row < rows; row++)
62
+ for (let col = 0; col < columns; col++) {
63
+ const x = col * 32, y = row * 32, bytes = Math.min(32, width - x) * 4;
64
+ for (let line = y; line < Math.min(y + 32, height); line++) {
65
+ const offset = (line * width + x) * 4;
66
+ if (!before.subarray(offset, offset + bytes).equals(after.subarray(offset, offset + bytes))) {
67
+ dirty.add(row * columns + col);
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ const boxes = [];
73
+ while (dirty.size) {
74
+ const first = dirty.values().next().value;
75
+ dirty.delete(first);
76
+ const stack = [first];
77
+ let left = first % columns, right = left, top = Math.floor(first / columns), bottom = top;
78
+ while (stack.length) {
79
+ const tile = stack.pop(), col = tile % columns, row = Math.floor(tile / columns);
80
+ left = Math.min(left, col);
81
+ right = Math.max(right, col);
82
+ top = Math.min(top, row);
83
+ bottom = Math.max(bottom, row);
84
+ for (const neighbor of [row > 0 ? tile - columns : -1, row + 1 < rows ? tile + columns : -1, col > 0 ? tile - 1 : -1, col + 1 < columns ? tile + 1 : -1]) {
85
+ if (dirty.delete(neighbor))
86
+ stack.push(neighbor);
87
+ }
88
+ }
89
+ boxes.push([left * 32, top * 32, Math.min(width, (right + 1) * 32) - left * 32, Math.min(height, (bottom + 1) * 32) - top * 32]);
90
+ }
91
+ return boxes;
92
+ }
93
+ function boundedDelay(value) {
94
+ const ms = Number(value);
95
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(ms) || ms > 120000)
96
+ throw new Error('wait-duration: milliseconds must be an integer from 0 to 120000');
97
+ return ms;
98
+ }
99
+ function overlaps(a, b) {
100
+ const [ax, ay, aw, ah] = a.split(',').map(Number);
101
+ const [bx, by, bw, bh] = b.split(',').map(Number);
102
+ return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
103
+ }
104
+ export async function serve(args) {
105
+ if (existsSync(socketPath))
106
+ throw new Error('session already running');
107
+ const source = args.includes('--source') ? args[args.indexOf('--source') + 1] : 'auto';
108
+ const display = args.includes('--display') ? args[args.indexOf('--display') + 1] : undefined;
109
+ const control = args.includes('--control');
110
+ const timeout = args.includes('--timeout') ? Number(args[args.indexOf('--timeout') + 1]) : 120000;
111
+ if (process.platform !== 'linux' && process.platform !== 'darwin')
112
+ throw new Error('Linux or macOS only');
113
+ if (process.platform === 'darwin' ? !['auto', 'display'].includes(source ?? '') || (display !== undefined && !/^\d+$/.test(display)) : source === 'display')
114
+ throw new Error('source: use display and a numeric --display on macOS, portal/x11 on Linux');
115
+ const executable = resolveEngine(process.env.DESKLINK_AXI_ENGINE);
116
+ if (!executable)
117
+ throw new Error(process.platform === 'darwin' && process.env.DESKLINK_MACOS === '0'
118
+ ? 'macOS engine disabled by DESKLINK_MACOS=0; unset it to enable the engine'
119
+ : 'desktop engine unavailable; set DESKLINK_AXI_ENGINE');
120
+ mkdirSync(join(socketPath, '..'), { recursive: true, mode: 0o700 });
121
+ const events = [];
122
+ const tokenFailure = {};
123
+ const checkToken = () => { if (tokenFailure.current)
124
+ throw new Error(`cannot persist portal restore token: ${tokenFailure.current.message}`); };
125
+ const portal = process.platform === 'linux' && source !== 'x11' && !(source === 'auto' && display);
126
+ const tokenFile = portal ? tokenPath() : undefined;
127
+ const motion = new Map();
128
+ let peer;
129
+ let offerReady = false;
130
+ const engine = await EngineClient.start(executable.command, executable.args, { requestTimeoutMs: 125000, onEvent: event => {
131
+ if (event.event !== 'session.frame.changed')
132
+ events.push(event);
133
+ if (event.event === 'session.restoreToken' && tokenFile) {
134
+ try {
135
+ saveToken(tokenFile, event.params.token);
136
+ }
137
+ catch (error) {
138
+ tokenFailure.current = error;
139
+ }
140
+ }
141
+ if (event.event === 'session.frame.changed') {
142
+ const now = Date.now();
143
+ const previous = [...motion];
144
+ motion.clear();
145
+ for (const box of event.params.damage) {
146
+ const region = box.join(',');
147
+ const prior = previous.find(([key, state]) => state.seq === event.params.seq - 1 && now - state.at < 600 && overlaps(key, region))?.[1];
148
+ motion.set(region, { seq: event.params.seq, at: now, count: prior ? prior.count + 1 : 1, reported: prior?.reported ?? false });
149
+ }
150
+ }
151
+ if (offerReady && peer && event.event === 'session.candidate')
152
+ peer.addRemoteCandidate(event.params.candidate, event.params.sdpMid || '0');
153
+ } });
154
+ const dir = mkdtempSync(join(tmpdir(), 'desklink-axi-'));
155
+ try {
156
+ const opened = await engine.openSession({
157
+ source: process.platform === 'darwin'
158
+ ? { kind: 'display', ...(display === undefined ? {} : { display_id: Number(display) }) }
159
+ : source === 'x11' || (source === 'auto' && display) ? { kind: 'x11', display } : { kind: 'portal' },
160
+ permissions: control ? ['view', 'control', 'clipboard'] : ['view'], loopbackTcp: true,
161
+ agentIndicator: control,
162
+ ...(tokenFile ? { restoreToken: takeToken(tokenFile) } : {}),
163
+ }, timeout);
164
+ checkToken();
165
+ peer = control ? new PeerConnection('desklink-axi', { iceServers: [], bindAddress: '127.0.0.1', enableIceTcp: true }) : undefined;
166
+ let channel;
167
+ let open = false;
168
+ let nextSeq = 1;
169
+ const pending = new Map();
170
+ if (peer) {
171
+ peer.onDataChannel(ch => {
172
+ if (ch.getLabel() === 'control') {
173
+ channel = ch;
174
+ ch.onOpen(() => { open = true; });
175
+ ch.onMessage(raw => { try {
176
+ const reply = JSON.parse(String(raw));
177
+ const waiting = pending.get(reply.seq ?? -1);
178
+ if (!waiting)
179
+ return;
180
+ pending.delete(reply.seq);
181
+ if (reply.kind === 'rejected')
182
+ waiting.reject(new Error(`${reply.code}: ${reply.message}`));
183
+ else
184
+ waiting.resolve();
185
+ }
186
+ catch { /* ignore unrelated notifications */ } });
187
+ }
188
+ });
189
+ peer.onLocalCandidate((candidate, mid) => { void engine.addCandidate(opened.sessionId, opened.generation, candidate, mid, Number(mid) || 0); });
190
+ peer.onLocalDescription((sdp) => { void engine.acceptAnswer(opened.sessionId, opened.generation, sdp); });
191
+ const deadline = Date.now() + 10000;
192
+ while (!events.some(event => event.event === 'session.description') && Date.now() < deadline)
193
+ await new Promise(r => setTimeout(r, 20));
194
+ const offer = events.find(event => event.event === 'session.description');
195
+ if (!offer || offer.event !== 'session.description')
196
+ throw new Error('transport offer unavailable');
197
+ peer.setRemoteDescription(offer.params.description.sdp, 'offer');
198
+ for (const event of events)
199
+ if (event.event === 'session.candidate') {
200
+ peer.addRemoteCandidate(event.params.candidate, event.params.sdpMid || '0');
201
+ }
202
+ offerReady = true;
203
+ }
204
+ let baseline = 0;
205
+ let text = [];
206
+ let regions = [];
207
+ const a11y = new A11yClient(process.env);
208
+ const axNodes = new Map();
209
+ let routeNote;
210
+ let lastFrame;
211
+ let observed;
212
+ const capture = async (since = baseline, wait = {}) => {
213
+ const path = join(dir, 'frame.raw');
214
+ const frame = await engine.request('session.frame', { session_id: opened.sessionId, since, path, ...wait });
215
+ const raw = readFileSync(path);
216
+ const damage = lastFrame?.seq === since && lastFrame.width === frame.width && lastFrame.height === frame.height
217
+ ? tileDamage(lastFrame.raw, raw, frame.width, frame.height).map(box => box.join(','))
218
+ : frame.damage.map(box => box.join(','));
219
+ lastFrame = { seq: frame.seq, width: frame.width, height: frame.height, raw };
220
+ observed = { seq: frame.seq, damage };
221
+ return { ...frame, changed: frame.seq !== baseline, damage, raw, previous: baseline };
222
+ };
223
+ const image = async (frame, box, path) => {
224
+ const png = await cropBuffer(frame, box);
225
+ const { PNG: P } = await import('pngjs');
226
+ (await import('node:fs')).writeFileSync(path, P.sync.write(png));
227
+ };
228
+ const cropBuffer = async (frame, box) => {
229
+ const { PNG } = await import('pngjs');
230
+ const raw = frame.raw;
231
+ const [x, y, w, h] = box;
232
+ const png = new PNG({ width: w, height: h });
233
+ for (let row = 0; row < h; row++)
234
+ for (let col = 0; col < w; col++) {
235
+ const from = ((y + row) * frame.width + x + col) * 4, to = (row * w + col) * 4;
236
+ png.data[to] = raw[from + 2];
237
+ png.data[to + 1] = raw[from + 1];
238
+ png.data[to + 2] = raw[from];
239
+ png.data[to + 3] = 255;
240
+ }
241
+ return png;
242
+ };
243
+ const ocr = async (frame, box = [0, 0, frame.width, frame.height]) => {
244
+ const imagePath = join(dir, 'ocr.png');
245
+ await image(frame, box, imagePath);
246
+ const { spawnSync } = await import('node:child_process');
247
+ const homebrew = ['/opt/homebrew/bin/tesseract', '/usr/local/bin/tesseract'].find(existsSync);
248
+ const result = spawnSync(process.platform === 'darwin' && homebrew ? homebrew : 'tesseract', [imagePath, 'stdout', 'tsv'], { encoding: 'utf8' });
249
+ if (result.error || result.status !== 0)
250
+ throw new Error('ocr-unavailable: install tesseract (brew install tesseract, sudo pacman -S tesseract or sudo apt install tesseract-ocr)');
251
+ const lines = new Map();
252
+ for (const cols of result.stdout.split('\n').slice(1).map(line => line.split('\t'))) {
253
+ if (cols.length < 12 || Number(cols[10]) < 0 || !cols[11]?.trim())
254
+ continue;
255
+ const key = cols.slice(1, 5).join(':');
256
+ if (!lines.has(key))
257
+ 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: [] });
258
+ const item = lines.get(key);
259
+ 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]) };
260
+ if (!item.text) {
261
+ item.x = word.x;
262
+ item.y = word.y;
263
+ }
264
+ item.words.push(word);
265
+ item.text += (item.text ? ' ' : '') + word.text;
266
+ }
267
+ return [...lines.values()];
268
+ };
269
+ const act = async (message) => {
270
+ if (!control)
271
+ throw new Error('input-unavailable: start --control');
272
+ for (let i = 0; i < 100 && !open; i++)
273
+ await new Promise(r => setTimeout(r, 50));
274
+ if (!open || !channel)
275
+ throw new Error('transport: control channel not connected');
276
+ const seq = nextSeq++;
277
+ await new Promise((resolve, reject) => {
278
+ const timer = setTimeout(() => { pending.delete(seq); reject(new Error('transport: input acknowledgement timed out')); }, 3000);
279
+ pending.set(seq, { resolve: () => { clearTimeout(timer); resolve(); }, reject: error => { clearTimeout(timer); reject(error); } });
280
+ if (!channel.sendMessage(JSON.stringify({ ...message, seq }))) {
281
+ pending.delete(seq);
282
+ clearTimeout(timer);
283
+ reject(new Error('transport: input send failed'));
284
+ }
285
+ });
286
+ };
287
+ const target = (value) => {
288
+ if (/^\d+,\d+$/.test(value))
289
+ return value.split(',').map(Number);
290
+ if (value.startsWith('@') && !text.some(t => t.ref === value))
291
+ throw new Error(`stale-ref: ${value}; run screen --query`);
292
+ const matches = text.filter(t => t.ref === value || t.text.toLowerCase().includes(value.toLowerCase()));
293
+ if (matches.length !== 1)
294
+ throw new Error(`${matches.length ? 'ambiguous' : 'not-found'}: ${value}; run screen --query`);
295
+ const item = matches[0];
296
+ const index = value === item.ref ? 0 : item.words.findIndex((_, i, all) => all.slice(i).map(w => w.text).join(' ').toLowerCase().startsWith(value.toLowerCase()));
297
+ const start = item.words[Math.max(index, 0)];
298
+ const count = value === item.ref ? item.words.length : value.split(/\s+/).length;
299
+ const end = item.words[Math.max(index, 0) + count - 1] ?? start;
300
+ return [Math.floor((start.x + end.x + end.w) / 2), Math.floor((start.y + end.y + end.h) / 2)];
301
+ };
302
+ const axTarget = async (ref) => {
303
+ const node = axNodes.get(ref);
304
+ if (!node)
305
+ throw new A11yError('stale-ref', `${ref} is not in any recent tree; run desklink-axi tree`);
306
+ const fresh = await a11y.revalidate(node);
307
+ if (!fresh.states.includes('enabled') || !fresh.states.includes('showing'))
308
+ throw new A11yError('not-actionable', `${ref} (${fresh.role} ${JSON.stringify(fresh.name)}) is disabled or hidden`);
309
+ return fresh;
310
+ };
311
+ const output = async (command, args) => {
312
+ let pendingDamage;
313
+ let waitSeen = baseline;
314
+ routeNote = undefined;
315
+ if (command === 'start')
316
+ return `session: open source=${source} ${display ?? ''} size=${opened.geometry.encoded.width}x${opened.geometry.encoded.height} permissions=view${control ? ',control' : ''}`;
317
+ if (command === 'health') {
318
+ const state = events.filter(event => event.event === 'session.state' || event.event === 'session.capture.stopped').at(-1);
319
+ if (state?.event === 'session.capture.stopped')
320
+ return `capture: stopped; reason: ${state.params.reason}`;
321
+ const frame = await engine.request('session.frame', { session_id: opened.sessionId, path: '' }).catch(() => undefined);
322
+ return state?.event === 'session.state' ? `capture: ${state.params.capture}; transport: ${state.params.transport}; frame: ${frame?.seq ?? 'unavailable'}` : 'capture: starting';
323
+ }
324
+ if (command === 'batch') {
325
+ // Transactional batch with outcome semantics (build-plan item 5). Steps
326
+ // are ["verb", ...args] arrays or {verb, args?, name?, wait?} objects.
327
+ // A named snapshot step binds its OCR items so later steps can target
328
+ // `@name.n` refs; assert steps turn observed text into task success.
329
+ // Per step, `input: applied` stays a transport acknowledgement; the
330
+ // effect field reports only what a post-action frame read saw, and the
331
+ // footer never presents acknowledged input as a verified outcome.
332
+ let raw;
333
+ try {
334
+ raw = JSON.parse(args[0] ?? '');
335
+ }
336
+ catch {
337
+ throw new Error('batch: expected a JSON array of steps');
338
+ }
339
+ const verbs = ['click', 'type', 'press', 'scroll', 'wait', 'snapshot', 'assert', 'tree', 'marks'];
340
+ const isStep = (step) => Array.isArray(step)
341
+ ? verbs.includes(String(step[0])) && step.slice(1).every(arg => typeof arg === 'string')
342
+ : typeof step === 'object' && step !== null && verbs.includes(String(step.verb));
343
+ if (!Array.isArray(raw) || !raw.length || raw.length > 30 || !raw.every(isStep)) {
344
+ throw new Error('batch: expected 1\u201330 steps as [click|type|press|scroll|wait|snapshot|assert|tree|marks, ...args] or {verb, args?, name?, wait?} objects');
345
+ }
346
+ const steps = raw.map(step => Array.isArray(step)
347
+ ? { verb: String(step[0]), args: step.slice(1).map(String) }
348
+ : { verb: String(step.verb),
349
+ args: Array.isArray(step.args) ? step.args.map(String) : [],
350
+ name: typeof step.name === 'string' ? step.name : undefined,
351
+ wait: typeof step.wait === 'string' ? step.wait : undefined });
352
+ const bindings = new Map();
353
+ const resolveBound = (arg) => {
354
+ const bound = /^@([A-Za-z][A-Za-z0-9_-]*)\.(\d+)$/.exec(arg);
355
+ if (!bound)
356
+ return arg;
357
+ const items = bindings.get(bound[1]);
358
+ if (!items)
359
+ return arg; // Not a named binding — tree (@a<gen>.<n>) and OCR refs flow to target().
360
+ const item = items[Number(bound[2]) - 1];
361
+ if (!item)
362
+ throw new Error(`stale-ref: ${arg}; binding holds ${items.length} items`);
363
+ const live = text.find(t => t.text === item.text && Math.abs(t.x - item.x) < 12 && Math.abs(t.y - item.y) < 12);
364
+ if (!live)
365
+ throw new Error(`stale-ref: ${arg}; run a fresh snapshot step`);
366
+ return live.ref;
367
+ };
368
+ const results = [];
369
+ let asserted = 0;
370
+ const label = (index, step) => `${index + 1}${step.name ? ` ${step.name}` : ''}:`;
371
+ const footer = () => asserted ? `effects: asserted[${asserted}]` : 'effects: unverified (input acknowledgements only)';
372
+ for (const [index, step] of steps.entries()) {
373
+ try {
374
+ if (step.verb === 'snapshot') {
375
+ const view = await output('screen', step.args[0] ? ['--query', step.args[0]] : []);
376
+ if (step.name)
377
+ bindings.set(step.name, [...text]);
378
+ results.push(`${label(index, step)} ${view.split('\n')[0]}`);
379
+ continue;
380
+ }
381
+ if (step.verb === 'assert') {
382
+ const absent = step.args[0] === '--absent';
383
+ const wanted = (absent ? step.args[1] : step.args[0]) ?? '';
384
+ if (!wanted)
385
+ throw new Error('assert: expected text (optionally after --absent)');
386
+ const found = !(await output('screen', ['--query', wanted])).includes('text: 0 items match');
387
+ if (found === absent)
388
+ throw new Error(`assert-failed: "${wanted}" ${absent ? 'is present but --absent was set' : 'not on screen; effect not confirmed'}`);
389
+ asserted++;
390
+ results.push(`${label(index, step)} assert: "${wanted}" ${absent ? 'absent' : 'found'}`);
391
+ continue;
392
+ }
393
+ const stepArgs = step.args.map(resolveBound);
394
+ if (step.verb === 'wait') {
395
+ results.push(`${label(index, step)} ${await output('wait', stepArgs)}`);
396
+ continue;
397
+ }
398
+ if (step.wait !== undefined)
399
+ stepArgs.push('--wait', step.wait);
400
+ const out = await output(step.verb, stepArgs);
401
+ const effect = out.includes('frame: ready') ? 'effect: change observed'
402
+ : out.includes('frame: timed out') ? 'effect: none within timeout' : 'input: applied';
403
+ results.push(`${label(index, step)} ${effect}`);
404
+ }
405
+ catch (error) {
406
+ results.push(`${label(index, step)} error: ${error.message}`);
407
+ return `error: batch: ${results.length - 1}/${steps.length} steps; ${footer()}\n${results.join('\n')}`;
408
+ }
409
+ }
410
+ return `batch: ${results.length}/${steps.length} steps; ${footer()}\n${results.join('\n')}`;
411
+ }
412
+ if (command === 'stop') {
413
+ try {
414
+ await engine.closeSession(opened.sessionId);
415
+ }
416
+ finally {
417
+ peer?.close();
418
+ await engine.stop();
419
+ server.close();
420
+ if (existsSync(socketPath))
421
+ unlinkSync(socketPath);
422
+ rmSync(dir, { recursive: true, force: true });
423
+ }
424
+ return 'session: stopped';
425
+ }
426
+ if (command === 'clipboard') {
427
+ if (!control)
428
+ throw new Error('input-unavailable: clipboard requires start --control');
429
+ if (args[0] === 'read') {
430
+ const result = await engine.readClipboard(opened.sessionId);
431
+ return `clipboard: ${args.includes('--full') ? result.text : result.text.slice(0, 1000)} (${result.text.length} chars)`;
432
+ }
433
+ if (args[0] === 'write') {
434
+ await engine.writeClipboard(opened.sessionId, args[1] ?? '');
435
+ return 'clipboard: written';
436
+ }
437
+ }
438
+ const action = ['click', 'drag', 'type', 'press', 'scroll'].includes(command);
439
+ const actionWait = action && args.includes('--wait') ? args[args.indexOf('--wait') + 1] : 'none';
440
+ const actionDelay = action && /^\d+$/.test(actionWait) ? boundedDelay(actionWait) : undefined;
441
+ if (action && args.some(arg => /^@\d+\.\d+$/.test(arg))) {
442
+ const current = await capture(baseline);
443
+ observed = undefined; // Validation must not consume the diff baseline.
444
+ if (current.seq !== baseline) {
445
+ for (const ref of args.filter(arg => /^@\d+\.\d+$/.test(arg))) {
446
+ const item = text.find(t => t.ref === ref);
447
+ if (!item)
448
+ throw new Error(`stale-ref: ${ref}; run screen --query`);
449
+ const x = Math.max(0, item.x - 32), y = Math.max(0, item.y - 32);
450
+ const right = Math.min(current.width, item.words.at(-1).x + item.words.at(-1).w + 32);
451
+ const bottom = Math.min(current.height, Math.max(...item.words.map(w => w.y + w.h)) + 32);
452
+ 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))
453
+ throw new Error(`stale-ref: ${ref} moved or changed; run screen --query`);
454
+ }
455
+ }
456
+ }
457
+ const preActionSeq = action && actionWait !== 'none' ? (await engine.request('session.frame', { session_id: opened.sessionId, path: '' })).seq : baseline;
458
+ const finishAction = async () => {
459
+ const applied = routeNote ? `input: applied (${routeNote})` : 'input: applied';
460
+ if (actionWait === 'none')
461
+ return applied;
462
+ try {
463
+ if (actionDelay !== undefined)
464
+ await new Promise(r => setTimeout(r, actionDelay));
465
+ else
466
+ await engine.request('session.frame', { session_id: opened.sessionId, since: baseline, after_seq: preActionSeq, ...(actionWait === 'settle' ? { still_ms: 150 } : {}), timeout_ms: 5000 });
467
+ }
468
+ catch (error) {
469
+ if (error.code === 'frame-timeout')
470
+ return `${applied}; frame: timed out`;
471
+ throw error;
472
+ }
473
+ return `${applied}; frame: ready`;
474
+ };
475
+ if (command === 'click' || command === 'drag') {
476
+ if (axRefPattern.test(args[0] ?? '') && command === 'click') {
477
+ const node = await axTarget(args[0]);
478
+ const fallback = `${node.x + Math.floor(node.w / 2)},${node.y + Math.floor(node.h / 2)}`;
479
+ try {
480
+ const action = await a11y.doAction(node);
481
+ routeNote = `atspi action ${JSON.stringify(action)}`;
482
+ return await finishAction();
483
+ }
484
+ catch (error) {
485
+ if (!['a11y-error', 'a11y-unsupported', 'action-failed'].includes(error.code))
486
+ throw error;
487
+ routeNote = 'coordinate fallback';
488
+ args[0] = fallback;
489
+ }
490
+ }
491
+ const [x, y] = target(args[0]);
492
+ const [endX, endY] = command === 'drag' ? target(args[1]) : [x, y];
493
+ const button = args.includes('right') ? 3 : 1;
494
+ await act({ kind: 'pointer', phase: 'move', x, y });
495
+ await act({ kind: 'pointer', phase: 'down', x, y, button });
496
+ try {
497
+ if (command === 'drag')
498
+ await act({ kind: 'pointer', phase: 'move', x: endX, y: endY });
499
+ await act({ kind: 'pointer', phase: 'up', x: endX, y: endY, button });
500
+ if (command === 'click' && args.includes('--double')) {
501
+ await act({ kind: 'pointer', phase: 'down', x, y, button });
502
+ await act({ kind: 'pointer', phase: 'up', x, y, button });
503
+ }
504
+ }
505
+ catch (error) {
506
+ await act({ kind: 'release_all' }).catch(() => undefined);
507
+ throw error;
508
+ }
509
+ }
510
+ else if (command === 'type') {
511
+ try {
512
+ if (args.includes('--into')) {
513
+ const into = args[args.indexOf('--into') + 1];
514
+ if (axRefPattern.test(into)) {
515
+ const node = await axTarget(into);
516
+ if (await a11y.grabFocus(node).catch(() => false))
517
+ routeNote = 'atspi focus';
518
+ else {
519
+ routeNote = 'coordinate focus';
520
+ const [x, y] = [node.x + Math.floor(node.w / 2), node.y + Math.floor(node.h / 2)];
521
+ await act({ kind: 'pointer', phase: 'move', x, y });
522
+ await act({ kind: 'pointer', phase: 'down', x, y });
523
+ await act({ kind: 'pointer', phase: 'up', x, y });
524
+ }
525
+ }
526
+ else {
527
+ const [x, y] = target(into);
528
+ await act({ kind: 'pointer', phase: 'move', x, y });
529
+ await act({ kind: 'pointer', phase: 'down', x, y });
530
+ await act({ kind: 'pointer', phase: 'up', x, y });
531
+ }
532
+ }
533
+ await act({ kind: 'text', text: args[0] ?? '' });
534
+ if (args.includes('--submit')) {
535
+ await act({ kind: 'key', name: 'Enter', down: true });
536
+ await act({ kind: 'key', name: 'Enter', down: false });
537
+ }
538
+ }
539
+ catch (error) {
540
+ await act({ kind: 'release_all' }).catch(() => undefined);
541
+ throw error;
542
+ }
543
+ }
544
+ else if (command === 'press') {
545
+ const parts = (args[0] ?? '').split('+');
546
+ const name = parts.pop();
547
+ const modifiers = parts.map(p => ({ ctrl: 'Ctrl', control: 'Control', alt: 'Alt', shift: 'Shift', meta: 'Meta' }[p.toLowerCase()] ?? p));
548
+ const key = name.length === 1 ? { character: name } : { name };
549
+ try {
550
+ await act({ kind: 'key', ...key, down: true, modifiers });
551
+ await act({ kind: 'key', ...key, down: false, modifiers });
552
+ }
553
+ catch (error) {
554
+ await act({ kind: 'release_all' }).catch(() => undefined);
555
+ throw error;
556
+ }
557
+ }
558
+ else if (command === 'scroll') {
559
+ if (args.includes('--at')) {
560
+ const [x, y] = target(args[args.indexOf('--at') + 1]);
561
+ await act({ kind: 'pointer', phase: 'move', x, y });
562
+ }
563
+ await act({ kind: 'wheel', dy: args[0] === 'up' ? -Number(args.includes('--amount') ? args[args.indexOf('--amount') + 1] : 3) : Number(args.includes('--amount') ? args[args.indexOf('--amount') + 1] : 3) });
564
+ }
565
+ else if (command === 'wait') {
566
+ const condition = args[0] ?? 'change';
567
+ const deadline = Date.now() + Number(args.includes('--timeout') ? args[args.indexOf('--timeout') + 1] : 5000);
568
+ if (/^\d+$/.test(condition)) {
569
+ const ms = boundedDelay(condition);
570
+ if (ms > deadline - Date.now())
571
+ throw new Error('settle-timeout: condition not met before deadline');
572
+ await new Promise(r => setTimeout(r, ms));
573
+ }
574
+ else if (condition === 'change' || condition === 'settle') {
575
+ await engine.request('session.frame', { session_id: opened.sessionId, since: baseline, ...(condition === 'change' ? { after_seq: baseline } : { still_ms: 150 }), timeout_ms: Math.max(0, deadline - Date.now()) });
576
+ }
577
+ else {
578
+ let seen = baseline, found = false;
579
+ while (Date.now() < deadline) {
580
+ const snapshot = await capture(seen, { after_seq: seen, timeout_ms: Math.max(0, deadline - Date.now()) }).catch(error => {
581
+ if (error.code === 'frame-timeout')
582
+ throw new Error('settle-timeout: condition not met before deadline');
583
+ throw error;
584
+ });
585
+ pendingDamage ??= [];
586
+ pendingDamage.push(...snapshot.damage);
587
+ waitSeen = snapshot.seq;
588
+ for (const box of snapshot.damage) {
589
+ const [x, y, w, h] = box.split(',').map(Number);
590
+ const left = Math.max(0, x - 32), top = Math.max(0, y - 32);
591
+ if ((await ocr(snapshot, [left, top, Math.min(snapshot.width - left, w + 64), Math.min(snapshot.height - top, h + 64)])).some(t => t.text.toLowerCase().includes(condition.toLowerCase()))) {
592
+ found = true;
593
+ break;
594
+ }
595
+ }
596
+ if (found)
597
+ break;
598
+ seen = snapshot.seq;
599
+ }
600
+ if (!found)
601
+ throw new Error('settle-timeout: condition not met before deadline');
602
+ }
603
+ return `wait: ${condition} met`;
604
+ }
605
+ if (command === 'tree') {
606
+ const axSnapshot = await a11y.snapshot();
607
+ for (const node of axSnapshot.nodes)
608
+ axNodes.set(node.ref, node);
609
+ for (const ref of axNodes.keys()) {
610
+ const seen = axRefPattern.exec(ref);
611
+ if (seen && Number(seen[1]) < axSnapshot.gen - 20)
612
+ axNodes.delete(ref);
613
+ }
614
+ const query = args.includes('--query') ? args[args.indexOf('--query') + 1] : undefined;
615
+ const items = query ? a11y.query(axSnapshot.nodes, query) : axSnapshot.nodes;
616
+ const limit = args.includes('--full') ? items.length : 40;
617
+ const treeFields = args.includes('--fields') ? args[args.indexOf('--fields') + 1].split(',') : ['ref', 'role', 'name', 'value', 'x', 'y', 'w', 'h', 'states'];
618
+ if (treeFields.some(field => !['ref', 'role', 'name', 'value', 'x', 'y', 'w', 'h', 'states'].includes(field)))
619
+ throw new Error('fields: valid fields are ref,role,name,value,x,y,w,h,states');
620
+ const rows = items.slice(0, limit).map(node => ` ${treeFields.map(field => JSON.stringify(field === 'states' ? node.states.filter(s => s !== 'visible' && s !== 'sensitive').join('|') :
621
+ field === 'ref' || field === 'role' || field === 'name' || field === 'value' ? (field === 'ref' ? node.ref : field === 'role' ? node.role : field === 'name' ? node.name : node.value ?? '') :
622
+ node[field])).join(',')}`).join('\n');
623
+ return `tree: ${axSnapshot.apps.length} apps ${axSnapshot.nodes.length} nodes gen=${axSnapshot.gen} estimated_tokens=~${Math.ceil(items.slice(0, limit).reduce((n, node) => n + node.name.length + node.role.length + (node.value?.length ?? 0) + 70, 0) / 4)}\n${items.length ? `ax[${Math.min(items.length, limit)} of ${items.length}]{${treeFields.join(',')}}:\n${rows}` : `ax: 0 nodes match ${JSON.stringify(query ?? 'tree')}`}${items.length > limit ? `\ntruncated: ${items.length - limit} more — use --full or --query` : ''}\nhelp[2]:\n desklink-axi click @a${axSnapshot.gen}.<n>\n desklink-axi type "<text>" --into @a${axSnapshot.gen}.<n>`;
624
+ }
625
+ if (action) {
626
+ return await finishAction();
627
+ }
628
+ const fields = args.includes('--fields') ? args[args.indexOf('--fields') + 1].split(',') : ['ref', 'text', 'x', 'y'];
629
+ if (fields.some(field => !['ref', 'text', 'x', 'y', 'w', 'h', 'conf', 'line'].includes(field)))
630
+ throw new Error('fields: valid fields are ref,text,x,y,w,h,conf,line');
631
+ const frame = await capture(pendingDamage ? waitSeen : baseline);
632
+ if (pendingDamage)
633
+ frame.damage = [...new Set([...pendingDamage, ...frame.damage])];
634
+ if (command === 'look') {
635
+ const item = text.find(t => t.ref === args[0]);
636
+ if (item && !item.ref.startsWith(`@${frame.seq}.`))
637
+ throw new Error(`stale-ref: ${args[0]}; run screen --query`);
638
+ if (args[0]?.startsWith('@') && !args[0].startsWith('@r') && !item)
639
+ throw new Error(`stale-ref: ${args[0]}; run screen --query`);
640
+ const requested = args.includes('--region') ? args[args.indexOf('--region') + 1] : args[0]?.startsWith('@r') ? regions[Number(args[0].slice(2)) - 1] : undefined;
641
+ if (args[0]?.startsWith('@r') && !requested)
642
+ throw new Error(`stale-ref: ${args[0]}; run diff`);
643
+ const box = requested ? requested.split(',').map(Number) : item ? [item.x, item.y, item.words.at(-1).x + item.words.at(-1).w - item.x, Math.max(...item.words.map(w => w.y + w.h)) - item.y] : [0, 0, frame.width, frame.height];
644
+ if (box.length !== 4 || box.some(n => !Number.isInteger(n)) || box[0] < 0 || box[1] < 0 || box[2] <= 0 || box[3] <= 0 || box[0] + box[2] > frame.width || box[1] + box[3] > frame.height)
645
+ throw new Error('coordinates: crop exceeds frame');
646
+ const path = args.includes('--out') ? args[args.indexOf('--out') + 1] : join(dir, `look-${frame.seq}.png`);
647
+ await image(frame, box, path);
648
+ return `image: ${path}\nregion: ${box.join(',')} cost: ~${Math.round(box[2] * box[3] / 750)} tokens to view`;
649
+ }
650
+ if (command === 'marks') {
651
+ const region = args.includes('--region') ? args[args.indexOf('--region') + 1].split(',').map(Number) : [0, 0, frame.width, frame.height];
652
+ if (region.length !== 4 || region.some(n => !Number.isInteger(n)) || region[0] < 0 || region[1] < 0 || region[2] <= 0 || region[3] <= 0 || region[0] + region[2] > frame.width || region[1] + region[3] > frame.height)
653
+ throw new Error('coordinates: region exceeds frame');
654
+ const [rx, ry, rw, rh] = region;
655
+ const axSnapshot = await a11y.snapshot();
656
+ for (const node of axSnapshot.nodes)
657
+ axNodes.set(node.ref, node);
658
+ for (const ref of axNodes.keys()) {
659
+ const seen = axRefPattern.exec(ref);
660
+ if (seen && Number(seen[1]) < axSnapshot.gen - 20)
661
+ axNodes.delete(ref);
662
+ }
663
+ const axCandidates = axSnapshot.nodes.filter(node => isActionable(node) && node.x + node.w > rx && node.x < rx + rw && node.y + node.h > ry && node.y < ry + rh)
664
+ .slice(0, 20)
665
+ .map(node => ({ ref: node.ref, label: node.name || node.action || node.role, role: node.role, x: Math.max(node.x, rx), y: Math.max(node.y, ry), w: Math.min(node.x + node.w, rx + rw) - Math.max(node.x, rx), h: Math.min(node.y + node.h, ry + rh) - Math.max(node.y, ry) }));
666
+ const ocrItems = await ocr(frame, region);
667
+ const kept = dedupeCandidates(axCandidates, ocrItems.map(item => ({ ref: item.ref, label: item.text, role: 'text', x: item.x, y: item.y, w: item.words.at(-1).x + item.words.at(-1).w - item.x, h: Math.max(...item.words.map(word => word.y + word.h)) - item.y })));
668
+ text = ocrItems.filter(item => kept.some(k => k.ref === item.ref));
669
+ const candidates = [...axCandidates, ...kept].slice(0, 40);
670
+ const buffer = await cropBuffer(frame, [rx, ry, rw, rh]);
671
+ drawMarks(buffer.data, rw, rh, candidates.map((candidate, index) => ({ mark: index + 1, x: candidate.x - rx, y: candidate.y - ry, w: candidate.w, h: candidate.h })));
672
+ const { PNG } = await import('pngjs');
673
+ const outPath = args.includes('--out') ? args[args.indexOf('--out') + 1] : join(dir, `marks-${frame.seq}.png`);
674
+ writeFileSync(outPath, PNG.sync.write(buffer));
675
+ const rows = candidates.map((candidate, index) => ` ${index + 1},${JSON.stringify(candidate.ref)},${JSON.stringify(candidate.label)},${JSON.stringify(candidate.role)}`).join('\n');
676
+ return `marks: ${candidates.length} candidates region=${region.join(',')} dimensions=${rw}x${rh} cost: ~${estimateMarksCost(rw, rh)} tokens frame=${frame.seq}\nimage: ${outPath}\n${candidates.length ? `marks[${candidates.length}]{mark,ref,label,role}:\n${rows}` : 'marks: 0 actionable candidates in region'}\nhelp[2]:\n desklink-axi click <ref from marks>\n desklink-axi marks --region x,y,w,h`;
677
+ }
678
+ if (command === 'screen' || command === 'home') {
679
+ const region = args.includes('--region') ? args[args.indexOf('--region') + 1].split(',').map(Number) : undefined;
680
+ if (region && (region.length !== 4 || region.some(n => !Number.isInteger(n)) || region[0] < 0 || region[1] < 0 || region[2] <= 0 || region[3] <= 0 || region[0] + region[2] > frame.width || region[1] + region[3] > frame.height))
681
+ throw new Error('coordinates: region exceeds frame');
682
+ const query = args.includes('--query') ? args[args.indexOf('--query') + 1] : undefined;
683
+ const prior = query && !region && !args.includes('--full') ? text.filter(t => t.text.toLowerCase().includes(query.toLowerCase())) : [];
684
+ if (prior.length === 1) {
685
+ const item = prior[0];
686
+ const x = Math.max(0, item.x - 64), y = Math.max(0, item.y - 64);
687
+ const right = Math.min(frame.width, item.words.at(-1).x + item.words.at(-1).w + 64);
688
+ const bottom = Math.min(frame.height, Math.max(...item.words.map(w => w.y + w.h)) + 64);
689
+ const found = await ocr(frame, [x, y, right - x, bottom - y]);
690
+ text = found.some(t => t.text.toLowerCase().includes(query.toLowerCase())) ? found : await ocr(frame, region);
691
+ }
692
+ else
693
+ text = await ocr(frame, region);
694
+ // Small UI text can vanish in a full-frame tesseract pass (page
695
+ // segmentation drops sparse rows — the benchmark's link-row hazard).
696
+ // When the full pass under-reads, tile the frame and merge so a query
697
+ // or snapshot reflects what is actually on screen. Bounded: 4 tiles.
698
+ const underRead = text.length < 8 && frame.width > 400 && frame.height > 300;
699
+ const queryMissed = query && !text.some(t => t.text.toLowerCase().includes(query.toLowerCase()));
700
+ if (!region && (queryMissed || underRead)) {
701
+ const midX = Math.floor(frame.width / 2), midY = Math.floor(frame.height / 2), pad = 24;
702
+ const tiles = [
703
+ [0, 0, midX + pad, midY + pad],
704
+ [Math.max(0, midX - pad), 0, frame.width - Math.max(0, midX - pad), midY + pad],
705
+ [0, Math.max(0, midY - pad), midX + pad, frame.height - Math.max(0, midY - pad)],
706
+ [Math.max(0, midX - pad), Math.max(0, midY - pad), frame.width - Math.max(0, midX - pad), frame.height - Math.max(0, midY - pad)],
707
+ ];
708
+ const merged = [...text];
709
+ for (const tile of tiles) {
710
+ const found = await ocr(frame, tile).catch(() => []);
711
+ for (const item of found) {
712
+ if (!merged.some(m => m.text === item.text && Math.abs(m.x - item.x) < 12 && Math.abs(m.y - item.y) < 12))
713
+ merged.push(item);
714
+ }
715
+ }
716
+ text = merged.map((t, i) => ({ ...t, ref: `@${frame.seq}.${i + 1}` }));
717
+ }
718
+ }
719
+ if (command === 'home')
720
+ return `session: open source=${source} ${display ?? ''} size=${frame.width}x${frame.height} permissions=view${control ? ',control' : ''}\nframe: ${frame.seq} settled=${frame.still_ms >= 150} unseen=${frame.changed ? frame.damage.length : 0} region\nwindows: unavailable on this compositor\ntext[${Math.min(text.length, 12)} of ${text.length}]{ref,text,x,y}:\n${text.slice(0, 12).map(t => ` ${t.ref},${JSON.stringify(t.text)},${t.x},${t.y}`).join('\n')}\nhelp[2]:\n desklink-axi diff\n desklink-axi screen --query "<words>"`;
721
+ if (command === 'screen') {
722
+ const query = args.includes('--query') ? args[args.indexOf('--query') + 1] : undefined;
723
+ const items = query ? text.filter(t => t.text.toLowerCase().includes(query.toLowerCase())) : text;
724
+ const limit = args.includes('--full') ? items.length : 40;
725
+ const rows = items.slice(0, limit).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');
726
+ return `screen: ${frame.width}x${frame.height} frame=${frame.seq} settled=${frame.still_ms >= 150} estimated_tokens=~${Math.ceil(items.slice(0, limit).reduce((n, t) => n + t.text.length + 35, 0) / 4)}\n${items.length ? `text[${Math.min(items.length, limit)} of ${items.length}]{${fields.join(',')}}:\n${rows}` : `text: 0 items match ${JSON.stringify(query ?? 'screen')} on frame ${frame.seq} (${text.length} items searched)`}${items.length > limit ? `\ntruncated: ${items.length - limit} more — use --full or --query` : ''}\nhelp[1]:\n desklink-axi click @${frame.seq}.<n>`;
727
+ }
728
+ if (frame.changed) {
729
+ const prev = text;
730
+ let kept = [...prev];
731
+ const dirty = [];
732
+ for (const region of frame.damage) {
733
+ const [rx, ry, rw, rh] = region.split(',').map(Number);
734
+ const x = Math.max(0, rx - 32), y = Math.max(0, ry - 32);
735
+ const right = Math.min(frame.width, rx + rw + 32), bottom = Math.min(frame.height, ry + rh + 32);
736
+ kept = kept.filter(t => !t.words.some(w => w.x < right && w.x + w.w > x && w.y < bottom && w.y + w.h > y));
737
+ dirty.push(...await ocr(frame, [x, y, right - x, bottom - y]));
738
+ }
739
+ 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))]
740
+ .map((t, i) => ({ ...t, ref: `@${frame.seq}.${i + 1}` }));
741
+ const unmatched = [...prev];
742
+ const appeared = text.filter(t => {
743
+ const index = unmatched.findIndex(p => p.text === t.text && Math.abs(p.x - t.x) < 12 && Math.abs(p.y - t.y) < 12);
744
+ if (index < 0)
745
+ return true;
746
+ unmatched.splice(index, 1);
747
+ return false;
748
+ });
749
+ const gone = unmatched.length;
750
+ if (frame.still_ms >= 150)
751
+ motion.clear();
752
+ const animating = [];
753
+ const visible = [];
754
+ const moving = [...motion].filter(([, state]) => state.count >= 3 && Date.now() - state.at < 600);
755
+ for (const region of [...frame.damage, ...moving.map(([key]) => key).filter(key => !frame.damage.some(region => overlaps(key, region)))]) {
756
+ const state = moving.find(([key]) => overlaps(key, region))?.[1];
757
+ const [x, y, w, h] = region.split(',').map(Number);
758
+ const textChanged = [...appeared, ...unmatched].some(t => t.words.some(word => word.x < x + w && word.x + word.w > x && word.y < y + h && word.y + word.h > y));
759
+ if (state && state.count >= 3 && !textChanged && !args.includes('--include-animating')) {
760
+ if (!state.reported) {
761
+ animating.push(region);
762
+ state.reported = true;
763
+ }
764
+ }
765
+ else
766
+ visible.push(region);
767
+ }
768
+ if (observed)
769
+ observed.damage = visible;
770
+ const changed = visible.length ? `${visible.length} region since frame ${frame.previous}` : `none since frame ${frame.previous}`;
771
+ 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
+ }
773
+ if (frame.still_ms >= 150)
774
+ motion.clear();
775
+ return `changed: none since frame ${frame.seq} (still ${frame.still_ms}ms)\nhelp[1]:\n desklink-axi screen --query "<words>"`;
776
+ };
777
+ let commands = Promise.resolve();
778
+ const server = createServer(socket => {
779
+ let request = '';
780
+ socket.on('data', chunk => {
781
+ request += chunk;
782
+ if (!request.includes('\n'))
783
+ return;
784
+ const { command, args } = JSON.parse(request.split('\n')[0]);
785
+ commands = commands.then(async () => {
786
+ observed = undefined;
787
+ try {
788
+ const result = await output(command, args);
789
+ const completed = observed;
790
+ if (completed && command !== 'look') {
791
+ baseline = completed.seq;
792
+ regions = completed.damage;
793
+ }
794
+ socket.end(result + '\n', () => { if (command === 'stop')
795
+ process.exit(0); });
796
+ }
797
+ catch (error) {
798
+ const message = error.message;
799
+ const code = error.code;
800
+ socket.end(`error: ${code ? `${code}: ` : ''}${message}\nsuggestion: desklink-axi ${message.includes('ocr') ? 'look' : 'screen'} --help\n`, () => { if (command === 'stop')
801
+ process.exit(1); });
802
+ }
803
+ });
804
+ });
805
+ });
806
+ await engine.request('session.frame', { session_id: opened.sessionId, after_seq: 0, timeout_ms: timeout });
807
+ checkToken();
808
+ server.listen(socketPath, () => chmodSync(socketPath, 0o600));
809
+ process.on('SIGTERM', () => { void engine.stop().finally(() => { server.close(); if (existsSync(socketPath))
810
+ unlinkSync(socketPath); rmSync(dir, { recursive: true, force: true }); process.exit(0); }); });
811
+ }
812
+ catch (error) {
813
+ peer?.close();
814
+ await engine.stop();
815
+ rmSync(dir, { recursive: true, force: true });
816
+ throw error;
817
+ }
818
+ }