@jian-ai/remote-control 0.8.1 → 0.8.2

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * macOS computer-use: primary-display screenshots, coord mapping, Jian overlay helper.
3
- * English-only messages. Imported by jian-remote.mjs on darwin.
3
+ * English-only messages. Imported by computer.mjs on darwin.
4
4
  */
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
6
  import { createHash } from 'node:crypto';
@@ -9,40 +9,38 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
9
  import { homedir } from 'node:os';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { COMPUTER_CAPABILITIES, imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
12
13
 
13
- export const COMPUTER_CAPABILITY = 'computer-macos-v1';
14
+ export { imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
15
+
16
+ export const COMPUTER_CAPABILITY = COMPUTER_CAPABILITIES.darwin;
14
17
  const IDLE_MS = 30_000;
15
18
  const HELPER_NAME = 'computer-helper';
19
+ /** How long we cache lock / display-asleep probes between HTTPS polls. */
20
+ const UI_STATE_CACHE_MS = 5_000;
21
+ export const MAC_UI_STATES = Object.freeze(['unlocked', 'locked', 'display_asleep', 'unknown']);
16
22
 
17
- /** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
18
- export function imagePixelToPoint(x, y, geometry) {
19
- const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
20
- if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
21
- throw new Error('Invalid screenshot geometry for coordinate mapping');
22
- }
23
- return {
24
- pointX: originX + (x + 0.5) * (pointWidth / widthPx),
25
- pointY: originY + (y + 0.5) * (pointHeight / heightPx),
26
- };
27
- }
23
+ const LOCKED_COMPUTER_MESSAGE =
24
+ 'Mac is locked — unlock the screen to use Computer Use. Shell and file tools may still work while the worker is online.';
28
25
 
29
- export function scaleFromGeometry(geometry) {
30
- return {
31
- imageToPointX: geometry.pointWidth / geometry.widthPx,
32
- imageToPointY: geometry.pointHeight / geometry.heightPx,
33
- };
34
- }
26
+ let cachedUiState = { at: 0, state: 'unknown' };
27
+ let workerIdleCaffeinate = null;
28
+ let sessionDisplayCaffeinate = null;
35
29
 
36
- /** Capture primary display and compress like the gateway’s former screenshotCommand. */
30
+ /**
31
+ * Capture primary display and compress for the worker channel.
32
+ * Single-pass ladder starts at 1600/42 (skips a full-res 1920@50 encode that often
33
+ * exceeded the budget and forced a second sips pass — common waste on Retina).
34
+ */
37
35
  export function capturePrimaryScreenshotBase64() {
38
36
  const script = [
39
37
  'tmp=$(mktemp /tmp/jian-shot.XXXXXX.jpg);',
40
38
  'screencapture -x -t jpg -D 1 "$tmp" || { rm -f "$tmp"; exit 1; };',
41
- 'sips -Z 1920 -s format jpeg -s formatOptions 50 "$tmp" >/dev/null 2>&1 || true;',
39
+ 'sips -Z 1600 -s format jpeg -s formatOptions 42 "$tmp" >/dev/null 2>&1 || true;',
42
40
  'size=$(wc -c < "$tmp" | tr -d " ");',
43
- 'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 40 "$tmp" >/dev/null 2>&1 || true; fi;',
41
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 38 "$tmp" >/dev/null 2>&1 || true; fi;',
44
42
  'size=$(wc -c < "$tmp" | tr -d " ");',
45
- 'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 35 "$tmp" >/dev/null 2>&1 || true; fi;',
43
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 34 "$tmp" >/dev/null 2>&1 || true; fi;',
46
44
  'w=$(sips -g pixelWidth "$tmp" 2>/dev/null | awk "/pixelWidth/{print \\$2}");',
47
45
  'h=$(sips -g pixelHeight "$tmp" 2>/dev/null | awk "/pixelHeight/{print \\$2}");',
48
46
  'echo "$w $h";',
@@ -137,7 +135,6 @@ async function ensureHelperBinary() {
137
135
  clearQuarantine(paths.binary);
138
136
  await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
139
137
  }
140
- // Keep source nearby for debugging / swiftc fallback after a partial install.
141
138
  try {
142
139
  const swift = packageSwiftPath();
143
140
  if (existsSync(swift)) await copyFile(swift, paths.source);
@@ -227,8 +224,11 @@ export class MacComputerSession {
227
224
 
228
225
  async ensure() {
229
226
  if (this.child && !this.child.killed) {
230
- await this.send({ cmd: 'ensureSession' });
231
- this.active = true;
227
+ if (!this.active) {
228
+ await this.send({ cmd: 'ensureSession' });
229
+ this.active = true;
230
+ }
231
+ startSessionDisplayAssertion(this.child.pid || process.pid);
232
232
  this.bumpIdle();
233
233
  return;
234
234
  }
@@ -247,6 +247,7 @@ export class MacComputerSession {
247
247
  this.failAll('Computer-use helper exited unexpectedly.');
248
248
  this.child = null;
249
249
  this.active = false;
250
+ stopSessionDisplayAssertion();
250
251
  });
251
252
  const ping = await this.send({ cmd: 'ping' });
252
253
  if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
@@ -263,6 +264,7 @@ export class MacComputerSession {
263
264
  };
264
265
  await this.send({ cmd: 'ensureSession' });
265
266
  this.active = true;
267
+ startSessionDisplayAssertion(this.child.pid || process.pid);
266
268
  this.bumpIdle();
267
269
  }
268
270
 
@@ -343,6 +345,7 @@ export class MacComputerSession {
343
345
  async end() {
344
346
  if (this.idleTimer) clearTimeout(this.idleTimer);
345
347
  this.idleTimer = null;
348
+ stopSessionDisplayAssertion();
346
349
  if (this.child) {
347
350
  try {
348
351
  await this.send({ cmd: 'endSession' });
@@ -371,7 +374,7 @@ export class MacComputerSession {
371
374
  }
372
375
 
373
376
  if (action === 'wait') {
374
- const ms = Math.round((computer.duration ?? 0.5) * 1000);
377
+ const ms = Math.round((computer.duration ?? 0.35) * 1000);
375
378
  await new Promise((r) => setTimeout(r, ms));
376
379
  if (this.active) this.bumpIdle();
377
380
  return {
@@ -383,6 +386,17 @@ export class MacComputerSession {
383
386
  };
384
387
  }
385
388
 
389
+ const uiBlocked = assertComputerUiAllowed();
390
+ if (uiBlocked) {
391
+ return {
392
+ exitCode: 1,
393
+ stdout: '',
394
+ stderr: uiBlocked,
395
+ timedOut: false,
396
+ computer: { ok: false, error: uiBlocked },
397
+ };
398
+ }
399
+
386
400
  if (action === 'screenshot') {
387
401
  await this.ensure();
388
402
  const shot = capturePrimaryScreenshotBase64();
@@ -410,22 +424,19 @@ export class MacComputerSession {
410
424
  await this.ensure();
411
425
 
412
426
  try {
413
- if (
414
- action === 'mouse_move' ||
415
- action === 'left_click' ||
416
- action === 'right_click' ||
417
- action === 'double_click'
418
- ) {
427
+ if (action === 'mouse_move') {
419
428
  if (!computer.coordinate) throw new Error('coordinate is required for this action.');
420
429
  const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
421
430
  const moved = await this.send({ cmd: 'move', pointX, pointY });
422
431
  if (!moved.ok) throw new Error(moved.error || 'Move failed.');
423
- if (action !== 'mouse_move') {
424
- const button = action === 'right_click' ? 'right' : 'left';
425
- const count = action === 'double_click' ? 2 : 1;
426
- const clicked = await this.send({ cmd: 'click', button, count });
427
- if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
428
- }
432
+ } else if (action === 'left_click' || action === 'right_click' || action === 'double_click') {
433
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
434
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
435
+ const button = action === 'right_click' ? 'right' : 'left';
436
+ const count = action === 'double_click' ? 2 : 1;
437
+ // One RPC: animate overlay, click, restore user cursor (helper-side).
438
+ const clicked = await this.send({ cmd: 'clickAt', pointX, pointY, button, count });
439
+ if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
429
440
  } else if (action === 'left_click_drag') {
430
441
  if (!computer.startCoordinate || !computer.coordinate) {
431
442
  throw new Error('startCoordinate and coordinate are required for drag.');
@@ -535,11 +546,130 @@ export async function probeComputerCapability() {
535
546
  await ensureHelperBinary();
536
547
  return true;
537
548
  } catch {
538
- // Still advertise: screenshot-only path can work; input fails with TCC guidance.
539
- // Prefer advertising when swiftc exists so gateway can send jobs after first grant.
540
549
  return (
541
550
  Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
542
551
  existsSync(helperPaths().binary)
543
552
  );
544
553
  }
545
554
  }
555
+
556
+ function normalizeUiState(value) {
557
+ return MAC_UI_STATES.includes(value) ? value : 'unknown';
558
+ }
559
+
560
+ /**
561
+ * Lock screen vs display sleep vs unlocked. Prefer the helper binary (CGSession +
562
+ * CGDisplayIsAsleep); fall back to unknown so we never invent "offline".
563
+ */
564
+ export function probeMacUiState({ force = false } = {}) {
565
+ if (process.platform !== 'darwin') return 'unknown';
566
+ const now = Date.now();
567
+ if (!force && now - cachedUiState.at < UI_STATE_CACHE_MS) return cachedUiState.state;
568
+
569
+ let state = 'unknown';
570
+ const binary = helperPaths().binary;
571
+ if (existsSync(binary)) {
572
+ const probed = spawnSync(binary, ['--session-status'], {
573
+ encoding: 'utf8',
574
+ timeout: 4_000,
575
+ maxBuffer: 16_000,
576
+ });
577
+ if (probed.status === 0) {
578
+ try {
579
+ const line = (probed.stdout || '').trim().split('\n').pop();
580
+ const parsed = JSON.parse(line || '{}');
581
+ if (parsed?.ok) state = normalizeUiState(parsed.state);
582
+ } catch {
583
+ state = 'unknown';
584
+ }
585
+ }
586
+ }
587
+
588
+ cachedUiState = { at: now, state };
589
+ return state;
590
+ }
591
+
592
+ /**
593
+ * Prevent idle system sleep for the worker process so HTTPS polls keep firing when the
594
+ * screen is locked. Does not claim lid-closed / clamshell immunity (see docs/remote.md).
595
+ */
596
+ export function startWorkerIdleAssertion(pid = process.pid) {
597
+ if (process.platform !== 'darwin') return null;
598
+ stopWorkerIdleAssertion();
599
+ try {
600
+ workerIdleCaffeinate = spawn('/usr/bin/caffeinate', ['-i', '-w', String(pid)], {
601
+ stdio: 'ignore',
602
+ detached: false,
603
+ });
604
+ workerIdleCaffeinate.on('error', () => {
605
+ workerIdleCaffeinate = null;
606
+ });
607
+ workerIdleCaffeinate.unref?.();
608
+ return workerIdleCaffeinate;
609
+ } catch {
610
+ workerIdleCaffeinate = null;
611
+ return null;
612
+ }
613
+ }
614
+
615
+ export function stopWorkerIdleAssertion() {
616
+ if (!workerIdleCaffeinate) return;
617
+ try {
618
+ workerIdleCaffeinate.kill('SIGTERM');
619
+ } catch {
620
+ // already gone
621
+ }
622
+ workerIdleCaffeinate = null;
623
+ }
624
+
625
+ /** While a computer-use session is open, also keep the display from idling off. */
626
+ function startSessionDisplayAssertion(pid = process.pid) {
627
+ if (process.platform !== 'darwin') return;
628
+ stopSessionDisplayAssertion();
629
+ try {
630
+ sessionDisplayCaffeinate = spawn('/usr/bin/caffeinate', ['-dimsu', '-w', String(pid)], {
631
+ stdio: 'ignore',
632
+ detached: false,
633
+ });
634
+ sessionDisplayCaffeinate.on('error', () => {
635
+ sessionDisplayCaffeinate = null;
636
+ });
637
+ sessionDisplayCaffeinate.unref?.();
638
+ } catch {
639
+ sessionDisplayCaffeinate = null;
640
+ }
641
+ }
642
+
643
+ function stopSessionDisplayAssertion() {
644
+ if (!sessionDisplayCaffeinate) return;
645
+ try {
646
+ sessionDisplayCaffeinate.kill('SIGTERM');
647
+ } catch {
648
+ // already gone
649
+ }
650
+ sessionDisplayCaffeinate = null;
651
+ }
652
+
653
+ /** User-activity assertion: nudge a sleeping (unlocked) display awake. */
654
+ export function wakeMacDisplay(seconds = 3) {
655
+ if (process.platform !== 'darwin') return;
656
+ const secs = Math.max(1, Math.min(30, Math.round(seconds)));
657
+ spawnSync('/usr/bin/caffeinate', ['-u', '-t', String(secs)], {
658
+ encoding: 'utf8',
659
+ timeout: (secs + 2) * 1000,
660
+ });
661
+ }
662
+
663
+ /**
664
+ * Before GUI actions: wake a sleeping display; refuse clearly when the session is locked.
665
+ * Returns null when Computer Use may proceed, or an English error string.
666
+ */
667
+ export function assertComputerUiAllowed() {
668
+ let state = probeMacUiState({ force: true });
669
+ if (state === 'display_asleep' || state === 'unknown') {
670
+ wakeMacDisplay(3);
671
+ state = probeMacUiState({ force: true });
672
+ }
673
+ if (state === 'locked') return LOCKED_COMPUTER_MESSAGE;
674
+ return null;
675
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared computer-use helpers: coords, capabilities, motion easing.
3
+ * Platform backends live in computer-{macos,linux,windows}.mjs.
4
+ */
5
+
6
+ export const COMPUTER_CAPABILITIES = Object.freeze({
7
+ darwin: 'computer-macos-v1',
8
+ linux: 'computer-linux-v1',
9
+ win32: 'computer-windows-v1',
10
+ });
11
+
12
+ const CAPABILITY_SET = new Set(Object.values(COMPUTER_CAPABILITIES));
13
+
14
+ /** True when a worker capability string means remote_computer is supported. */
15
+ export function isComputerCapability(value) {
16
+ return CAPABILITY_SET.has(value);
17
+ }
18
+
19
+ export function capabilityForPlatform(platform = process.platform) {
20
+ return COMPUTER_CAPABILITIES[platform] ?? null;
21
+ }
22
+
23
+ /** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
24
+ export function imagePixelToPoint(x, y, geometry) {
25
+ const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
26
+ if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
27
+ throw new Error('Invalid screenshot geometry for coordinate mapping');
28
+ }
29
+ return {
30
+ pointX: originX + (x + 0.5) * (pointWidth / widthPx),
31
+ pointY: originY + (y + 0.5) * (pointHeight / heightPx),
32
+ };
33
+ }
34
+
35
+ export function scaleFromGeometry(geometry) {
36
+ return {
37
+ imageToPointX: geometry.pointWidth / geometry.widthPx,
38
+ imageToPointY: geometry.pointHeight / geometry.heightPx,
39
+ };
40
+ }
41
+
42
+ /** Ease-out cubic — snappy agent loops, still readable as motion. */
43
+ export function easeOutCubic(t) {
44
+ const x = Math.min(1, Math.max(0, t));
45
+ return 1 - (1 - x) ** 3;
46
+ }
47
+
48
+ /**
49
+ * Duration (ms) for overlay travel. Caps keep agent loops fast on long jumps.
50
+ * ~0.25 ms/px with floor 28 ms / ceiling 110 ms.
51
+ */
52
+ export function motionDurationMs(distancePx) {
53
+ const d = Math.max(0, distancePx);
54
+ return Math.round(Math.min(110, Math.max(28, d * 0.25)));
55
+ }
56
+
57
+ export function pointDistance(a, b) {
58
+ const dx = (b.pointX ?? b.x) - (a.pointX ?? a.x);
59
+ const dy = (b.pointY ?? b.y) - (a.pointY ?? a.y);
60
+ return Math.hypot(dx, dy);
61
+ }