conductor-remote 1.46.2 → 1.46.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-Cox9xTfw.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-DeCCNN08.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-DGZkOBT-.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"039e5ab1adab0c250569cb906306c5a8"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DGZkOBT-.css",revision:null},{url:"assets/index-Cox9xTfw.js",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"7981a45fcfdd796b99d3615870365723"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DeCCNN08.js",revision:null},{url:"assets/index-DGZkOBT-.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -1,5 +1,5 @@
1
1
  // `conductor-remote nosleep [duration | setup | status]` — keep this Mac fully
2
- // awake, including with the lid closed, so the relay stays reachable and
2
+ // awake and block its automatic screen lock, so the relay stays reachable and
3
3
  // AppleScript sends can reach Conductor while you're away from the desk.
4
4
  //
5
5
  // The lever is `pmset -a disablesleep 1` (root): unlike a `caffeinate` idle
@@ -11,14 +11,13 @@
11
11
  // root shell whose EXIT trap restores the captured values, so Ctrl-C, a timeout, or
12
12
  // a crash can't leave the Mac unable to sleep.
13
13
  //
14
- // Caveat: awake is necessary but not sufficient for AppleScript *delivery*. A
15
- // locked screen blocks `activate` outright (#85/#87), so a lid-closed Mac serves
16
- // reads and notifications while every send parks in `src/parked.ts` until the
17
- // next unlock that is the designed path, not a failure. Whether a closed lid
18
- // with no display *also* leaves the window server undrivable is untested here,
19
- // and untestable while the lock wall stands in front of it. Strip-clean
14
+ // The helper also owns a process-scoped `caffeinate -d` assertion by default.
15
+ // ScreenSaverDaemon checks that assertion before starting the idle screen saver
16
+ // that locks the session. An explicit lock or lid close can still lock macOS;
17
+ // those sends park in `src/parked.ts` until the next unlock. Strip-clean
20
18
  // (plain-node type-stripping), stdlib-only — see CLAUDE.md.
21
19
  import { spawn } from 'node:child_process';
20
+ import { installedServiceEnvironment, preventScreenLockEnabled } from "../src/config.js";
22
21
  import { HELPER_PATH, helperFile, helperReady, installedHelper, NOSLEEP_BODY } from "../src/nosleep-helper.js";
23
22
  import { setup, status } from "./nosleep-setup.js";
24
23
  /** Parse `90m` / `2h` / `30s` / bare seconds into seconds; null = run until Ctrl-C. */
@@ -53,24 +52,26 @@ async function main() {
53
52
  return;
54
53
  }
55
54
  const seconds = parseDuration(arg);
55
+ const configuredMode = process.env.PREVENT_SCREEN_LOCK ?? installedServiceEnvironment().PREVENT_SCREEN_LOCK;
56
+ const preventScreenLock = preventScreenLockEnabled(configuredMode);
56
57
  // The shared body reads its window from an argument (0 = until killed) rather than
57
58
  // having one interpolated in, because the installed helper is a fixed file the
58
59
  // sudoers rule names — see nosleep-helper.ts. `label` is only echoed back, and the
59
60
  // script re-validates it, so the two paths print the same confirmation.
60
- const args = [String(seconds ?? 0), arg ?? ''];
61
+ const args = [String(seconds ?? 0), arg ?? '', preventScreenLock ? '1' : '0'];
61
62
  console.info('conductor-remote nosleep — keeping this Mac awake (incl. lid-closed system sleep).');
62
63
  console.info(seconds !== null ? `Duration: ${arg} — Ctrl-C to stop early.` : 'Runs until you press Ctrl-C.');
63
- // Be honest about the half this doesn't buy: awake is not drivable. A locked screen
64
- // blocks every UI write (#85/#87), so a lid-closed Mac serves reads and notifications
65
- // while sends park until the next unlock — by design, not by failure.
66
- console.info('Note: sleep only — lid-closed *sending* still parks until you unlock.');
64
+ console.info(preventScreenLock
65
+ ? 'Automatic screen lock: blocked for this window.'
66
+ : 'Automatic screen lock: allowed by config (prevent-screen-lock=off).');
67
67
  // Prefer the root-owned helper `nosleep setup` installs: same script, no prompt, and
68
68
  // it is the only path a TTY-less caller (the LaunchAgent, and so the phone) can take.
69
69
  // Fall back to piping the body through `sudo sh -c`, which is the un-installed
70
70
  // experience and asks for a password.
71
71
  const viaHelper = await helperReady();
72
72
  if (viaHelper && installedHelper() !== helperFile()) {
73
- console.warn(`⚠ ${HELPER_PATH} is from an older version re-run \`nosleep setup\` to refresh it.`);
73
+ console.error(`✗ ${HELPER_PATH} is out of date — run \`conductor-remote nosleep setup\`, then try again.`);
74
+ process.exit(1);
74
75
  }
75
76
  if (!viaHelper) {
76
77
  console.info('Tip: `conductor-remote nosleep setup` installs this once so it stops asking.');
@@ -12,6 +12,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
12
12
  import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
+ import { installedServiceEnvironment, serviceEnvironmentWithSetting } from "../src/config.js";
15
16
  import { packageRoot } from "../src/pkg-root.js";
16
17
  import { driftWarningLines, exposeStorePath, magicDnsName, normalizeExposeMode, relayPort, tailscaleBin, writeUrlHost } from "../src/tailscale.js";
17
18
  import { qrLines } from "./qr.js";
@@ -33,6 +34,7 @@ const FLAG_ENV = {
33
34
  '--hostname': 'RELAY_HOSTNAME',
34
35
  '--token': 'RELAY_TOKEN',
35
36
  '--write-strategy': 'WRITE_STRATEGY',
37
+ '--prevent-screen-lock': 'PREVENT_SCREEN_LOCK',
36
38
  '--auto-update': 'AUTO_UPDATE',
37
39
  '--auto-update-interval': 'AUTO_UPDATE_INTERVAL_MINUTES',
38
40
  '--funnel-watchdog': 'FUNNEL_WATCHDOG',
@@ -57,6 +59,10 @@ function applyFlags(argv) {
57
59
  console.error(`flag ${name} needs a value (e.g. ${name} <value>)`);
58
60
  process.exit(1);
59
61
  }
62
+ if (name === '--prevent-screen-lock' && value !== 'on' && value !== 'off') {
63
+ console.error(`flag ${name} must be on or off`);
64
+ process.exit(1);
65
+ }
60
66
  process.env[envKey] = value;
61
67
  }
62
68
  }
@@ -143,6 +149,8 @@ function buildPlist() {
143
149
  envEntries.push(['EXPOSE', process.env.EXPOSE]);
144
150
  if (process.env.WRITE_STRATEGY)
145
151
  envEntries.push(['WRITE_STRATEGY', process.env.WRITE_STRATEGY]);
152
+ if (process.env.PREVENT_SCREEN_LOCK)
153
+ envEntries.push(['PREVENT_SCREEN_LOCK', process.env.PREVENT_SCREEN_LOCK]);
146
154
  if (process.env.RELAY_HOST)
147
155
  envEntries.push(['RELAY_HOST', process.env.RELAY_HOST]);
148
156
  if (process.env.RELAY_PORT)
@@ -436,6 +444,14 @@ function install() {
436
444
  fs.mkdirSync(logDir, { recursive: true });
437
445
  fs.writeFileSync(plistPath, buildPlist());
438
446
  reloadAgent();
447
+ const changedSetting = process.env.CONDUCTOR_REMOTE_CONFIG_SET;
448
+ if (changedSetting) {
449
+ if (changedSetting === 'expose' || changedSetting === 'port' || changedSetting === 'hostname')
450
+ ensureTailscale();
451
+ console.info(`✓ set ${changedSetting}; the relay restarted with the new value.`);
452
+ console.info(' Check it with: conductor-remote config');
453
+ return;
454
+ }
439
455
  console.info(`✓ installed LaunchAgent ${LABEL}`);
440
456
  console.info(` plist: ${plistPath}`);
441
457
  console.info(` logs: ${logDir}/relay.log`);
@@ -469,18 +485,9 @@ function restart() {
469
485
  * believes something else — which is exactly the bug that made EXPOSE a plist entry.
470
486
  */
471
487
  function readPlistEnv() {
472
- try {
473
- const out = execFileSync('plutil', ['-convert', 'json', '-o', '-', plistPath], {
474
- encoding: 'utf8',
475
- stdio: 'pipe'
476
- });
477
- const env = JSON.parse(out)?.EnvironmentVariables;
478
- return env && typeof env === 'object' ? env : {};
479
- }
480
- catch {
481
- return {};
482
- }
488
+ return installedServiceEnvironment();
483
489
  }
490
+ const CONFIG_KEYS = new Map(Object.entries(FLAG_ENV).map(([flag, env]) => [flag.slice(2), env]));
484
491
  const HOME = os.homedir();
485
492
  const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
486
493
  const KNOBS = [
@@ -503,6 +510,7 @@ const KNOBS = [
503
510
  { name: 'port', env: 'RELAY_PORT', fallback: () => '8787' },
504
511
  { name: 'host', env: 'RELAY_HOST', fallback: () => '127.0.0.1' },
505
512
  { name: 'write-strategy', env: 'WRITE_STRATEGY', fallback: () => 'applescript' },
513
+ { name: 'prevent-screen-lock', env: 'PREVENT_SCREEN_LOCK', fallback: () => 'on' },
506
514
  { name: 'auto-update', env: 'AUTO_UPDATE', fallback: () => 'auto' },
507
515
  { name: 'auto-update-interval', env: 'AUTO_UPDATE_INTERVAL_MINUTES', fallback: () => '5 (minutes)' },
508
516
  {
@@ -524,15 +532,47 @@ const KNOBS = [
524
532
  fallback: () => tilde(path.join(HOME, 'conductor', 'workspaces'))
525
533
  }
526
534
  ];
527
- /**
528
- * What is this daemon actually configured with, and where did each value come from.
529
- *
530
- * Read-only on purpose. Every knob is written by `service install`, and a second write path for the
531
- * same state is how the plist and the `expose` file came to disagree in the first place. What was
532
- * missing was the ability to *see* the disagreement, so that is what this adds — including a live
533
- * cross-check of the Tailscale posture against the configured one.
534
- */
535
+ /** Set one daemon knob while preserving every other value already baked into the plist. */
536
+ function setConfig(args) {
537
+ const [name, value, ...extra] = args;
538
+ const envKey = name ? CONFIG_KEYS.get(name) : undefined;
539
+ if (!name || value === undefined || extra.length > 0) {
540
+ console.error('usage: conductor-remote config set <setting> <value>');
541
+ process.exit(1);
542
+ }
543
+ if (!envKey) {
544
+ console.error(`config set: unknown setting "${name}"\n known: ${[...CONFIG_KEYS.keys()].join(', ')}`);
545
+ process.exit(1);
546
+ }
547
+ if (name === 'prevent-screen-lock' && value !== 'on' && value !== 'off') {
548
+ console.error('config set: prevent-screen-lock must be on or off');
549
+ process.exit(1);
550
+ }
551
+ if (!fs.existsSync(plistPath)) {
552
+ console.error('config set: the service is not installed. Run `conductor-remote service install` first.');
553
+ process.exit(1);
554
+ }
555
+ // Re-run install in a fresh process so module-level values such as RELAY_PORT see
556
+ // the new environment. Start from the plist, which protects unrelated settings
557
+ // from ambient shell variables and from being reset to defaults.
558
+ const childEnv = serviceEnvironmentWithSetting(process.env, readPlistEnv(), CONFIG_KEYS.values(), envKey, value);
559
+ childEnv.CONDUCTOR_REMOTE_CONFIG_SET = name;
560
+ const cli = path.join(projectDir, 'bin', 'cli.js');
561
+ const result = spawnSync(process.execPath, [cli, 'service', 'install'], { env: childEnv, stdio: 'inherit' });
562
+ if (result.error)
563
+ console.error(`config set: could not restart the service (${result.error.message})`);
564
+ process.exit(result.status ?? 1);
565
+ }
566
+ /** What the daemon is configured with, where values came from, and an optional setter. */
535
567
  function config() {
568
+ if (process.argv[3] === 'set') {
569
+ setConfig(process.argv.slice(4));
570
+ return;
571
+ }
572
+ if (process.argv[3] !== undefined) {
573
+ console.error('usage: conductor-remote config [set <setting> <value>]');
574
+ process.exit(1);
575
+ }
536
576
  if (!fs.existsSync(plistPath)) {
537
577
  console.info(`plist: (not installed)\n\nRun \`conductor-remote service install\` first.`);
538
578
  return;
@@ -574,7 +614,7 @@ function config() {
574
614
  console.info(` ${r.name.padEnd(width)} ${r.value.padEnd(valueWidth)} ${r.source}`);
575
615
  }
576
616
  console.info('\n (values come from the daemon\u2019s own plist environment, not this shell\u2019s)');
577
- console.info(' change one with: conductor-remote service install --<setting> <value>');
617
+ console.info(' change one with: conductor-remote config set <setting> <value>');
578
618
  // The cross-check worth having. A posture the relay believes and a Tailscale that is doing something
579
619
  // else is invisible in every other command, and it is self-healing in the wrong direction: the funnel
580
620
  // watchdog only runs for `public`, so a relay that thinks it is public will re-register Funnel.
@@ -1,9 +1,14 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import crypto from 'node:crypto';
2
3
  import fs from 'node:fs';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { packageRoot } from "./pkg-root.js";
6
7
  const home = os.homedir();
8
+ /** The CLI and daemon share one on/off setting. Only `off` opts out. */
9
+ export function preventScreenLockEnabled(raw = process.env.PREVENT_SCREEN_LOCK) {
10
+ return raw?.trim().toLowerCase() !== 'off';
11
+ }
7
12
  /**
8
13
  * The relay's own state directory — its token, its Funnel posture, its undelivered
9
14
  * first prompts. Never Conductor's: everything about Conductor is read from the DB.
@@ -11,6 +16,34 @@ const home = os.homedir();
11
16
  export function stateDir() {
12
17
  return path.join(home, 'Library', 'Application Support', 'conductor-remote');
13
18
  }
19
+ /** The runtime configuration baked into the installed LaunchAgent, or an empty object. */
20
+ export function installedServiceEnvironment() {
21
+ const plist = path.join(home, 'Library', 'LaunchAgents', 'no.adluna.conductor-remote.plist');
22
+ try {
23
+ const out = execFileSync('plutil', ['-convert', 'json', '-o', '-', plist], {
24
+ encoding: 'utf8',
25
+ stdio: 'pipe'
26
+ });
27
+ const env = JSON.parse(out)?.EnvironmentVariables;
28
+ return env && typeof env === 'object' ? env : {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ /** Build the environment for a one-setting service update without changing its other knobs. */
35
+ export function serviceEnvironmentWithSetting(ambient, installed, configKeys, key, value) {
36
+ const next = { ...ambient };
37
+ const known = new Set(configKeys);
38
+ for (const configKey of known)
39
+ delete next[configKey];
40
+ for (const [installedKey, installedValue] of Object.entries(installed)) {
41
+ if (known.has(installedKey))
42
+ next[installedKey] = installedValue;
43
+ }
44
+ next[key] = value;
45
+ return next;
46
+ }
14
47
  /** Where a generated token is persisted so a phone's saved URL stays valid across relay restarts. */
15
48
  function tokenStorePath() {
16
49
  return path.join(stateDir(), 'token');
@@ -62,6 +95,7 @@ export function loadConfig() {
62
95
  host,
63
96
  token: resolveToken(),
64
97
  writeStrategy,
98
+ preventScreenLock: preventScreenLockEnabled(),
65
99
  publicDir: resolvePublicDir()
66
100
  };
67
101
  }
@@ -20,8 +20,8 @@ const execFileP = promisify(execFile);
20
20
  /** Absolute path of the root-owned helper. Must sit in a directory only root can write. */
21
21
  export const HELPER_PATH = '/usr/local/libexec/conductor-remote-nosleep';
22
22
  /**
23
- * Where the armed window records itself: `<pid> <expiry-epoch> <start-token>` (expiry
24
- * 0 = until killed). Written by root, mode 0644, because the relay has to *read* it on
23
+ * Where the armed window records itself: `<pid> <expiry-epoch> <start-token> <prevent-lock>`
24
+ * (expiry 0 = until killed). Written by root, mode 0644, because the relay has to *read* it on
25
25
  * every status poll and a sudo round-trip per poll would be absurd. It is the lock as
26
26
  * well as the record — see the one-owner note on NOSLEEP_BODY.
27
27
  *
@@ -31,7 +31,8 @@ export const HELPER_PATH = '/usr/local/libexec/conductor-remote-nosleep';
31
31
  * as root — so `kill -0` succeeds against whatever inherited the number, and `--stop`
32
32
  * would then SIGTERM an unrelated root process. Readers that only want the pid and the
33
33
  * expiry (`src/nosleep.ts` ▸ readPidfile) can ignore it; a two-field file from an older
34
- * version still parses, and skips the check.
34
+ * version still parses, and skips the check. The fourth field records whether this
35
+ * window also holds the display assertion that blocks the automatic screen lock.
35
36
  */
36
37
  export const PIDFILE_PATH = '/var/run/conductor-remote-nosleep.pid';
37
38
  /**
@@ -40,10 +41,13 @@ export const PIDFILE_PATH = '/var/run/conductor-remote-nosleep.pid';
40
41
  * file that reads fine and never takes effect.
41
42
  */
42
43
  export const SUDOERS_PATH = '/etc/sudoers.d/conductor-remote';
44
+ /** Fixed absolute path because this body runs through a passwordless root helper. */
45
+ export const CAFFEINATE_PATH = '/usr/bin/caffeinate';
43
46
  /**
44
47
  * `$1` = seconds to stay awake, 0 = until killed, or one of `--check` / `--stop`.
45
48
  * `$2` = optional display label ("90m") echoed back in the confirmation, validated to
46
49
  * the same charset the CLI accepts so nothing arbitrary reaches the terminal.
50
+ * `$3` = 1 to block the automatic screen lock for the window, 0 to allow it.
47
51
  *
48
52
  * Restore is on the EXIT trap only; INT/TERM/HUP just `exit`, so a signal can't run
49
53
  * the restore twice. The captured values are read *before* anything changes and put
@@ -107,8 +111,10 @@ export const NOSLEEP_BODY = [
107
111
  'fi',
108
112
  'secs=$arg',
109
113
  'label=${2:-}',
114
+ 'preventlock=${3:-1}',
110
115
  "case \"$secs\" in '' | *[!0-9]*) echo 'nosleep: seconds must be digits (0 = until killed)' >&2; exit 64 ;; esac",
111
116
  'case "$label" in *[!0-9smh]*) label=\'\' ;; esac',
117
+ 'case "$preventlock" in 0 | 1) ;; *) echo \'nosleep: prevent-screen-lock must be 0 or 1\' >&2; exit 64 ;; esac',
112
118
  '',
113
119
  '# Take over from any incumbent BEFORE capturing, or we would capture its flipped',
114
120
  '# values and restore those — the one way this leaves a Mac unable to sleep.',
@@ -132,8 +138,10 @@ export const NOSLEEP_BODY = [
132
138
  "ds=$(pmset -g | awk '/SleepDisabled/{print $2;exit}')",
133
139
  '',
134
140
  "sleeper=''",
141
+ "displaykeeper=''",
135
142
  'cleanup() {',
136
143
  '\tif [ -n "$sleeper" ]; then kill "$sleeper" 2>/dev/null || true; fi',
144
+ '\tif [ -n "$displaykeeper" ]; then kill "$displaykeeper" 2>/dev/null || true; wait "$displaykeeper" 2>/dev/null || true; fi',
137
145
  '\tpmset -b standby "${sb:-1}" powernap "${pn:-1}"',
138
146
  '\tpmset -a disablesleep "${ds:-0}"',
139
147
  // Only clear the record if it is still ours: a successor that already took over
@@ -148,10 +156,20 @@ export const NOSLEEP_BODY = [
148
156
  'pmset -b standby 0 powernap 0',
149
157
  'pmset -a disablesleep 1',
150
158
  '',
159
+ '# ScreenSaverDaemon consults PreventUserIdleDisplaySleep before starting the idle',
160
+ '# screen saver that locks the session. The assertion is process-scoped, so macOS',
161
+ '# drops it even if the helper is killed before its EXIT trap can run.',
162
+ 'if [ "$preventlock" = 1 ]; then',
163
+ `\t${CAFFEINATE_PATH} -d -w "$$" &`,
164
+ '\tdisplaykeeper=$!',
165
+ '\tsleep 0.1',
166
+ '\tif ! kill -0 "$displaykeeper" 2>/dev/null; then echo "nosleep: could not block the automatic screen lock" >&2; exit 69; fi',
167
+ 'fi',
168
+ '',
151
169
  'until=0',
152
170
  'if [ "$secs" -gt 0 ]; then until=$(( $(date +%s) + secs )); fi',
153
171
  // 0644 so the relay can poll the state without a sudo round-trip per tick.
154
- 'printf \'%s %s %s\\n\' "$$" "$until" "$(procStart $$)" > "$pidfile" && chmod 644 "$pidfile"',
172
+ 'printf \'%s %s %s %s\\n\' "$$" "$until" "$(procStart $$)" "$preventlock" > "$pidfile" && chmod 644 "$pidfile"',
155
173
  '',
156
174
  '# A long window is ambiguous without the weekday: "until 15:45" reads as today.',
157
175
  'clock() {',
@@ -171,6 +189,11 @@ export const NOSLEEP_BODY = [
171
189
  '\techo "✓ Sleep disabled at $(date \'+%H:%M\') (incl. lid closed) — until you press Ctrl-C."',
172
190
  '\tsleep 2147483647 &',
173
191
  'fi',
192
+ 'if [ "$preventlock" = 1 ]; then',
193
+ '\techo "⚠ Automatic screen lock is disabled for this window. Anyone with physical access can use this Mac."',
194
+ 'else',
195
+ '\techo "Automatic screen lock remains enabled (prevent-screen-lock=off)."',
196
+ 'fi',
174
197
  'sleeper=$!',
175
198
  'wait "$sleeper"'
176
199
  ].join('\n');
@@ -29,13 +29,17 @@
29
29
  * re-`setup` — gated on ioreg's AppleClamshellState: an open lid, or a desktop Mac
30
30
  * that has none, keeps the restore-only behaviour, because forcing sleep on a Mac
31
31
  * someone may be sitting at is worse than the bug.
32
+ * - **The same window blocks the idle screen lock by default.** The root helper owns
33
+ * a `PreventUserIdleDisplaySleep` assertion through `caffeinate -d`; macOS drops it
34
+ * with the process even if the EXIT trap never runs. The pidfile records that mode
35
+ * so the phone can warn accurately, and the persisted setting can opt out.
32
36
  *
33
37
  * Stdlib only, strip-clean — see CLAUDE.md.
34
38
  */
35
39
  import { execFile, spawn } from 'node:child_process';
36
40
  import fs from 'node:fs';
37
41
  import { promisify } from 'node:util';
38
- import { HELPER_PATH, helperReady, PIDFILE_PATH } from "./nosleep-helper.js";
42
+ import { HELPER_PATH, helperFile, helperReady, installedHelper, PIDFILE_PATH } from "./nosleep-helper.js";
39
43
  const execFileP = promisify(execFile);
40
44
  /** Longest window the API will arm. A phone tap should never be able to disable sleep forever. */
41
45
  export const MAX_SECONDS = 12 * 3600;
@@ -52,7 +56,7 @@ function alive(pid) {
52
56
  return err.code === 'EPERM';
53
57
  }
54
58
  }
55
- /** Parse `<pid> <expiry-epoch-seconds>`; expiry 0 means "until stopped". */
59
+ /** Parse `<pid> <expiry-epoch-seconds> <start-token> <prevent-lock>`; expiry 0 means "until stopped". */
56
60
  function readPidfile() {
57
61
  let raw;
58
62
  try {
@@ -61,12 +65,17 @@ function readPidfile() {
61
65
  catch {
62
66
  return null;
63
67
  }
64
- const [pidRaw, untilRaw] = raw.trim().split(/\s+/);
68
+ const [pidRaw, untilRaw, , preventLockRaw] = raw.trim().split(/\s+/);
65
69
  const pid = Number(pidRaw);
66
70
  if (!Number.isInteger(pid) || pid <= 0)
67
71
  return null;
68
72
  const untilSec = Number(untilRaw);
69
- return { pid, until: Number.isFinite(untilSec) && untilSec > 0 ? untilSec * 1000 : null };
73
+ return {
74
+ pid,
75
+ until: Number.isFinite(untilSec) && untilSec > 0 ? untilSec * 1000 : null,
76
+ // Three-field records came from the old helper, which held no display assertion.
77
+ preventsScreenLock: preventLockRaw === '1'
78
+ };
70
79
  }
71
80
  /**
72
81
  * Armed-ness on its own: a local file read plus a signal probe, no subprocess at all.
@@ -82,7 +91,13 @@ function armedRecord() {
82
91
  return rec && alive(rec.pid) ? rec : null;
83
92
  }
84
93
  function armedState(rec) {
85
- return { available: true, armed: true, until: rec.until, pid: rec.pid };
94
+ return {
95
+ available: true,
96
+ armed: true,
97
+ until: rec.until,
98
+ pid: rec.pid,
99
+ preventsScreenLock: rec.preventsScreenLock
100
+ };
86
101
  }
87
102
  /**
88
103
  * Current state. `helperReady()` shells out to sudo, so it is only consulted when nothing
@@ -92,7 +107,9 @@ export async function nosleepState() {
92
107
  const rec = armedRecord();
93
108
  if (rec)
94
109
  return armedState(rec);
95
- return { available: await helperReady(), armed: false, until: null, pid: null };
110
+ const ready = await helperReady();
111
+ const current = installedHelper() === helperFile();
112
+ return { available: ready && current, armed: false, until: null, pid: null, preventsScreenLock: false };
96
113
  }
97
114
  /**
98
115
  * Whether the lid is physically shut. `null` means the probe couldn't say — a desktop Mac
@@ -150,8 +167,8 @@ function sleepSoon(why) {
150
167
  function unavailable() {
151
168
  return {
152
169
  ok: false,
153
- error: 'Passwordless nosleep isn’t installed. Run `conductor-remote nosleep setup` on the Mac.',
154
- state: { available: false, armed: false, until: null, pid: null }
170
+ error: 'Passwordless nosleep is missing or out of date. Run `conductor-remote nosleep setup` on the Mac.',
171
+ state: { available: false, armed: false, until: null, pid: null, preventsScreenLock: false }
155
172
  };
156
173
  }
157
174
  /**
@@ -159,8 +176,8 @@ function unavailable() {
159
176
  * rather than stacking — the helper enforces that, and it has to, since two owners would
160
177
  * restore each other's flipped values and leave sleep disabled for good.
161
178
  */
162
- export async function armNoSleep(seconds) {
163
- if (!(await helperReady()))
179
+ export async function armNoSleep(seconds, preventScreenLock = true) {
180
+ if (!(await helperReady()) || installedHelper() !== helperFile())
164
181
  return unavailable();
165
182
  // Floor of 1, not 0. The helper reads 0 as "until killed", so anything under a second
166
183
  // truncates straight past MAX_SECONDS into a window nothing ever closes — which is the
@@ -168,7 +185,7 @@ export async function armNoSleep(seconds) {
168
185
  const secs = Math.min(MAX_SECONDS, Math.max(1, Math.trunc(seconds)));
169
186
  // Detached, own session, no stdio: it has to survive this relay's own restarts,
170
187
  // which autoupdate performs routinely and without warning.
171
- const child = spawn('sudo', ['-n', HELPER_PATH, String(secs), ''], {
188
+ const child = spawn('sudo', ['-n', HELPER_PATH, String(secs), '', preventScreenLock ? '1' : '0'], {
172
189
  detached: true,
173
190
  stdio: 'ignore'
174
191
  });
@@ -214,7 +231,11 @@ export async function disarmNoSleep() {
214
231
  sleepSoon('the keep-awake window was ended from the phone');
215
232
  else
216
233
  console.info('nosleep: window ended with the lid open (or unreadable) — sleep re-enabled, not forced');
217
- return { ok: true, willSleep, state: { available: true, armed: false, until: null, pid: null } };
234
+ return {
235
+ ok: true,
236
+ willSleep,
237
+ state: { available: true, armed: false, until: null, pid: null, preventsScreenLock: false }
238
+ };
218
239
  }
219
240
  await new Promise(r => setTimeout(r, 200));
220
241
  }
@@ -671,7 +671,7 @@ const server = http.createServer(async (req, res) => {
671
671
  // truncates to 0 — an unbounded window from a request that looked bounded.
672
672
  if (!Number.isInteger(seconds) || seconds < 1)
673
673
  return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
674
- const result = await armNoSleep(seconds);
674
+ const result = await armNoSleep(seconds, cfg.preventScreenLock);
675
675
  return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
676
676
  }
677
677
  // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.46.2",
3
+ "version": "1.46.3",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
@@ -50,12 +50,13 @@
50
50
  "typecheck": "tsc -p tsconfig.json",
51
51
  "lint": "biome check .",
52
52
  "fix": "biome check . --fix",
53
- "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify",
53
+ "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:config && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify",
54
54
  "release": "semantic-release",
55
55
  "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true",
57
57
  "check:attachments": "node scripts/check-attachments.ts",
58
58
  "check:applescript": "node scripts/check-applescript.ts",
59
+ "check:config": "node scripts/check-config.ts",
59
60
  "check:firstprompt": "node scripts/check-firstprompt.ts",
60
61
  "check:model-labels": "node scripts/check-model-labels.ts",
61
62
  "check:sendonce": "node scripts/check-sendonce.ts",