unsnooze 1.10.0 → 1.12.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
@@ -2,6 +2,149 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.12.2 — 2026-07-16
6
+
7
+ - **Daemon autostart self-heal**: updating via npm never touches the
8
+ launchd/systemd unit file, so pre-1.12 users would keep the PATH-less unit
9
+ (daemon can't find tmux → every revival dies) until they manually re-ran
10
+ `unsnooze install --daemon`. The daemon now detects a PATH-less unit on
11
+ startup, regenerates it, and reloads itself — every affected user is fixed
12
+ automatically on their first daemon restart after updating (at the latest,
13
+ the next reboot). Manual `unsnooze install --daemon` still works and is no
14
+ longer required.
15
+
16
+ ## 1.12.1 — 2026-07-16
17
+
18
+ - CI-only: pin the platform in a doctor test whose `launchctl` assertion
19
+ failed on Linux runners. No runtime changes — this is 1.12.0 plus a green
20
+ release pipeline (1.12.0 was tagged but never published).
21
+
22
+ ## 1.12.0 — 2026-07-16
23
+
24
+ - **Daemon PATH fix** (fix: every launchd-daemon revival on Homebrew Macs died
25
+ silently with `spawn tmux ENOENT` — launchd gives daemons a bare
26
+ `/usr/bin:/bin:/usr/sbin:/sbin` and tmux lives in `/opt/homebrew/bin`): the
27
+ launchd plist and systemd unit now bake the install-time `PATH`. Re-run
28
+ `unsnooze install --daemon` once after updating to regenerate the unit.
29
+ Revival `new-window` failures are now logged (they were silent), and
30
+ `failed` records survive the sweeper so `unsnooze status` can show *why* a
31
+ session gave up (age-based prune still expires them).
32
+ - **`unsnooze preview [id]`** — a true dry-run: per session, exactly what the
33
+ resumer WOULD do right now (type into which pane, drive the menu, reopen in
34
+ which session, probe, defer) and why — every gate (paused, not due, held by
35
+ workspace/context guard, backoff, attempt cap) spelled out, including the
36
+ final wake message with any guard suffix. Sends nothing, mutates nothing.
37
+ Preview shares its decision code (`planFor`/`assessPane`/guard evaluators)
38
+ with the real dispatcher, so it cannot drift. Exit codes: `0` nothing would
39
+ wake now, `2` at least one actionable wake, `1` error.
40
+ - **ntfy push notifications** (`ntfyTopic` / `ntfyServer` / `ntfyToken` /
41
+ `ntfyPrivacy`) — off until a topic is set; fires *alongside* the local
42
+ channel on limit-hit / resumed / gave-up (gave-up pushes at high priority).
43
+ JSON-to-root publishing (emoji-safe titles), Bearer-token support for authed
44
+ or self-hosted servers, bodies capped, fire-and-forget with a 5s timeout.
45
+ `ntfyPrivacy=terse` keeps directory paths out of pushed bodies — ntfy.sh
46
+ topics are a public namespace, so the docs push unguessable topic names.
47
+ - **Trust & security docs**: a "Trust & security" section at the top of the
48
+ README (what unsnooze types and never types, grounded in the actual
49
+ mechanisms) and a full `SECURITY.md` (threat model, honest residual risks,
50
+ private vulnerability reporting, supported versions). Enable GitHub Private
51
+ Vulnerability Reporting in the repo settings to activate the report button.
52
+ - **Release provenance**: `.github/workflows/release.yml` publishes to npm on
53
+ `v*` tags via trusted publishing (OIDC, token-less) with
54
+ `--provenance` — after a one-time trusted-publisher setup on npmjs.com
55
+ (repo `saaranshM/unsnooze`, workflow `release.yml`).
56
+ - **Reproducible demo**: `demo/demo.tape` (VHS) renders `assets/demo.gif` —
57
+ staged fixture ledger + stub agent, but the `unsnooze status` beat is real.
58
+ - Note: the pre-release name `claude-session-guard` was never published to
59
+ npm, so there is nothing to deprecate on the registry; `unsnooze doctor
60
+ --fix` remains the migration path for local installs.
61
+
62
+ ## 1.11.0 — 2026-07-16
63
+
64
+ - **`unsnooze doctor [--fix]`** — install health check + migration sweep for
65
+ the pre-release claude-session-guard (csg) install. Detects zombie csg
66
+ monitors/resumers, old launchd/systemd units, the orphaned
67
+ `~/.claude-session-guard` state dir, and the stale global package (even when
68
+ its `csg` bin symlink dangles). `--fix` stops the processes, unloads and
69
+ removes the units, and archives the state dir (never deletes data, never
70
+ runs npm — the `npm rm -g claude-session-guard` step stays yours).
71
+ `unsnooze install` now runs the detection and points at doctor when
72
+ leftovers exist.
73
+ - **Pane identity & ownership** (fix: tmux pane ids are server-global and
74
+ recycled — a stale monitor or reap could type into or close somebody
75
+ else's pane): managed panes are stamped with a `@unsnooze_owner` pane
76
+ option at launch and revival; every message-injection, menu-drive, reap,
77
+ and auto-reap decision now answers two independent questions first —
78
+ *is this pane ours* (stamp/lease; a mismatched stamp vetoes even a
79
+ matching foreground command) and *is our agent still running in it*
80
+ (lease pid+birth or foreground command; the stamp alone never counts,
81
+ since it outlives the agent and the pane may now be the user's shell).
82
+ Monitors exit once their lease disappears instead of scraping whatever the
83
+ pane becomes. Legacy records without leases are never force-closed.
84
+ - **Launch failures degrade instead of dying** (fix: a tmux-level
85
+ session-start failure — `duplicate session`, `open terminal failed`, dead
86
+ socket, nesting refusal, socket permission errors — used to exit with
87
+ tmux's status and no agent): launchWrapped now reads tmux's stderr,
88
+ distinguishes "session never started" from "session ran and ended", and
89
+ falls back to the unwatched agent CLI with a message.
90
+ - **Zellij detection fix**: `capturePane` now dumps scrollback
91
+ (`dump-screen --full`) — a limit banner that scrolled between polls was
92
+ previously invisible on Zellij. Pre-0.35 zellij rejects the flag; capture
93
+ learns that once and degrades to the viewport-only form instead of
94
+ failing every poll.
95
+ - **Reset-time correctness**: weekly "resets Tuesday 9am" wakes land on the
96
+ exact wall-clock time across DST boundaries (day-stepping used to drift
97
+ ±1h); a mangled banner clock ("resets 45:99") is rejected instead of
98
+ throwing; "wait <duration>" only parses when a duration actually follows
99
+ (no more summing stray durations out of prose).
100
+ - **State safety**: sweepers use compare-and-set so `markStaleAbandoned` can
101
+ no longer clobber a record that resumed mid-sweep; the state lock records
102
+ its holder pid and is only ever stolen from a dead process; user-invoked
103
+ `reap` skips `resumed` panes that were active within `reapIdleAfter`
104
+ (closing a live working agent contradicted its own contract).
105
+ - **Install backups**: the first-ever run snapshots your pristine
106
+ settings/rc as `.unsnooze-orig` (kept forever); `.unsnooze-bak` keeps
107
+ rolling per run.
108
+
109
+ ## 1.10.1 — 2026-07-16
110
+
111
+ - **Upgrade-window fail-safe** (fix: `npm install -g` briefly leaves `bin/`
112
+ present with `src/` missing; the router then died with `MODULE_NOT_FOUND`
113
+ *inside* the freshly-wrapped tmux session — a visible open/close flash with
114
+ terminal-probe garbage — and the launchd daemon crash-looped thousands of
115
+ times into `daemon.log`): agent-launch paths (`_run`, bare `claude` args)
116
+ now degrade to the plain agent CLI when the package can't load; background
117
+ paths (hook, monitor, resumer, daemon, update-check) exit 0 quietly. Only
118
+ module *load* failures are caught — runtime errors still surface, and an
119
+ agent that already ran is never re-run.
120
+ - **Daemon crash-loop guards**: launchd plist gains `ThrottleInterval 30`;
121
+ the systemd unit moves to `Restart=always` + `RestartSec=30` (a clean
122
+ exit-0 must also respawn — `on-failure` would leave the daemon dead after
123
+ an intentional exit) with the start rate-limit disabled so a long broken
124
+ install can never trip the unit into a permanent `failed` state. A
125
+ version-skew watch makes a long-lived daemon exit cleanly (and get
126
+ restarted on fresh code) when `npm -g` swaps the package underneath it —
127
+ no more zombie daemons running deleted code.
128
+ - **Log rotation**: `unsnooze.log` and `daemon.log` are capped at 5 MB with
129
+ one rotated generation (`.1`) — a crash-loop can no longer grow a log
130
+ without bound. `daemon.log` is rotated copy-truncate style because launchd
131
+ holds its fd open for the daemon's whole lifetime.
132
+ - **Resume-retry backoff** (fix: five resume attempts burned in ~2 minutes
133
+ when a revival kept failing): failed attempts now back off exponentially
134
+ (1m, 2m, 4m… capped at 30m). Manual `resume-now` records are exempt — an
135
+ explicit immediate wake is never silently deferred. After `unsnooze` gives
136
+ up, a session re-arms only on a fresh limit detection.
137
+ - **Singleton-lock hygiene**: the resumer lock is acquired atomically
138
+ (`wx`), a lock held by a recycled pid that is not actually an unsnooze
139
+ process is taken over instead of honored forever, and the daemon's
140
+ "another resumer holds the lock" log line is throttled to ~once per 15
141
+ minutes instead of every 30 s tick.
142
+ - **Update notice on the launch path**: wrapper-only users (who never run
143
+ `unsnooze status`) now get the one-line "new version available" notice on
144
+ stderr right after their agent session ends — outer terminal only, TTY
145
+ only, never in `-p/--print` runs, at most once per day. The launch path
146
+ also refreshes the daily version-check cache.
147
+
5
148
  ## 1.10.0 — 2026-07-14
6
149
 
7
150
  - **Session-name ownership** (fix: interactive `claude` dying with
package/README.md CHANGED
@@ -37,6 +37,43 @@ existing tool solves only a slice of it:
37
37
  | Survives laptop sleep & weekly-scale waits | ✅ epoch polling | partial | partial | n/a |
38
38
  | Settings + first-run wizard | ✅ | ❌ | ❌ | ❌ |
39
39
 
40
+ ## Trust & security
41
+
42
+ unsnooze is a **scheduler that presses your keys — not an auto-approver.** It waits
43
+ for your usage limit to reset, then resumes the *same* session. It never changes how
44
+ your agent handles permissions.
45
+
46
+ - **Types only after proving the pane is yours.** Every keystroke requires both
47
+ *identity* (a tmux `@unsnooze_owner` stamp, else a lease = process id + birth
48
+ time — a mismatch vetoes, because pane ids get recycled) and *liveness* (your
49
+ agent is still running there). Pane closes require proven identity (idle
50
+ threshold too for `resumed` panes). Ownership unprovable → it reopens a fresh
51
+ session instead of typing.
52
+ - **See before you trust: `unsnooze preview`.** A true dry-run — it prints exactly
53
+ what would be typed, where, and why (or what's holding it back), and sends
54
+ nothing. Preview shares its decision code with the real dispatcher, so it cannot
55
+ drift from what dispatch actually does.
56
+ - **Answers Claude's limit menu safely — never a blind Enter.** It locates the
57
+ cursor and the **"Stop and wait for limit to reset"** option and computes the
58
+ exact moves. Unreadable menu → it presses nothing. It will never select
59
+ "Upgrade your plan." Toggle with `menuAutoAnswer`.
60
+ - **No `--dangerously-skip-permissions`, no auto-trust, no auto-approve.** unsnooze
61
+ never passes bypass flags, never presses "Yes, I trust this folder," and never
62
+ touches MCP config. Whatever your agent does *after* resuming is governed by
63
+ **its own** permission model — the same as if you'd typed the message yourself.
64
+ - **Nearly zero network, zero telemetry.** One version check to
65
+ `registry.npmjs.org` (nothing identifying; `updateCheck=false` turns it off), and
66
+ push notifications only if *you* configure an ntfy topic. State stays local under
67
+ `~/.unsnooze`.
68
+ - **Reversible install.** The settings hook and rc-file wrappers are backed up
69
+ first (`*.unsnooze-orig` pristine + `*.unsnooze-bak` rolling); `unsnooze
70
+ uninstall` removes every change. Releases are published with npm provenance.
71
+
72
+ **Honest limits:** unsnooze *does* inject keystrokes into your live terminal on
73
+ your behalf, and it does not sandbox your agent or defend against prompt
74
+ injection / malicious repos — that's your agent's job. Full threat model, residual
75
+ risks, and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
76
+
40
77
  ## Supported CLIs
41
78
 
42
79
  - **Claude Code** — dual-channel detection: the `StopFailure` hook (authoritative,
@@ -171,8 +208,11 @@ unsnooze status # tracked sessions + reset countdowns
171
208
  unsnooze resume-now [id|--all] # don't wait for the reset time
172
209
  unsnooze cancel [id|--all] # stop tracking a session
173
210
  unsnooze message <id> "text" # per-session wake message (--clear to reset)
211
+ unsnooze preview [id] # dry-run: what WOULD happen right now, and why —
212
+ # nothing is typed or opened
174
213
  unsnooze sessions # list unsnooze-owned mux sessions + panes
175
214
  unsnooze reap [--dry-run|--yes] # close finished panes / empty sessions (default dry-run)
215
+ unsnooze doctor [--fix] # install health check + retire old csg leftovers
176
216
  unsnooze config list # settings (see below)
177
217
  unsnooze config set <k> <v> # e.g. autoResume off
178
218
  unsnooze logs [-f] # what unsnooze has been doing
@@ -207,6 +247,10 @@ unsnooze help # full command list (also -h / --help)
207
247
  | `reapResumed` | `false` | Opt-in: auto-close `resumed` panes idle longer than `reapIdleAfter`. Off by default — use `unsnooze reap --yes` for explicit cleanup. |
208
248
  | `reapIdleAfter` | `604800000` (7d) | Idle age (ms) before an opt-in auto-reap closes a `resumed` pane. |
209
249
  | `updateCheck` | `true` | Daily new-version check (a plain GET to the npm registry, nothing identifying is sent). Notices after commands + one desktop toast per version. |
250
+ | `ntfyTopic` | `""` | [ntfy](https://ntfy.sh) push notifications — **off until set**. Fires *alongside* the local channel on limit-hit / resumed / gave-up. ⚠️ ntfy.sh topics are public — the name is the password. Use an unguessable one, e.g. `unsnooze-$(openssl rand -hex 8)`, a token, or a self-hosted server. |
251
+ | `ntfyServer` | `https://ntfy.sh` | ntfy server base URL (self-hosted servers welcome). |
252
+ | `ntfyToken` | `""` | Optional `tk_…` access token (`Authorization: Bearer`) for reserved topics / authed servers. |
253
+ | `ntfyPrivacy` | `full` | `terse` keeps directory paths out of pushed bodies (titles only) — recommended on public ntfy.sh topics. |
210
254
 
211
255
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
212
256
  all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
@@ -386,8 +430,13 @@ npm test # unit tests (node:test)
386
430
  ./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
387
431
  # scratch tmux session (no real limits needed)
388
432
  bash -n scripts/e2e-zellij.sh # syntax-check the reserved-session Zellij smoke test
433
+ vhs demo/demo.tape # regenerate assets/demo.gif (brew install vhs)
389
434
  ```
390
435
 
436
+ Releases are tagged (`git tag v<version> && git push origin v<version>`) and
437
+ published to npm by CI with [provenance](https://docs.npmjs.com/generating-provenance-statements)
438
+ via trusted publishing — see `.github/workflows/release.yml`.
439
+
391
440
  ## License
392
441
 
393
442
  [MIT](LICENSE)
package/bin/unsnooze.js CHANGED
@@ -2,11 +2,85 @@
2
2
  // unsnooze — subcommand router; anything unrecognized is treated as claude
3
3
  // args and passed through the launcher (back-compat with the zsh wrapper).
4
4
 
5
+ import { spawnSync } from 'node:child_process';
6
+
5
7
  const [, , cmd, ...rest] = process.argv;
6
8
 
9
+ // --- upgrade-window fail-safe -----------------------------------------------
10
+ // During `npm install -g` the package dir is briefly half-written: bin/ can
11
+ // exist while src/ is missing, so the zsh wrapper's file guard passes but the
12
+ // dynamic imports below throw ERR_MODULE_NOT_FOUND. That window once produced
13
+ // an instantly-dying wrapped tmux session for every `claude` (and 12,989
14
+ // launchd daemon crash-loops). Rules: agent-launch paths degrade to the plain
15
+ // CLI; background paths (hook/monitor/resumer/daemon) exit 0 quietly. Only
16
+ // module LOAD failures are caught — a runtime error after the module loaded
17
+ // must not re-run an agent that may already have run.
18
+
19
+ // Mirrors each adapter's bin resolution without importing src/. Codex loses
20
+ // its PATH walk here — plain `codex` is the correct degraded answer.
21
+ const AGENT_FALLBACK_BINS = {
22
+ claude: () => process.env.UNSNOOZE_CLAUDE_BIN || 'claude',
23
+ codex: () => process.env.UNSNOOZE_CODEX_BIN || 'codex',
24
+ grok: () => process.env.UNSNOOZE_GROK_BIN || 'grok',
25
+ qwen: () => process.env.UNSNOOZE_QWEN_BIN || 'qwen',
26
+ kimi: () => process.env.UNSNOOZE_KIMI_BIN || 'kimi',
27
+ opencode: () => process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
28
+ agy: () => process.env.UNSNOOZE_AGY_BIN || 'agy',
29
+ };
30
+
31
+ async function safeImport(specifier) {
32
+ try {
33
+ return await import(specifier);
34
+ } catch {
35
+ return null; // missing/half-written module — caller picks the degraded path
36
+ }
37
+ }
38
+
39
+ function runAgentFallback(agentId, args) {
40
+ const bin = (AGENT_FALLBACK_BINS[agentId] || AGENT_FALLBACK_BINS.claude)();
41
+ process.stderr.write('unsnooze: install incomplete (upgrade in progress?) — running without limit-watch.\n');
42
+ const r = spawnSync(bin, args, {
43
+ stdio: 'inherit',
44
+ env: { ...process.env, UNSNOOZE_ACTIVE: '1' },
45
+ });
46
+ return r.status ?? 1;
47
+ }
48
+
7
49
  // Only human-facing commands may print update notices — never the wrapper
8
50
  // passthrough, hooks, or daemons (their output lands in agent panes/logs).
9
- const USER_FACING = new Set(['status', 'resume-now', 'cancel', 'message', 'config', 'logs', 'report', 'sessions', 'reap', 'help', '-h', '--help', '--help-unsnooze']);
51
+ const USER_FACING = new Set(['status', 'resume-now', 'cancel', 'message', 'config', 'logs', 'report', 'sessions', 'reap', 'doctor', 'preview', 'help', '-h', '--help', '--help-unsnooze']);
52
+
53
+ // Every named subcommand; anything else (or no args) is an agent launch.
54
+ const NAMED_COMMANDS = new Set([
55
+ ...USER_FACING, 'setup', 'install', 'uninstall', 'update', 'daemon',
56
+ '_hook-stopfailure', '_monitor', '_run', '_resumer', '_update-check',
57
+ ]);
58
+ const isLaunchPath = cmd === '_run' || cmd === undefined || !NAMED_COMMANDS.has(cmd);
59
+ const launchArgs = cmd === '_run' ? rest.slice(1) : (cmd === undefined ? [] : [cmd, ...rest]);
60
+
61
+ // Post-session update notice (update-notifier pattern): wrapper-only users
62
+ // never run `unsnooze status`, so the moment the agent exits — screen
63
+ // restored, user at their prompt — is where a notice reliably reaches them.
64
+ // Outer process only: inside any multiplexer our stderr is a pane that may be
65
+ // about to close (wrapped sessions die with the agent); the outer wrapper
66
+ // prints on the real terminal after tmux/zellij hands the screen back.
67
+ async function maybeLaunchExitNotice(args) {
68
+ try {
69
+ if (process.env.TMUX || process.env.ZELLIJ || process.env.UNSNOOZE_ACTIVE === '1') return;
70
+ if (!process.stderr.isTTY) return; // pipes, CI, scripts
71
+ if (args.includes('-p') || args.includes('--print')) return; // non-interactive runs
72
+ const mod = await safeImport('../src/update-check.js');
73
+ if (!mod) return;
74
+ const notice = mod.launchExitNotice();
75
+ if (notice) process.stderr.write(`\n${notice}\n`);
76
+ // Keep the cache fresh for users who only ever launch agents — without
77
+ // this, no daemon and no user-facing command means no check ever runs.
78
+ if (mod.isCacheStale()) {
79
+ const spawnMod = await safeImport('../src/spawn.js');
80
+ spawnMod?.spawnDetached(['_update-check']);
81
+ }
82
+ } catch { /* notices must never break the launch path */ }
83
+ }
10
84
 
11
85
  async function maybeUpdateNotices() {
12
86
  try {
@@ -64,6 +138,14 @@ async function main() {
64
138
  const { cmdReap } = await import('../src/cli.js');
65
139
  return cmdReap(rest);
66
140
  }
141
+ case 'doctor': {
142
+ const { cmdDoctor } = await import('../src/doctor.js');
143
+ return cmdDoctor(rest);
144
+ }
145
+ case 'preview': {
146
+ const { cmdPreview } = await import('../src/cli.js');
147
+ return cmdPreview(rest);
148
+ }
67
149
  case 'install': {
68
150
  const { cmdInstall } = await import('../src/install.js');
69
151
  return cmdInstall(rest);
@@ -73,43 +155,88 @@ async function main() {
73
155
  return cmdUninstall(rest);
74
156
  }
75
157
  case '_hook-stopfailure': {
76
- const { runHook } = await import('../src/hook.js');
77
- return runHook(rest);
158
+ const mod = await safeImport('../src/hook.js');
159
+ if (!mod) return 0; // never fail (or pollute) an agent turn
160
+ return mod.runHook(rest);
78
161
  }
79
162
  case '_monitor': {
80
163
  if (!rest[0] || !rest[2]) { console.error('unsnooze _monitor: mux owner pane required'); return 2; }
81
- const { runMonitor } = await import('../src/monitor.js');
82
- return runMonitor(rest[0], rest[1], rest[2], rest[3], rest[4]);
164
+ const mod = await safeImport('../src/monitor.js');
165
+ if (!mod) return 0; // detached — nothing useful to report
166
+ return mod.runMonitor(rest[0], rest[1], rest[2], rest[3], rest[4]);
83
167
  }
84
168
  case '_run': {
85
169
  if (!rest[0]) { console.error('unsnooze _run: agent id required'); return 2; }
86
- const { runLauncher } = await import('../src/launcher.js');
87
- return runLauncher(rest.slice(1), rest[0]);
170
+ const mod = await safeImport('../src/launcher.js');
171
+ if (!mod) return runAgentFallback(rest[0], rest.slice(1));
172
+ return mod.runLauncher(rest.slice(1), rest[0]);
88
173
  }
89
174
  case '_resumer': {
90
- const { runResumer } = await import('../src/resumer.js');
91
- return runResumer();
175
+ const mod = await safeImport('../src/resumer.js');
176
+ if (!mod) return 0;
177
+ return mod.runResumer();
92
178
  }
93
179
  case 'update': {
94
180
  const { runSelfUpdate } = await import('../src/update-check.js');
95
181
  return runSelfUpdate();
96
182
  }
97
183
  case '_update-check': {
98
- const { runUpdateCheck } = await import('../src/update-check.js');
99
- return runUpdateCheck();
184
+ const mod = await safeImport('../src/update-check.js');
185
+ if (!mod) return 0;
186
+ return mod.runUpdateCheck();
100
187
  }
101
188
  case 'daemon': {
102
189
  // Persistent resumer + transcript watcher: detects and revives limit
103
190
  // stops from GUI surfaces (VS Code extension, desktop apps) where no
104
191
  // shell wrapper or multiplexer pane exists. Run via launchd/systemd or a shell.
105
- const { runResumer } = await import('../src/resumer.js');
106
- const { createWatcher } = await import('../src/watcher.js');
192
+ // Exit 0 on load failure: with launchd KeepAlive a crash here means an
193
+ // instant-respawn crash-loop for the whole upgrade window.
194
+ const resumerMod = await safeImport('../src/resumer.js');
195
+ const watcherMod = await safeImport('../src/watcher.js');
196
+ if (!resumerMod || !watcherMod) return 0;
197
+ const { runResumer } = resumerMod;
198
+ const { createWatcher } = watcherMod;
107
199
  const controller = new AbortController();
108
200
  process.on('SIGTERM', () => controller.abort());
109
201
  process.on('SIGINT', () => controller.abort());
202
+ // Self-heal pre-1.12 autostart units: they lack PATH, so this daemon
203
+ // cannot find tmux and every revival dies. Healing rewrites the unit
204
+ // and reloads it — which intentionally kills THIS process; the
205
+ // supervisor restarts us under the fixed unit. One-time: healed units
206
+ // pass the check forever after.
207
+ const installMod = await safeImport('../src/install.js');
208
+ if (installMod?.healDaemonAutostart) {
209
+ try {
210
+ const healed = installMod.healDaemonAutostart();
211
+ if (healed) {
212
+ const lm = await safeImport('../src/logger.js');
213
+ lm?.log('daemon', `autostart unit lacked PATH — regenerated ${healed}, reloading (self-heal)`);
214
+ }
215
+ } catch { /* heal is best-effort — a broken unit must not block the daemon */ }
216
+ }
217
+ // daemon.log is launchd/systemd-captured stdout+stderr. launchd holds
218
+ // an open fd on it for our whole lifetime, so rotation must be
219
+ // copy-truncate: renaming would leave launchd appending to the renamed
220
+ // inode forever, with no fresh daemon.log until the next respawn.
221
+ const loggerMod = await safeImport('../src/logger.js');
222
+ const configMod = await safeImport('../src/config.js');
223
+ if (loggerMod && configMod) {
224
+ const { join } = await import('node:path');
225
+ loggerMod.copyTruncateIfLarge(join(configMod.STATE_DIR, 'daemon.log'));
226
+ }
227
+ // Version-skew guard: when npm swaps the package underneath us, exit
228
+ // cleanly so launchd/systemd restart the daemon on the fresh code.
229
+ const updMod = await safeImport('../src/update-check.js');
230
+ if (updMod?.hasVersionSkew) {
231
+ setInterval(() => {
232
+ if (updMod.hasVersionSkew()) controller.abort();
233
+ }, 15 * 60_000).unref();
234
+ }
110
235
  // Daily update check from the daemon: GUI-only users never run CLI
111
236
  // commands, so this is what gets them the "new version" desktop toast.
112
- const { spawnDetached } = await import('../src/spawn.js');
237
+ const spawnMod = await safeImport('../src/spawn.js');
238
+ if (!spawnMod) return 0;
239
+ const { spawnDetached } = spawnMod;
113
240
  spawnDetached(['_update-check']);
114
241
  setInterval(() => spawnDetached(['_update-check']), 24 * 3_600_000).unref();
115
242
  return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
@@ -130,6 +257,12 @@ Usage:
130
257
  unsnooze sessions list unsnooze-owned mux sessions + panes
131
258
  unsnooze reap [--dry-run|--yes] close terminal-record panes / empty sessions
132
259
  (default: dry-run; pass --yes to apply)
260
+ unsnooze doctor [--fix] check install health; find (and with --fix
261
+ retire) leftovers of the old
262
+ claude-session-guard install
263
+ unsnooze preview [id] dry-run: what WOULD happen right now, and
264
+ why — nothing is typed or opened (exit 2
265
+ when a wake is actionable, else 0)
133
266
  unsnooze logs [-f] show (or follow) the unsnooze log
134
267
  unsnooze update update unsnooze itself to the latest version
135
268
  unsnooze daemon persistent watcher for GUI sessions (VS Code
@@ -148,14 +281,16 @@ Usage:
148
281
  default: {
149
282
  // Everything else (including no args, --resume, -c, plain prompts) is a
150
283
  // claude invocation — back-compat for the plain wrapper.
151
- const { runLauncher } = await import('../src/launcher.js');
152
284
  const args = cmd === undefined ? [] : [cmd, ...rest];
153
- return runLauncher(args, 'claude');
285
+ const mod = await safeImport('../src/launcher.js');
286
+ if (!mod) return runAgentFallback('claude', args);
287
+ return mod.runLauncher(args, 'claude');
154
288
  }
155
289
  }
156
290
  }
157
291
 
158
292
  main().then(async code => {
159
293
  if (USER_FACING.has(cmd)) await maybeUpdateNotices();
294
+ else if (isLaunchPath) await maybeLaunchExitNotice(launchArgs);
160
295
  process.exitCode = typeof code === 'number' ? code : 0;
161
296
  }).catch(err => { console.error(`unsnooze: ${err.stack || err}`); process.exitCode = 1; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.10.0",
3
+ "version": "1.12.2",
4
4
  "description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity when the 5-hour/weekly usage limit resets — tmux, Zellij, VS Code & desktop apps. Wakes every limit-stopped AI coding session automatically.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { getConfig, setConfigValue, listConfig, CONFIG_FILE } from './settings.j
10
10
  import { spawnResumerIfNeeded } from './spawn.js';
11
11
  import { getMultiplexer } from './multiplexer.js';
12
12
  import { listOwnedSessions, reap, attachHint } from './reap.js';
13
+ import { planFor } from './resumer.js';
13
14
 
14
15
  function fmtCountdown(ms) {
15
16
  if (ms <= 0) return 'due now';
@@ -204,6 +205,76 @@ export function cmdConfig(rest) {
204
205
  }
205
206
  }
206
207
 
208
+ // `unsnooze preview [id]` — dry-run: what WOULD the resumer do right now,
209
+ // and why? Shares planFor/assessPane/evaluate*Guard with the real dispatch,
210
+ // so this can never drift from what dispatch actually does. Read-only pane
211
+ // captures only — nothing is typed, opened, or modified.
212
+ // Exit codes (terraform -detailed-exitcode style): 0 = nothing would wake
213
+ // right now, 2 = at least one actionable wake, 1 = internal error.
214
+ export async function cmdPreview(rest = [], {
215
+ resolveMux = rec => getMultiplexer(rec.mux, { owner: rec.paneOwner }),
216
+ matchesLease = undefined,
217
+ print = console.log,
218
+ now = Date.now(),
219
+ } = {}) {
220
+ try {
221
+ const idOrAll = rest.find(a => !a.startsWith('-'));
222
+ const state = readState();
223
+ let records = Object.values(state.sessions);
224
+ if (idOrAll) {
225
+ records = records.filter(s => s.key.startsWith(idOrAll) || (s.sessionId || '').startsWith(idOrAll));
226
+ }
227
+ if (records.length === 0) {
228
+ print(idOrAll ? `unsnooze preview: no session matching "${idOrAll}".` : 'unsnooze preview: no tracked sessions.');
229
+ return 0;
230
+ }
231
+ print('unsnooze preview — what WOULD happen right now (nothing is sent)\n');
232
+ const VERBS = {
233
+ inject: p => `would TYPE the wake message into pane ${p.target?.pane}`,
234
+ 'drive-menu': p => `would SELECT "Stop and wait for limit to reset" in pane ${p.target?.pane}`,
235
+ reopen: p => `would REOPEN in session "${p.target?.session}"${p.messageViaPane ? ' and type the message once the TUI shows an idle prompt' : ' (message travels in argv)'}`,
236
+ busy: () => 'would DEFER — agent is busy',
237
+ 'menu-held': () => 'limit menu on screen — would WAIT (menuAutoAnswer off)',
238
+ probe: () => 'would PROBE (reset time unknown) — not a resume',
239
+ waiting: () => 'waiting for the reset',
240
+ paused: () => 'paused — would do nothing',
241
+ held: () => 'held — would do nothing until resume-now',
242
+ 'give-up': () => 'would GIVE UP (attempt cap reached)',
243
+ verifying: () => 'verifying an in-flight resume',
244
+ retry: () => 'would RETRY later (pane capture failed)',
245
+ none: () => 'nothing to do',
246
+ };
247
+ let actionable = false;
248
+ for (const rec of records.sort((x, y) => (x.resetAt || 0) - (y.resetAt || 0))) {
249
+ let plan;
250
+ try {
251
+ plan = await planFor(rec, {
252
+ mux: resolveMux(rec),
253
+ ...(matchesLease ? { matchesLease } : {}),
254
+ now,
255
+ });
256
+ } catch (err) {
257
+ print(` ${rec.key.slice(0, 12)}: preview failed: ${err.message}`);
258
+ continue;
259
+ }
260
+ const id = rec.sessionId ? rec.sessionId.slice(0, 8) : '(no id)';
261
+ print(` [${(rec.status || '?').toUpperCase().padEnd(9)}] ${id} ${(rec.agent || 'claude').padEnd(6)} ${rec.cwd || ''}`);
262
+ print(` ${(VERBS[plan.action] || (() => plan.action))(plan)}`);
263
+ for (const g of plan.gates) print(` · ${g}`);
264
+ if (plan.message && ['inject', 'reopen'].includes(plan.action)) {
265
+ const m = plan.message.length > 140 ? `${plan.message.slice(0, 140)}…` : plan.message;
266
+ print(` message: "${m.replace(/\n+/g, ' ⏎ ')}"`);
267
+ }
268
+ if (['inject', 'drive-menu', 'reopen'].includes(plan.action)) actionable = true;
269
+ }
270
+ print('\nNothing was typed, opened, or modified. `unsnooze resume-now <id>` wakes a session immediately.');
271
+ return actionable ? 2 : 0;
272
+ } catch (err) {
273
+ console.error(`unsnooze preview: ${err.message}`);
274
+ return 1;
275
+ }
276
+ }
277
+
207
278
  // `unsnooze sessions` — list unsnooze-owned mux sessions with panes + records.
208
279
  export async function cmdSessions() {
209
280
  const owned = await listOwnedSessions();