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 +143 -0
- package/README.md +49 -0
- package/bin/unsnooze.js +151 -16
- package/package.json +1 -1
- package/src/cli.js +71 -0
- package/src/doctor.js +310 -0
- package/src/install.js +74 -5
- package/src/launcher.js +5 -0
- package/src/lease.js +22 -0
- package/src/logger.js +40 -1
- package/src/monitor.js +19 -1
- package/src/multiplexers/tmux.js +61 -2
- package/src/multiplexers/zellij.js +20 -3
- package/src/notify-ntfy.js +76 -0
- package/src/notify.js +9 -0
- package/src/reap.js +43 -2
- package/src/resumer.js +289 -72
- package/src/settings.js +13 -0
- package/src/state.js +39 -9
- package/src/time-parser.js +31 -15
- package/src/update-check.js +34 -0
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
|
|
77
|
-
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);
|
|
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
|
|
82
|
-
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]);
|
|
83
167
|
}
|
|
84
168
|
case '_run': {
|
|
85
169
|
if (!rest[0]) { console.error('unsnooze _run: agent id required'); return 2; }
|
|
86
|
-
const
|
|
87
|
-
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]);
|
|
88
173
|
}
|
|
89
174
|
case '_resumer': {
|
|
90
|
-
const
|
|
91
|
-
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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();
|