@jian-ai/remote-control 0.8.2 → 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.2';
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,10 +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
+ }) {
139
180
  // ProcessType=Interactive opts the worker out of App Nap. Without it, macOS can freeze the
140
181
  // Node poll loop while the screen is locked or idle; lastSeenAt ages out and the gateway
141
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.
142
187
  return `<?xml version="1.0" encoding="UTF-8"?>
143
188
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
144
189
  <plist version="1.0"><dict>
@@ -151,9 +196,17 @@ export function buildLaunchAgentPlist({ label, executable, script, logPath }) {
151
196
  <string>${xmlEscape(script)}</string>
152
197
  <string>run</string>
153
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>
154
206
  <key>ProcessType</key><string>Interactive</string>
155
207
  <key>RunAtLoad</key><true/>
156
208
  <key>KeepAlive</key><true/>
209
+ <key>ThrottleInterval</key><integer>${Number(throttleIntervalSec) || 10}</integer>
157
210
  <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
158
211
  <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
159
212
  </dict></plist>
@@ -243,7 +296,7 @@ export function versionsCompatible(localVersion, gatewayVersion) {
243
296
  local: local.raw,
244
297
  gateway: gateway.raw,
245
298
  required: `${gateway.major}.${gateway.minor}`,
246
- 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`,
247
300
  };
248
301
  }
249
302
 
@@ -252,6 +305,50 @@ export function compatibilityError(localVersion, gatewayVersion) {
252
305
  return result.ok ? null : result.message;
253
306
  }
254
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
+
255
352
  export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
256
353
  return (current + chunk.toString('utf8')).slice(0, cap);
257
354
  }
@@ -478,19 +575,11 @@ function fetchNpmWorkerScript(version) {
478
575
  }
479
576
  }
480
577
 
481
- /** Copy a worker script into ~/.config so npx and global installs share one service path. */
482
- async function materializeWorkerScript(sourceBytes) {
483
- await mkdir(configDir, { recursive: true, mode: 0o700 });
484
- if (sourceBytes) {
485
- const temp = `${installedScript}.${process.pid}.tmp`;
486
- await writeFile(temp, sourceBytes, { mode: 0o700 });
487
- await rename(temp, installedScript);
488
- } else {
489
- const source = fileURLToPath(import.meta.url);
490
- if (source !== installedScript) await copyFile(source, installedScript);
491
- }
492
- if (process.platform !== 'win32') await chmod(installedScript, 0o700);
493
- // Companion modules + OS helpers (computer use). Best-effort beside the worker.
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() {
494
583
  try {
495
584
  const here = dirname(fileURLToPath(import.meta.url));
496
585
  const companions = [
@@ -513,25 +602,215 @@ async function materializeWorkerScript(sourceBytes) {
513
602
  } catch {
514
603
  // Install still succeeds; computer-use probe will explain if the helper is missing.
515
604
  }
605
+ }
606
+
607
+ /** Copy a worker script into ~/.config so npx and global installs share one service path. */
608
+ async function materializeWorkerScript(sourceBytes) {
609
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
610
+ if (sourceBytes) {
611
+ const temp = `${installedScript}.${process.pid}.tmp`;
612
+ await writeFile(temp, sourceBytes, { mode: 0o700 });
613
+ await rename(temp, installedScript);
614
+ } else {
615
+ const source = fileURLToPath(import.meta.url);
616
+ if (source !== installedScript) await copyFile(source, installedScript);
617
+ }
618
+ if (process.platform !== 'win32') await chmod(installedScript, 0o700);
619
+ await materializeCompanionModules();
516
620
  return installedScript;
517
621
  }
518
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;
627
+ try {
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
+ }
701
+ }
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);
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);
720
+ } catch {
721
+ // Best-effort; a stale pid file is cleared on the next acquire.
722
+ }
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
+
519
740
  /**
520
- * Register or bounce the per-user service so it runs the installed script and reconnects.
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 };
781
+ }
782
+
783
+ /**
784
+ * Register (if needed) and start exactly one worker. Always stops any previous instance first.
521
785
  * On macOS, ProgramArguments[0] is the Jian Remote.app Node copy — not system node — so
522
786
  * Login Items / TCC show "Jian Remote".
523
787
  */
