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 +47 -1
- package/bin/unsnooze.js +24 -1
- package/package.json +1 -1
- package/src/dashboard/data.js +1 -5
- package/src/doctor.js +40 -0
- package/src/install.js +157 -26
- package/src/launcher.js +3 -2
- package/src/monitor.js +29 -2
- package/src/resumer.js +35 -6
- package/src/spawn.js +17 -1
- package/src/update-check.js +70 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/dashboard/data.js
CHANGED
|
@@ -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(/</g, '<').replace(/>/g, '>')
|
|
197
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/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({
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/update-check.js
CHANGED
|
@@ -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();
|