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 +164 -0
- package/README.md +68 -0
- package/bin/unsnooze.js +147 -16
- package/package.json +1 -1
- package/src/cli.js +157 -4
- package/src/config.js +17 -2
- package/src/doctor.js +310 -0
- package/src/hook.js +33 -6
- package/src/install.js +63 -6
- package/src/launcher.js +29 -7
- package/src/lease.js +22 -0
- package/src/logger.js +40 -1
- package/src/monitor.js +142 -14
- package/src/multiplexers/session-name.js +28 -0
- package/src/multiplexers/tmux.js +124 -4
- package/src/multiplexers/zellij.js +131 -8
- package/src/notify-ntfy.js +76 -0
- package/src/notify.js +9 -0
- package/src/reap.js +228 -0
- package/src/resumer.js +464 -91
- package/src/sessions.js +2 -1
- package/src/settings.js +20 -0
- package/src/spawn.js +23 -1
- package/src/state.js +128 -10
- package/src/time-parser.js +136 -55
- package/src/update-check.js +34 -0
- package/src/watcher.js +10 -3
- package/src/watchers/claude.js +44 -1
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
|
|
69
|
-
return
|
|
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
|
|
74
|
-
return
|
|
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
|
|
79
|
-
return
|
|
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
|
|
83
|
-
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|