conductor-remote 1.36.4 → 1.37.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.
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-D_jeEda_.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-D3FckDjN.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-BHetu12h.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 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:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"84c717c249bf494f7fe7381c2005db32"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-D_jeEda_.js",revision:null},{url:"assets/index-BHetu12h.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
+ 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:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"13ed6b91304eca7567c470e6f43e4f2b"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-D3FckDjN.js",revision:null},{url:"assets/index-BHetu12h.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\//]}))});
@@ -7,7 +7,7 @@
7
7
  * function below degrades to "not available" rather than failing loudly when the rule
8
8
  * isn't installed, because that is the normal state for anyone who hasn't opted in.
9
9
  *
10
- * Two properties matter and neither is obvious:
10
+ * Three properties matter and none is obvious:
11
11
  *
12
12
  * - **The armed window must outlive this process.** `autoupdate` deliberately
13
13
  * `exit()`s to reload and launchd restarts us; if the helper were an ordinary child
@@ -19,6 +19,16 @@
19
19
  * `kill(pid, 0)` from this process raises EPERM rather than succeeding. EPERM means
20
20
  * it is alive and not ours; ESRCH means it is gone. Treating EPERM as dead would
21
21
  * report every armed window as idle.
22
+ * - **Ending a window has to put a shut Mac to sleep itself.** Clamshell sleep is a
23
+ * lid-close *event*, not a state macOS keeps re-checking, and the lid closed while
24
+ * `disablesleep` was up — so the helper's restore re-allows sleep without causing
25
+ * any, and a lid-closed Mac that ended its window (the phone's "Let it sleep", or
26
+ * the timer running out) just sat there awake indefinitely. The relay follows the
27
+ * restore with `pmset sleepnow` — which needs no root (only pmset *settings* do),
28
+ * so it lives here and not in the installed helper, and existing setups need no
29
+ * re-`setup` — gated on ioreg's AppleClamshellState: an open lid, or a desktop Mac
30
+ * that has none, keeps the restore-only behaviour, because forcing sleep on a Mac
31
+ * someone may be sitting at is worse than the bug.
22
32
  *
23
33
  * Stdlib only, strip-clean — see CLAUDE.md.
24
34
  */
@@ -84,6 +94,59 @@ export async function nosleepState() {
84
94
  return armedState(rec);
85
95
  return { available: await helperReady(), armed: false, until: null, pid: null };
86
96
  }
