unsnooze 1.2.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,23 @@
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
+
12
+ ## 1.3.0 — 2026-07-11
13
+
14
+ - **Per-session wake messages**: `unsnooze message <id|--all> "<text>"` sets a
15
+ custom resume message for specific tracked sessions (`--clear` reverts).
16
+ Precedence: per-session → per-agent (`resumeMessages.<id>`) → global
17
+ `resumeMessage`. Applies on both wake paths — typed into a live pane, or
18
+ carried in argv for `codex resume` — and sessions with a custom message
19
+ show a `msg: "…"` marker in `unsnooze status`.
20
+
3
21
  ## 1.2.0 — 2026-07-10
4
22
 
5
23
  ### GUI surfaces: VS Code extension, desktop apps
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
@@ -128,6 +129,7 @@ claude / codex / grok # normal usage — wrapped automatically
128
129
  unsnooze status # tracked sessions + reset countdowns
129
130
  unsnooze resume-now [id|--all] # don't wait for the reset time
130
131
  unsnooze cancel [id|--all] # stop tracking a session
132
+ unsnooze message <id> "text" # per-session wake message (--clear to reset)
131
133
  unsnooze config list # settings (see below)
132
134
  unsnooze config set <k> <v> # e.g. autoResume off
133
135
  unsnooze logs [-f] # what unsnooze has been doing
@@ -149,9 +151,10 @@ unsnooze help # full command list (also -h / --help)
149
151
  | `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
150
152
  | `notifications` | `true` | Desktop notification on limit detected / session resumed / gave up. |
151
153
  | `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
152
- | `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. |
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`. |
153
155
  | `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
154
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. |
155
158
 
156
159
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
157
160
  all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
@@ -199,6 +202,42 @@ unsnooze raises **native Windows toasts** through `powershell.exe` (no
199
202
  not supported — without tmux there is nothing to watch; unsnooze will tell you
200
203
  so and run your CLI unwatched instead of breaking it.
201
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
+
202
241
  ## Development
203
242
 
204
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': {
@@ -30,6 +48,10 @@ async function main() {
30
48
  const { cmdReport } = await import('../src/report.js');
31
49
  return cmdReport(rest);
32
50
  }
51
+ case 'message': {
52
+ const { cmdMessage } = await import('../src/cli.js');
53
+ return cmdMessage(rest);
54
+ }
33
55
  case 'config': {
34
56
  const { cmdConfig } = await import('../src/cli.js');
35
57
  return cmdConfig(rest);
@@ -60,6 +82,10 @@ async function main() {
60
82
  const { runResumer } = await import('../src/resumer.js');
61
83
  return runResumer();
62
84
  }
85
+ case '_update-check': {
86
+ const { runUpdateCheck } = await import('../src/update-check.js');
87
+ return runUpdateCheck();
88
+ }
63
89
  case 'daemon': {
64
90
  // Persistent resumer + transcript watcher: detects and revives limit
65
91
  // stops from GUI surfaces (VS Code extension, desktop apps) where no
@@ -69,6 +95,11 @@ async function main() {
69
95
  const controller = new AbortController();
70
96
  process.on('SIGTERM', () => controller.abort());
71
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();
72
103
  return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
73
104
  }
74
105
  case 'help':
@@ -83,12 +114,13 @@ Usage:
83
114
  unsnooze status list tracked sessions + reset countdowns
84
115
  unsnooze resume-now [id|--all] resume stopped session(s) immediately
85
116
  unsnooze cancel [id|--all] stop tracking session(s)
117
+ unsnooze message <id|--all> <t> set a per-session wake message (--clear to reset)
86
118
  unsnooze logs [-f] show (or follow) the unsnooze log
87
119
  unsnooze daemon persistent watcher for GUI sessions (VS Code
88
120
  extension, desktop apps) — no tmux needed to
89
121
  detect; revival still opens in tmux
90
122
  unsnooze config [list|get|set] view or change settings (toggles, global +
91
- per-agent resume messages)
123
+ per-agent resume messages, updateCheck)
92
124
  unsnooze setup interactive setup wizard (agents + toggles)
93
125
  unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
94
126
  unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
@@ -106,5 +138,7 @@ Usage:
106
138
  }
107
139
  }
108
140
 
109
- main().then(code => { process.exitCode = typeof code === 'number' ? code : 0; })
110
- .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.2.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/cli.js CHANGED
@@ -30,16 +30,19 @@ export function cmdStatus() {
30
30
  const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
31
31
  const reset = s.resetAt ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})` : '?';
