@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.
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
5
- import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import {
5
+ existsSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { chmod, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
6
14
  import { homedir, tmpdir } from 'node:os';
7
15
  import { dirname, join, resolve } from 'node:path';
8
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -11,17 +19,41 @@ const configDir = join(homedir(), '.config', 'jian-remote');
11
19
  const configPath = join(configDir, 'config.json');
12
20
  /** Stable path the OS service runs — not the npx cache or a transient install location. */
13
21
  const installedScript = join(configDir, 'jian-remote.mjs');
22
+ /** Exclusive run lock — prevents KeepAlive / CLI races from leaving two pollers alive. */
23
+ const workerLockPath = join(configDir, 'worker.pid');
24
+ const updateStatePath = join(configDir, 'update-state.json');
25
+ const MACOS_LAUNCH_AGENT_LABEL = 'com.jian.remote';
26
+ const LINUX_SERVICE_UNIT = 'jian-remote.service';
27
+ const WINDOWS_TASK_NAME = 'Jian Remote';
28
+ /** Minimal PATH for launchd — app-identity Node copies otherwise inherit an empty env. */
29
+ export const LAUNCH_AGENT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
30
+ /** KeepAlive floor so a bad update cannot spin-restart every second. */
31
+ export const LAUNCH_AGENT_THROTTLE_INTERVAL_SEC = 10;
32
+
14
33
  const args = process.argv.slice(2);
15
34
  const option = (name) => {
16
35
  const index = args.indexOf(name);
17
36
  return index < 0 ? undefined : args[index + 1];
18
37
  };
19
38
 
39
+ /**
40
+ * Parse argv for the top-level command and version flags (`version`, `--version`, `-V`).
41
+ * Version flags win over a subcommand so `jian-remote --version update` still prints the version.
42
+ */
43
+ export function parseCliInvocation(argv = args) {
44
+ const list = Array.isArray(argv) ? argv : [];
45
+ if (list.includes('--version') || list.includes('-V') || list[0] === 'version') {
46
+ return { command: 'version', versionRequested: true };
47
+ }
48
+ const command = list[0] && !list[0].startsWith('-') ? list[0] : undefined;
49
+ return { command, versionRequested: false };
50
+ }
51
+
20
52
  /**
21
53
  * Stamped to the release tag on npm publish and when the gateway serves this script.
22
54
  * Compatibility with the gateway requires the same major.minor (see versionsCompatible).
23
55
  */
24
- export const PACKAGE_VERSION = '0.8.1';
56
+ export const PACKAGE_VERSION = '0.8.3';
25
57
 
26
58
  /** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
27
59
  const STDOUT_CAP = 400_000;
@@ -135,7 +167,23 @@ export function buildMacosAppInfoPlist() {
135
167
  `;
136
168
  }
137
169
 
138
- export function buildLaunchAgentPlist({ label, executable, script, logPath }) {
170
+ export function buildLaunchAgentPlist({
171
+ label,
172
+ executable,
173
+ script,
174
+ logPath,
175
+ pathEnv = LAUNCH_AGENT_PATH,
176
+ throttleIntervalSec = LAUNCH_AGENT_THROTTLE_INTERVAL_SEC,
177
+ home = homedir(),
178
+ tmpDir = process.env.TMPDIR || '/tmp',
179
+ }) {
180
+ // ProcessType=Interactive opts the worker out of App Nap. Without it, macOS can freeze the
181
+ // Node poll loop while the screen is locked or idle; lastSeenAt ages out and the gateway
182
+ // shows Offline even though the process is still "running".
183
+ // Explicit PATH + HOME: launchd + the Jian Remote.app Node copy otherwise inherit a sparse
184
+ // env. Missing HOME makes os.homedir() / shell cwd wrong; without /bin on PATH, child tools
185
+ // fail. Invalid cwd surfaces as the misleading "spawn /bin/sh ENOENT" from libuv.
186
+ // ThrottleInterval: after an update exit, KeepAlive must not immediately fight a CLI restart.
139
187
  return `<?xml version="1.0" encoding="UTF-8"?>
140
188
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
141
189
  <plist version="1.0"><dict>
@@ -148,8 +196,17 @@ export function buildLaunchAgentPlist({ label, executable, script, logPath }) {
148
196
  <string>${xmlEscape(script)}</string>
149
197
  <string>run</string>
150
198
  </array>
199
+ <key>WorkingDirectory</key><string>${xmlEscape(home)}</string>
200
+ <key>EnvironmentVariables</key>
201
+ <dict>
202
+ <key>PATH</key><string>${xmlEscape(pathEnv)}</string>
203
+ <key>HOME</key><string>${xmlEscape(home)}</string>
204
+ <key>TMPDIR</key><string>${xmlEscape(tmpDir)}</string>
205
+ </dict>
206
+ <key>ProcessType</key><string>Interactive</string>
151
207
  <key>RunAtLoad</key><true/>
152
208
  <key>KeepAlive</key><true/>
209
+ <key>ThrottleInterval</key><integer>${Number(throttleIntervalSec) || 10}</integer>
153
210
  <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
154
211
  <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
155
212
  </dict></plist>
@@ -239,7 +296,7 @@ export function versionsCompatible(localVersion, gatewayVersion) {
239
296
  local: local.raw,
240
297
  gateway: gateway.raw,
241
298
  required: `${gateway.major}.${gateway.minor}`,
242
- message: `This remote-control ${local.raw} is incompatible with gateway ${gateway.raw}. Jian requires the same major.minor (${gateway.major}.${gateway.minor}). Run: npx @jian-ai/remote-control@${gateway.raw} update`,
299
+ message: `This remote-control ${local.raw} is incompatible with gateway ${gateway.raw}. Jian requires the same major.minor (${gateway.major}.${gateway.minor}). Connected workers try to self-update to the gateway version automatically; if that fails, run: npx @jian-ai/remote-control@${gateway.raw} update`,
243
300
  };
244
301
  }
245
302
 
@@ -248,6 +305,50 @@ export function compatibilityError(localVersion, gatewayVersion) {
248
305
  return result.ok ? null : result.message;
249
306
  }
250
307
 
308
+ /**
309
+ * True when the worker should self-update so its PACKAGE_VERSION matches the gateway release.
310
+ * Covers patch bumps and major.minor skew (fleet upgrade to the gateway’s version). Dev /
311
+ * unversioned gateways never force a match.
312
+ */
313
+ export function shouldMatchGatewayVersion(localVersion, gatewayVersion) {
314
+ if (isDevVersion(gatewayVersion)) return false;
315
+ const gateway = parseRelease(gatewayVersion);
316
+ if (!gateway) return false;
317
+ if (isDevVersion(localVersion)) return true;
318
+ const local = parseRelease(localVersion);
319
+ if (!local) return true;
320
+ return local.raw !== gateway.raw;
321
+ }
322
+
323
+ /** Soft ceiling so a failed fleet update does not KeepAlive-spin every few seconds. */
324
+ export function nextUpdateBackoffMs(failureCount) {
325
+ const n = Math.max(0, Number(failureCount) || 0);
326
+ // 5s, 10s, … then cap at 30 minutes (2^9 * 5s = 2560s > 30m).
327
+ return Math.min(30 * 60_000, 5_000 * 2 ** Math.min(n, 9));
328
+ }
329
+
330
+ /**
331
+ * Reject garbage / HTML / truncated downloads before replacing ~/.config/jian-remote/jian-remote.mjs.
332
+ * Credentials and config.json are never touched by the updater.
333
+ */
334
+ export function validateWorkerScriptBytes(bytes) {
335
+ if (!Buffer.isBuffer(bytes) && typeof bytes !== 'string') {
336
+ return { ok: false, message: 'Worker update was empty or unreadable.' };
337
+ }
338
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
339
+ if (!buffer.length || buffer.length > 1_000_000) {
340
+ return { ok: false, message: 'Invalid worker update size.' };
341
+ }
342
+ const source = buffer.toString('utf8');
343
+ if (!source.includes('Jian Remote') || !/export const PACKAGE_VERSION = '[^']+'/.test(source)) {
344
+ return { ok: false, message: 'Worker update did not look like a Jian Remote script.' };
345
+ }
346
+ if (!source.includes('updateIfNeeded') || !source.includes('async function run(')) {
347
+ return { ok: false, message: 'Worker update was missing required entrypoints.' };
348
+ }
349
+ return { ok: true, version: readEmbeddedVersion(source) };
350
+ }
351
+
251
352
  export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
252
353
  return (current + chunk.toString('utf8')).slice(0, cap);
253
354
  }
