unsnooze 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 — 2026-07-12
4
+
5
+ - **Update notices**: unsnooze now checks the npm registry (at most once a
6
+ day, a plain GET with nothing identifying) and tells you when a new version
7
+ is out — a one-line notice after CLI commands, and a single desktop toast
8
+ per version from the daemon. After you update, the next command shows a
9
+ short "what's new" straight from the bundled changelog. Turn it all off
10
+ with `unsnooze config set updateCheck off`.
11
+
3
12
  ## 1.3.0 — 2026-07-11
4
13
 
5
14
  - **Per-session wake messages**: `unsnooze message <id|--all> "<text>"` sets a
package/README.md CHANGED
@@ -9,9 +9,10 @@
9
9
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3fb950)](package.json)
10
10
  [![license](https://img.shields.io/badge/license-MIT-8b949e)](LICENSE)
11
11
 
12
- **Claude Code · Codex CLI · Grok** — when they hit a usage limit, your session just… stops.<br/>
13
- unsnooze tracks **every** limit-stopped session across all your projects and
14
- **wakes each one up in tmux the moment the limit resets.**
12
+ **Claude Code · Codex CLI · Grok** — when they hit the 5-hour or weekly usage limit
13
+ ("You've hit your usage limit"), your session just… stops.<br/>
14
+ unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
15
+ your projects and **wakes each one up in tmux the moment the usage limit resets.**
15
16
 
16
17
  ```sh
17
18
  npm install -g unsnooze && unsnooze setup
@@ -153,6 +154,7 @@ unsnooze help # full command list (also -h / --help)
153
154
  | `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. Override it for a single session with `unsnooze message <id> "…"` — visible in `unsnooze status`. |
154
155
  | `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
155
156
  | `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
157
+ | `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. |
156
158
 
157
159
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
158
160
  all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
@@ -200,6 +202,42 @@ unsnooze raises **native Windows toasts** through `powershell.exe` (no
200
202
  not supported — without tmux there is nothing to watch; unsnooze will tell you
201
203
  so and run your CLI unwatched instead of breaking it.
202
204
 
205
+ ## FAQ
206
+
207
+ ### What does "You've hit your usage limit" mean in Claude Code?
208
+
209
+ Claude subscription plans meter usage in a rolling 5-hour window plus a weekly
210
+ cap. When either runs out, Claude Code stops mid-task and shows the banner with
211
+ a reset time ("resets 3pm"). Nothing is lost — the session can be resumed with
212
+ `claude --resume <id>` once the limit resets. unsnooze does exactly that,
213
+ automatically, for every stopped session.
214
+
215
+ ### What about Codex — "You've hit your usage limit. Try again at …"?
216
+
217
+ Same idea: ChatGPT-plan Codex has 5-hour and weekly windows. The Codex TUI
218
+ stays open at the composer after the banner, so unsnooze either types into the
219
+ live pane or reopens the session with `codex resume <id>` at the reset time it
220
+ parsed from the banner.
221
+
222
+ ### Does this get around the rate limit?
223
+
224
+ No. unsnooze waits for the reset exactly like you would, resumes once, and
225
+ verifies the limit actually lifted. It replaces the 4am alarm, not the limit.
226
+
227
+ ### How do I update, and how do I know when to?
228
+
229
+ `npm i -g unsnooze`. unsnooze checks the npm registry at most once a day and
230
+ tells you when a newer version exists (a line after CLI commands, plus one
231
+ desktop notification per version); after updating, the next command shows a
232
+ short "what's new" from the changelog. It's a plain registry GET with nothing
233
+ identifying — turn it off with `unsnooze config set updateCheck off`.
234
+
235
+ ### Does it work if my laptop was asleep or the terminal was closed?
236
+
237
+ Yes — reset times are stored as absolute timestamps and checked every 30
238
+ seconds, so a laptop that slept through the reset resumes on the next tick,
239
+ and dead panes are reopened by session id in a fresh tmux window.
240
+
203
241
  ## Development
204
242
 
205
243
  ```sh
package/bin/unsnooze.js CHANGED
@@ -4,6 +4,24 @@
4
4
 
5
5
  const [, , cmd, ...rest] = process.argv;
6
6
 
7
+ // Only human-facing commands may print update notices — never the wrapper
8
+ // 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']);
10
+
11
+ async function maybeUpdateNotices() {
12
+ try {
13
+ const { whatsNewNotice, updateNotice, isCacheStale } = await import('../src/update-check.js');
14
+ const whatsNew = whatsNewNotice();
15
+ if (whatsNew) console.error(`\n${whatsNew}`);
16
+ const notice = updateNotice();
17
+ if (notice) console.error(`\n${notice}`);
18
+ if (isCacheStale()) {
19
+ const { spawnDetached } = await import('../src/spawn.js');
20
+ spawnDetached(['_update-check']);
21
+ }
22
+ } catch { /* update UX must never break a command */ }
23
+ }
24
+
7
25
  async function main() {
8
26
  switch (cmd) {
9
27
  case 'status': {
@@ -64,6 +82,10 @@ async function main() {
64
82
  const { runResumer } = await import('../src/resumer.js');
65
83
  return runResumer();
66
84
  }
85
+ case '_update-check': {
86
+ const { runUpdateCheck } = await import('../src/update-check.js');
87
+ return runUpdateCheck();
88
+ }
67
89
  case 'daemon': {
68
90
  // Persistent resumer + transcript watcher: detects and revives limit
69
91
  // stops from GUI surfaces (VS Code extension, desktop apps) where no
@@ -73,6 +95,11 @@ async function main() {
73
95
  const controller = new AbortController();
74
96
  process.on('SIGTERM', () => controller.abort());
75
97
  process.on('SIGINT', () => controller.abort());
98
+ // Daily update check from the daemon: GUI-only users never run CLI
99
+ // commands, so this is what gets them the "new version" desktop toast.
100
+ const { spawnDetached } = await import('../src/spawn.js');
101
+ spawnDetached(['_update-check']);
102
+ setInterval(() => spawnDetached(['_update-check']), 24 * 3_600_000).unref();
76
103
  return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
77
104
  }
78
105
  case 'help':
@@ -93,7 +120,7 @@ Usage:
93
120
  extension, desktop apps) — no tmux needed to
94
121
  detect; revival still opens in tmux
95
122
  unsnooze config [list|get|set] view or change settings (toggles, global +
96
- per-agent resume messages)
123
+ per-agent resume messages, updateCheck)
97
124
  unsnooze setup interactive setup wizard (agents + toggles)
98
125
  unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
99
126
  unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
@@ -111,5 +138,7 @@ Usage:
111
138
  }
112
139
  }
113
140
 
114
- main().then(code => { process.exitCode = typeof code === 'number' ? code : 0; })
115
- .catch(err => { console.error(`unsnooze: ${err.stack || err}`); process.exitCode = 1; });
141
+ main().then(async code => {
142
+ if (USER_FACING.has(cmd)) await maybeUpdateNotices();
143
+ process.exitCode = typeof code === 'number' ? code : 0;
144
+ }).catch(err => { console.error(`unsnooze: ${err.stack || err}`); process.exitCode = 1; });
package/package.json CHANGED
@@ -1,17 +1,27 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.3.0",
4
- "description": "Wakes every limit-stopped AI coding session (Claude Code, Codex CLI, Grok) in tmux the moment the usage limit resets.",
3
+ "version": "1.4.0",
4
+ "description": "Auto-resume Claude Code, Codex CLI & Grok sessions in tmux when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
5
5
  "keywords": [
6
+ "claude",
6
7
  "claude-code",
7
8
  "codex",
8
9
  "codex-cli",
10
+ "openai",
11
+ "anthropic",
9
12
  "grok",
10
13
  "usage-limit",
14
+ "usage limit",
11
15
  "rate-limit",
16
+ "rate limit",
17
+ "5-hour limit",
12
18
  "auto-resume",
19
+ "auto-retry",
20
+ "resume",
21
+ "retry",
13
22
  "tmux",
14
23
  "ai-agents",
24
+ "agentic-coding",
15
25
  "cli"
16
26
  ],
17
27
  "author": "Saaransh Menon <saaransh.dev2811@gmail.com>",
package/src/settings.js CHANGED
@@ -17,6 +17,7 @@ export const DEFAULTS = {
17
17
  menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
18
18
  notifications: true, // desktop notifications on detect/resume
19
19
  guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
20
+ updateCheck: true, // daily registry version check + update notices/toast
20
21
  resumeMessage: 'Continue where you left off. The session was interrupted by a usage limit which has now reset — pick up the task you were working on and finish it.',
21
22
  resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
22
23
  agents: { claude: true, codex: true, grok: false }, // grok is experimental
@@ -28,6 +29,7 @@ const ENV_NAMES = {
28
29
  menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
29
30
  notifications: 'UNSNOOZE_NOTIFICATIONS',
30
31
  guiWatch: 'UNSNOOZE_GUI_WATCH',
32
+ updateCheck: 'UNSNOOZE_UPDATE_CHECK',
31
33
  resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
32
34
  'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
33
35
  'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
package/src/state.js CHANGED
@@ -96,6 +96,9 @@ export function upsertSession(record) {
96
96
  ...record,
97
97
  // Never downgrade a known sessionId to null.
98
98
  sessionId: record.sessionId || existing.sessionId,
99
+ // A detection that races an in-flight resume must not flip the record
100
+ // back to 'stopped' — the post-resume verify pass owns that outcome.
101
+ status: existing.status === 'resuming' ? existing.status : record.status,
99
102
  key: existingKey,
100
103
  };
101
104
  state.sessions[existingKey] = merged;
@@ -114,8 +117,11 @@ function findDuplicate(state, record) {
114
117
  // Same sessionId living under a pane-based key (a scrape record that later
115
118
  // learned its id through a merge).
116
119
  if (record.sessionId && s.sessionId === record.sessionId) return key;
120
+ // 'resuming' counts too: while the resumer types into a pane, a scrape can
121
+ // still see the banner for a few hundred ms — that must not fork a second
122
+ // record (it would double-resume the session).
117
123
  if (s.pane && s.pane === record.pane
118
- && s.status === 'stopped'
124
+ && (s.status === 'stopped' || s.status === 'resuming')
119
125
  && Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
120
126
  return key;
121
127
  }
@@ -0,0 +1,120 @@
1
+ // Update notifications. npm can't push updates, so the CLI nudges:
2
+ // - user-facing commands print a one-line notice (from CACHE only — the
3
+ // registry fetch happens in a detached `_update-check`, never inline)
4
+ // - the daemon fires ONE desktop toast per new version
5
+ // - after the user updates, the next command shows "what's new" straight
6
+ // from the CHANGELOG.md bundled in the npm tarball (zero network)
7
+ // All of it is gated on the `updateCheck` setting / UNSNOOZE_UPDATE_CHECK.
8
+ // The check is a plain GET to registry.npmjs.org — nothing identifying.
9
+
10
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
11
+ import { join, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { getConfig } from './settings.js';
14
+ import { notify } from './notify.js';
15
+
16
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
+ export const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
18
+
19
+ const CACHE_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || join(process.env.HOME || '', '.unsnooze'), 'update-check.json');
20
+ const CHECK_INTERVAL_MS = 24 * 3_600_000;
21
+ const RELEASES_URL = 'https://github.com/saaranshM/unsnooze/releases';
22
+
23
+ export function readCache() {
24
+ try { return JSON.parse(readFileSync(CACHE_FILE(), 'utf-8')); } catch { return {}; }
25
+ }
26
+
27
+ export function writeCache(patch) {
28
+ const merged = { ...readCache(), ...patch };
29
+ const path = CACHE_FILE();
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ const tmp = join(dirname(path), `.update-check.tmp.${process.pid}`);
32
+ writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n');
33
+ renameSync(tmp, path);
34
+ return merged;
35
+ }
36
+
37
+ // Plain x.y.z compare. We never publish prereleases; anything that isn't
38
+ // three numbers loses (a garbage registry response must never nag).
39
+ export function isNewer(candidate, current) {
40
+ const parse = v => (typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v)) ? v.split('.').map(Number) : null;
41
+ const a = parse(candidate);
42
+ const b = parse(current);
43
+ if (!a || !b) return false;
44
+ for (let i = 0; i < 3; i++) {
45
+ if (a[i] !== b[i]) return a[i] > b[i];
46
+ }
47
+ return false;
48
+ }
49
+
50
+ export function isCacheStale(now = Date.now()) {
51
+ return now - (readCache().lastCheckedAt || 0) > CHECK_INTERVAL_MS;
52
+ }
53
+
54
+ export async function fetchLatest({ fetcher = globalThis.fetch, timeoutMs = 3000 } = {}) {
55
+ try {
56
+ const ctrl = new AbortController();
57
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
58
+ try {
59
+ const res = await fetcher('https://registry.npmjs.org/unsnooze/latest', {
60
+ signal: ctrl.signal, headers: { accept: 'application/json' },
61
+ });
62
+ if (!res.ok) return null;
63
+ const body = await res.json();
64
+ return typeof body?.version === 'string' ? body.version : null;
65
+ } finally {
66
+ clearTimeout(timer);
67
+ }
68
+ } catch {
69
+ return null; // offline, timeout, bad JSON — never the CLI's problem
70
+ }
71
+ }
72
+
73
+ // Cache-only, instant. Printed to stderr after user-facing commands.
74
+ export function updateNotice(pkgVersion = PKG_VERSION) {
75
+ if (!getConfig('updateCheck')) return null;
76
+ const { latest } = readCache();
77
+ if (!latest || !isNewer(latest, pkgVersion)) return null;
78
+ return `unsnooze ${latest} is available (you have ${pkgVersion}) — npm i -g unsnooze · ${RELEASES_URL}`;
79
+ }
80
+
81
+ // The "## <version>" block from the CHANGELOG.md that ships in the tarball.
82
+ export function changelogSection(version, maxLines = 6) {
83
+ try {
84
+ const lines = readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf-8').split('\n');
85
+ const start = lines.findIndex(l => l.startsWith(`## ${version}`));
86
+ if (start === -1) return null;
87
+ const rest = lines.slice(start + 1);
88
+ const end = rest.findIndex(l => l.startsWith('## '));
89
+ const section = rest.slice(0, end === -1 ? undefined : end)
90
+ .filter(l => l.trim() !== '')
91
+ .slice(0, maxLines);
92
+ return section.length ? section.join('\n') : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // Once after an update: "you're now on X, here's what changed."
99
+ export function whatsNewNotice(pkgVersion = PKG_VERSION) {
100
+ if (!getConfig('updateCheck')) return null;
101
+ const { lastRunVersion } = readCache();
102
+ if (lastRunVersion === pkgVersion) return null;
103
+ writeCache({ lastRunVersion: pkgVersion });
104
+ if (!lastRunVersion) return null; // first ever run — nothing to announce
105
+ const section = changelogSection(pkgVersion);
106
+ return `Updated to ${pkgVersion}.` + (section ? ` What's new:\n${section}` : ` ${RELEASES_URL}/tag/v${pkgVersion}`);
107
+ }
108
+
109
+ // The detached `_update-check`: fetch, cache, toast once per new version.
110
+ export async function runUpdateCheck({ fetcher, notifier = notify, now = Date.now() } = {}) {
111
+ if (!getConfig('updateCheck')) return 0;
112
+ const latest = await fetchLatest({ fetcher });
113
+ if (!latest) return 0;
114
+ const cache = writeCache({ lastCheckedAt: now, latest });
115
+ if (isNewer(latest, PKG_VERSION) && cache.notifiedVersion !== latest) {
116
+ notifier('unsnooze update available', `${latest} is out (you have ${PKG_VERSION}) — npm i -g unsnooze`);
117
+ writeCache({ notifiedVersion: latest });
118
+ }
119
+ return 0;
120
+ }