524
- 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
+
525
805
  if (process.platform === 'darwin') {
526
806
  const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
527
- const plistPath = join(launchAgents, 'com.jian.remote.plist');
528
- const label = 'com.jian.remote';
807
+ const plistPath = macosPlistPath();
529
808
  const app = await materializeMacosAppRuntime();
530
809
  await mkdir(launchAgents, { recursive: true, mode: 0o700 });
531
810
  await writeFile(
532
811
  plistPath,
533
812
  buildLaunchAgentPlist({
534
- label,
813
+ label: MACOS_LAUNCH_AGENT_LABEL,
535
814
  executable: app.executable,
536
815
  script,
537
816
  logPath: join(configDir, 'worker.log'),
@@ -539,7 +818,9 @@ async function startOrRestartService(script) {
539
818
  { mode: 0o600 },
540
819
  );
541
820
  const domain = `gui/${process.getuid()}`;
821
+ // bootout again after writing the plist so KeepAlive cannot resurrect the old definition.
542
822
  spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
823
+ sleepMs(400);
543
824
  const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
544
825
  if (loaded.status !== 0) throw new Error('Could not start Jian Remote with launchd');
545
826
  return {
@@ -552,24 +833,16 @@ async function startOrRestartService(script) {
552
833
  await mkdir(unitDir, { recursive: true, mode: 0o700 });
553
834
  const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
554
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`;
555
- await writeFile(join(unitDir, 'jian-remote.service'), unit, { mode: 0o600 });
556
- for (const command of [
557
- ['daemon-reload'],
558
- ['enable', '--now', 'jian-remote.service'],
559
- ['restart', 'jian-remote.service'],
560
- ]) {
836
+ await writeFile(linuxUnitPath(), unit, { mode: 0o600 });
837
+ for (const command of [['daemon-reload'], ['enable', '--now', LINUX_SERVICE_UNIT]]) {
561
838
  const result = spawnSync('systemctl', ['--user', ...command], { stdio: 'inherit' });
562
839
  if (result.status !== 0)
563
840
  throw new Error('Could not install/start Jian Remote with systemd --user');
564
841
  }
565
- return { service: 'systemd --user', detail: 'jian-remote.service' };
842
+ return { service: 'systemd --user', detail: LINUX_SERVICE_UNIT };
566
843
  }
567
844
  if (process.platform === 'win32') {
568
845
  const action = `"${process.execPath}" "${script}" run`;
569
- spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
570
- stdio: 'ignore',
571
- windowsHide: true,
572
- });
573
846
  runChecked(
574
847
  'schtasks.exe',
575
848
  [
@@ -577,7 +850,7 @@ async function startOrRestartService(script) {
577
850
  '/SC',
578
851
  'ONLOGON',
579
852
  '/TN',
580
- 'Jian Remote',
853
+ WINDOWS_TASK_NAME,
581
854
  '/TR',
582
855
  action,
583
856
  '/F',
@@ -589,15 +862,49 @@ async function startOrRestartService(script) {
589
862
  );
590
863
  runChecked(
591
864
  'schtasks.exe',
592
- ['/Run', '/TN', 'Jian Remote'],
865
+ ['/Run', '/TN', WINDOWS_TASK_NAME],
593
866
  'Could not start the Jian Remote scheduled task',
594
867
  );
595
- return { service: 'Task Scheduler', detail: 'Jian Remote (ONLOGON)' };
868
+ return { service: 'Task Scheduler', detail: `${WINDOWS_TASK_NAME} (ONLOGON)` };
596
869
  }
597
870
  printInfo(`Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`);
598
871
  return { service: 'none', detail: process.platform };
599
872
  }
600
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
+
601
908
  async function install() {
602
909
  const gateway = option('--gateway');
603
910
  const machineId = option('--machine');
@@ -720,20 +1027,40 @@ async function update() {
720
1027
  let scriptBytes;
721
1028
  let installedVersion = PACKAGE_VERSION;
722
1029
  // When this process already matches the target (typical: npx @jian-ai/remote-control@X update),
723
- // 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.
724
1033
  const selfMatchesTarget =
725
1034
  target === 'latest'
726
1035
  ? !isDevVersion(PACKAGE_VERSION)
727
- : PACKAGE_VERSION === target.replace(/^v/, '') ||
728
- versionsCompatible(PACKAGE_VERSION, target).ok;
1036
+ : PACKAGE_VERSION === target.replace(/^v/, '');
729
1037
 
730
1038
  if (selfMatchesTarget && !isDevVersion(PACKAGE_VERSION)) {
731
1039
  scriptBytes = await readFile(fileURLToPath(import.meta.url));
732
1040
  installedVersion = PACKAGE_VERSION;
733
1041
  } else {
734
- printInfo(`Downloading @jian-ai/remote-control@${target}…`);
735
- scriptBytes = fetchNpmWorkerScript(target);
736
- 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
+ }
737
1064
  }
738
1065
 
739
1066
  const error = compatibilityError(installedVersion, gatewayVersion);
@@ -763,27 +1090,125 @@ async function update() {
763
1090
  printSuccess('Updated Jian Remote', facts);
764
1091
  }
765
1092
 
766
- async function executeShell(task) {
767
- return await new Promise((resolveResult) => {
768
- 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);
769
1194
  const commandArgs =
770
- process.platform === 'win32'
1195
+ platform === 'win32'
771
1196
  ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', task.command]
772
1197
  : ['-lc', task.command];
773
- const child = spawn(command, commandArgs, {
774
- cwd: task.cwd || homedir(),
775
- env: process.env,
1198
+ const child = spawnImpl(shell, commandArgs, {
1199
+ cwd: resolved.cwd,
1200
+ env: workerChildEnv(),
776
1201
  stdio: ['ignore', 'pipe', 'pipe'],
777
1202
  });
778
1203
  let stdout = '';
779
- let stderr = '';
1204
+ let stderr = resolved.note ? `${resolved.note}\n` : '';
780
1205
  let stdoutSeen = 0;
781
1206
  let timedOut = false;
782
1207
  const timeout = setTimeout(() => {
783
1208
  timedOut = true;
784
1209
  child.kill('SIGTERM');
785
1210
  setTimeout(() => {
786
- if (process.platform === 'win32' && child.pid) {
1211
+ if (platform === 'win32' && child.pid) {
787
1212
  spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
788
1213
  stdio: 'ignore',
789
1214
  windowsHide: true,
@@ -793,11 +1218,11 @@ async function executeShell(task) {
793
1218
  }
794
1219
  }, 2000).unref();
795
1220
  }, task.timeoutMs);
796
- child.stdout.on('data', (chunk) => {
1221
+ child.stdout?.on('data', (chunk) => {
797
1222
  stdoutSeen += chunk.toString('utf8').length;
798
1223
  stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
799
1224
  });
800
- child.stderr.on('data', (chunk) => {
1225
+ child.stderr?.on('data', (chunk) => {
801
1226
  stderr = cappedAppend(stderr, chunk, STDERR_CAP);
802
1227
  });
803
1228
  child.on('error', (error) => {
@@ -806,13 +1231,16 @@ async function executeShell(task) {
806
1231
  taskId: task.id,
807
1232
  exitCode: null,
808
1233
  stdout,
809
- stderr: cappedAppend(stderr, error.message, STDERR_CAP),
1234
+ stderr: cappedAppend(
1235
+ stderr,
1236
+ formatShellSpawnError(error, { shell, cwd: resolved.cwd }),
1237
+ STDERR_CAP,
1238
+ ),
810
1239
  timedOut,
811
1240
  });
812
1241
  });
813
1242
  child.on('close', (code) => {
814
1243
  clearTimeout(timeout);
815
- // Mark silent stdout cuts so screenshot tools can reject grey/truncated JPEGs.
816
1244
  if (stdoutSeen > STDOUT_CAP) {
817
1245
  stderr = cappedAppend(
818
1246
  stderr,
@@ -825,6 +1253,10 @@ async function executeShell(task) {
825
1253
  });
826
1254
  }
827
1255
 
1256
+ async function executeShell(task) {
1257
+ return executeShellTask(task);
1258
+ }
1259
+
828
1260
  async function execute(task) {
829
1261
  if (task.kind === 'computer') {
830
1262
  const { executeComputerTask } = await import('./computer.mjs');
@@ -833,8 +1265,112 @@ async function execute(task) {
833
1265
  return executeShell(task);
834
1266
  }
835
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
+ */
836
1373
  async function updateIfNeeded(base, headers) {
837
- // Prefer the installed copy under ~/.config so updates land where the service points.
838
1374
  const script = installedScript;
839
1375
  let local;
840
1376
  try {
@@ -842,30 +1378,170 @@ async function updateIfNeeded(base, headers) {
842
1378
  } catch {
843
1379
  local = await readFile(fileURLToPath(import.meta.url));
844
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
+ }
845
1388
  const etag = `"${createHash('sha256').update(local).digest('hex')}"`;
846
1389
  const response = await fetch(`${base}/update`, {
847
1390
  headers: { ...headers, 'if-none-match': etag },
848
1391
  redirect: 'error',
849
1392
  signal: AbortSignal.timeout(15_000),
850
1393
  });
851
- 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
+ }
852
1399
  if (!response.ok) throw new Error(`worker update check failed: HTTP ${response.status}`);
853
1400
  const updated = Buffer.from(await response.arrayBuffer());
854
- if (!updated.length || updated.length > 1_000_000) throw new Error('Invalid worker update size');
855
- await mkdir(configDir, { recursive: true, mode: 0o700 });
856
- const temp = `${script}.${process.pid}.tmp`;
857
- await writeFile(temp, updated, { mode: 0o700 });
858
- await rename(temp, script);
859
- console.log('Worker updated; restarting to apply it.');
860
- if (process.platform === 'win32') {
861
- const child = spawn(process.execPath, [script, 'run'], {
862
- detached: true,
863
- stdio: 'ignore',
864
- windowsHide: true,
865
- });
866
- 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}`);
867
1414
  }
868
- 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;
869
1545
  }
870
1546
 
871
1547
  async function postResult(base, headers, result) {
@@ -939,6 +1615,29 @@ async function postResult(base, headers, result) {
939
1615
  }
940
1616
 
941
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
+
942
1641
  const config = await loadConfig();
943
1642
  const capabilities = [];
944
1643
  let probeMacUiState = () => 'unknown';
@@ -953,12 +1652,8 @@ async function run() {
953
1652
  try {
954
1653
  const mac = await import('./computer-macos.mjs');
955
1654
  mac.startWorkerIdleAssertion(process.pid);
1655
+ stopMacIdle = () => mac.stopWorkerIdleAssertion();
956
1656
  probeMacUiState = () => mac.probeMacUiState();
957
- for (const signal of ['SIGINT', 'SIGTERM']) {
958
- process.once(signal, () => {
959
- mac.stopWorkerIdleAssertion();
960
- });
961
- }
962
1657
  } catch {
963
1658
  // Idle assertion / uiState probe optional; shell still works.
964
1659
  }
@@ -971,33 +1666,26 @@ async function run() {
971
1666
  ...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
972
1667
  });
973
1668
  const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
974
- // Fail fast before the poll loop so an incompatible build does not look "online then stuck".
975
- 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());
976
1672
  console.log(
977
1673
  `Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''}${capabilities.length ? `, capabilities ${capabilities.join(',')}` : ''})`,
978
1674
  );
979
1675
  let failures = 0;
980
- 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;
981
1678
  const delay = async (attempt) => {
982
1679
  const ceiling = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 6));
983
1680
  const wait = Math.random() * ceiling;
984
1681
  console.error(`Retrying in ${(wait / 1000).toFixed(1)}s (attempt ${attempt})…`);
985
1682
  await new Promise((resolveDelay) => setTimeout(resolveDelay, wait));
986
1683
  };
987
- const ensureCompatible = async () => {
988
- const current = await fetchGatewayVersion(config.gateway);
989
- const error = compatibilityError(PACKAGE_VERSION, current);
990
- if (error) {
991
- console.error(error);
992
- process.exit(1);
993
- }
994
- };
995
1684
  while (true) {
996
1685
  try {
997
1686
  const headers = pollHeaders();
998
1687
  if (Date.now() >= nextUpdateCheck) {
999
- await ensureCompatible();
1000
- await updateIfNeeded(base, headers);
1688
+ await reconcileWorkerWithGateway(config, base, headers);
1001
1689
  nextUpdateCheck = Date.now() + 5 * 60_000;
1002
1690
  }
1003
1691
  const response = await fetch(`${base}/tasks/next`, {
@@ -1041,25 +1729,34 @@ export function isCliEntry(argv1 = process.argv[1], metaUrl = import.meta.url) {
1041
1729
 
1042
1730
  if (isCliEntry()) {
1043
1731
  try {
1044
- if (args[0] === 'install') await install();
1045
- else if (args[0] === 'configure') await configure();
1046
- else if (args[0] === 'update') await update();
1047
- 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();
1048
1741
  else {
1049
1742
  const color = shouldColorize();
1050
1743
  const title = paint('Jian Remote', ANSI.bold, color);
1051
1744
  const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
1052
1745
  console.log(`${title} worker ${pkg}
1746
+ version | --version | -V
1053
1747
  install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
1054
1748
  configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
1055
1749
  update [--version X.Y.Z|latest]
1750
+ stop | start | restart
1056
1751
  run
1057
1752
 
1058
1753
  Install: npm install -g @jian-ai/remote-control
1059
1754
  Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
1060
1755
  Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
1061
1756
  Update: npx @jian-ai/remote-control@latest update
1062
- (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`);
1063
1760
  }
1064
1761
  } catch (error) {
1065
1762
  const color = shouldColorize({ isTTY: process.stderr.isTTY });