32
32
  const origin = s.origin ?? (s.pane ? 'cli' : '?');
33
+ const msg = s.resumeMessage
34
+ ? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
35
+ : '';
33
36
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
34
- console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}`);
37
+ console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}`);
35
38
  }
36
39
  return 0;
37
40
  }
38
41
 
39
- function selectKeys(state, idOrAll) {
40
- const stopped = Object.values(state.sessions).filter(s => s.status === 'stopped');
41
- if (idOrAll === '--all' || idOrAll === undefined) return stopped.map(s => s.key);
42
- const match = stopped.filter(s => s.key.startsWith(idOrAll) || (s.sessionId || '').startsWith(idOrAll));
42
+ function selectKeys(state, idOrAll, statuses = ['stopped']) {
43
+ const candidates = Object.values(state.sessions).filter(s => statuses.includes(s.status));
44
+ if (idOrAll === '--all' || idOrAll === undefined) return candidates.map(s => s.key);
45
+ const match = candidates.filter(s => s.key.startsWith(idOrAll) || (s.sessionId || '').startsWith(idOrAll));
43
46
  return match.map(s => s.key);
44
47
  }
45
48
 
@@ -82,6 +85,33 @@ export function cmdLogs(follow) {
82
85
  return 0;
83
86
  }
84
87
 
88
+ // `unsnooze message <id|--all> <text...>` — set (or --clear) the wake message
89
+ // for specific sessions; the resumer prefers it over the global setting.
90
+ export function cmdMessage(rest) {
91
+ const [idOrAll, ...textParts] = rest;
92
+ const clear = textParts[0] === '--clear';
93
+ const text = textParts.join(' ').trim();
94
+ if (!idOrAll || (!clear && !text)) {
95
+ console.error('unsnooze message <id|--all> <text...> (or --clear to revert to the default)');
96
+ return 2;
97
+ }
98
+ const state = readState();
99
+ // stopped OR resuming: editing the message before a retry is legitimate.
100
+ const keys = selectKeys(state, idOrAll, ['stopped', 'resuming']);
101
+ if (keys.length === 0) { console.log('unsnooze: no matching active sessions.'); return 1; }
102
+ updateState(s => {
103
+ for (const key of keys) {
104
+ if (!s.sessions[key]) continue;
105
+ if (clear) delete s.sessions[key].resumeMessage;
106
+ else s.sessions[key].resumeMessage = text;
107
+ }
108
+ });
109
+ console.log(clear
110
+ ? `unsnooze: cleared custom message on ${keys.length} session(s) (global default applies).`
111
+ : `unsnooze: ${keys.length} session(s) will wake with: "${text}"`);
112
+ return 0;
113
+ }
114
+
85
115
  // `unsnooze config list | get <key> | set <key> <value>`
86
116
  export function cmdConfig(rest) {
87
117
  const [action, key, ...valueParts] = rest;
package/src/resumer.js CHANGED
@@ -72,8 +72,10 @@ function selfCommand() {
72
72
  export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand() } = {}) {
73
73
  const key = rec.key;
74
74
  const agent = getAgent(rec.agent);
75
- // Explicit option wins; otherwise the agent's own message (or the global).
76
- resumeMessage = resumeMessage ?? resolveResumeMessage(agent.id);
75
+ // Wake-message precedence: per-session (`unsnooze message <id> "..."`)
76
+ // explicit option per-agent (`resumeMessages.<id>`) → global. Applies to
77
+ // both the live-pane sendText and the argv reopen path.
78
+ resumeMessage = rec.resumeMessage ?? resumeMessage ?? resolveResumeMessage(agent.id);
77
79
 
78
80
  // Live-pane path: only if the pane still exists AND the agent CLI is its
79
81
  // foreground command (pane ids get recycled — never inject into a random
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
+ }