@jian-ai/remote-control 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,16 @@ npx @jian-ai/remote-control@latest update
29
29
  Reads `~/.config/jian-remote` (no token, machine id, or gateway flags), installs a compatible
30
30
  build, and restarts the worker service.
31
31
 
32
+ ## Version and service control
33
+
34
+ ```sh
35
+ jian-remote version # also --version / -V
36
+ jian-remote stop | start | restart
37
+ ```
38
+
39
+ `start` / `restart` stop any previous worker before launching one process (LaunchAgent,
40
+ systemd --user, or Task Scheduler).
41
+
32
42
  ## Configure
33
43
 
34
44
  Change the stored gateway URL and/or worker token without a full reinstall (restarts the
@@ -41,8 +51,16 @@ jian-remote configure --gateway https://gateway.example --token WORKER_TOKEN
41
51
 
42
52
  ## Compatibility
43
53
 
44
- Worker and gateway must share the same major.minor. See [docs/remote.md](../../docs/remote.md).
54
+ Connected workers self-update to the gateway’s release (gateway `/update` script, npm
55
+ fallback) including major.minor skew, then restart the OS service. Manual `install` /
56
+ `update` still gate on `/health`. See [docs/remote.md](../../docs/remote.md).
57
+
58
+ Requires Node.js 24+. Computer Use works on macOS, Linux (X11 + `xdotool`/`ffmpeg`), and
59
+ Windows. Published builds include a prebuilt macOS `computer-helper` (no Xcode on the worker
60
+ Mac) plus Linux/Windows helper scripts. From source on a Mac, run `macos/build-helper.sh`, or
61
+ let the worker compile with `swiftc` when Command Line Tools are installed.
45
62
 
46
- Requires Node.js 24+. Published builds include a prebuilt macOS `computer-helper` for Computer
47
- Use (no Xcode on the worker Mac). From source, run `macos/build-helper.sh` on a Mac, or let the
48
- worker compile with `swiftc` when Command Line Tools are installed.
63
+ On macOS the LaunchAgent uses `ProcessType=Interactive` and an idle `caffeinate` so the worker
64
+ keeps polling while the screen is locked (App Nap must not freeze timers). A locked Mac stays
65
+ **online** (`uiState: locked` is advisory): the worker wakes the display and attempts
66
+ Computer Use; it does not soft-refuse. See [docs/remote.md](../../docs/remote.md).
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Linux computer-use: ffmpeg/x11grab screenshots + Python X11 helper (overlay + xdotool).
3
+ * English-only messages. Imported by computer.mjs on linux.
4
+ */
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync } from 'node:fs';
8
+ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { homedir, tmpdir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { COMPUTER_CAPABILITIES, imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
13
+
14
+ export { imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
15
+
16
+ export const COMPUTER_CAPABILITY = COMPUTER_CAPABILITIES.linux;
17
+ const IDLE_MS = 30_000;
18
+ const HELPER_NAME = 'computer-helper.py';
19
+
20
+ function helperPaths(home = homedir()) {
21
+ const support = join(home, '.local', 'share', 'jian-remote');
22
+ return {
23
+ support,
24
+ script: join(support, HELPER_NAME),
25
+ stamp: join(support, `${HELPER_NAME}.sha256`),
26
+ };
27
+ }
28
+
29
+ export function packageHelperPaths(
30
+ packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'),
31
+ ) {
32
+ return {
33
+ script: join(packageRoot, 'linux', HELPER_NAME),
34
+ };
35
+ }
36
+
37
+ function stampForBytes(bytes) {
38
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 16);
39
+ }
40
+
41
+ function hasXdotool() {
42
+ return Boolean(spawnSync('which', ['xdotool'], { encoding: 'utf8' }).stdout?.trim());
43
+ }
44
+
45
+ function hasFfmpeg() {
46
+ return Boolean(spawnSync('which', ['ffmpeg'], { encoding: 'utf8' }).stdout?.trim());
47
+ }
48
+
49
+ function displayGeometry() {
50
+ if (hasXdotool()) {
51
+ const geo = spawnSync('xdotool', ['getdisplaygeometry'], { encoding: 'utf8' });
52
+ if (geo.status === 0) {
53
+ const [w, h] = (geo.stdout || '').trim().split(/\s+/).map(Number);
54
+ if (w > 0 && h > 0) return { width: w, height: h };
55
+ }
56
+ }
57
+ const xd = spawnSync('xdpyinfo', [], { encoding: 'utf8' });
58
+ if (xd.status === 0) {
59
+ const match = /dimensions:\s+(\d+)x(\d+)/.exec(xd.stdout || '');
60
+ if (match) return { width: Number(match[1]), height: Number(match[2]) };
61
+ }
62
+ return { width: 1920, height: 1080 };
63
+ }
64
+
65
+ /** Primary display JPEG via ffmpeg x11grab; scale to ≤1600 long side, channel budget. */
66
+ export function capturePrimaryScreenshotBase64() {
67
+ if (!hasFfmpeg()) {
68
+ throw new Error(
69
+ 'ffmpeg is required for screenshots on Linux Jian Remote. Install ffmpeg and retry.',
70
+ );
71
+ }
72
+ if (!process.env.DISPLAY) {
73
+ throw new Error('DISPLAY is not set. Computer use needs an active graphical session.');
74
+ }
75
+ const { width, height } = displayGeometry();
76
+ const dir = spawnSync('mktemp', ['-d', join(tmpdir(), 'jian-shot.XXXXXX')], {
77
+ encoding: 'utf8',
78
+ });
79
+ const tmpDir = (dir.stdout || '').trim();
80
+ if (dir.status !== 0 || !tmpDir) throw new Error('Could not create temp dir for screenshot.');
81
+ const jpg = join(tmpDir, 'shot.jpg');
82
+ const long = Math.max(width, height);
83
+ const scale = long > 1600 ? 1600 / long : 1;
84
+ const outW = Math.max(1, Math.round(width * scale));
85
+ const outH = Math.max(1, Math.round(height * scale));
86
+ const vf = `scale=${outW}:${outH}`;
87
+ const result = spawnSync(
88
+ 'ffmpeg',
89
+ [
90
+ '-y',
91
+ '-loglevel',
92
+ 'error',
93
+ '-f',
94
+ 'x11grab',
95
+ '-draw_mouse',
96
+ '0',
97
+ '-video_size',
98
+ `${width}x${height}`,
99
+ '-i',
100
+ process.env.DISPLAY,
101
+ '-frames:v',
102
+ '1',
103
+ '-vf',
104
+ vf,
105
+ '-q:v',
106
+ '5',
107
+ jpg,
108
+ ],
109
+ { encoding: 'utf8', maxBuffer: 600_000 },
110
+ );
111
+ if (result.status !== 0) {
112
+ spawnSync('rm', ['-rf', tmpDir]);
113
+ throw new Error((result.stderr || result.stdout || 'Screenshot failed').trim());
114
+ }
115
+ // Recompress if still over budget (rare at 1600/q5).
116
+ const size = Number(
117
+ spawnSync('wc', ['-c', jpg], { encoding: 'utf8' }).stdout?.trim().split(/\s+/)[0],
118
+ );
119
+ if (Number.isFinite(size) && size > 280_000) {
120
+ const smaller = join(tmpDir, 'shot2.jpg');
121
+ spawnSync(
122
+ 'ffmpeg',
123
+ ['-y', '-loglevel', 'error', '-i', jpg, '-vf', 'scale=1280:-2', '-q:v', '7', smaller],
124
+ { encoding: 'utf8' },
125
+ );
126
+ if (existsSync(smaller)) {
127
+ spawnSync('mv', [smaller, jpg]);
128
+ }
129
+ }
130
+ const identify = spawnSync(
131
+ 'ffprobe',
132
+ [
133
+ '-v',
134
+ 'error',
135
+ '-select_streams',
136
+ 'v:0',
137
+ '-show_entries',
138
+ 'stream=width,height',
139
+ '-of',
140
+ 'csv=p=0:s=x',
141
+ jpg,
142
+ ],
143
+ { encoding: 'utf8' },
144
+ );
145
+ let widthPx = outW;
146
+ let heightPx = outH;
147
+ if (identify.status === 0 && identify.stdout?.includes('x')) {
148
+ const [w, h] = identify.stdout.trim().split('x').map(Number);
149
+ if (w > 0 && h > 0) {
150
+ widthPx = w;
151
+ heightPx = h;
152
+ }
153
+ }
154
+ const b64 = spawnSync('base64', ['-w', '0', jpg], { encoding: 'utf8', maxBuffer: 600_000 });
155
+ spawnSync('rm', ['-rf', tmpDir]);
156
+ if (b64.status !== 0 || !b64.stdout) throw new Error('Screenshot produced no image data.');
157
+ return { base64: b64.stdout.replace(/\s+/g, ''), widthPx, heightPx };
158
+ }
159
+
160
+ async function ensureHelperScript() {
161
+ const paths = helperPaths();
162
+ await mkdir(paths.support, { recursive: true, mode: 0o755 });
163
+ const pkg = packageHelperPaths().script;
164
+ let sourceText;
165
+ try {
166
+ sourceText = await readFile(pkg, 'utf8');
167
+ } catch {
168
+ try {
169
+ sourceText = await readFile(paths.script, 'utf8');
170
+ } catch {
171
+ throw new Error(
172
+ 'Computer-use helper is missing from this install. Reinstall or update @jian-ai/remote-control.',
173
+ );
174
+ }
175
+ }
176
+ const hash = stampForBytes(Buffer.from(sourceText, 'utf8'));
177
+ let previous = '';
178
+ try {
179
+ previous = (await readFile(paths.stamp, 'utf8')).trim();
180
+ } catch {
181
+ previous = '';
182
+ }
183
+ if (previous !== hash || !existsSync(paths.script)) {
184
+ await writeFile(paths.script, sourceText, { mode: 0o755 });
185
+ await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
186
+ }
187
+ await chmod(paths.script, 0o755);
188
+ return paths.script;
189
+ }
190
+
191
+ export async function materializeComputerHelperSources(configDir) {
192
+ if (process.platform !== 'linux') return;
193
+ try {
194
+ const pkg = packageHelperPaths().script;
195
+ if (!existsSync(pkg)) return;
196
+ const support = helperPaths().support;
197
+ await mkdir(support, { recursive: true, mode: 0o755 });
198
+ const localLinux = join(configDir, 'linux');
199
+ await mkdir(localLinux, { recursive: true, mode: 0o755 });
200
+ await copyFile(pkg, join(support, HELPER_NAME));
201
+ await copyFile(pkg, join(localLinux, HELPER_NAME));
202
+ await chmod(join(support, HELPER_NAME), 0o755);
203
+ await chmod(join(localLinux, HELPER_NAME), 0o755);
204
+ } catch {
205
+ // Best-effort.
206
+ }
207
+ }
208
+
209
+ export class LinuxComputerSession {
210
+ constructor() {
211
+ this.child = null;
212
+ this.buffer = '';
213
+ this.pending = [];
214
+ this.geometry = null;
215
+ this.shotGeometry = null;
216
+ this.idleTimer = null;
217
+ this.active = false;
218
+ }
219
+
220
+ async ensure() {
221
+ if (this.child && !this.child.killed) {
222
+ if (!this.active) {
223
+ await this.send({ cmd: 'ensureSession' });
224
+ this.active = true;
225
+ }
226
+ this.bumpIdle();
227
+ return;
228
+ }
229
+ if (!hasXdotool()) {
230
+ throw new Error(
231
+ 'xdotool is required for Linux computer use (X11). Install xdotool and grant input access.',
232
+ );
233
+ }
234
+ const script = await ensureHelperScript();
235
+ const python =
236
+ spawnSync('which', ['python3'], { encoding: 'utf8' }).stdout?.trim() || 'python3';
237
+ this.child = spawn(python, [script], {
238
+ stdio: ['pipe', 'pipe', 'pipe'],
239
+ env: { ...process.env },
240
+ });
241
+ this.buffer = '';
242
+ this.child.stdout.setEncoding('utf8');
243
+ this.child.stdout.on('data', (chunk) => this.onData(chunk));
244
+ this.child.stderr.on('data', (chunk) => {
245
+ const text = chunk.toString('utf8').trim();
246
+ if (text) console.error(text);
247
+ });
248
+ this.child.on('exit', () => {
249
+ this.failAll('Computer-use helper exited unexpectedly.');
250
+ this.child = null;
251
+ this.active = false;
252
+ });
253
+ const ping = await this.send({ cmd: 'ping' });
254
+ if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
255
+ const geo = await this.send({ cmd: 'geometry' });
256
+ if (!geo.ok) throw new Error(geo.error || 'Could not read display geometry.');
257
+ this.geometry = {
258
+ originX: Number(geo.originX) || 0,
259
+ originY: Number(geo.originY) || 0,
260
+ pointWidth: Number(geo.pointWidth),
261
+ pointHeight: Number(geo.pointHeight),
262
+ pixelWidth: Number(geo.pixelWidth),
263
+ pixelHeight: Number(geo.pixelHeight),
264
+ scaleFactor: Number(geo.scaleFactor) || 1,
265
+ };
266
+ await this.send({ cmd: 'ensureSession' });
267
+ this.active = true;
268
+ this.bumpIdle();
269
+ }
270
+
271
+ bumpIdle() {
272
+ if (this.idleTimer) clearTimeout(this.idleTimer);
273
+ this.idleTimer = setTimeout(() => {
274
+ if (!this.active || !this.child) return;
275
+ void this.send({ cmd: 'setIdle' }).catch(() => {});
276
+ }, IDLE_MS);
277
+ this.idleTimer.unref?.();
278
+ }
279
+
280
+ onData(chunk) {
281
+ this.buffer += chunk;
282
+ for (;;) {
283
+ const idx = this.buffer.indexOf('\n');
284
+ if (idx < 0) break;
285
+ const line = this.buffer.slice(0, idx).trim();
286
+ this.buffer = this.buffer.slice(idx + 1);
287
+ if (!line) continue;
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(line);
291
+ } catch {
292
+ this.failNext('Computer-use helper returned invalid JSON.');
293
+ continue;
294
+ }
295
+ const next = this.pending.shift();
296
+ if (next) next.resolve(parsed);
297
+ }
298
+ }
299
+
300
+ failNext(message) {
301
+ const next = this.pending.shift();
302
+ if (next) next.reject(new Error(message));
303
+ }
304
+
305
+ failAll(message) {
306
+ while (this.pending.length) {
307
+ this.pending.shift().reject(new Error(message));
308
+ }
309
+ }
310
+
311
+ send(payload) {
312
+ return new Promise((resolve, reject) => {
313
+ if (!this.child?.stdin) {
314
+ reject(new Error('Computer-use helper is not running.'));
315
+ return;
316
+ }
317
+ this.pending.push({ resolve, reject });
318
+ this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
319
+ if (error) {
320
+ this.pending.pop();
321
+ reject(error);
322
+ }
323
+ });
324
+ });
325
+ }
326
+
327
+ requireShotGeometry() {
328
+ if (!this.shotGeometry) {
329
+ throw new Error('Take a screenshot before using screen coordinates.');
330
+ }
331
+ return this.shotGeometry;
332
+ }
333
+
334
+ mapCoordinate(coordinate) {
335
+ const [x, y] = coordinate;
336
+ const shot = this.requireShotGeometry();
337
+ if (x < 0 || y < 0 || x >= shot.widthPx || y >= shot.heightPx) {
338
+ throw new Error(
339
+ `Coordinate (${x}, ${y}) is outside the last screenshot (${shot.widthPx}×${shot.heightPx}).`,
340
+ );
341
+ }
342
+ return imagePixelToPoint(x, y, shot);
343
+ }
344
+
345
+ async end() {
346
+ if (this.idleTimer) clearTimeout(this.idleTimer);
347
+ this.idleTimer = null;
348
+ if (this.child) {
349
+ try {
350
+ await this.send({ cmd: 'endSession' });
351
+ } catch {
352
+ // ignore
353
+ }
354
+ this.child.kill('SIGTERM');
355
+ this.child = null;
356
+ }
357
+ this.active = false;
358
+ this.shotGeometry = null;
359
+ this.geometry = null;
360
+ }
361
+
362
+ async runAction(computer) {
363
+ const { action } = computer;
364
+ if (action === 'end_session') {
365
+ await this.end();
366
+ return {
367
+ exitCode: 0,
368
+ stdout: '',
369
+ stderr: '',
370
+ timedOut: false,
371
+ computer: { ok: true },
372
+ };
373
+ }
374
+
375
+ if (action === 'wait') {
376
+ const ms = Math.round((computer.duration ?? 0.35) * 1000);
377
+ await new Promise((r) => setTimeout(r, ms));
378
+ if (this.active) this.bumpIdle();
379
+ return {
380
+ exitCode: 0,
381
+ stdout: '',
382
+ stderr: '',
383
+ timedOut: false,
384
+ computer: { ok: true },
385
+ };
386
+ }
387
+
388
+ if (action === 'screenshot') {
389
+ await this.ensure();
390
+ const shot = capturePrimaryScreenshotBase64();
391
+ if (!this.geometry) throw new Error('Display geometry unavailable.');
392
+ this.shotGeometry = {
393
+ ...this.geometry,
394
+ widthPx: shot.widthPx,
395
+ heightPx: shot.heightPx,
396
+ };
397
+ this.bumpIdle();
398
+ return {
399
+ exitCode: 0,
400
+ stdout: shot.base64,
401
+ stderr: '',
402
+ timedOut: false,
403
+ computer: {
404
+ ok: true,
405
+ widthPx: shot.widthPx,
406
+ heightPx: shot.heightPx,
407
+ scale: scaleFromGeometry(this.shotGeometry),
408
+ },
409
+ };
410
+ }
411
+
412
+ await this.ensure();
413
+
414
+ try {
415
+ if (action === 'mouse_move') {
416
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
417
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
418
+ const moved = await this.send({ cmd: 'move', pointX, pointY });
419
+ if (!moved.ok) throw new Error(moved.error || 'Move failed.');
420
+ } else if (action === 'left_click' || action === 'right_click' || action === 'double_click') {
421
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
422
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
423
+ const button = action === 'right_click' ? 'right' : 'left';
424
+ const count = action === 'double_click' ? 2 : 1;
425
+ const clicked = await this.send({ cmd: 'clickAt', pointX, pointY, button, count });
426
+ if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
427
+ } else if (action === 'left_click_drag') {
428
+ if (!computer.startCoordinate || !computer.coordinate) {
429
+ throw new Error('startCoordinate and coordinate are required for drag.');
430
+ }
431
+ const from = this.mapCoordinate(computer.startCoordinate);
432
+ const to = this.mapCoordinate(computer.coordinate);
433
+ const dragged = await this.send({
434
+ cmd: 'drag',
435
+ fromX: from.pointX,
436
+ fromY: from.pointY,
437
+ toX: to.pointX,
438
+ toY: to.pointY,
439
+ });
440
+ if (!dragged.ok) throw new Error(dragged.error || 'Drag failed.');
441
+ } else if (action === 'type') {
442
+ if (!computer.text) throw new Error('text is required for type.');
443
+ const typed = await this.send({ cmd: 'type', text: computer.text });
444
+ if (!typed.ok) throw new Error(typed.error || 'Type failed.');
445
+ } else if (action === 'key') {
446
+ if (!computer.key) throw new Error('key is required for key.');
447
+ const keyed = await this.send({ cmd: 'key', key: computer.key });
448
+ if (!keyed.ok) throw new Error(keyed.error || 'Key failed.');
449
+ } else if (action === 'scroll') {
450
+ if (!computer.coordinate) throw new Error('coordinate is required for scroll.');
451
+ if (!computer.scrollDirection) throw new Error('scrollDirection is required for scroll.');
452
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
453
+ const scrolled = await this.send({
454
+ cmd: 'scroll',
455
+ pointX,
456
+ pointY,
457
+ direction: computer.scrollDirection,
458
+ amount: computer.scrollAmount ?? 3,
459
+ });
460
+ if (!scrolled.ok) throw new Error(scrolled.error || 'Scroll failed.');
461
+ } else {
462
+ throw new Error(`Unsupported computer action: ${action}`);
463
+ }
464
+ } catch (error) {
465
+ const message = error instanceof Error ? error.message : String(error);
466
+ return {
467
+ exitCode: 1,
468
+ stdout: '',
469
+ stderr: message,
470
+ timedOut: false,
471
+ computer: { ok: false, error: message },
472
+ };
473
+ }
474
+
475
+ this.bumpIdle();
476
+ return {
477
+ exitCode: 0,
478
+ stdout: '',
479
+ stderr: '',
480
+ timedOut: false,
481
+ computer: { ok: true },
482
+ };
483
+ }
484
+ }
485
+
486
+ let sharedSession = null;
487
+
488
+ export function computerSession() {
489
+ if (!sharedSession) sharedSession = new LinuxComputerSession();
490
+ return sharedSession;
491
+ }
492
+
493
+ export async function executeComputerTask(task) {
494
+ const session = computerSession();
495
+ const computer = task.computer;
496
+ if (!computer?.action) {
497
+ return {
498
+ taskId: task.id,
499
+ exitCode: 1,
500
+ stdout: '',
501
+ stderr: 'Computer task missing action.',
502
+ timedOut: false,
503
+ computer: { ok: false, error: 'Computer task missing action.' },
504
+ };
505
+ }
506
+ const timeoutMs = task.timeoutMs || 30_000;
507
+ let timedOut = false;
508
+ const result = await Promise.race([
509
+ session.runAction(computer),
510
+ new Promise((resolve) => {
511
+ setTimeout(() => {
512
+ timedOut = true;
513
+ resolve({
514
+ exitCode: null,
515
+ stdout: '',
516
+ stderr: 'Computer action timed out',
517
+ timedOut: true,
518
+ computer: { ok: false, error: 'Computer action timed out' },
519
+ });
520
+ }, timeoutMs);
521
+ }),
522
+ ]);
523
+ return { taskId: task.id, ...result, timedOut: timedOut || Boolean(result.timedOut) };
524
+ }
525
+
526
+ export async function probeComputerCapability() {
527
+ if (process.platform !== 'linux') return false;
528
+ // X11 (or XWayland) + xdotool. Pure Wayland without xdotool is a documented gap.
529
+ if (!process.env.DISPLAY) return false;
530
+ if (!hasXdotool()) return false;
531
+ try {
532
+ await ensureHelperScript();
533
+ return true;
534
+ } catch {
535
+ return false;
536
+ }
537
+ }