conductor-remote 1.46.2 → 1.47.0

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.
@@ -29,16 +29,20 @@
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
- export const MAX_SECONDS = 12 * 3600;
45
+ export const MAX_SECONDS = 16 * 3600;
42
46
  /**
43
47
  * Whether `pid` exists. EPERM is the interesting case: the armed helper runs as root, so
44
48
  * signalling it from here is refused, and that refusal is itself proof it is alive.
@@ -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
  }
@@ -257,7 +278,7 @@ function rescanExpiry() {
257
278
  expiryKey = null;
258
279
  void windowExpired(fireAt);
259
280
  }, Math.max(0, fireAt - Date.now()));
260
- // A 12h timer must not hold the process open, and it survives nothing anyway — a
281
+ // A 16h timer must not hold the process open, and it survives nothing anyway — a
261
282
  // restarted relay rebuilds it from the pidfile in watchNoSleepExpiry's first rescan.
262
283
  expiryTimer.unref();
263
284
  }
@@ -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.47.0",
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",