unsnooze 1.9.0 → 1.12.1

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,170 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.12.1 — 2026-07-16
6
+
7
+ - CI-only: pin the platform in a doctor test whose `launchctl` assertion
8
+ failed on Linux runners. No runtime changes — this is 1.12.0 plus a green
9
+ release pipeline (1.12.0 was tagged but never published).
10
+
11
+ ## 1.12.0 — 2026-07-16
12
+
13
+ - **Daemon PATH fix** (fix: every launchd-daemon revival on Homebrew Macs died
14
+ silently with `spawn tmux ENOENT` — launchd gives daemons a bare
15
+ `/usr/bin:/bin:/usr/sbin:/sbin` and tmux lives in `/opt/homebrew/bin`): the
16
+ launchd plist and systemd unit now bake the install-time `PATH`. Re-run
17
+ `unsnooze install --daemon` once after updating to regenerate the unit.
18
+ Revival `new-window` failures are now logged (they were silent), and
19
+ `failed` records survive the sweeper so `unsnooze status` can show *why* a
20
+ session gave up (age-based prune still expires them).
21
+ - **`unsnooze preview [id]`** — a true dry-run: per session, exactly what the
22
+ resumer WOULD do right now (type into which pane, drive the menu, reopen in
23
+ which session, probe, defer) and why — every gate (paused, not due, held by
24
+ workspace/context guard, backoff, attempt cap) spelled out, including the
25
+ final wake message with any guard suffix. Sends nothing, mutates nothing.
26
+ Preview shares its decision code (`planFor`/`assessPane`/guard evaluators)
27
+ with the real dispatcher, so it cannot drift. Exit codes: `0` nothing would
28
+ wake now, `2` at least one actionable wake, `1` error.
29
+ - **ntfy push notifications** (`ntfyTopic` / `ntfyServer` / `ntfyToken` /
30
+ `ntfyPrivacy`) — off until a topic is set; fires *alongside* the local
31
+ channel on limit-hit / resumed / gave-up (gave-up pushes at high priority).
32
+ JSON-to-root publishing (emoji-safe titles), Bearer-token support for authed
33
+ or self-hosted servers, bodies capped, fire-and-forget with a 5s timeout.
34
+ `ntfyPrivacy=terse` keeps directory paths out of pushed bodies — ntfy.sh
35
+ topics are a public namespace, so the docs push unguessable topic names.
36
+ - **Trust & security docs**: a "Trust & security" section at the top of the
37
+ README (what unsnooze types and never types, grounded in the actual
38
+ mechanisms) and a full `SECURITY.md` (threat model, honest residual risks,
39
+ private vulnerability reporting, supported versions). Enable GitHub Private
40
+ Vulnerability Reporting in the repo settings to activate the report button.
41
+ - **Release provenance**: `.github/workflows/release.yml` publishes to npm on
42
+ `v*` tags via trusted publishing (OIDC, token-less) with
43
+ `--provenance` — after a one-time trusted-publisher setup on npmjs.com
44
+ (repo `saaranshM/unsnooze`, workflow `release.yml`).
45
+ - **Reproducible demo**: `demo/demo.tape` (VHS) renders `assets/demo.gif` —
46
+ staged fixture ledger + stub agent, but the `unsnooze status` beat is real.
47
+ - Note: the pre-release name `claude-session-guard` was never published to
48
+ npm, so there is nothing to deprecate on the registry; `unsnooze doctor
49
+ --fix` remains the migration path for local installs.
50
+
51
+ ## 1.11.0 — 2026-07-16
52
+
53
+ - **`unsnooze doctor [--fix]`** — install health check + migration sweep for
54
+ the pre-release claude-session-guard (csg) install. Detects zombie csg
55
+ monitors/resumers, old launchd/systemd units, the orphaned
56
+ `~/.claude-session-guard` state dir, and the stale global package (even when
57
+ its `csg` bin symlink dangles). `--fix` stops the processes, unloads and
58
+ removes the units, and archives the state dir (never deletes data, never
59
+ runs npm — the `npm rm -g claude-session-guard` step stays yours).
60
+ `unsnooze install` now runs the detection and points at doctor when
61
+ leftovers exist.
62
+ - **Pane identity & ownership** (fix: tmux pane ids are server-global and
63
+ recycled — a stale monitor or reap could type into or close somebody
64
+ else's pane): managed panes are stamped with a `@unsnooze_owner` pane
65
+ option at launch and revival; every message-injection, menu-drive, reap,
66
+ and auto-reap decision now answers two independent questions first —
67
+ *is this pane ours* (stamp/lease; a mismatched stamp vetoes even a
68
+ matching foreground command) and *is our agent still running in it*
69
+ (lease pid+birth or foreground command; the stamp alone never counts,
70
+ since it outlives the agent and the pane may now be the user's shell).
71
+ Monitors exit once their lease disappears instead of scraping whatever the
72
+ pane becomes. Legacy records without leases are never force-closed.
73
+ - **Launch failures degrade instead of dying** (fix: a tmux-level
74
+ session-start failure — `duplicate session`, `open terminal failed`, dead
75
+ socket, nesting refusal, socket permission errors — used to exit with
76
+ tmux's status and no agent): launchWrapped now reads tmux's stderr,
77
+ distinguishes "session never started" from "session ran and ended", and
78
+ falls back to the unwatched agent CLI with a message.
79
+ - **Zellij detection fix**: `capturePane` now dumps scrollback
80
+ (`dump-screen --full`) — a limit banner that scrolled between polls was
81
+ previously invisible on Zellij. Pre-0.35 zellij rejects the flag; capture
82
+ learns that once and degrades to the viewport-only form instead of
83
+ failing every poll.
84
+ - **Reset-time correctness**: weekly "resets Tuesday 9am" wakes land on the
85
+ exact wall-clock time across DST boundaries (day-stepping used to drift
86
+ ±1h); a mangled banner clock ("resets 45:99") is rejected instead of
87
+ throwing; "wait <duration>" only parses when a duration actually follows
88
+ (no more summing stray durations out of prose).
89
+ - **State safety**: sweepers use compare-and-set so `markStaleAbandoned` can
90
+ no longer clobber a record that resumed mid-sweep; the state lock records
91
+ its holder pid and is only ever stolen from a dead process; user-invoked
92
+ `reap` skips `resumed` panes that were active within `reapIdleAfter`
93
+ (closing a live working agent contradicted its own contract).
94
+ - **Install backups**: the first-ever run snapshots your pristine
95
+ settings/rc as `.unsnooze-orig` (kept forever); `.unsnooze-bak` keeps
96
+ rolling per run.
97
+
98
+ ## 1.10.1 — 2026-07-16
99
+
100
+ - **Upgrade-window fail-safe** (fix: `npm install -g` briefly leaves `bin/`
101
+ present with `src/` missing; the router then died with `MODULE_NOT_FOUND`
102
+ *inside* the freshly-wrapped tmux session — a visible open/close flash with
103
+ terminal-probe garbage — and the launchd daemon crash-looped thousands of
104
+ times into `daemon.log`): agent-launch paths (`_run`, bare `claude` args)
105
+ now degrade to the plain agent CLI when the package can't load; background
106
+ paths (hook, monitor, resumer, daemon, update-check) exit 0 quietly. Only
107
+ module *load* failures are caught — runtime errors still surface, and an
108
+ agent that already ran is never re-run.
109
+ - **Daemon crash-loop guards**: launchd plist gains `ThrottleInterval 30`;
110
+ the systemd unit moves to `Restart=always` + `RestartSec=30` (a clean
111
+ exit-0 must also respawn — `on-failure` would leave the daemon dead after
112
+ an intentional exit) with the start rate-limit disabled so a long broken
113
+ install can never trip the unit into a permanent `failed` state. A
114
+ version-skew watch makes a long-lived daemon exit cleanly (and get
115
+ restarted on fresh code) when `npm -g` swaps the package underneath it —
116
+ no more zombie daemons running deleted code.
117
+ - **Log rotation**: `unsnooze.log` and `daemon.log` are capped at 5 MB with
118
+ one rotated generation (`.1`) — a crash-loop can no longer grow a log
119
+ without bound. `daemon.log` is rotated copy-truncate style because launchd
120
+ holds its fd open for the daemon's whole lifetime.
121
+ - **Resume-retry backoff** (fix: five resume attempts burned in ~2 minutes
122
+ when a revival kept failing): failed attempts now back off exponentially
123
+ (1m, 2m, 4m… capped at 30m). Manual `resume-now` records are exempt — an
124
+ explicit immediate wake is never silently deferred. After `unsnooze` gives
125
+ up, a session re-arms only on a fresh limit detection.
126
+ - **Singleton-lock hygiene**: the resumer lock is acquired atomically
127
+ (`wx`), a lock held by a recycled pid that is not actually an unsnooze
128
+ process is taken over instead of honored forever, and the daemon's
129
+ "another resumer holds the lock" log line is throttled to ~once per 15
130
+ minutes instead of every 30 s tick.
131
+ - **Update notice on the launch path**: wrapper-only users (who never run
132
+ `unsnooze status`) now get the one-line "new version available" notice on
133
+ stderr right after their agent session ends — outer terminal only, TTY
134
+ only, never in `-p/--print` runs, at most once per day. The launch path
135
+ also refreshes the daily version-check cache.
136
+
137
+ ## 1.10.0 — 2026-07-14
138
+
139
+ - **Session-name ownership** (fix: interactive `claude` dying with
140
+ `duplicate session: unsnooze`): the interactive launcher owns the base name
141
+ `unsnooze` (and `unsnooze-2`… on collision); the resumer daemon may join a
142
+ live session but only ever **creates** `unsnooze-resumed`. Records now
143
+ discover the live mux session via `sessionForPane` instead of freezing the
144
+ load-time `MUX_SESSION_NAME` constant. tmux `newWindow` returns
145
+ `paneOwner: null` (pane ids are server-global). Uninstall stops the resumer.
146
+ Failed session creation degrades to an unwatched agent CLI instead of
147
+ bricking `claude`/`codex`. New: `unsnooze sessions`, `unsnooze reap
148
+ [--dry-run|--yes]`; optional `reapResumed` / `reapIdleAfter`. Zellij revival
149
+ uses `--close-on-exit` and closes the default shell pane left by
150
+ `attach -b -c`. Env: `UNSNOOZE_SESSION_NAME`, `UNSNOOZE_RESUME_SESSION`.
151
+ - **Reset-time accuracy** (fix: blind `now + 5h` fallback and +24h rollover
152
+ of already-past clock times): reset times are anchored to the banner's own
153
+ timestamp (`bannerAt`), not the scrape moment. Claude stops prefer the dated
154
+ transcript entry over an undated pane scrape. An absolute clock time already
155
+ past relative to wall clock means the limit already reset (due now), not
156
+ tomorrow. Compact durations like `1h 30m` sum all tokens. Unparseable banners
157
+ probe cheaply (`PROBE_INTERVAL_MS`, backoff to `PROBE_MAX_MS`) instead of
158
+ sleeping five hours; hard ceiling remains `FALLBACK_RESET_MS`. Monitor first
159
+ tick requires corroboration; later ticks can upgrade a weak estimate.
160
+ `unsnooze status` shows provenance (`absolute, from transcript` vs
161
+ `guessed: no reset time found — probing`).
162
+ - **Upgrade-safe state migration** for existing installs: `tmuxSession` →
163
+ `muxSession` (unchanged), bogus tmux `paneOwner` values cleared so leases
164
+ match again, old blind `fallback` waits beyond the probe ladder are pulled
165
+ into the first probe window (absolute/relative schedules untouched),
166
+ `TMUX_SESSION_NAME` alias kept, `reapResumed` defaults off, new config keys
167
+ only add defaults (existing `config.json` keeps working without edits).
168
+
5
169
  ## 1.9.0 — 2026-07-13
6
170
 
7
171
  - **Context-size guard** (`contextGuard`: `off` | `inform` | `pause`, default
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,6 +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
213
+ unsnooze sessions # list unsnooze-owned mux sessions + panes
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
174
216
  unsnooze config list # settings (see below)
175
217
  unsnooze config set <k> <v> # e.g. autoResume off
176
218
  unsnooze logs [-f] # what unsnooze has been doing
@@ -202,11 +244,32 @@ unsnooze help # full command list (also -h / --help)
202
244
  | `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
203
245
  | `contextGuard` | `inform` | Big cold context at wake? Waking a session re-reads its **entire context at full uncached price** ([why](#why-did-resuming-a-big-session-eat-so-much-of-my-quota)). `inform` resumes and notifies you of the size; `pause` holds sessions above the threshold for `unsnooze resume-now`; `off` disables. Claude Code only for now. |
204
246
  | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
247
+ | `reapResumed` | `false` | Opt-in: auto-close `resumed` panes idle longer than `reapIdleAfter`. Off by default — use `unsnooze reap --yes` for explicit cleanup. |
248
+ | `reapIdleAfter` | `604800000` (7d) | Idle age (ms) before an opt-in auto-reap closes a `resumed` pane. |
205
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. |
206
254
 
207
255
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
208
256
  all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
209
257
 
258
+ ### Multiplexer session names
259
+
260
+ Interactive launches own the base session name (default **`unsnooze`**); a second
261
+ concurrent terminal takes `unsnooze-2`, etc. The resumer daemon may **join** a
262
+ live session but only ever **creates** `unsnooze-resumed`, so a revived agent
263
+ never steals the interactive name.
264
+
265
+ | env | default | meaning |
266
+ |---|---|---|
267
+ | `UNSNOOZE_SESSION_NAME` | `unsnooze` | Interactive base session name (also accepts legacy `UNSNOOZE_TMUX_SESSION`). |
268
+ | `UNSNOOZE_RESUME_SESSION` | `<base>-resumed` | Session the daemon creates when the pane's original session is gone. |
269
+
270
+ Find revived agents with `unsnooze status` (prints an attach hint when the
271
+ session is live), `unsnooze sessions`, or e.g. `tmux attach -t unsnooze-resumed`.
272
+
210
273
  ### Notification channels
211
274
 
212
275
  When a limit is hit, a session resumes, or unsnooze gives up, it can alert you
@@ -367,8 +430,13 @@ npm test # unit tests (node:test)
367
430
  ./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
368
431
  # scratch tmux session (no real limits needed)
369
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)
370
434
  ```
371
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
+
372
440
  ## License
373
441
 
374
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', '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 {
@@ -56,6 +130,22 @@ async function main() {
56
130
  const { cmdConfig } = await import('../src/cli.js');
57
131
  return cmdConfig(rest);
58
132
  }
133
+ case 'sessions': {
134
+ const { cmdSessions } = await import('../src/cli.js');
135
+ return cmdSessions();
136
+ }
137
+ case 'reap': {
138
+ const { cmdReap } = await import('../src/cli.js');
139
+ return cmdReap(rest);
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
+ }
59
149
  case 'install': {
60
150
  const { cmdInstall } = await import('../src/install.js');
61
151
  return cmdInstall(rest);
@@ -65,43 +155,73 @@ async function main() {
65
155
  return cmdUninstall(rest);
66
156
  }
67
157
  case '_hook-stopfailure': {
68
- const { runHook } = await import('../src/hook.js');
69
- 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);
70
161
  }
71
162
  case '_monitor': {
72
163
  if (!rest[0] || !rest[2]) { console.error('unsnooze _monitor: mux owner pane required'); return 2; }
73
- const { runMonitor } = await import('../src/monitor.js');
74
- 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]);
75
167
  }
76
168
  case '_run': {
77
169
  if (!rest[0]) { console.error('unsnooze _run: agent id required'); return 2; }
78
- const { runLauncher } = await import('../src/launcher.js');
79
- 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]);
80
173
  }
81
174
  case '_resumer': {
82
- const { runResumer } = await import('../src/resumer.js');
83
- return runResumer();
175
+ const mod = await safeImport('../src/resumer.js');
176
+ if (!mod) return 0;
177
+ return mod.runResumer();
84
178
  }
85
179
  case 'update': {
86
180
  const { runSelfUpdate } = await import('../src/update-check.js');
87
181
  return runSelfUpdate();
88
182
  }
89
183
  case '_update-check': {
90
- const { runUpdateCheck } = await import('../src/update-check.js');
91
- return runUpdateCheck();
184
+ const mod = await safeImport('../src/update-check.js');
185
+ if (!mod) return 0;
186
+ return mod.runUpdateCheck();
92
187
  }
93
188
  case 'daemon': {
94
189
  // Persistent resumer + transcript watcher: detects and revives limit
95
190
  // stops from GUI surfaces (VS Code extension, desktop apps) where no
96
191
  // shell wrapper or multiplexer pane exists. Run via launchd/systemd or a shell.
97
- const { runResumer } = await import('../src/resumer.js');
98
- 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;
99
199
  const controller = new AbortController();
100
200
  process.on('SIGTERM', () => controller.abort());
101
201
  process.on('SIGINT', () => controller.abort());
202
+ // daemon.log is launchd/systemd-captured stdout+stderr. launchd holds
203
+ // an open fd on it for our whole lifetime, so rotation must be
204
+ // copy-truncate: renaming would leave launchd appending to the renamed
205
+ // inode forever, with no fresh daemon.log until the next respawn.
206
+ const loggerMod = await safeImport('../src/logger.js');
207
+ const configMod = await safeImport('../src/config.js');
208
+ if (loggerMod && configMod) {
209
+ const { join } = await import('node:path');
210
+ loggerMod.copyTruncateIfLarge(join(configMod.STATE_DIR, 'daemon.log'));
211
+ }
212
+ // Version-skew guard: when npm swaps the package underneath us, exit
213
+ // cleanly so launchd/systemd restart the daemon on the fresh code.
214
+ const updMod = await safeImport('../src/update-check.js');
215
+ if (updMod?.hasVersionSkew) {
216
+ setInterval(() => {
217
+ if (updMod.hasVersionSkew()) controller.abort();
218
+ }, 15 * 60_000).unref();
219
+ }
102
220
  // Daily update check from the daemon: GUI-only users never run CLI
103
221
  // commands, so this is what gets them the "new version" desktop toast.
104
- const { spawnDetached } = await import('../src/spawn.js');
222
+ const spawnMod = await safeImport('../src/spawn.js');
223
+ if (!spawnMod) return 0;
224
+ const { spawnDetached } = spawnMod;
105
225
  spawnDetached(['_update-check']);
106
226
  setInterval(() => spawnDetached(['_update-check']), 24 * 3_600_000).unref();
107
227
  return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
@@ -119,6 +239,15 @@ Usage:
119
239
  unsnooze resume-now [id|--all] resume stopped session(s) immediately
120
240
  unsnooze cancel [id|--all] stop tracking session(s)
121
241
  unsnooze message <id|--all> <t> set a per-session wake message (--clear to reset)
242
+ unsnooze sessions list unsnooze-owned mux sessions + panes
243
+ unsnooze reap [--dry-run|--yes] close terminal-record panes / empty sessions
244
+ (default: dry-run; pass --yes to apply)
245
+ unsnooze doctor [--fix] check install health; find (and with --fix
246
+ retire) leftovers of the old
247
+ claude-session-guard install
248
+ unsnooze preview [id] dry-run: what WOULD happen right now, and
249
+ why — nothing is typed or opened (exit 2
250
+ when a wake is actionable, else 0)
122
251
  unsnooze logs [-f] show (or follow) the unsnooze log
123
252
  unsnooze update update unsnooze itself to the latest version
124
253
  unsnooze daemon persistent watcher for GUI sessions (VS Code
@@ -137,14 +266,16 @@ Usage:
137
266
  default: {
138
267
  // Everything else (including no args, --resume, -c, plain prompts) is a
139
268
  // claude invocation — back-compat for the plain wrapper.
140
- const { runLauncher } = await import('../src/launcher.js');
141
269
  const args = cmd === undefined ? [] : [cmd, ...rest];
142
- return runLauncher(args, 'claude');
270
+ const mod = await safeImport('../src/launcher.js');
271
+ if (!mod) return runAgentFallback('claude', args);
272
+ return mod.runLauncher(args, 'claude');
143
273
  }
144
274
  }
145
275
  }
146
276
 
147
277
  main().then(async code => {
148
278
  if (USER_FACING.has(cmd)) await maybeUpdateNotices();
279
+ else if (isLaunchPath) await maybeLaunchExitNotice(launchArgs);
149
280
  process.exitCode = typeof code === 'number' ? code : 0;
150
281
  }).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.9.0",
3
+ "version": "1.12.1",
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",