unsnooze 1.19.0 → 1.19.2

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,5 +1,223 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.2 — 2026-09-27
4
+
5
+ Setup on Windows no longer creates a Scheduled Task. That task is what
6
+ Microsoft Defender flagged as a Trojan.
7
+
8
+ ### Website redesign
9
+
10
+ - The new night-shift design now covers the homepage, documentation, changelog,
11
+ and feedback page, with responsive layouts and reduced-motion support.
12
+ - Release metadata refreshes every five minutes so the site follows published
13
+ npm versions. Failed feedback-board reads now show an error instead of
14
+ loading indefinitely.
15
+ - Windows setup and troubleshooting docs describe the on-demand daemon.
16
+
17
+ ### Codex wake recovery
18
+
19
+ - Team/business stops with a null credits balance now retain the exhausted
20
+ five-hour window's reset instead of being held as a workspace credit wall.
21
+ - If Codex consumes Enter as a pasted newline, verification retries Enter
22
+ up to three times while the exact wake prompt remains in the composer.
23
+ Each retry rechecks pane ownership and agent liveness. The old banner is
24
+ not re-recorded as a new stop, and the wake text is never pasted twice.
25
+ An unconfirmed submission is left visible for manual inspection.
26
+
27
+ ### No Scheduled Task on Windows
28
+
29
+ A user reported that Microsoft Defender flagged `unsnooze setup` as
30
+ `Trojan:Win32/Commando.A!ml` as it created the `\unsnooze` Scheduled Task. It
31
+ was a false positive, but an understandable one. Setup ran
32
+ `schtasks /create /f /sc onlogon /tr "node.exe …\AppData\Roaming\npm\…\unsnooze.js daemon"`:
33
+ an interpreter told to run a script out of `%APPDATA%` at every logon, which is
34
+ exactly how malware persists, and `Commando.A!ml` is Defender's
35
+ machine-learning verdict on command lines like that one. A Run key or a
36
+ Startup-folder entry reads the same way, so the daemon uses none of them now.
37
+ The task rarely worked anyway: from a shell that is not elevated, `schtasks`
38
+ refuses an `onlogon` task with "Access is denied", and setup said it was
39
+ installed regardless.
40
+
41
+ On Windows the daemon is now an ordinary background process:
42
+
43
+ - **Setup starts it.** Saying yes to guarding GUI sessions (or running
44
+ `unsnooze install --daemon`) starts the daemon at once and records that
45
+ choice in `~/.unsnooze/daemon-on-demand`. Nothing is registered with Windows.
46
+ - **It is started again on demand.** Every agent launched through the
47
+ PowerShell wrappers, and every Claude Code StopFailure hook, starts the
48
+ daemon if it is not running. After a restart it is back as soon as you use
49
+ an agent.
50
+ - **One runs at a time.** The daemon keeps a heartbeat pidfile
51
+ (`~/.unsnooze/daemon.pid`), and a second daemon stands down at once. Once the
52
+ heartbeat is five minutes old the file no longer counts, whoever its pid
53
+ belongs to by then, so a pid Windows recycles after a sign-out cannot keep
54
+ the daemon from starting. A resumer lock left behind by a daemon that was
55
+ killed is cleared too, so it cannot stall every resume.
56
+ - **It carries no session with it.** Started from inside a Claude Code
57
+ session (by the hook), it used to inherit that session's environment,
58
+ including `CLAUDECODE=1`, Claude Code's nested-session guard. Every Claude
59
+ session it revived would then have refused to start. Background processes
60
+ now drop the variables that mark one session; your own settings, such as
61
+ `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_USE_BEDROCK`, are kept. They also run in
62
+ your home directory, not the project that happened to start them, which
63
+ Windows would otherwise refuse to delete or rename until sign-out. The
64
+ transient resumer the hook starts gets the same treatment, on every
65
+ platform.
66
+ - **No console windows.** A background daemon has no console. The `git` calls
67
+ it makes when a session stops and wakes, and the agent a revival starts,
68
+ now run hidden instead of each popping up a window of its own.
69
+ - **Its output goes to `~/.unsnooze/daemon.log`**, as the launchd daemon's
70
+ does, so a crash leaves a trace.
71
+ - **Re-running setup restarts it.** `unsnooze install --daemon` stops the
72
+ running daemon and starts a fresh one. That is how it picks up a new
73
+ version, or a `PATH` that now finds an agent, and `unsnooze doctor` now
74
+ says so instead of pointing at `schtasks`.
75
+
76
+ If you use only the Codex app or IDE on Windows, nothing you do passes through
77
+ a wrapper or the hook. After signing in, run `unsnooze install --daemon` (or
78
+ launch any wrapped agent) to start the daemon.
79
+
80
+ `unsnooze uninstall` removes the marker first, then stops the daemon, so
81
+ nothing can start it again halfway through; a daemon that was only starting at
82
+ that moment sees the marker gone and exits. It also deletes a `\unsnooze`
83
+ Scheduled Task left by an earlier version, after checking that one exists, so
84
+ a machine that never had one only ever sees a read-only query. If the delete
85
+ is refused (a task created from an administrator shell can need one to
86
+ remove), it prints the command to run. Setup does not go looking for that
87
+ task: that would mean running `schtasks` again. A daemon such a task already
88
+ started keeps running until you sign out; from the next sign-in the task only
89
+ starts the same on-demand daemon, which the pidfile keeps to one. macOS and
90
+ Linux are unchanged (launchd / systemd).
91
+
92
+ ## 1.19.1 — 2026-09-21
93
+
94
+ A Codex usage reading that was averaged down, a headless Codex revival
95
+ that could never have worked — reported as a success — and Codex stops that
96
+ were never seen at all behind a proxy or in a reverted thread.
97
+
98
+ ### `unsnooze usage` reports Codex's exact percentage as read
99
+
100
+ The 64% in [issue #20](https://github.com/saaranshM/unsnooze/issues/20) was
101
+ `(29 + 99) / 2`: a jump of more than 15 points was averaged with the reading
102
+ before it and still labelled `exact`. The averaging was meant to soften a
103
+ one-tick spike, but at the moment a session is blocked no further reading
104
+ arrives, so the low number stayed for the rest of the window — and 64 is
105
+ below the 80/95 warning thresholds, so the usage-wall notification never
106
+ fired. Codex's `used_percent` is the server's own number and is now shown
107
+ unchanged (still clamped to 0–100).
108
+
109
+ Two things could put a much lower reading in front of the latest one: Codex
110
+ writes one snapshot per rate-limit bucket per response, and `unsnooze usage`
111
+ read every bucket (`premium`, `codex_other`, …) as the account window; and
112
+ Codex Desktop tabs and sub-agents each write their own rollout, so "the
113
+ previous reading" could be another thread's minutes-old snapshot. Only the
114
+ account bucket feeds the 5h/weekly lines now, and burn is measured between
115
+ readings of the same rollout. Thanks to
116
+ [@mio-tsuki](https://github.com/mio-tsuki) for working out the arithmetic.
117
+
118
+ ### Headless Codex revivals run, and a revival that dies says so
119
+
120
+ [Issue #25](https://github.com/saaranshM/unsnooze/issues/25): on Windows the
121
+ daemon's Scheduled Task could not find `codex`, every revival died with
122
+ `spawn codex ENOENT`, and unsnooze logged `verified resumed` anyway.
123
+
124
+ - **`codex exec resume`.** Under the headless backend a revival ran the Codex
125
+ TUI, which exits before touching the session when stdin is not a terminal —
126
+ on every OS, since the backend shipped in 1.16.0. Headless Codex revivals
127
+ now run `codex exec resume <id> "<prompt>"`, which continues the same
128
+ conversation non-interactively, the way Claude's headless revival already
129
+ passed its prompt in argv. It is given `--skip-git-repo-check`: `exec`
130
+ refuses any directory that is not a git repository, which the TUI never
131
+ did, and the session being revived already ran there. `resumeExtraArgs.codex`
132
+ goes right after `exec`, where `-s`, `-p` and `--add-dir` parse; after
133
+ `resume` they were rejected outright.
134
+ - **A dead revival is a failed attempt.** With no pane to capture, an empty
135
+ capture used to count as a cleared banner. The headless backend now records
136
+ each revival's exit, and a non-zero one puts the stop back on the ledger
137
+ with backoff and a `last error` that carries the child's own words — visible
138
+ in `unsnooze status`, and kept when unsnooze finally gives up on the session.
139
+ A revival still running at the 20-second check is watched for up to two
140
+ minutes before it counts as resumed, long enough to catch the failures that
141
+ take a while (an auth refresh giving up, a retry loop); exit 0 counts as
142
+ resumed. A revival started by a resumer the Claude hook spawned no longer
143
+ inherits Claude's nested-launch marker, which had made it drop
144
+ `launchExtraArgs` and fail with a bare "exit 1".
145
+ - **A revival into a deleted directory no longer takes the daemon down.** When
146
+ a session's directory was gone (a removed worktree, say), the headless
147
+ launch failed with an error nothing was listening for, and the daemon
148
+ crashed. It is now an ordinary failed attempt that names the reason.
149
+ - **`unsnooze preview` shows the headless command.** It described a typed
150
+ resume and the Codex TUI for sessions that dispatch revives through argv
151
+ and `codex exec resume`.
152
+ - **Windows finds the Codex runtime.** The Desktop/Store install keeps
153
+ `codex.exe` under a versioned directory that changes on update, and a daemon
154
+ started at logon keeps the PATH it was born with. Codex is now resolved at
155
+ launch: `UNSNOOZE_CODEX_BIN`, then `codex.exe` on PATH, then the newest
156
+ runtime under `%LOCALAPPDATA%\OpenAI\Codex\bin`. A `.cmd`/`.bat` shim
157
+ cannot be launched directly by Node; the launcher now says so and names the
158
+ variable to point at the `.exe` instead of failing with `EINVAL`.
159
+ - **`unsnooze doctor` names each agent's binary** as the launcher would resolve
160
+ it, and reports an agent that cannot be launched as a health problem — on
161
+ Windows with the note that the daemon's environment can lag the shell's and
162
+ how to refresh it. Only for agents that have run on the machine: Claude and
163
+ Codex are both on by default, and a Claude-only machine is not unhealthy for
164
+ having no `codex`. The lookup follows spawn's own Windows rules: an `.exe`
165
+ anywhere on PATH wins over an npm shim earlier on it, quoted PATH entries
166
+ count, and a path given without its extension finds the `.exe`.
167
+ - **Codex stops bind to the right reset.** A `rate_limit_reached` at a reported
168
+ 99.x% was scheduled for the *weekly* reset (the latest one), days out; it now
169
+ binds the window nearest exhaustion. A workspace wall — credits depleted or
170
+ a spend cap, with no window spent — has no reset to wait for: it is announced
171
+ as needing you rather than "resumes when the limit resets", probed, and then
172
+ held with the remedy (add credits or raise the cap, then continue the
173
+ session in Codex) instead of a blind wake. A spent window (99% or more)
174
+ still governs when a workspace reason rides along, since its reset is what
175
+ brings the plan allowance back. `unsnooze status --json` shows the reason as
176
+ `limitReason`.
177
+ - **A weaker reading no longer undoes an exact reset.** A re-detected stop
178
+ whose only evidence is a banner ("Try again later.") used to replace the
179
+ exact reset the rollout snapshot had given, turning a known wake into a
180
+ 15-minute probe; the stronger schedule now stands until it passes, the rule
181
+ pane detection already followed. `unsnooze usage` in the dashboard also
182
+ stops reading Codex burn as idle whenever the daemon is running.
183
+
184
+ The Windows resolution follows the layout in the report and wants a Windows
185
+ run before it is trusted; continuing an open Codex Desktop thread in place
186
+ (`codex queue`) is a separate follow-up. Thanks to
187
+ [@nwn900](https://github.com/nwn900) for the precise report.
188
+
189
+ ### Codex stops behind a proxy, and in reverted threads
190
+
191
+ [Issue #27](https://github.com/saaranshM/unsnooze/issues/27) found two ways
192
+ a Codex desktop-app or IDE session went untracked:
193
+
194
+ - **Reverted threads are watched.** An edited, regenerated or `/undo`-ed
195
+ thread continues in `rollout-<ts>-<thread id>_<rollout id>.jsonl`, a name
196
+ the rollout watcher never opened. Those files are now watched, counted by
197
+ `unsnooze usage`, and resumed by the stable thread id.
198
+ - **A stop recorded only as an error is a stop.** Behind an OpenAI-compatible
199
+ proxy the rate-limit headers never reach Codex, every snapshot has empty
200
+ windows, and no stop was ever detected. Since codex-cli 0.145 the failed
201
+ turn's `task_complete` carries the limit error itself; unsnooze now reads
202
+ it and dates it from the banner the way a scraped pane is dated ("Try again
203
+ later." is probed). When the same turn also wrote an exhausted snapshot,
204
+ its exact reset still wins, and a workspace wall — in the snapshot or in
205
+ the banner's own words — stays a wall. Only the banner counts: Codex marks
206
+ billing and plan errors ("Quota exceeded…", "upgrade to Plus…") with the
207
+ same `usage_limit_exceeded` code, and those are not stops, nor is a bare 429,
208
+ which carries no reset time.
209
+ - **Codex's current banner is recognized.** Codex now writes "You’ve hit your
210
+ usage limit" with a typographic apostrophe; the anchor accepts both, in
211
+ rollouts and in panes.
212
+ - **A reset in the banner's own minute is not tomorrow.** Codex prints the
213
+ reset time without seconds, so a limit hit at 7:36:15 that resets at
214
+ 7:36:40 says "try again at 7:36 AM" — which was read as 7:36 the next
215
+ morning, a day late. It now wakes at 7:37.
216
+
217
+ Thanks to [@d-jiao](https://github.com/d-jiao) for the report, the fix in
218
+ [PR #28](https://github.com/saaranshM/unsnooze/pull/28), and checking it
219
+ against 523 real rollouts.
220
+
3
221
  ## 1.19.0 — 2026-09-10
4
222
 
5
223
  Fish shell support, direct help/version commands, and a fix for Codex sessions
package/README.md CHANGED
@@ -244,6 +244,15 @@ the session files those surfaces already write:
244
244
  one minute by an empty `premium` bucket with no credits, using the previous
245
245
  future reset time. The snapshots must come from the same rollout; 99% alone
246
246
  is not a stop signal. This works across watcher polls and daemon restarts.
247
+ Since codex-cli 0.145 the failed turn's `task_complete` also carries the
248
+ limit error itself (`codex_error_info: "usage_limit_exceeded"` plus the
249
+ banner text), and unsnooze reads that too — it is the only signal when
250
+ Codex runs behind an OpenAI-compatible proxy (`model_providers.<x>.base_url`),
251
+ where the rate-limit headers never reach Codex and every snapshot has empty
252
+ windows. The banner's time is parsed like a scraped pane; "Try again later."
253
+ takes the probe path. Reverted threads (the desktop app's edit/regenerate,
254
+ `/undo`) continue in `rollout-<ts>-<thread id>_<rollout id>.jsonl`; those
255
+ files are watched and resumed by the stable thread id.
247
256
  - **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
248
257
  sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
249
258
  those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
@@ -256,7 +265,8 @@ the continued conversation stays visible in the GUI's own history. (Resuming
256
265
  *inside* the GUI panel isn't possible today: no extension/app exposes an
257
266
  IPC/URI that can send a prompt.)
258
267
 
259
- Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit),
268
+ Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit;
269
+ on Windows the daemon is started on demand instead — see [Windows](#windows)),
260
270
  or run `unsnooze install --daemon` / `unsnooze daemon` yourself. Turn it off
261
271
  anytime with `unsnooze config set guiWatch off`.
262
272
 
@@ -375,7 +385,8 @@ unsnooze config set <k> <v> # e.g. autoResume off
375
385
  unsnooze logs [-f] # what unsnooze has been doing
376
386
  unsnooze update # update unsnooze itself
377
387
  unsnooze daemon # persistent GUI-session watcher (usually run
378
- # by launchd/systemd via `install --daemon`)
388
+ # by launchd/systemd via `install --daemon`;
389
+ # on Windows, started on demand)
379
390
  unsnooze report [agent] # capture a pane to report an undetected banner
380
391
  unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
381
392
  unsnooze help # full command list (also -h / --help)
@@ -719,7 +730,10 @@ herdr restores saved agent panes itself, so a revival on top would resume the
719
730
  same conversation twice.
720
731
  ² Into a fresh workspace; cmux has no joinable named-session model.
721
732
  ³ As a detached process. Its output goes to `~/.unsnooze/headless/<session>.log`,
722
- since there is no scrollback to read it out of later.
733
+ since there is no scrollback to read it out of later; the prompt rides in argv
734
+ (`claude --resume <id> "…"`, `codex exec resume <id> "…"`), and a revival that
735
+ exits non-zero is retried, with its last output as the `last error` in
736
+ `unsnooze status`.
723
737
  ⁴ Nothing to wrap into — your own terminal is the session.
724
738
 
725
739
  Pick one explicitly with `unsnooze config set multiplexer tmux|zellij|herdr|cmux|headless`.
@@ -755,15 +769,30 @@ Windows, WSL is still the better home.
755
769
  ### Windows
756
770
 
757
771
  Native Windows works: PowerShell wrappers, a cmd-safe StopFailure hook, and a
758
- logon-triggered Scheduled Task for the daemon. Install as usual and run
759
- `unsnooze doctor` to confirm.
760
-
761
- Two honest caveats. The daemon is started at logon but not restarted if it
762
- dies — Task Scheduler is not a supervisor the way launchd and systemd are — so
763
- after upgrading unsnooze, log out and back in (or run `unsnooze daemon`
764
- yourself). And stored-password fleet hosts still need Git-for-Windows or WSL
765
- `ssh`; native `ssh.exe` requires an `.exe` askpass helper unsnooze does not yet
766
- ship.
772
+ daemon started on demand. Install as usual and run `unsnooze doctor` to confirm.
773
+
774
+ unsnooze registers nothing to run at sign-in on Windows — no Scheduled Task, Run
775
+ key or Startup-folder entry. (Up to 1.19.1 it created a logon Scheduled Task,
776
+ and Microsoft Defender flagged setup as `Trojan:Win32/Commando.A!ml` for it;
777
+ `unsnooze uninstall` removes one left behind.)
778
+ Setup starts the daemon instead, and every agent you launch from PowerShell and
779
+ every Claude Code StopFailure hook starts it again whenever it is not running,
780
+ so after a restart it is back as soon as you use an agent. If you only use the
781
+ Codex app or IDE, nothing you do passes through a wrapper or the hook: run
782
+ `unsnooze install --daemon` after signing in to start it.
783
+
784
+ Three honest caveats. Nothing supervises the daemon the way launchd and systemd
785
+ do, and a running daemon keeps the code it started with, so after upgrading
786
+ unsnooze re-run `unsnooze install --daemon` (or sign out and back in). The
787
+ daemon also keeps the `PATH` it was born with: an agent whose install directory
788
+ changes on update (the Codex Desktop/Store runtime lives under
789
+ `%LOCALAPPDATA%\OpenAI\Codex\bin\<hash>`) can vanish from the daemon's view
790
+ while your shell still finds it — unsnooze looks that directory up at launch
791
+ time, `unsnooze doctor` prints what each agent resolves to, and
792
+ `UNSNOOZE_CODEX_BIN` (or `UNSNOOZE_CLAUDE_BIN`, …) pins a path; point it at an
793
+ `.exe`, since Node cannot launch a `.cmd` shim directly. And stored-password
794
+ fleet hosts still need Git-for-Windows or WSL `ssh`; native `ssh.exe` requires
795
+ an `.exe` askpass helper unsnooze does not yet ship.
767
796
 
768
797
  WSL remains the richer option, because that is where the Unix multiplexers live
769
798
  — and where the agent CLIs often already are:
package/bin/unsnooze.js CHANGED
@@ -223,81 +223,107 @@ async function main() {
223
223
  case 'daemon': {
224
224
  // Persistent resumer + transcript watcher: detects and revives limit
225
225
  // stops from GUI surfaces (VS Code extension, desktop apps) where no
226
- // shell wrapper or multiplexer pane exists. Run via launchd/systemd or a shell.
226
+ // shell wrapper or multiplexer pane exists. Run via launchd/systemd, on
227
+ // demand on native Windows (spawn.js ensureDaemon), or from a shell.
227
228
  // Exit 0 on load failure: with launchd KeepAlive a crash here means an
228
229
  // instant-respawn crash-loop for the whole upgrade window.
229
230
  const resumerMod = await safeImport('../src/resumer.js');
230
231
  const watcherMod = await safeImport('../src/watcher.js');
231
- if (!resumerMod || !watcherMod) return 0;
232
+ const spawnMod = await safeImport('../src/spawn.js');
233
+ if (!resumerMod || !watcherMod || !spawnMod) return 0;
232
234
  const { runResumer } = resumerMod;
233
235
  const { createWatcher } = watcherMod;
234
236
  const controller = new AbortController();
235
237
  process.on('SIGTERM', () => controller.abort());
236
238
  process.on('SIGINT', () => controller.abort());
237
- // Self-heal pre-1.12 autostart units: they lack PATH, so this daemon
238
- // cannot find tmux and every revival dies. Healing rewrites the unit
239
- // and reloads it — which intentionally kills THIS process; the
240
- // supervisor restarts us under the fixed unit. One-time: healed units
241
- // pass the check forever after.
242
- const installMod = await safeImport('../src/install.js');
243
- if (installMod?.healDaemonAutostart) {
244
- try {
245
- const healed = installMod.healDaemonAutostart();
246
- if (healed) {
247
- const lm = await safeImport('../src/logger.js');
248
- lm?.log('daemon', `autostart unit lacked PATH — regenerated ${healed}, reloading (self-heal)`);
249
- }
250
- } catch { /* heal is best-effort — a broken unit must not block the daemon */ }
251
- }
252
- // daemon.log is launchd/systemd-captured stdout+stderr. launchd holds
253
- // an open fd on it for our whole lifetime, so rotation must be
254
- // copy-truncate: renaming would leave launchd appending to the renamed
255
- // inode forever, with no fresh daemon.log until the next respawn.
256
- const loggerMod = await safeImport('../src/logger.js');
257
- const configMod = await safeImport('../src/config.js');
258
- if (loggerMod && configMod) {
259
- const { join } = await import('node:path');
260
- loggerMod.copyTruncateIfLarge(join(configMod.STATE_DIR, 'daemon.log'));
239
+ // Native Windows has no launchd/systemd to keep this a single instance,
240
+ // and the wrappers and the hook can both start one at once. Claim the
241
+ // pidfile before doing anything else: a daemon that finds a live one
242
+ // already running exits here, and one that later finds itself replaced
243
+ // stands down. --on-demand (how ensureDaemon starts it) also ties the
244
+ // daemon to its marker, so uninstall ends it rather than sign-out.
245
+ const onDemand = rest.includes('--on-demand');
246
+ let releaseDaemon = null;
247
+ if (process.platform === 'win32') {
248
+ const wanted = onDemand ? () => spawnMod.onDemandWanted() : () => true;
249
+ if (!wanted()) return 0; // uninstalled between the start and now
250
+ releaseDaemon = spawnMod.claimDaemon({ onLost: () => controller.abort(), wanted });
251
+ if (!releaseDaemon) {
252
+ // Reaches a terminal only when a user ran `unsnooze daemon` by hand;
253
+ // an on-demand start has nowhere to print.
254
+ const pid = spawnMod.runningDaemonPid();
255
+ console.error(`unsnooze: the daemon is already running${pid ? ` (pid ${pid})` : ''} — not starting another`);
256
+ return 0;
257
+ }
261
258
  }
262
- // Version-skew guard: when npm swaps the package underneath us, exit
263
- // cleanly so launchd/systemd restart the daemon on the fresh code.
264
- // Interval stays at 15 minutes on purpose — a tighter loop raises the
265
- // odds of restarting INTO a half-installed package, which is the
266
- // crash-loop the unit throttles exist to survive.
267
- // Exiting is only safe under a supervisor. `unsnooze daemon` run from a
268
- // shell has nothing to restart it, so there it warns and keeps watching
269
- // rather than ending GUI watching silently.
270
- const updMod = await safeImport('../src/update-check.js');
271
- const notifyMod = await safeImport('../src/notify.js');
272
- if (updMod?.hasVersionSkew) {
273
- let warnedSkew = false;
274
- setInterval(() => {
275
- const action = updMod.daemonSkewAction({
276
- skewed: updMod.hasVersionSkew(),
277
- // Unknown means assume supervised: that is how the daemon is
278
- // installed for all but a handful of users, and it preserves the
279
- // long-standing behavior when install.js could not be loaded.
280
- supervised: installMod?.isSupervised ? installMod.isSupervised() : true,
281
- alreadyWarned: warnedSkew,
282
- });
283
- if (action === 'restart') controller.abort();
284
- else if (action === 'warn') {
285
- warnedSkew = true;
286
- const msg = 'package was upgraded on disk but this daemon is not supervised — '
287
- + 'still running the old code; restart it to pick up the new version';
288
- loggerMod?.log('daemon', msg);
289
- notifyMod?.notify('unsnooze needs a restart', msg, { priority: 4 });
290
- }
291
- }, 15 * 60_000).unref();
259
+ // From the claim on, every way out gives it back.
260
+ try {
261
+ // Self-heal pre-1.12 autostart units: they lack PATH, so this daemon
262
+ // cannot find tmux and every revival dies. Healing rewrites the unit
263
+ // and reloads it — which intentionally kills THIS process; the
264
+ // supervisor restarts us under the fixed unit. One-time: healed units
265
+ // pass the check forever after.
266
+ const installMod = await safeImport('../src/install.js');
267
+ if (installMod?.healDaemonAutostart) {
268
+ try {
269
+ const healed = installMod.healDaemonAutostart();
270
+ if (healed) {
271
+ const lm = await safeImport('../src/logger.js');
272
+ lm?.log('daemon', `autostart unit lacked PATH — regenerated ${healed}, reloading (self-heal)`);
273
+ }
274
+ } catch { /* heal is best-effort — a broken unit must not block the daemon */ }
275
+ }
276
+ // daemon.log is launchd/systemd-captured stdout+stderr. launchd holds
277
+ // an open fd on it for our whole lifetime, so rotation must be
278
+ // copy-truncate: renaming would leave launchd appending to the renamed
279
+ // inode forever, with no fresh daemon.log until the next respawn.
280
+ const loggerMod = await safeImport('../src/logger.js');
281
+ const configMod = await safeImport('../src/config.js');
282
+ if (loggerMod && configMod) {
283
+ const { join } = await import('node:path');
284
+ loggerMod.copyTruncateIfLarge(join(configMod.STATE_DIR, 'daemon.log'));
285
+ }
286
+ // Version-skew guard: when npm swaps the package underneath us, exit
287
+ // cleanly so launchd/systemd restart the daemon on the fresh code.
288
+ // Interval stays at 15 minutes on purpose — a tighter loop raises the
289
+ // odds of restarting INTO a half-installed package, which is the
290
+ // crash-loop the unit throttles exist to survive.
291
+ // Exiting is only safe under a supervisor. `unsnooze daemon` run from a
292
+ // shell has nothing to restart it, so there it warns and keeps watching
293
+ // rather than ending GUI watching silently.
294
+ const updMod = await safeImport('../src/update-check.js');
295
+ const notifyMod = await safeImport('../src/notify.js');
296
+ if (updMod?.hasVersionSkew) {
297
+ let warnedSkew = false;
298
+ setInterval(() => {
299
+ const action = updMod.daemonSkewAction({
300
+ skewed: updMod.hasVersionSkew(),
301
+ // Unknown means assume supervised: that is how the daemon is
302
+ // installed for all but a handful of users, and it preserves the
303
+ // long-standing behavior when install.js could not be loaded.
304
+ supervised: installMod?.isSupervised ? installMod.isSupervised() : true,
305
+ alreadyWarned: warnedSkew,
306
+ });
307
+ if (action === 'restart') controller.abort();
308
+ else if (action === 'warn') {
309
+ warnedSkew = true;
310
+ const msg = 'package was upgraded on disk but this daemon is not supervised — '
311
+ + 'still running the old code; '
312
+ + (onDemand ? 'run `unsnooze install --daemon` to restart it' : 'restart it to pick up the new version');
313
+ loggerMod?.log('daemon', msg);
314
+ notifyMod?.notify('unsnooze needs a restart', msg, { priority: 4 });
315
+ }
316
+ }, 15 * 60_000).unref();
317
+ }
318
+ // Daily update check from the daemon: GUI-only users never run CLI
319
+ // commands, so this is what gets them the "new version" desktop toast.
320
+ const { spawnDetached } = spawnMod;
321
+ spawnDetached(['_update-check']);
322
+ setInterval(() => spawnDetached(['_update-check']), 24 * 3_600_000).unref();
323
+ return await runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
324
+ } finally {
325
+ releaseDaemon?.();
292
326
  }
293
- // Daily update check from the daemon: GUI-only users never run CLI
294
- // commands, so this is what gets them the "new version" desktop toast.
295
- const spawnMod = await safeImport('../src/spawn.js');
296
- if (!spawnMod) return 0;
297
- const { spawnDetached } = spawnMod;
298
- spawnDetached(['_update-check']);
299
- setInterval(() => spawnDetached(['_update-check']), 24 * 3_600_000).unref();
300
- return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
301
327
  }
302
328
  case 'help':
303
329
  case '-h':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.0",
3
+ "version": "1.19.2",
4
4
  "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
5
5
  "keywords": [
6
6
  "claude",