@@ -474,6 +575,35 @@ function fetchNpmWorkerScript(version) {
474
575
  }
475
576
  }
476
577
 
578
+ /**
579
+ * Copy computer-use companions next to the installed worker. Never overwrites jian-remote.mjs
580
+ * (auto-update must not clobber a gateway-stamped script with an older npm copy).
581
+ */
582
+ async function materializeCompanionModules() {
583
+ try {
584
+ const here = dirname(fileURLToPath(import.meta.url));
585
+ const companions = [
586
+ 'computer.mjs',
587
+ 'computer-shared.mjs',
588
+ 'computer-macos.mjs',
589
+ 'computer-linux.mjs',
590
+ 'computer-windows.mjs',
591
+ ];
592
+ for (const name of companions) {
593
+ const src = join(here, name);
594
+ const dest = join(configDir, name);
595
+ if (existsSync(src) && src !== dest) {
596
+ await copyFile(src, dest);
597
+ if (process.platform !== 'win32') await chmod(dest, 0o700);
598
+ }
599
+ }
600
+ const { materializeComputerHelperSources } = await import('./computer.mjs');
601
+ await materializeComputerHelperSources(configDir);
602
+ } catch {
603
+ // Install still succeeds; computer-use probe will explain if the helper is missing.
604
+ }
605
+ }
606
+
477
607
  /** Copy a worker script into ~/.config so npx and global installs share one service path. */
