unsnooze 1.14.2 → 1.14.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/CHANGELOG.md CHANGED
@@ -1,6 +1,52 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.14.3 — 2026-08-08
4
+
5
+ - **Upgrades now reach running watchers.** A per-pane monitor lives as long as
6
+ the agent and a transient resumer lives until the reset; neither has a
7
+ supervisor, and `npm i -g unsnooze` cannot change code a running process has
8
+ already loaded. A session started before an upgrade therefore kept applying
9
+ the old rules indefinitely — which is why 1.14.2's resume-lifecycle fix could
10
+ be installed and still change nothing on a long-lived pane. Both processes now
11
+ notice that the package changed underneath them, hand off to a replacement on
12
+ the fresh code, and stand down. Sessions launched under 1.14.2 or earlier need
13
+ one restart to pick this up; after that, upgrades propagate on their own.
14
+ - **Hand-offs are verified, never assumed.** `spawn` resolves the node binary,
15
+ which exists regardless of the package, so a tree caught mid-swap returns a
16
+ healthy-looking pid for a child that is already dead. The hand-off now checks
17
+ that the entry point exists before starting anything, and that the replacement
18
+ survived startup before standing down. Either check failing keeps the old
19
+ process watching — a stale watcher beats no watcher.
20
+ - **A manually run daemon no longer dies on upgrade.** `unsnooze daemon` started
21
+ from a shell has nothing to restart it, so exiting on version skew ended GUI
22
+ watching silently. It now detects whether launchd/systemd is actually
23
+ supervising it: supervised, it exits and returns on fresh code; unsupervised,
24
+ it warns once and keeps watching. The 15-minute detection interval is
25
+ unchanged — a tighter loop raises the odds of restarting into a half-installed
26
+ package.
27
+ - **The daemon PATH self-heal actually heals.** The 1.12 repair regenerated the
28
+ autostart unit from the PATH of whichever process called it, and the only
29
+ caller is the daemon itself — whose PATH is the minimal one launchd and
30
+ systemd provide. It wrote the broken PATH straight back, then, because the
31
+ check only asked whether a PATH block was *present*, treated the job as done
32
+ forever. Observed live: a daemon running with `PATH=/usr/bin:/bin:/usr/sbin:/sbin`
33
+ while tmux sat in `/opt/homebrew/bin`, so every GUI revival failed with ENOENT.
34
+ The heal now asks the login shell for a usable PATH and rewrites the unit only
35
+ when the result can actually find the multiplexer *and* differs from what is
36
+ already there — rewriting reloads the unit, which kills the calling daemon, so
37
+ a heal that cannot improve anything must do nothing.
38
+ - **`unsnooze doctor` reports a daemon that cannot reach its multiplexer**, and
39
+ `--fix` repairs it. This was previously invisible, because doctor probes tmux
40
+ from your shell's PATH rather than the daemon's.
41
+ - **Version stamps in the log.** The monitor and resumer record which build they
42
+ are running as they start, so a pasted log answers "is this process even on
43
+ the new code" without further diagnosis.
44
+ - Internal: one shared `pidAlive` now backs the resumer's lock hygiene, the
45
+ dashboard, and the hand-off. It rejects pid 0, which `kill(0, 0)` reports as
46
+ alive because that signals the caller's own process group — a lock file
47
+ holding `0` could otherwise be honored forever. Writing an autostart unit
48
+ outside the live location no longer activates it: every unit carries the same
49
+ label, so loading a copy hijacks the real job instead of adding a second one.
4
50
 
5
51
  ## 1.14.2 — 2026-08-06
6
52
 
package/bin/unsnooze.js CHANGED
@@ -256,10 +256,33 @@ async function main() {
256
256
  }
257
257
  // Version-skew guard: when npm swaps the package underneath us, exit
258
258
  // cleanly so launchd/systemd restart the daemon on the fresh code.
259
+ // Interval stays at 15 minutes on purpose — a tighter loop raises the
260
+ // odds of restarting INTO a half-installed package, which is the
261
+ // crash-loop the unit throttles exist to survive.
262
+ // Exiting is only safe under a supervisor. `unsnooze daemon` run from a
263
+ // shell has nothing to restart it, so there it warns and keeps watching
264
+ // rather than ending GUI watching silently.
259
265
  const updMod = await safeImport('../src/update-check.js');
