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 +9 -0
- package/README.md +41 -3
- package/bin/unsnooze.js +32 -3
- package/package.json +12 -2
- package/src/settings.js +2 -0
- package/src/state.js +7 -1
- package/src/update-check.js +120 -0
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
|
[](package.json)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
|
-
**Claude Code · Codex CLI · Grok** — when they hit
|
|
13
|
-
|
|
14
|
-
|
|
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 => {
|
|
115
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
+
}
|