97
+ /**
98
+ * Whether the lid is physically shut. `null` means the probe couldn't say — a desktop Mac
99
+ * has no clamshell and no such key — and callers must read that as "don't force anything",
100
+ * never as either lid state.
101
+ */
102
+ async function clamshellClosed() {
103
+ try {
104
+ const { stdout } = await execFileP('ioreg', ['-r', '-k', 'AppleClamshellState', '-d', '1'], { timeout: 5000 });
105
+ const m = stdout.match(/"AppleClamshellState"\s*=\s*(Yes|No)/);
106
+ return m ? m[1] === 'Yes' : null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /** Ask macOS to sleep now. Unprivileged on purpose: `sleepnow` is the one pmset verb that needs no root. */
113
+ async function sleepNow() {
114
+ try {
115
+ await execFileP('pmset', ['sleepnow'], { timeout: 10_000 });
116
+ return { ok: true };
117
+ }
118
+ catch (err) {
119
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
120
+ }
121
+ }
122
+ /**
123
+ * How long a disarm answer gets to leave the Mac before the network suspends with it. The
124
+ * phone's tap awaits the DELETE and immediately re-reads /api/settings (~90ms of concurrent
125
+ * subprocesses plus one funnel round trip), so 2s covers both with room, and nobody watches
126
+ * a lid-closed Mac take two extra seconds to sleep.
127
+ */
128
+ const SLEEP_AFTER_REPLY_MS = 2000;
129
+ /**
130
+ * Sleep the Mac in a moment, unless a new window arms in the meantime — a tap pair of
131
+ * "Let it sleep" then "1h" must end with a Mac that stays awake, not one that sleeps
132
+ * two seconds into its fresh window.
133
+ */
134
+ function sleepSoon(why) {
135
+ const t = setTimeout(() => {
136
+ void (async () => {
137
+ if (armedRecord()) {
138
+ console.info(`nosleep: not sleeping the Mac (${why}) — a new keep-awake window armed first`);
139
+ return;
140
+ }
141
+ const res = await sleepNow();
142
+ if (res.ok)
143
+ console.info(`nosleep: lid is closed — putting the Mac to sleep (${why})`);
144
+ else
145
+ console.error(`nosleep: pmset sleepnow failed (${why}): ${res.error}`);
146
+ })();
147
+ }, SLEEP_AFTER_REPLY_MS);
148
+ t.unref();
149
+ }
87
150
  function unavailable() {
88
151
  return {
89
152
  ok: false,
@@ -116,13 +179,23 @@ export async function armNoSleep(seconds) {
116
179
  const deadline = Date.now() + 10_000;
117
180
  while (Date.now() < deadline) {
118
181
  const rec = armedRecord();
119
- if (rec)
182
+ if (rec) {
183
+ // Watch this window's own end: the helper's restore re-allows sleep but causes
184
+ // none, so a lid still shut at expiry needs the nudge below.
185
+ rescanExpiry();
120
186
  return { ok: true, state: armedState(rec) };
187
+ }
121
188
  await new Promise(r => setTimeout(r, 200));
122
189
  }
123
190
  return { ok: false, error: 'nosleep did not report itself armed within 10s', state: await nosleepState() };
124
191
  }
125
- /** Disarm. Goes through the helper because the armed process is root and we can't signal it. */
192
+ /**
193
+ * Disarm. Goes through the helper because the armed process is root and we can't signal it.
194
+ * A shut lid then gets `pmset sleepnow` a beat later — restoring `disablesleep 0` re-allows
195
+ * sleep without causing any (see the module note), and "Let it sleep" tapped from a phone
196
+ * means exactly that. The caller is answered `willSleep` first, so the phone hears the
197
+ * confirmation before the network goes down with the Mac.
198
+ */
126
199
  export async function disarmNoSleep() {
127
200
  if (!(await helperReady()))
128
201
  return unavailable();
@@ -134,9 +207,85 @@ export async function disarmNoSleep() {
134
207
  }
135
208
  const deadline = Date.now() + 8000;
136
209
  while (Date.now() < deadline) {
137
- if (!armedRecord())
138
- return { ok: true, state: { available: true, armed: false, until: null, pid: null } };
210
+ if (!armedRecord()) {
211
+ cancelExpiryWatch();
212
+ const willSleep = (await clamshellClosed()) === true;
213
+ if (willSleep)
214
+ sleepSoon('the keep-awake window was ended from the phone');
215
+ else
216
+ 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 } };
218
+ }
139
219
  await new Promise(r => setTimeout(r, 200));
140
220
  }
141
221
  return { ok: false, error: 'nosleep is still armed after --stop', state: await nosleepState() };
142
222
  }
223
+ // --- The window's own expiry -------------------------------------------------------------
224
+ //
225
+ // "Awake until 12:30" promises a Mac that sleeps at 12:30, and the helper alone cannot keep
226
+ // that promise on a shut lid (its restore re-allows sleep, nothing more). Teaching the
227
+ // *installed* helper to sleepnow would strand every existing setup on the old behaviour —
228
+ // the root copy deliberately never self-updates — so the relay watches the recorded expiry
229
+ // instead, which also covers windows armed from a terminal.
230
+ /** Slack after the recorded expiry for the helper's own restore to land before we look. */
231
+ const EXPIRY_GRACE_MS = 15_000;
232
+ /** A timer this far past its schedule fired on wake-from-sleep, not on schedule. */
233
+ const EXPIRY_LATE_MS = 60_000;
234
+ /** How often to re-read the pidfile for windows this process didn't arm (CLI, pre-restart). */
235
+ const EXPIRY_RESCAN_MS = 60_000;
236
+ let expiryTimer = null;
237
+ /** Identity of the window the timer is armed for — `pid:until`, so a takeover reschedules. */
238
+ let expiryKey = null;
239
+ function cancelExpiryWatch() {
240
+ if (expiryTimer)
241
+ clearTimeout(expiryTimer);
242
+ expiryTimer = null;
243
+ expiryKey = null;
244
+ }
245
+ function rescanExpiry() {
246
+ const rec = armedRecord();
247
+ const key = rec && rec.until !== null ? `${rec.pid}:${rec.until}` : null;
248
+ if (key === expiryKey)
249
+ return;
250
+ cancelExpiryWatch();
251
+ if (!rec || rec.until === null)
252
+ return;
253
+ expiryKey = key;
254
+ const fireAt = rec.until + EXPIRY_GRACE_MS;
255
+ expiryTimer = setTimeout(() => {
256
+ expiryTimer = null;
257
+ expiryKey = null;
258
+ void windowExpired(fireAt);
259
+ }, Math.max(0, fireAt - Date.now()));
260
+ // A 12h timer must not hold the process open, and it survives nothing anyway — a
261
+ // restarted relay rebuilds it from the pidfile in watchNoSleepExpiry's first rescan.
262
+ expiryTimer.unref();
263
+ }
264
+ async function windowExpired(plannedAt) {
265
+ // Node timers don't run while the Mac sleeps; one that fires long after its schedule
266
+ // fired because something woke the Mac, and sleeping a Mac someone just woke is the one
267
+ // wrong move here. On schedule ± a minute is the only firing that means "expiry".
268
+ const lateMs = Date.now() - plannedAt;
269
+ if (lateMs > EXPIRY_LATE_MS) {
270
+ console.info(`nosleep: expiry check ran ${Math.round(lateMs / 1000)}s late (Mac was asleep?) — leaving it awake`);
271
+ return;
272
+ }
273
+ // A record still (or again) present is a takeover or a helper mid-restore — not ours to end.
274
+ if (armedRecord())
275
+ return;
276
+ if ((await clamshellClosed()) !== true) {
277
+ console.info('nosleep: keep-awake window expired with the lid open (or unreadable) — leaving the Mac awake');
278
+ return;
279
+ }
280
+ const res = await sleepNow();
281
+ if (res.ok)
282
+ console.info('nosleep: keep-awake window expired with the lid closed — putting the Mac to sleep');
283
+ else
284
+ console.error(`nosleep: pmset sleepnow failed at window expiry: ${res.error}`);
285
+ }
286
+ /** Start watching for armed windows reaching their recorded expiry. Called once at relay startup. */
287
+ export function watchNoSleepExpiry() {
288
+ rescanExpiry();
289
+ const iv = setInterval(rescanExpiry, EXPIRY_RESCAN_MS);
290
+ iv.unref();
291
+ }
@@ -11,7 +11,7 @@ import { startFunnelWatchdog } from "./funnel-watchdog.js";
11
11
  import { workspaceDiff } from "./git.js";
12
12
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
13
13
  import { mergePr } from "./merge.js";
14
- import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState } from "./nosleep.js";
14
+ import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
15
15
  import { notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
16
16
  import { ParkedPromptQueue } from "./parked.js";
17
17
  import { attachPrStatus } from "./pr.js";
@@ -849,4 +849,8 @@ server.listen(cfg.port, cfg.host, () => {
849
849
  // Watch for turns ending and push them to subscribed phones. Idle (one small local
850
850
  // query per tick) until a device subscribes; see notify.ts.
851
851
  startNotifier(reads);
852
+ // Watch armed keep-awake windows for their recorded expiry: the helper's restore only
853
+ // re-allows sleep, so a lid still shut at expiry needs the relay's `pmset sleepnow`
854
+ // (see nosleep.ts). Also picks a window back up after the relay's own restarts.
855
+ watchNoSleepExpiry();
852
856
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.36.4",
3
+ "version": "1.37.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.",