266
+ const notifyMod = await safeImport('../src/notify.js');
260
267
  if (updMod?.hasVersionSkew) {
268
+ let warnedSkew = false;
261
269
  setInterval(() => {
262
- if (updMod.hasVersionSkew()) controller.abort();
270
+ const action = updMod.daemonSkewAction({
271
+ skewed: updMod.hasVersionSkew(),
272
+ // Unknown means assume supervised: that is how the daemon is
273
+ // installed for all but a handful of users, and it preserves the
274
+ // long-standing behavior when install.js could not be loaded.
275
+ supervised: installMod?.isSupervised ? installMod.isSupervised() : true,
276
+ alreadyWarned: warnedSkew,
277
+ });
278
+ if (action === 'restart') controller.abort();
279
+ else if (action === 'warn') {
280
+ warnedSkew = true;
281
+ const msg = 'package was upgraded on disk but this daemon is not supervised — '
282
+ + 'still running the old code; restart it to pick up the new version';
283
+ loggerMod?.log('daemon', msg);
284
+ notifyMod?.notify('unsnooze needs a restart', msg, { priority: 4 });
285
+ }
263
286
  }, 15 * 60_000).unref();
264
287
  }
265
288
  // Daily update check from the daemon: GUI-only users never run CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.14.2",
3
+ "version": "1.14.3",
4
4
  "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—across tmux, Zellij, VS Code, and desktop apps.",