478
608
  async function materializeWorkerScript(sourceBytes) {
479
609
  await mkdir(configDir, { recursive: true, mode: 0o700 });
@@ -486,41 +616,201 @@ async function materializeWorkerScript(sourceBytes) {
486
616
  if (source !== installedScript) await copyFile(source, installedScript);
487
617
  }
488
618
  if (process.platform !== 'win32') await chmod(installedScript, 0o700);
489
- // Companion modules + macOS helper source (computer use). Best-effort beside the worker.
619
+ await materializeCompanionModules();
620
+ return installedScript;
621
+ }
622
+
623
+ /** True when `pid` is a live process (best-effort; false on ESRCH / invalid). */
624
+ export function isPidAlive(pid) {
625
+ const n = Number(pid);
626
+ if (!Number.isInteger(n) || n <= 0) return false;
490
627
  try {
491
- const here = dirname(fileURLToPath(import.meta.url));
492
- const computerSrc = join(here, 'computer-macos.mjs');
493
- const computerDest = join(configDir, 'computer-macos.mjs');
494
- if (existsSync(computerSrc) && computerSrc !== computerDest) {
495
- await copyFile(computerSrc, computerDest);
496
- if (process.platform !== 'win32') await chmod(computerDest, 0o700);
628
+ process.kill(n, 0);
629
+ return true;
630
+ } catch {
631
+ return false;
632
+ }
633
+ }
634
+
635
+ export function readWorkerLockPid(lockPath = workerLockPath) {
636
+ try {
637
+ const raw = readFileSync(lockPath, 'utf8').trim();
638
+ const pid = Number(raw);
639
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
640
+ } catch {
641
+ return undefined;
642
+ }
643
+ }
644
+
645
+ function sleepMs(ms) {
646
+ const sab = new SharedArrayBuffer(4);
647
+ const ia = new Int32Array(sab);
648
+ Atomics.wait(ia, 0, 0, Math.max(0, Number(ms) || 0));
649
+ }
650
+
651
+ /**
652
+ * Stop a stray worker PID (SIGTERM → wait → SIGKILL). Used by stop/restart and the run lock.
653
+ */
654
+ export function terminateWorkerPid(
655
+ pid,
656
+ { waitMs = 8_000, kill = process.kill.bind(process) } = {},
657
+ ) {
658
+ if (!isPidAlive(pid)) return { stopped: true, alreadyDead: true };
659
+ try {
660
+ kill(pid, 'SIGTERM');
661
+ } catch {
662
+ return { stopped: !isPidAlive(pid) };
663
+ }
664
+ const deadline = Date.now() + waitMs;
665
+ while (Date.now() < deadline && isPidAlive(pid)) sleepMs(100);
666
+ if (isPidAlive(pid)) {
667
+ try {
668
+ kill(pid, 'SIGKILL');
669
+ } catch {
670
+ // ESRCH — already gone.
671
+ }
672
+ sleepMs(100);
673
+ }
674
+ return { stopped: !isPidAlive(pid) };
675
+ }
676
+
677
+ /**
678
+ * Claim the exclusive worker lock. If another live PID holds it, terminate that process first
679
+ * so KeepAlive / CLI restarts never leave two pollers on the same machine.
680
+ */
681
+ export async function acquireWorkerLock({
682
+ lockPath = workerLockPath,
683
+ pid = process.pid,
684
+ maxAttempts = 8,
685
+ } = {}) {
686
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
687
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
688
+ const existing = readWorkerLockPid(lockPath);
689
+ if (existing && existing !== pid && isPidAlive(existing)) {
690
+ console.error(
691
+ `Another Jian Remote worker is running (pid ${existing}); stopping it before continuing.`,
692
+ );
693
+ terminateWorkerPid(existing);
694
+ }
695
+ if (existing && !isPidAlive(existing)) {
696
+ try {
697
+ await unlink(lockPath);
698
+ } catch {
699
+ // Lost the race to remove a stale lock; retry create.
700
+ }
497
701
  }
498
- if (process.platform === 'darwin') {
499
- const { materializeComputerHelperSources } = await import('./computer-macos.mjs');
500
- await materializeComputerHelperSources(configDir);
702
+ try {
703
+ writeFileSync(lockPath, `${pid}\n`, { flag: 'wx', mode: 0o600 });
704
+ return { ok: true, pid };
705
+ } catch (error) {
706
+ if (error && error.code !== 'EEXIST') throw error;
707
+ sleepMs(150);
501
708
  }
709
+ }
710
+ throw new Error(
711
+ `Could not claim the Jian Remote worker lock at ${lockPath}. Stop other workers with: jian-remote stop`,
712
+ );
713
+ }
714
+
715
+ export async function releaseWorkerLock({ lockPath = workerLockPath, pid = process.pid } = {}) {
716
+ const existing = readWorkerLockPid(lockPath);
717
+ if (existing !== undefined && existing !== pid) return;
718
+ try {
719
+ await unlink(lockPath);
502
720
  } catch {
503
- // Install still succeeds; computer-use probe will explain if the helper is missing.
721
+ // Best-effort; a stale pid file is cleared on the next acquire.
504
722
  }
505
- return installedScript;
723
+ }
724
+
725
+ function macosPlistPath() {
726
+ return join(homedir(), 'Library', 'LaunchAgents', `${MACOS_LAUNCH_AGENT_LABEL}.plist`);
727
+ }
728
+
729
+ function linuxUnitPath() {
730
+ return join(homedir(), '.config', 'systemd', 'user', LINUX_SERVICE_UNIT);
731
+ }
732
+
733
+ export function serviceKindForPlatform(platform = process.platform) {
734
+ if (platform === 'darwin') return 'LaunchAgent';
735
+ if (platform === 'linux') return 'systemd --user';
736
+ if (platform === 'win32') return 'Task Scheduler';
737
+ return 'none';
738
+ }
739
+
740
+ /**
741
+ * Fully stop the OS service and any worker still holding the pid lock.
742
+ * Clear success/failure for the `jian-remote stop` CLI.
743
+ */
744
+ async function stopService() {
745
+ const kind = serviceKindForPlatform();
746
+ if (process.platform === 'darwin') {
747
+ const plistPath = macosPlistPath();
748
+ const domain = `gui/${process.getuid()}`;
749
+ spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
750
+ spawnSync('launchctl', ['bootout', `${domain}/${MACOS_LAUNCH_AGENT_LABEL}`], {
751
+ stdio: 'ignore',
752
+ });
753
+ } else if (process.platform === 'linux') {
754
+ spawnSync('systemctl', ['--user', 'stop', LINUX_SERVICE_UNIT], { stdio: 'ignore' });
755
+ } else if (process.platform === 'win32') {
756
+ spawnSync('schtasks.exe', ['/End', '/TN', WINDOWS_TASK_NAME], {
757
+ stdio: 'ignore',
758
+ windowsHide: true,
759
+ });
760
+ } else {
761
+ throw new Error(`Service stop is unsupported on ${process.platform}.`);
762
+ }
763
+
764
+ const locked = readWorkerLockPid();
765
+ if (locked) {
766
+ const result = terminateWorkerPid(locked);
767
+ if (!result.stopped) {
768
+ throw new Error(
769
+ `Stopped the ${kind} job but worker pid ${locked} is still running. Try again or kill it manually.`,
770
+ );
771
+ }
772
+ }
773
+ await releaseWorkerLock({ pid: locked ?? process.pid });
774
+ // Also clear a lock we never owned (stop from a CLI process).
775
+ try {
776
+ await unlink(workerLockPath);
777
+ } catch {
778
+ // absent
779
+ }
780
+ return { service: kind, stopped: true };
506
781
  }
507
782
 
508
783
  /**
509
- * Register or bounce the per-user service so it runs the installed script and reconnects.
784
+ * Register (if needed) and start exactly one worker. Always stops any previous instance first.
510
785
  * On macOS, ProgramArguments[0] is the Jian Remote.app Node copy — not system node — so
511
786
  * Login Items / TCC show "Jian Remote".
512
787
  */
513
- async function startOrRestartService(script) {
788
+ async function startService(script = installedScript) {
789
+ try {
790
+ await stopService();
791
+ } catch (error) {
792
+ // First install / never registered: stop may complain on exotic platforms only.
793
+ // A live pid that will not die must still fail the start.
794
+ if (error instanceof Error && /still running/i.test(error.message)) throw error;
795
+ }
796
+
797
+ try {
798
+ await readFile(script);
799
+ } catch {
800
+ throw new Error(
801
+ `No installed worker at ${script}. Run: jian-remote install --gateway … --machine … --token …`,
802
+ );
803
+ }
804
+
514
805
  if (process.platform === 'darwin') {
515
806
  const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
516
- const plistPath = join(launchAgents, 'com.jian.remote.plist');
517
- const label = 'com.jian.remote';
807
+ const plistPath = macosPlistPath();
518
808
  const app = await materializeMacosAppRuntime();
519
809
  await mkdir(launchAgents, { recursive: true, mode: 0o700 });
520
810
  await writeFile(
521
811
  plistPath,
522
812
  buildLaunchAgentPlist({
523
- label,
813
+ label: MACOS_LAUNCH_AGENT_LABEL,
524
814
  executable: app.executable,
525
815
  script,
526
816
  logPath: join(configDir, 'worker.log'),
@@ -528,7 +818,9 @@ async function startOrRestartService(script) {
528
818
  { mode: 0o600 },
529
819
  );
530
820
  const domain = `gui/${process.getuid()}`;
821
+ // bootout again after writing the plist so KeepAlive cannot resurrect the old definition.
531
822
  spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
823
+ sleepMs(400);
532
824
  const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
533
825
  if (loaded.status !== 0) throw new Error('Could not start Jian Remote with launchd');
534
826
  return {
@@ -541,24 +833,16 @@ async function startOrRestartService(script) {
541
833
  await mkdir(unitDir, { recursive: true, mode: 0o700 });
542
834
  const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
543
835
  const unit = `[Unit]\nDescription=Jian Remote worker\nAfter=network-online.target\n\n[Service]\nExecStart=${quote(process.execPath)} ${quote(script)} run\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n`;
544
- await writeFile(join(unitDir, 'jian-remote.service'), unit, { mode: 0o600 });
545
- for (const command of [
546
- ['daemon-reload'],
547
- ['enable', '--now', 'jian-remote.service'],
548
- ['restart', 'jian-remote.service'],
549
- ]) {
836
+ await writeFile(linuxUnitPath(), unit, { mode: 0o600 });
837
+ for (const command of [['daemon-reload'], ['enable', '--now', LINUX_SERVICE_UNIT]]) {
550
838
  const result = spawnSync('systemctl', ['--user', ...command], { stdio: 'inherit' });
551
839
  if (result.status !== 0)
552
840
  throw new Error('Could not install/start Jian Remote with systemd --user');
553
841
  }
554
- return { service: 'systemd --user', detail: 'jian-remote.service' };
842
+ return { service: 'systemd --user', detail: LINUX_SERVICE_UNIT };
555
843
  }
556
844
  if (process.platform === 'win32') {
557
845
  const action = `"${process.execPath}" "${script}" run`;
558
- spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
559
- stdio: 'ignore',
560
- windowsHide: true,
561
- });
562
846
  runChecked(
563
847
  'schtasks.exe',
564
848
  [
@@ -566,7 +850,7 @@ async function startOrRestartService(script) {
566
850
  '/SC',
567
851
  'ONLOGON',
568
852
  '/TN',
569
- 'Jian Remote',
853
+ WINDOWS_TASK_NAME,
570
854
  '/TR',
571
855
  action,
572
856
  '/F',
@@ -578,15 +862,49 @@ async function startOrRestartService(script) {
578
862
  );
579
863
  runChecked(
580
864
  'schtasks.exe',
581
- ['/Run', '/TN', 'Jian Remote'],
865
+ ['/Run', '/TN', WINDOWS_TASK_NAME],
582
866
  'Could not start the Jian Remote scheduled task',
583
867
  );
584
- return { service: 'Task Scheduler', detail: 'Jian Remote (ONLOGON)' };
868
+ return { service: 'Task Scheduler', detail: `${WINDOWS_TASK_NAME} (ONLOGON)` };
585
869
  }
586
870
  printInfo(`Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`);
587
871
  return { service: 'none', detail: process.platform };
588
872
  }
589
873
 
874
+ async function restartService(script = installedScript) {
875
+ // stopService is invoked inside startService; call stop first for clear CLI semantics / logs.
876
+ await stopService().catch(() => undefined);
877
+ return startService(script);
878
+ }
879
+
880
+ /** @deprecated use restartService — kept as the install/configure/update entry name. */
881
+ async function startOrRestartService(script) {
882
+ return restartService(script);
883
+ }
884
+
885
+ async function printVersionAndExit() {
886
+ console.log(PACKAGE_VERSION);
887
+ }
888
+
889
+ async function cliStop() {
890
+ const result = await stopService();
891
+ printSuccess('Stopped Jian Remote', [['Service', `${result.service} stopped`]]);
892
+ }
893
+
894
+ async function cliStart() {
895
+ const started = await startService(installedScript);
896
+ const facts = [['Service', `${started.service} started`]];
897
+ if (started.detail) facts.push(['Identity', started.detail]);
898
+ printSuccess('Started Jian Remote', facts);
899
+ }
900
+
901
+ async function cliRestart() {
902
+ const started = await restartService(installedScript);
903
+ const facts = [['Service', `${started.service} restarted`]];
904
+ if (started.detail) facts.push(['Identity', started.detail]);
905
+ printSuccess('Restarted Jian Remote', facts);
906
+ }
907
+
590
908
  async function install() {
591
909
  const gateway = option('--gateway');
592
910
  const machineId = option('--machine');
@@ -709,20 +1027,40 @@ async function update() {
709
1027
  let scriptBytes;
710
1028
  let installedVersion = PACKAGE_VERSION;
711
1029
  // When this process already matches the target (typical: npx @jian-ai/remote-control@X update),
712
- // copy ourselves. Otherwise pull the exact package from npm.
1030
+ // copy ourselves. Require an **exact** semver match — same major.minor alone used to let a
1031
+ // global 0.8.1 CLI rewrite the installed script while the gateway was already on 0.8.2,
1032
+ // fighting auto-update and flip-flopping two pollers.
713
1033
  const selfMatchesTarget =
714
1034
  target === 'latest'
715
1035
  ? !isDevVersion(PACKAGE_VERSION)
716
- : PACKAGE_VERSION === target.replace(/^v/, '') ||
717
- versionsCompatible(PACKAGE_VERSION, target).ok;
1036
+ : PACKAGE_VERSION === target.replace(/^v/, '');
718
1037
 
719
1038
  if (selfMatchesTarget && !isDevVersion(PACKAGE_VERSION)) {
720
1039
  scriptBytes = await readFile(fileURLToPath(import.meta.url));
721
1040
  installedVersion = PACKAGE_VERSION;
722
1041
  } else {
723
- printInfo(`Downloading @jian-ai/remote-control@${target}…`);
724
- scriptBytes = fetchNpmWorkerScript(target);
725
- installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
1042
+ // Prefer the gateway’s stamped script when targeting that release — works when npm has not
1043
+ // published the version yet. Fall back to npm pack for pins / latest.
1044
+ const wantGatewayScript =
1045
+ gatewayVersion &&
1046
+ target !== 'latest' &&
1047
+ parseRelease(target)?.raw === parseRelease(gatewayVersion)?.raw;
1048
+ if (wantGatewayScript) {
1049
+ try {
1050
+ printInfo('Downloading worker script from the gateway…');
1051
+ scriptBytes = await fetchGatewayWorkerScript(config.gateway);
1052
+ installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
1053
+ } catch (error) {
1054
+ printInfo(
1055
+ `Gateway script download failed (${error instanceof Error ? error.message : error}); trying npm…`,
1056
+ );
1057
+ }
1058
+ }
1059
+ if (!scriptBytes) {
1060
+ printInfo(`Downloading @jian-ai/remote-control@${target}…`);
1061
+ scriptBytes = fetchNpmWorkerScript(target);
1062
+ installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
1063
+ }
726
1064
  }
727
1065
 
728
1066
  const error = compatibilityError(installedVersion, gatewayVersion);
@@ -752,27 +1090,125 @@ async function update() {
752
1090
  printSuccess('Updated Jian Remote', facts);
753
1091
  }
754
1092
 
755
- async function executeShell(task) {
756
- return await new Promise((resolveResult) => {
757
- const command = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
1093
+ /** Absolute shell path for remote commands — never rely on PATH for the shell itself. */
1094
+ export function shellCommandForPlatform(platform = process.platform) {
1095
+ if (platform === 'win32') return 'powershell.exe';
1096
+ if (existsSync('/bin/sh')) return '/bin/sh';
1097
+ if (existsSync('/bin/bash')) return '/bin/bash';
1098
+ if (existsSync('/usr/bin/sh')) return '/usr/bin/sh';
1099
+ return '/bin/sh';
1100
+ }
1101
+
1102
+ /**
1103
+ * Resolve the working directory for a remote shell task.
1104
+ * libuv reports a missing cwd as `spawn /bin/sh ENOENT` even when the shell exists — Computer
1105
+ * Use kept working (no cwd) while run_remote_command failed. Fall back to $HOME when the
1106
+ * requested path is missing or not a directory, and surface an English note in stderr.
1107
+ */
1108
+ export function resolveShellCwd(
1109
+ requested,
1110
+ { home = homedir(), exists = existsSync, stat = statSync } = {},
1111
+ ) {
1112
+ const fallbackCandidates = [home, process.env.TMPDIR, '/tmp', '/'].filter(Boolean);
1113
+ const fallback =
1114
+ fallbackCandidates.find((path) => {
1115
+ try {
1116
+ return exists(path) && stat(path).isDirectory();
1117
+ } catch {
1118
+ return false;
1119
+ }
1120
+ }) || '/';
1121
+
1122
+ if (requested == null || requested === '') {
1123
+ return { cwd: fallback, note: undefined, usedFallback: false };
1124
+ }
1125
+ const target = String(requested).trim();
1126
+ if (!target) {
1127
+ return { cwd: fallback, note: undefined, usedFallback: false };
1128
+ }
1129
+ try {
1130
+ if (!exists(target)) {
1131
+ return {
1132
+ cwd: fallback,
1133
+ note: `Requested cwd ${target} does not exist on this computer; using ${fallback}.`,
1134
+ usedFallback: true,
1135
+ };
1136
+ }
1137
+ if (!stat(target).isDirectory()) {
1138
+ return {
1139
+ cwd: fallback,
1140
+ note: `Requested cwd ${target} is not a directory; using ${fallback}.`,
1141
+ usedFallback: true,
1142
+ };
1143
+ }
1144
+ return { cwd: target, note: undefined, usedFallback: false };
1145
+ } catch {
1146
+ return {
1147
+ cwd: fallback,
1148
+ note: `Requested cwd ${target} is not usable; using ${fallback}.`,
1149
+ usedFallback: true,
1150
+ };
1151
+ }
1152
+ }
1153
+
1154
+ /** Env for shell / helper children: always a usable PATH and HOME. */
1155
+ export function workerChildEnv(baseEnv = process.env) {
1156
+ const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
1157
+ const current = baseEnv[pathKey] ?? baseEnv.PATH ?? '';
1158
+ const prefix = process.platform === 'win32' ? '' : LAUNCH_AGENT_PATH;
1159
+ const merged = prefix ? `${prefix}${current ? `:${current}` : ''}` : current || LAUNCH_AGENT_PATH;
1160
+ const home = baseEnv.HOME || baseEnv.USERPROFILE || homedir();
1161
+ return {
1162
+ ...baseEnv,
1163
+ [pathKey]: merged,
1164
+ PATH: merged,
1165
+ HOME: home,
1166
+ ...(process.platform === 'win32' ? { USERPROFILE: home } : {}),
1167
+ };
1168
+ }
1169
+
1170
+ /**
1171
+ * Explain spawn failures. Node’s `spawn /bin/sh ENOENT` usually means the **cwd** was missing,
1172
+ * not that /bin/sh vanished — rewrite so operators and agents can act on it.
1173
+ */
1174
+ export function formatShellSpawnError(error, { shell, cwd } = {}) {
1175
+ const code = error && typeof error === 'object' ? error.code : undefined;
1176
+ const message = error instanceof Error ? error.message : String(error);
1177
+ if (code === 'ENOENT' || /\bENOENT\b/.test(message)) {
1178
+ const shellMissing = shell && !existsSync(shell);
1179
+ if (shellMissing) {
1180
+ return `Could not find the shell executable ${shell}. Install a POSIX shell or reinstall Jian Remote.`;
1181
+ }
1182
+ return `Could not start the remote shell (${shell || '/bin/sh'}) with cwd ${cwd || '(unknown)'}: the working directory is missing or not usable (Node reports this as spawn ENOENT even when the shell exists).`;
1183
+ }
1184
+ return message;
1185
+ }
1186
+
1187
+ /**
1188
+ * Run a remote shell task. Exported for behavior tests (cwd fallback, absolute shell, PATH).
1189
+ */
1190
+ export function executeShellTask(task, { spawnImpl = spawn, platform = process.platform } = {}) {
1191
+ return new Promise((resolveResult) => {
1192
+ const shell = shellCommandForPlatform(platform);
1193
+ const resolved = resolveShellCwd(task.cwd);
758
1194
  const commandArgs =
759
- process.platform === 'win32'
1195
+ platform === 'win32'
760
1196
  ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', task.command]
761
1197
  : ['-lc', task.command];
762
- const child = spawn(command, commandArgs, {
763
- cwd: task.cwd || homedir(),
764
- env: process.env,
1198
+ const child = spawnImpl(shell, commandArgs, {
1199
+ cwd: resolved.cwd,
1200
+ env: workerChildEnv(),
765
1201
  stdio: ['ignore', 'pipe', 'pipe'],
766
1202
  });
767
1203
  let stdout = '';
768
- let stderr = '';
1204
+ let stderr = resolved.note ? `${resolved.note}\n` : '';
769
1205
  let stdoutSeen = 0;
770
1206
  let timedOut = false;
771
1207
  const timeout = setTimeout(() => {
772
1208
  timedOut = true;
773
1209
  child.kill('SIGTERM');
774
1210
  setTimeout(() => {
775
- if (process.platform === 'win32' && child.pid) {
1211
+ if (platform === 'win32' && child.pid) {
776
1212
  spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
777
1213
  stdio: 'ignore',
778
1214
  windowsHide: true,
@@ -782,11 +1218,11 @@ async function executeShell(task) {
782
1218
  }
783
1219
  }, 2000).unref();
784
1220
  }, task.timeoutMs);
785
- child.stdout.on('data', (chunk) => {
1221
+ child.stdout?.on('data', (chunk) => {
786
1222
  stdoutSeen += chunk.toString('utf8').length;
787
1223
  stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
788
1224
  });
789
- child.stderr.on('data', (chunk) => {
1225
+ child.stderr?.on('data', (chunk) => {
790
1226
  stderr = cappedAppend(stderr, chunk, STDERR_CAP);
791
1227
  });
792
1228
  child.on('error', (error) => {
@@ -795,13 +1231,16 @@ async function executeShell(task) {
795
1231
  taskId: task.id,
796
1232
  exitCode: null,
797
1233
  stdout,
798
- stderr: cappedAppend(stderr, error.message, STDERR_CAP),
1234
+ stderr: cappedAppend(
1235
+ stderr,
1236
+ formatShellSpawnError(error, { shell, cwd: resolved.cwd }),
1237
+ STDERR_CAP,
1238
+ ),
799
1239
  timedOut,
800
1240
  });
801
1241
  });
802
1242
  child.on('close', (code) => {
803
1243
  clearTimeout(timeout);
804
- // Mark silent stdout cuts so screenshot tools can reject grey/truncated JPEGs.
805
1244
  if (stdoutSeen > STDOUT_CAP) {
806
1245
  stderr = cappedAppend(
807
1246
  stderr,
@@ -814,26 +1253,124 @@ async function executeShell(task) {
814
1253
  });
815
1254
  }
816
1255
 
1256
+ async function executeShell(task) {
1257
+ return executeShellTask(task);
1258
+ }
1259
+
817
1260
  async function execute(task) {
818
1261
  if (task.kind === 'computer') {
819
- if (process.platform !== 'darwin') {
820
- return {
821
- taskId: task.id,
822
- exitCode: 1,
823
- stdout: '',
824
- stderr: 'Computer use is supported on macOS Jian Remote only.',
825
- timedOut: false,
826
- computer: { ok: false, error: 'Computer use is supported on macOS Jian Remote only.' },
827
- };
828
- }
829
- const { executeComputerTask } = await import('./computer-macos.mjs');
1262
+ const { executeComputerTask } = await import('./computer.mjs');
830
1263
  return executeComputerTask(task);
831
1264
  }
832
1265
  return executeShell(task);
833
1266
  }
834
1267
 
1268
+ async function readUpdateState() {
1269
+ try {
1270
+ const raw = JSON.parse(await readFile(updateStatePath, 'utf8'));
1271
+ return {
1272
+ failures: Number(raw?.failures) || 0,
1273
+ lastError: typeof raw?.lastError === 'string' ? raw.lastError : undefined,
1274
+ lastFailureAt: typeof raw?.lastFailureAt === 'string' ? raw.lastFailureAt : undefined,
1275
+ };
1276
+ } catch {
1277
+ return { failures: 0 };
1278
+ }
1279
+ }
1280
+
1281
+ async function clearUpdateState() {
1282
+ try {
1283
+ rmSync(updateStatePath, { force: true });
1284
+ } catch {
1285
+ // Best-effort; a stale state file only delays the next retry.
1286
+ }
1287
+ }
1288
+
1289
+ async function recordUpdateFailure(error) {
1290
+ const previous = await readUpdateState();
1291
+ const failures = previous.failures + 1;
1292
+ const lastError = error instanceof Error ? error.message : String(error);
1293
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
1294
+ await writeFile(
1295
+ updateStatePath,
1296
+ `${JSON.stringify(
1297
+ {
1298
+ failures,
1299
+ lastError,
1300
+ lastFailureAt: new Date().toISOString(),
1301
+ },
1302
+ null,
1303
+ 2,
1304
+ )}\n`,
1305
+ { mode: 0o600 },
1306
+ );
1307
+ return { failures, lastError, backoffMs: nextUpdateBackoffMs(failures) };
1308
+ }
1309
+
1310
+ /**
1311
+ * True when this process’s PACKAGE_VERSION no longer matches the installed script on disk.
1312
+ * A surviving poller after another worker already applied an update must exit so KeepAlive
1313
+ * loads the new file — ETag 304 alone is not enough (disk matches gateway, memory is stale).
1314
+ */
1315
+ export function isStaleInstalledWorker(processVersion, installedSource) {
1316
+ if (isDevVersion(processVersion)) return false;
1317
+ if (typeof installedSource !== 'string' || !installedSource) return false;
1318
+ const diskVersion = readEmbeddedVersion(installedSource);
1319
+ if (!diskVersion || diskVersion === 'unknown' || isDevVersion(diskVersion)) return false;
1320
+ return diskVersion !== processVersion;
1321
+ }
1322
+
1323
+ /**
1324
+ * Atomically replace the installed worker script and exit so LaunchAgent / systemd KeepAlive
1325
+ * (or a detached Windows child) load the new code. Never touches config.json.
1326
+ * Does not call startOrRestartService — that would race KeepAlive and leave two pollers.
1327
+ */
1328
+ async function applyWorkerScriptAndRestart(updated, { sourceLabel }) {
1329
+ const checked = validateWorkerScriptBytes(updated);
1330
+ if (!checked.ok) throw new Error(checked.message);
1331
+ // Same bytes already on disk and already loaded — do not thrash restart loops when npm and
1332
+ // the gateway script disagree about packaging but stamp the same version.
1333
+ try {
1334
+ const existing = await readFile(installedScript);
1335
+ if (
1336
+ existing.equals(Buffer.isBuffer(updated) ? updated : Buffer.from(updated)) &&
1337
+ checked.version === PACKAGE_VERSION
1338
+ ) {
1339
+ await clearUpdateState();
1340
+ return false;
1341
+ }
1342
+ } catch {
1343
+ // No installed script yet — fall through and write.
1344
+ }
1345
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
1346
+ const temp = `${installedScript}.${process.pid}.tmp`;
1347
+ await writeFile(temp, updated, { mode: 0o700 });
1348
+ await rename(temp, installedScript);
1349
+ if (process.platform !== 'win32') await chmod(installedScript, 0o700);
1350
+ // Best-effort companions from this process’s package (gateway /update is main script only).
1351
+ await materializeCompanionModules();
1352
+ await clearUpdateState();
1353
+ console.log(`Worker updated to ${checked.version} from ${sourceLabel}; restarting to apply it.`);
1354
+ await releaseWorkerLock();
1355
+ if (process.platform === 'win32') {
1356
+ // Task Scheduler has no KeepAlive; spawn the new script then exit this process.
1357
+ // Only one detached child — do not also leave this process polling.
1358
+ const child = spawn(process.execPath, [installedScript, 'run'], {
1359
+ detached: true,
1360
+ stdio: 'ignore',
1361
+ windowsHide: true,
1362
+ });
1363
+ child.unref();
1364
+ }
1365
+ // macOS LaunchAgent KeepAlive / Linux systemd Restart=always reload this path.
1366
+ process.exit(0);
1367
+ }
1368
+
1369
+ /**
1370
+ * Pull the gateway-served worker (same bytes as /jian-remote.mjs, auth’d). Prefer this over npm
1371
+ * for fleets that can reach the gateway but not the registry.
1372
+ */
835
1373
  async function updateIfNeeded(base, headers) {
836
- // Prefer the installed copy under ~/.config so updates land where the service points.
837
1374
  const script = installedScript;
838
1375
  let local;
839
1376
  try {
@@ -841,30 +1378,170 @@ async function updateIfNeeded(base, headers) {
841
1378
  } catch {
842
1379
  local = await readFile(fileURLToPath(import.meta.url));
843
1380
  }
1381
+ if (isStaleInstalledWorker(PACKAGE_VERSION, local.toString('utf8'))) {
1382
+ console.log(
1383
+ `Worker process ${PACKAGE_VERSION} is stale (installed script is newer); restarting to load it.`,
1384
+ );
1385
+ await releaseWorkerLock();
1386
+ process.exit(0);
1387
+ }
844
1388
  const etag = `"${createHash('sha256').update(local).digest('hex')}"`;
845
1389
  const response = await fetch(`${base}/update`, {
846
1390
  headers: { ...headers, 'if-none-match': etag },
847
1391
  redirect: 'error',
848
1392
  signal: AbortSignal.timeout(15_000),
849
1393
  });
850
- if (response.status === 304) return false;
1394
+ if (response.status === 304) {
1395
+ // Disk matches gateway. If this process is still the wrong release, exit to reload disk
1396
+ // (second poller that survived an earlier update).
1397
+ return false;
1398
+ }
851
1399
  if (!response.ok) throw new Error(`worker update check failed: HTTP ${response.status}`);
852
1400
  const updated = Buffer.from(await response.arrayBuffer());
853
- if (!updated.length || updated.length > 1_000_000) throw new Error('Invalid worker update size');
854
- await mkdir(configDir, { recursive: true, mode: 0o700 });
855
- const temp = `${script}.${process.pid}.tmp`;
856
- await writeFile(temp, updated, { mode: 0o700 });
857
- await rename(temp, script);
858
- console.log('Worker updated; restarting to apply it.');
859
- if (process.platform === 'win32') {
860
- const child = spawn(process.execPath, [script, 'run'], {
861
- detached: true,
862
- stdio: 'ignore',
863
- windowsHide: true,
864
- });
865
- child.unref();
1401
+ const applied = await applyWorkerScriptAndRestart(updated, { sourceLabel: 'the gateway' });
1402
+ // applyWorkerScriptAndRestart exits on a real replace; false means already current.
1403
+ return applied !== false;
1404
+ }
1405
+
1406
+ /** Public /jian-remote.mjs — same stamped bytes as authenticated /update, no worker token. */
1407
+ async function fetchGatewayWorkerScript(gatewayBase, fetcher = fetch) {
1408
+ const response = await fetcher(`${gatewayBase.replace(/\/$/, '')}/jian-remote.mjs`, {
1409
+ redirect: 'error',
1410
+ signal: AbortSignal.timeout(30_000),
1411
+ });
1412
+ if (!response.ok) {
1413
+ throw new Error(`Could not download gateway worker script: HTTP ${response.status}`);
866
1414
  }
867
- process.exit(0);
1415
+ return Buffer.from(await response.arrayBuffer());
1416
+ }
1417
+
1418
+ /**
1419
+ * Fallback when authenticated /update did not leave disk on the gateway release.
1420
+ * Prefer public /jian-remote.mjs (works when npm is not promoted yet); then npm pack.
1421
+ */
1422
+ async function updateFromNpm(gatewayVersion, gatewayBase) {
1423
+ const target = parseRelease(gatewayVersion)?.raw;
1424
+ if (!target)
1425
+ throw new Error('Cannot download a worker update without a gateway release version.');
1426
+ if (gatewayBase) {
1427
+ try {
1428
+ console.log(`Downloading worker script from the gateway (/jian-remote.mjs)…`);
1429
+ const fromGateway = await fetchGatewayWorkerScript(gatewayBase);
1430
+ const applied = await applyWorkerScriptAndRestart(fromGateway, {
1431
+ sourceLabel: 'the gateway (/jian-remote.mjs)',
1432
+ });
1433
+ if (applied !== false) return true;
1434
+ } catch (error) {
1435
+ console.error(
1436
+ `Gateway script download failed (${error instanceof Error ? error.message : error}); trying npm…`,
1437
+ );
1438
+ }
1439
+ }
1440
+ console.log(`Downloading @jian-ai/remote-control@${target} from npm…`);
1441
+ const scriptBytes = fetchNpmWorkerScript(target);
1442
+ const applied = await applyWorkerScriptAndRestart(scriptBytes, {
1443
+ sourceLabel: `npm @${target}`,
1444
+ });
1445
+ return applied !== false;
1446
+ }
1447
+
1448
+ /**
1449
+ * On connect and every few minutes: match the gateway’s worker script. Same major.minor patch
1450
+ * bumps use the authenticated /update ETag path; major.minor skew also self-updates to the
1451
+ * gateway version (gateway script first, npm fallback) instead of refusing forever.
1452
+ *
1453
+ * Prefer the gateway script. Only hit npm when the gateway path did not leave the **installed**
1454
+ * script on the gateway release — never rewrite a correct gateway install with an older npm
1455
+ * tarball (staged-but-unpublished packages caused 0.8.1↔0.8.2 thrash).
1456
+ */
1457
+ async function reconcileWorkerWithGateway(config, base, headers) {
1458
+ const gatewayVersion = await fetchGatewayVersion(config.gateway);
1459
+ const gatewayRelease = parseRelease(gatewayVersion)?.raw;
1460
+ const compat = versionsCompatible(PACKAGE_VERSION, gatewayVersion);
1461
+ const mustMatch = shouldMatchGatewayVersion(PACKAGE_VERSION, gatewayVersion);
1462
+
1463
+ let installedVersion;
1464
+ try {
1465
+ installedVersion = readEmbeddedVersion((await readFile(installedScript)).toString('utf8'));
1466
+ } catch {
1467
+ installedVersion = undefined;
1468
+ }
1469
+
1470
+ let gatewayUpdateError;
1471
+ try {
1472
+ const changed = await updateIfNeeded(base, headers);
1473
+ // Re-read after a no-op check; updateIfNeeded exits on apply.
1474
+ try {
1475
+ installedVersion = readEmbeddedVersion((await readFile(installedScript)).toString('utf8'));
1476
+ } catch {
1477
+ // keep previous
1478
+ }
1479
+ // 304 and still wrong version → gateway bytes match disk but this process is stale, or the
1480
+ // gateway could not stamp a newer script. Fall through to npm only when disk is wrong too.
1481
+ if (!changed && mustMatch) {
1482
+ if (gatewayRelease && installedVersion === gatewayRelease) {
1483
+ console.log(
1484
+ `Installed worker is ${installedVersion} but this process is ${PACKAGE_VERSION}; restarting to load the installed script.`,
1485
+ );
1486
+ await releaseWorkerLock();
1487
+ process.exit(0);
1488
+ }
1489
+ gatewayUpdateError = new Error(
1490
+ `Gateway worker script is unchanged but this process is ${PACKAGE_VERSION} (gateway ${gatewayVersion}).`,
1491
+ );
1492
+ }
1493
+ } catch (error) {
1494
+ gatewayUpdateError = error instanceof Error ? error : new Error(String(error));
1495
+ }
1496
+
1497
+ const diskAlreadyMatches = Boolean(gatewayRelease) && installedVersion === gatewayRelease;
1498
+
1499
+ if (gatewayUpdateError && (mustMatch || !compat.ok) && !diskAlreadyMatches) {
1500
+ console.error(
1501
+ `Gateway self-update did not apply (${gatewayUpdateError.message}); trying npm for ${gatewayVersion}…`,
1502
+ );
1503
+ try {
1504
+ await updateFromNpm(gatewayVersion, config.gateway);
1505
+ } catch (npmError) {
1506
+ const recorded = await recordUpdateFailure(npmError);
1507
+ console.error(
1508
+ `Automatic worker update failed (${recorded.lastError}). Will retry after ${(recorded.backoffMs / 1000).toFixed(0)}s. Credentials in ${configPath} were not changed.`,
1509
+ );
1510
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, recorded.backoffMs));
1511
+ if (!compat.ok) {
1512
+ console.error(
1513
+ compatibilityError(PACKAGE_VERSION, gatewayVersion) ??
1514
+ `This remote-control ${PACKAGE_VERSION} cannot connect to gateway ${gatewayVersion}.`,
1515
+ );
1516
+ process.exit(1);
1517
+ }
1518
+ // Compatible (same major.minor) but newer patch unavailable — keep running on the old build.
1519
+ console.error('Continuing on the installed worker; will retry the update later.');
1520
+ return gatewayVersion;
1521
+ }
1522
+ } else if (gatewayUpdateError && diskAlreadyMatches) {
1523
+ console.error(
1524
+ `Worker update check reported ${gatewayUpdateError.message}; installed script already matches gateway ${gatewayRelease}. Not rewriting from npm.`,
1525
+ );
1526
+ } else if (gatewayUpdateError) {
1527
+ // Optional content refresh failed while still compatible — log and keep polling.
1528
+ console.error(`Worker update check failed: ${gatewayUpdateError.message}`);
1529
+ }
1530
+
1531
+ if (!compat.ok) {
1532
+ // Update paths returned without exiting (should be rare); refuse with backoff.
1533
+ const recorded = await recordUpdateFailure(
1534
+ new Error(`Still incompatible with gateway ${gatewayVersion} after update attempts.`),
1535
+ );
1536
+ console.error(
1537
+ compatibilityError(PACKAGE_VERSION, gatewayVersion) ??
1538
+ `This remote-control ${PACKAGE_VERSION} cannot connect to gateway ${gatewayVersion}.`,
1539
+ );
1540
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, recorded.backoffMs));
1541
+ process.exit(1);
1542
+ }
1543
+
1544
+ return gatewayVersion;
868
1545
  }
869
1546
 
870
1547
  async function postResult(base, headers, result) {
@@ -938,49 +1615,77 @@ async function postResult(base, headers, result) {
938
1615
  }
939
1616
 
940
1617
  async function run() {
1618
+ await acquireWorkerLock();
1619
+ let stopMacIdle = () => {};
1620
+ const shutdown = () => {
1621
+ try {
1622
+ stopMacIdle();
1623
+ } catch {
1624
+ // ignore
1625
+ }
1626
+ try {
1627
+ const existing = readWorkerLockPid();
1628
+ if (existing === process.pid) rmSync(workerLockPath, { force: true });
1629
+ } catch {
1630
+ // Best-effort unlock.
1631
+ }
1632
+ };
1633
+ for (const signal of ['SIGINT', 'SIGTERM']) {
1634
+ process.once(signal, () => {
1635
+ shutdown();
1636
+ process.exit(0);
1637
+ });
1638
+ }
1639
+ process.once('exit', shutdown);
1640
+
941
1641
  const config = await loadConfig();
942
1642
  const capabilities = [];
1643
+ let probeMacUiState = () => 'unknown';
1644
+ try {
1645
+ const { probeComputerCapability, computerCapabilityName } = await import('./computer.mjs');
1646
+ const name = computerCapabilityName();
1647
+ if (name && (await probeComputerCapability())) capabilities.push(name);
1648
+ } catch {
1649
+ // Computer use unavailable until helper is ready; shell tasks still work.
1650
+ }
943
1651
  if (process.platform === 'darwin') {
944
1652
  try {
945
- const { probeComputerCapability, COMPUTER_CAPABILITY } = await import('./computer-macos.mjs');
946
- if (await probeComputerCapability()) capabilities.push(COMPUTER_CAPABILITY);
1653
+ const mac = await import('./computer-macos.mjs');
1654
+ mac.startWorkerIdleAssertion(process.pid);
1655
+ stopMacIdle = () => mac.stopWorkerIdleAssertion();
1656
+ probeMacUiState = () => mac.probeMacUiState();
947
1657
  } catch {
948
- // Computer use unavailable until helper builds; shell tasks still work.
1658
+ // Idle assertion / uiState probe optional; shell still works.
949
1659
  }
950
1660
  }
951
- const headers = {
1661
+ const pollHeaders = () => ({
952
1662
  authorization: `Bearer ${config.token}`,
953
1663
  'x-jian-platform': `${process.platform}-${process.arch}`,
954
1664
  'x-jian-remote-version': PACKAGE_VERSION,
1665
+ 'x-jian-remote-ui-state': probeMacUiState(),
955
1666
  ...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
956
- };
1667
+ });
957
1668
  const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
958
- // Fail fast before the poll loop so an incompatible build does not look "online then stuck".
959
- const gatewayVersion = await assertCompatibleWithGateway(config.gateway);
1669
+ // Match the gateway worker (including major.minor skew) before polling so fleets do not
1670
+ // crash-loop on refuse without attempting a self-update first.
1671
+ const gatewayVersion = await reconcileWorkerWithGateway(config, base, pollHeaders());
960
1672
  console.log(
961
1673
  `Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''}${capabilities.length ? `, capabilities ${capabilities.join(',')}` : ''})`,
962
1674
  );
963
1675
  let failures = 0;
964
- let nextUpdateCheck = 0;
1676
+ // First reconcile already ran; space the next check like the historical five-minute cadence.
1677
+ let nextUpdateCheck = Date.now() + 5 * 60_000;
965
1678
  const delay = async (attempt) => {
966
1679
  const ceiling = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 6));
967
1680
  const wait = Math.random() * ceiling;
968
1681
  console.error(`Retrying in ${(wait / 1000).toFixed(1)}s (attempt ${attempt})…`);
969
1682
  await new Promise((resolveDelay) => setTimeout(resolveDelay, wait));
970
1683
  };
971
- const ensureCompatible = async () => {
972
- const current = await fetchGatewayVersion(config.gateway);
973
- const error = compatibilityError(PACKAGE_VERSION, current);
974
- if (error) {
975
- console.error(error);
976
- process.exit(1);
977
- }
978
- };
979
1684
  while (true) {
980
1685
  try {
1686
+ const headers = pollHeaders();
981
1687
  if (Date.now() >= nextUpdateCheck) {
982
- await ensureCompatible();
983
- await updateIfNeeded(base, headers);
1688
+ await reconcileWorkerWithGateway(config, base, headers);
984
1689
  nextUpdateCheck = Date.now() + 5 * 60_000;
985
1690
  }
986
1691
  const response = await fetch(`${base}/tasks/next`, {
@@ -1024,25 +1729,34 @@ export function isCliEntry(argv1 = process.argv[1], metaUrl = import.meta.url) {
1024
1729
 
1025
1730
  if (isCliEntry()) {
1026
1731
  try {
1027
- if (args[0] === 'install') await install();
1028
- else if (args[0] === 'configure') await configure();
1029
- else if (args[0] === 'update') await update();
1030
- else if (args[0] === 'run') await run();
1732
+ const invocation = parseCliInvocation(args);
1733
+ if (invocation.command === 'version') await printVersionAndExit();
1734
+ else if (invocation.command === 'install') await install();
1735
+ else if (invocation.command === 'configure') await configure();
1736
+ else if (invocation.command === 'update') await update();
1737
+ else if (invocation.command === 'stop') await cliStop();
1738
+ else if (invocation.command === 'start') await cliStart();
1739
+ else if (invocation.command === 'restart') await cliRestart();
1740
+ else if (invocation.command === 'run') await run();
1031
1741
  else {
1032
1742
  const color = shouldColorize();
1033
1743
  const title = paint('Jian Remote', ANSI.bold, color);
1034
1744
  const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
1035
1745
  console.log(`${title} worker ${pkg}
1746
+ version | --version | -V
1036
1747
  install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
1037
1748
  configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
1038
1749
  update [--version X.Y.Z|latest]
1750
+ stop | start | restart
1039
1751
  run
1040
1752
 
1041
1753
  Install: npm install -g @jian-ai/remote-control
1042
1754
  Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
1043
1755
  Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
1044
1756
  Update: npx @jian-ai/remote-control@latest update
1045
- (upgrades the worker binary; reads ~/.config/jian-remote)`);
1757
+ (upgrades the worker binary; reads ~/.config/jian-remote)
1758
+ Service: jian-remote stop | start | restart
1759
+ Version: jian-remote version`);
1046
1760
  }
1047
1761
  } catch (error) {
1048
1762
  const color = shouldColorize({ isTTY: process.stderr.isTTY });