5
5
  "keywords": [
6
6
  "claude",
@@ -14,11 +14,7 @@ import {
14
14
  import { listOwnedSessions } from '../reap.js';
15
15
  import { runDoctor } from '../doctor.js';
16
16
  import { fetchFleet, readHosts } from '../fleet.js';
17
-
18
- function pidAlive(pid) {
19
- if (!pid) return false;
20
- try { process.kill(pid, 0); return true; } catch { return false; }
21
- }
17
+ import { pidAlive } from '../spawn.js';
22
18
 
23
19
  export function loadStatusSnapshot() {
24
20
  const state = readState();
package/src/doctor.js CHANGED
@@ -138,6 +138,7 @@ export async function runDoctor({
138
138
  mux = getMultiplexer(),
139
139
  hookInstalled = defaultHookInstalled,
140
140
  wrappersInstalled = defaultWrappersInstalled,
141
+ autostartDir = null,
141
142
  } = {}) {
142
143
  const findings = [];
143
144
 
@@ -204,6 +205,25 @@ export async function runDoctor({
204
205
  });
205
206
  }
206
207
 
208
+ // The daemon reaches tmux through PATH, and launchd/systemd hand it a
209
+ // minimal one — so a unit that bakes the wrong PATH revives nothing while
210
+ // looking perfectly healthy from here. Lazy import: install.js imports this
211
+ // module, so a static import would cycle (same reason resumer.js defers
212
+ // prompt-queue.js).
213
+ try {
214
+ const { daemonPathBroken } = await import('./install.js');
215
+ const broken = daemonPathBroken({ platform, dir: autostartDir, muxBin: mux.name || 'tmux' });
216
+ if (broken) {
217
+ findings.push({
218
+ id: 'daemon-path', kind: 'health',
219
+ title: `daemon autostart cannot find ${broken.muxBin} — every revival will fail`,
220
+ detail: ` ${broken.unit}\n baked PATH: ${broken.path}\n`
221
+ + ` run: unsnooze doctor --fix (or: unsnooze install --daemon from your shell)`,
222
+ fix: { action: 'heal-daemon-path', platform, dir: autostartDir },
223
+ });
224
+ }
225
+ } catch { /* best-effort: a PATH check must never break doctor */ }
226
+
207
227
  findings.push({
208
228
  id: 'daemon', kind: 'info',
209
229
  title: daemonRunning() ? 'resumer/daemon: running' : 'resumer/daemon: not running (starts on the next limit stop)',
@@ -225,6 +245,9 @@ export async function applyFixes(report, {
225
245
  kill = defaultKill,
226
246
  runner = defaultRunner,
227
247
  now = Date.now(),
248
+ resolvePath = undefined,
249
+ currentPath = undefined,
250
+ activate = undefined,
228
251
  } = {}) {
229
252
  const actions = [];
230
253
  for (const finding of report.findings) {
@@ -244,6 +267,23 @@ export async function applyFixes(report, {
244
267
  actions.push({ action: 'removed-unit', unit });
245
268
  log(`doctor: removed csg autostart ${unit}`);
246
269
  }
270
+ } else if (fix.action === 'heal-daemon-path') {
271
+ const { healDaemonAutostart } = await import('./install.js');
272
+ const healed = healDaemonAutostart({
273
+ platform: fix.platform, dir: fix.dir,
274
+ ...(resolvePath ? { resolvePath } : {}),
275
+ ...(currentPath !== undefined ? { currentPath } : {}),
276
+ ...(activate ? { activate } : {}),
277
+ });
278
+ // healDaemonAutostart declines when it has no better PATH to offer —
279
+ // that refusal is the crash-loop guard, so surface it rather than
280
+ // claiming a repair that did not happen.
281
+ if (healed) {
282
+ actions.push({ action: 'healed-daemon-path', unit: healed });
283
+ log(`doctor: rewrote ${healed} with a working PATH`);
284
+ } else {
285
+ actions.push({ action: 'error', detail: 'could not determine a PATH that finds the multiplexer — run `unsnooze install --daemon` from your shell' });
286
+ }
247
287
  } else if (fix.action === 'archive-dir') {
248
288
  let target = `${fix.dir}.bak`;
249
289
  if (existsSync(target)) target = `${fix.dir}.bak.${now}`;
package/src/install.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
12
12
  import { execFileSync } from 'node:child_process';
13
- import { homedir } from 'node:os';
13
+ import { homedir, userInfo } from 'node:os';
14
14
  import { join, dirname } from 'node:path';
15
15
  import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
16
16
  import { getConfig, configFileExists } from './settings.js';
@@ -149,6 +149,23 @@ export function installZshrcBlock(content, agents = ['claude']) {
149
149
 
150
150
  export const DAEMON_LABEL = 'com.unsnooze.daemon';
151
151
 
152
+ // Is this process running under the supervisor we install, rather than from
153
+ // somebody's shell? It decides whether exiting is safe: a supervised daemon
154
+ // that exits on version skew comes straight back on fresh code, while an
155
+ // unsupervised one exits into nothing and GUI watching stops silently.
156
+ //
157
+ // Both markers are injected by the supervisor itself and were read off real
158
+ // processes rather than inferred: launchd sets XPC_SERVICE_NAME to the job
159
+ // label for its jobs (an interactive shell reports the literal '0'), and
160
+ // systemd sets a per-invocation INVOCATION_ID for every unit it starts.
161
+ // Matching the exact label, not merely "some launchd job", keeps an unrelated
162
+ // XPC service from being mistaken for our supervisor.
163
+ export function isSupervised({ platform = process.platform, env = process.env } = {}) {
164
+ if (platform === 'darwin') return env.XPC_SERVICE_NAME === DAEMON_LABEL;
165
+ if (platform === 'linux') return typeof env.INVOCATION_ID === 'string' && env.INVOCATION_ID !== '';
166
+ return false; // nowhere else do we install a supervisor to be run by
167
+ }
168
+
152
169
  // Env overrides keep tests/e2e away from the real LaunchAgents / systemd dirs.
153
170
  function autostartDir(platform) {
154
171
  if (platform === 'darwin') {
@@ -157,6 +174,78 @@ function autostartDir(platform) {
157
174
  return process.env.UNSNOOZE_SYSTEMD_USER_DIR || join(homedir(), '.config', 'systemd', 'user');
158
175
  }
159
176
 
177
+ // The one place that knows what our unit file is called on each platform.
178
+ export function autostartUnitPath({ platform = process.platform, dir = null } = {}) {
179
+ const base = dir || autostartDir(platform);
180
+ if (platform === 'darwin') return join(base, `${DAEMON_LABEL}.plist`);
181
+ if (platform === 'linux') return join(base, 'unsnooze.service');
182
+ return null;
183
+ }
184
+
185
+ // Can this PATH string actually find `bin`? The daemon reaches tmux through
186
+ // PATH (`execFile('tmux', …)`), so a unit whose baked PATH fails this test
187
+ // cannot revive anything — every call dies with ENOENT. Pure and synchronous
188
+ // on purpose: this decides whether to rewrite-and-reload a unit, and that
189
+ // decision must be deterministic.
190
+ export function pathResolves(pathStr, bin) {
191
+ if (typeof pathStr !== 'string' || pathStr === '') return false;
192
+ return pathStr.split(':').some(d => d && existsSync(join(d, bin)));
193
+ }
194
+
195
+ function xmlUnescape(s) {
196
+ return s.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
197
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
198
+ }
199
+
200
+ // The PATH a unit currently bakes in, or null when it bakes none.
201
+ function bakedUnitPath(content, platform) {
202
+ if (platform === 'darwin') {
203
+ const m = content.match(/<key>PATH<\/key>\s*<string>([^<]*)<\/string>/);
204
+ return m ? xmlUnescape(m[1]) : null;
205
+ }
206
+ const m = content.match(/^Environment="PATH=(.*)"$/m);
207
+ return m ? m[1].replace(/%%/g, '%') : null;
208
+ }
209
+
210
+ function defaultLoginPathRunner(cmd, args, opts) {
211
+ return execFileSync(cmd, args, opts);
212
+ }
213
+
214
+ // Ask the user's login shell what PATH it builds. This exists because the only
215
+ // caller of the self-heal is the daemon itself, and a daemon's PATH is the
216
+ // launchd/systemd minimal one — the very thing being repaired. Reading
217
+ // process.env.PATH there just writes the breakage back.
218
+ //
219
+ // PATH is stripped from the child env so the login files construct it fresh
220
+ // rather than appending to the broken value we inherited. Failure returns null:
221
+ // a bad guess is worse than leaving the unit alone.
222
+ export function resolveLoginPath({
223
+ shell = process.env.SHELL || userInfo().shell,
224
+ runner = defaultLoginPathRunner,
225
+ timeoutMs = 3000,
226
+ } = {}) {
227
+ if (!shell) return null;
228
+ try {
229
+ const { PATH, ...env } = process.env; // eslint-disable-line no-unused-vars
230
+ const out = runner(shell, ['-lc', 'echo $PATH'], {
231
+ encoding: 'utf-8', timeout: timeoutMs, env, stdio: ['ignore', 'pipe', 'ignore'],
232
+ });
233
+ const path = String(out || '').trim();
234
+ return path || null;
235
+ } catch {
236
+ return null; // no shell, slow rc files, non-zero exit — all "no answer"
237
+ }
238
+ }
239
+
240
+ // The best PATH we can offer a unit: the caller's own if it already finds the
241
+ // binary (the `unsnooze setup`-from-a-shell case), otherwise whatever the login
242
+ // shell reports. null means we have nothing better to write.
243
+ function bestUnitPath({ currentPath, resolvePath, muxBin }) {
244
+ if (pathResolves(currentPath, muxBin)) return currentPath;
245
+ const fresh = resolvePath();
246
+ return fresh && pathResolves(fresh, muxBin) ? fresh : null;
247
+ }
248
+
160
249
  export function launchdPlist({
161
250
  nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN,
162
251
  logFile = join(STATE_DIR, 'daemon.log'),
@@ -227,22 +316,31 @@ function defaultActivate(cmd, args) {
227
316
  }
228
317
  }
229
318
 
230
- export function installDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
319
+ export function installDaemonAutostart({
320
+ platform = process.platform, dir = null, activate = defaultActivate, path = undefined,
321
+ } = {}) {
322
+ const target = autostartUnitPath({ platform, dir });
323
+ if (!target) return null; // native Windows: no supported multiplexer to revive into
324
+ // Safety interlock. Every unit we generate carries the SAME label, so running
325
+ // the real launchctl/systemctl against a unit written somewhere else (a test
326
+ // fixture, a staging copy) does not create a second job — it HIJACKS the
327
+ // user's live one, leaving it pointed at a directory that may not outlive the
328
+ // caller. So the real activator only ever runs for the unit path this
329
+ // platform actually uses; an injected activator is always honored, since a
330
+ // caller that supplied one has said what it wants to happen.
331
+ const act = (activate === defaultActivate && target !== autostartUnitPath({ platform }))
332
+ ? () => true
333
+ : activate;
231
334
  if (platform === 'darwin') {
232
- const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
233
- atomicWrite(target, launchdPlist());
234
- activate('launchctl', ['unload', target]); // reload cleanly if already loaded
235
- activate('launchctl', ['load', '-w', target]);
335
+ atomicWrite(target, launchdPlist(path ? { path } : {}));
336
+ act('launchctl', ['unload', target]); // reload cleanly if already loaded
337
+ act('launchctl', ['load', '-w', target]);
236
338
  return target;
237
339
  }
238
- if (platform === 'linux') {
239
- const target = join(dir || autostartDir(platform), 'unsnooze.service');
240
- atomicWrite(target, systemdUnit());
241
- activate('systemctl', ['--user', 'daemon-reload']);
242
- activate('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
243
- return target;
244
- }
245
- return null; // native Windows: no supported multiplexer to revive into
340
+ atomicWrite(target, systemdUnit(path ? { path } : {}));
341
+ act('systemctl', ['--user', 'daemon-reload']);
342
+ act('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
343
+ return target;
246
344
  }
247
345
 
248
346
  // One-time self-heal for units written before 1.12.0: they carry no PATH, so
@@ -251,20 +349,53 @@ export function installDaemonAutostart({ platform = process.platform, dir = null
251
349
  // so the daemon repairs it on startup: regenerate + reload. Reloading kills
252
350
  // the calling daemon — by design; the supervisor restarts it under the fixed
253
351
  // unit. Returns the healed target, or null when nothing needed healing.
254
- export function healDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
255
- const marker = platform === 'darwin' ? 'EnvironmentVariables' : 'Environment="PATH=';
256
- const target = platform === 'darwin'
257
- ? join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`)
258
- : platform === 'linux'
259
- ? join(dir || autostartDir(platform), 'unsnooze.service')
260
- : null;
352
+ // Does an installed unit bake a PATH that cannot reach the multiplexer? This
353
+ // is invisible from a user's shell — `doctor` finds tmux on the rich PATH it
354
+ // inherited and calls the install healthy — so the daemon's OWN baked PATH has
355
+ // to be inspected directly. Returns null when there is nothing to report
356
+ // (no unit, no baked PATH, or a PATH that works).
357
+ export function daemonPathBroken({ platform = process.platform, dir = null, muxBin = 'tmux' } = {}) {
358
+ const unit = autostartUnitPath({ platform, dir });
359
+ if (!unit || !existsSync(unit)) return null; // not a daemon-autostart user
360
+ let content;
361
+ try { content = readFileSync(unit, 'utf-8'); } catch { return null; }
362
+ const baked = bakedUnitPath(content, platform);
363
+ if (baked == null || pathResolves(baked, muxBin)) return null;
364
+ return { unit, path: baked, muxBin };
365
+ }
366
+
367
+ export function healDaemonAutostart({
368
+ platform = process.platform, dir = null, activate = defaultActivate,
369
+ resolvePath = resolveLoginPath, muxBin = 'tmux',
370
+ currentPath = process.env.PATH || '',
371
+ } = {}) {
372
+ const target = autostartUnitPath({ platform, dir });
261
373
  if (!target || !existsSync(target)) return null; // not a daemon-autostart user
262
- try {
263
- if (readFileSync(target, 'utf-8').includes(marker)) return null; // already current
264
- } catch {
265
- return null; // unreadable — leave it alone
374
+ let content;
375
+ try { content = readFileSync(target, 'utf-8'); } catch { return null; }
376
+
377
+ const marker = platform === 'darwin' ? 'EnvironmentVariables' : 'Environment="PATH=';
378
+ const better = bestUnitPath({ currentPath, resolvePath, muxBin });
379
+
380
+ // Pre-1.12 unit: no PATH at all. Always worth healing — but prefer a PATH
381
+ // that actually works over the caller's, which is minimal when (as always)
382
+ // the caller is the daemon.
383
+ if (!content.includes(marker)) {
384
+ return installDaemonAutostart({ platform, dir, activate, path: better || currentPath });
266
385
  }
267
- return installDaemonAutostart({ platform, dir, activate });
386
+
387
+ // Present but useless. This is the trap the original heal fell into: run
388
+ // from the daemon, it baked the daemon's own tmux-less PATH back in, and the
389
+ // marker being present then made it "already current" forever.
390
+ const baked = bakedUnitPath(content, platform);
391
+ if (baked == null || pathResolves(baked, muxBin)) return null; // genuinely fine
392
+
393
+ // CRASH-LOOP GUARD. Healing reloads the unit, which kills this daemon; the
394
+ // supervisor restarts it and we arrive here again. Rewriting without a
395
+ // strictly better PATH would repeat every 30s forever, which is worse than
396
+ // the bug. No improvement available → leave it exactly as it is.
397
+ if (!better || better === baked) return null;
398
+ return installDaemonAutostart({ platform, dir, activate, path: better });
268
399
  }
269
400
 
270
401
  export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
package/src/launcher.js CHANGED
@@ -8,7 +8,7 @@ import { spawn, spawnSync } from 'node:child_process';
8
8
  import { getMultiplexer } from './multiplexer.js';
9
9
  import { getAgent } from './agents/index.js';
10
10
  import { getConfig } from './settings.js';
11
- import { spawnDetached } from './spawn.js';
11
+ import { spawnDetached, monitorSpawnArgs } from './spawn.js';
12
12
  import { makeLogger } from './logger.js';
13
13
  import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
14
14
  import { SessionCreateError } from './multiplexers/session-name.js';
@@ -79,7 +79,8 @@ export function runLauncher(args, agentId = 'claude') {
79
79
  if (typeof mux.stampPaneOwner === 'function') {
80
80
  mux.stampPaneOwner(pane, leaseId).catch(() => { /* legacy tmux */ });
81
81
  }
82
- spawnDetached(['_monitor', mux.name, paneOwner || '', pane, agent.id, leaseId],
82
+ spawnDetached(
83
+ monitorSpawnArgs({ muxName: mux.name, paneOwner, pane, agentId: agent.id, leaseId }),
83
84
  { UNSNOOZE_CWD: process.cwd() });
84
85
  log(`launching ${agent.id} in ${mux.name} ${paneOwner ?? '-'}:${pane}, monitor spawned`);
85
86
  } else {
package/src/monitor.js CHANGED
@@ -24,7 +24,8 @@ import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
24
24
  import { upsertSession, setStatus, readState, updateState } from './state.js';
25
25
  import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
26
26
  import { latestRateLimitFromTranscript } from './watchers/claude.js';
27
- import { spawnResumerIfNeeded } from './spawn.js';
27
+ import { spawnResumerIfNeeded, spawnDetached, monitorSpawnArgs, UNSNOOZE_BIN } from './spawn.js';
28
+ import { restartOnVersionSkew, hasVersionSkew, PKG_VERSION } from './update-check.js';
28
29
  import { makeLogger } from './logger.js';
29
30
  import { addressHash, readLease } from './lease.js';
30
31
  import { claudeRecordEnv, hasClaudeParentUsageAfter } from './sessions.js';
@@ -37,6 +38,7 @@ export function createMonitor({
37
38
  muxName = 'tmux', paneOwner = null, pane, leaseId = null, cwd,
38
39
  agent = getAgent('claude'), mux = getMultiplexer(muxName, { owner: paneOwner }),
39
40
  scrapeInterval = SCRAPE_INTERVAL_MS, notifier = notify,
41
+ spawner = spawnDetached, versionSkewed = hasVersionSkew, handoffBin = UNSNOOZE_BIN,
40
42
  }) {
41
43
  const notifyCtx = { mux: muxName, pane, paneOwner };
42
44
  let trackedKey = null; // state key of the record we created
@@ -239,6 +241,28 @@ export function createMonitor({
239
241
  }
240
242
  }
241
243
 
244
+ // A monitor is spawned once per pane and lives as long as the agent, so it
245
+ // routinely outlives an `npm i -g unsnooze`: the package on disk changes,
246
+ // this process keeps the modules it loaded at launch, and no supervisor
247
+ // exists to restart it. That is how 1.14.2's stop-completion fix reached a
248
+ // user's disk without ever reaching the process that completes stops — a
249
+ // pane launched five days earlier went on applying the deleted rule.
250
+ // Hand off to a replacement on fresh code and stand down.
251
+ //
252
+ // Ordering matters twice over. It runs AFTER the pane/lease checks, so an
253
+ // upgrade landing as the agent exits does not respawn a watcher onto the
254
+ // user's bare shell; and BEFORE consumeMarker(), which unlinks the event
255
+ // file — handing off after that would swallow the pending overload event.
256
+ if (await restartOnVersionSkew({
257
+ args: monitorSpawnArgs({ muxName, paneOwner, pane, agentId: agent.id, leaseId }),
258
+ env: { UNSNOOZE_CWD: cwd },
259
+ spawner, skewed: versionSkewed, binPath: handoffBin,
260
+ log: msg => log(`pane ${pane}: ${msg}`),
261
+ })) {
262
+ running = false;
263
+ return;
264
+ }
265
+
242
266
  const marker = consumeMarker();
243
267
  let text;
244
268
  try {
@@ -366,7 +390,10 @@ export function createMonitor({
366
390
 
367
391
  return {
368
392
  async run() {
369
- log(`monitor started for pane ${pane} (cwd ${cwd})`);
393
+ // Stamp the build: a monitor can outlive several upgrades, so "which
394
+ // version is this process actually running" is the first question any
395
+ // log attachment has to answer.
396
+ log(`monitor started for pane ${pane} (cwd ${cwd}) — unsnooze ${PKG_VERSION}`);
370
397
  while (running) {
371
398
  await tick();
372
399
  if (!running) break;
package/src/resumer.js CHANGED
@@ -26,7 +26,8 @@ import { latestRateLimitFromTranscript } from './watchers/claude.js';
26
26
  import { getConfig, resolveResumeMessage } from './settings.js';
27
27
  import { workspaceFingerprint, workspaceChanged, describeChange } from './workspace.js';
28
28
  import { notify } from './notify.js';
29
- import { UNSNOOZE_BIN } from './spawn.js';
29
+ import { UNSNOOZE_BIN, spawnDetached, pidAlive } from './spawn.js';
30
+ import { restartOnVersionSkew, hasVersionSkew, PKG_VERSION } from './update-check.js';
30
31
  import { makeLogger } from './logger.js';
31
32
  import { createLeaseId, leaseMatches, paneOwnedByRecord } from './lease.js';
32
33
  import { autoReapIfEnabled, attachHint } from './reap.js';
@@ -37,10 +38,6 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
37
38
  const MAX_VERIFY_RETRIES = 3;
38
39
  const ctxOf = rec => ({ mux: rec.mux, pane: rec.pane, paneOwner: rec.paneOwner });
39
40
 
40
- function pidAlive(pid) {
41
- try { process.kill(pid, 0); return true; } catch { return false; }
42
- }
43
-
44
41
  // Is the lock-holder pid actually an unsnooze resumer? Pids get recycled: a
45
42
  // lock naming a live-but-unrelated program would otherwise be honored forever
46
43
  // and stops would never dispatch. Lock acquirers are only ever `_resumer` or
@@ -932,6 +929,7 @@ export function routeDispatchOutcome(result, rec, deferCounts, { maxBusyDefers =
932
929
  export async function runResumer({
933
930
  resolveMux = resolveRecordMux, pollInterval = POLL_INTERVAL_MS,
934
931
  persistent = false, watcher = null, signal = null, queueMux = null,
932
+ spawner = spawnDetached, versionSkewed = hasVersionSkew, handoffBin = UNSNOOZE_BIN,
935
933
  } = {}) {
936
934
  // A transient hook-spawned resumer may hold the lock right now; a daemon
937
935
  // outlives it, so wait for the lock instead of dying. The watcher MUST keep
@@ -951,12 +949,43 @@ export async function runResumer({
951
949
  await sleep(pollInterval);
952
950
  }
953
951
  updateState(state => { state.resumerPid = process.pid; });
954
- log(`resumer started (pid ${process.pid}${persistent ? ', persistent' : ''})`);
952
+ // Stamp the build — a transient resumer waits out the whole reset and can
953
+ // easily be older than the package on disk. See the monitor's start line.
954
+ log(`resumer started (pid ${process.pid}${persistent ? ', persistent' : ''}, unsnooze ${PKG_VERSION})`);
955
955
  const deferCounts = new Map();
956
956
 
957
957
  try {
958
958
  for (;;) {
959
959
  if (signal?.aborted) { log('shutdown requested — resumer exiting'); return 0; }
960
+
961
+ // Version-skew hand-off. A transient resumer is spawned at the stop and
962
+ // waits out the whole reset — hours — so an `npm i -g` landing mid-wait
963
+ // leaves the process that SENDS THE WAKE running deleted code. Nothing
964
+ // supervises it, so it replaces itself. The daemon is exempt: launchd/
965
+ // systemd own its restart and bin/unsnooze.js already aborts it on skew;
966
+ // forking a rival from under a supervisor would just fight the unit.
967
+ //
968
+ // Release the singleton BEFORE the replacement starts. acquireSingleton
969
+ // honors a live holder, so a child that starts while we still hold the
970
+ // lock exits on sight and we would end up with no resumer at all.
971
+ if (!persistent && versionSkewed()) {
972
+ const handed = await restartOnVersionSkew({
973
+ args: ['_resumer'], spawner, skewed: () => true, log, binPath: handoffBin,
974
+ // Released only once the hand-off is actually going ahead — never
975
+ // for one the pre-flight already ruled out.
976
+ beforeSpawn: () => {
977
+ releaseSingleton();
978
+ updateState(state => { if (state.resumerPid === process.pid) state.resumerPid = null; });
979
+ },
980
+ });
981
+ if (handed) return 0;
982
+ // Hand-off refused or failed: take the lock back rather than keep
983
+ // resuming unlocked, where a second resumer could dispatch the same
984
+ // record. Idempotent when beforeSpawn never ran — we still hold it.
985
+ acquireSingleton();
986
+ updateState(state => { state.resumerPid = process.pid; });
987
+ }
988
+
960
989
  await tickWatcher();
961
990
 
962
991
  // Pre-wall usage warnings (1.13) — after watcher so samples are fresh.
package/src/spawn.js CHANGED
@@ -21,7 +21,23 @@ export function spawnDetached(args, env = {}) {
21
21
  return child.pid;
22
22
  }
23
23
 
24
- function pidAlive(pid) {
24
+ // The one definition of how a per-pane monitor is launched. The launcher uses
25
+ // it to start one; a version-skewed monitor uses it to hand off to its own
26
+ // replacement. Keeping a single builder is what stops the two from drifting —
27
+ // a mismatched respawn would silently watch the wrong pane or, with a null
28
+ // hole in the argv, throw inside child_process and take the watcher with it.
29
+ export function monitorSpawnArgs({ muxName, paneOwner, pane, agentId, leaseId }) {
30
+ return ['_monitor', muxName, paneOwner || '', pane, agentId, leaseId || ''];
31
+ }
32
+
33
+ // Signal 0 probes for existence without delivering anything. Shared: the
34
+ // resumer's lock hygiene, the dashboard's liveness column, and the version-skew
35
+ // hand-off all need the same answer.
36
+ export function pidAlive(pid) {
37
+ // Reject 0 before probing: kill(0, 0) targets our OWN process group and
38
+ // succeeds, so a garbage lock file holding "0" would read as a live holder
39
+ // forever. Number.isFinite alone does not catch it.
40
+ if (!Number.isFinite(pid) || pid <= 0) return false;
25
41
  try { process.kill(pid, 0); return true; } catch { return false; }
26
42
  }
27
43
 
@@ -7,16 +7,19 @@
7
7
  // All of it is gated on the `updateCheck` setting / UNSNOOZE_UPDATE_CHECK.
8
8
  // The check is a plain GET to registry.npmjs.org — nothing identifying.
9
9
 
10
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
10
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { join, dirname } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { getConfig } from './settings.js';
15
15
  import { notify } from './notify.js';
16
+ import { UNSNOOZE_BIN, pidAlive } from './spawn.js';
16
17
 
17
18
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
19
  export const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
19
20
 
21
+ const defaultSleep = ms => new Promise(r => setTimeout(r, ms));
22
+
20
23
  const CACHE_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || join(process.env.HOME || '', '.unsnooze'), 'update-check.json');
21
24
  const CHECK_INTERVAL_MS = 24 * 3_600_000;
22
25
  const RELEASES_URL = 'https://github.com/saaranshM/unsnooze/releases';
@@ -70,6 +73,72 @@ export function hasVersionSkew({ root = ROOT, current = PKG_VERSION } = {}) {
70
73
  }
71
74
  }
72
75
 
76
+ // Skew hand-off for long-lived processes that have NO supervisor: per-pane
77
+ // monitors (spawned once by the launcher, alive as long as the agent) and
78
+ // transient resumers (alive until resetAt — routinely hours). The daemon can
79
+ // simply exit on skew because launchd/systemd bring it back; these cannot, so
80
+ // they must spawn their own replacement on the fresh code and only then stand
81
+ // down. Without this a pane launched before an upgrade keeps deciding with the
82
+ // code it loaded at launch — how 1.14.2's stop-completion fix reached disk on
83
+ // a user's host without ever reaching the process that makes the decision.
84
+ //
85
+ // Returns true only when a replacement is confirmed spawned; the caller stands
86
+ // down on true and keeps running on false. A failed spawn must never take the
87
+ // old process with it — a stale watcher beats no watcher.
88
+ export async function restartOnVersionSkew({
89
+ args, env = {}, spawner, skewed = hasVersionSkew, log = () => {},
90
+ binPath = UNSNOOZE_BIN, beforeSpawn = null,
91
+ confirmDelayMs = 400, alive = pidAlive, sleep = defaultSleep,
92
+ } = {}) {
93
+ if (!skewed()) return false;
94
+ // Pre-flight. `npm i -g` can be observed mid-swap: a new, readable
95
+ // package.json over a tree whose bin/ has not landed yet. Spawning into that
96
+ // hands off to nothing, so treat it as "not ready" and try again next tick.
97
+ if (!existsSync(binPath)) {
98
+ log(`version skew detected but ${binPath} is missing (upgrade in flight) — continuing on old code`);
99
+ return false;
100
+ }
101
+ // Only now may the caller give anything up (the resumer releases its
102
+ // singleton here) — never for a hand-off that was already impossible.
103
+ beforeSpawn?.();
104
+ let pid = null;
105
+ try {
106
+ pid = spawner(args, env);
107
+ } catch (err) {
108
+ log(`version skew detected but respawn failed (${err.message}) — continuing on old code`);
109
+ return false;
110
+ }
111
+ if (!Number.isFinite(pid) || pid <= 0) {
112
+ log('version skew detected but respawn returned no pid — continuing on old code');
113
+ return false;
114
+ }
115
+ // Post-flight. A pid is not proof of a running replacement: spawn() resolves
116
+ // the NODE BINARY, which exists no matter what, so a missing or broken script
117
+ // still returns a pid and fires no 'error' event — the child is simply dead
118
+ // by the time we look. Confirm it survived startup before standing down.
119
+ await sleep(confirmDelayMs);
120
+ if (!alive(pid)) {
121
+ log(`version skew: replacement pid ${pid} died on startup — continuing on old code`);
122
+ return false;
123
+ }
124
+ log(`version skew: package replaced on disk — handed off to pid ${pid}, exiting`);
125
+ return true;
126
+ }
127
+
128
+ // What a daemon should do when it notices the package changed underneath it.
129
+ // Supervised (launchd/systemd): exit — the supervisor brings it back on fresh
130
+ // code, which is why the unit files use KeepAlive / Restart=always. Anything
131
+ // else — `unsnooze daemon` run straight from a shell — has nothing to restart
132
+ // it, so exiting would silently end GUI watching. There it keeps running on
133
+ // the old code and says so, once: the same "stale beats none" trade the
134
+ // monitor makes. Kept as a pure decision so the policy is testable without
135
+ // standing up a daemon.
136
+ export function daemonSkewAction({ skewed, supervised, alreadyWarned = false } = {}) {
137
+ if (!skewed) return 'none';
138
+ if (supervised) return 'restart';
139
+ return alreadyWarned ? 'none' : 'warn';
140
+ }
141
+
73
142
  export async function fetchLatest({ fetcher = globalThis.fetch, timeoutMs = 3000 } = {}) {
74
143
  try {
75
144
  const ctrl = new AbortController();