unsnooze 1.8.0 → 1.9.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
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.9.0 — 2026-07-13
6
+
7
+ - **Context-size guard** (`contextGuard`: `off` | `inform` | `pause`, default
8
+ `inform`; threshold `contextGuardTokens`, default `100000`): waking a
9
+ session hours after a limit stop re-reads its entire context at full
10
+ uncached price (the provider's prompt cache expires in minutes) — a
11
+ 150k-token session can eat a real slice of a fresh 5-hour window the moment
12
+ it wakes. unsnooze now estimates the size from the session transcript (the
13
+ last `message.usage` entry, tail-read) before dispatch: `inform` resumes
14
+ and notifies you of the price once the wake lands; `pause` holds sessions
15
+ at or above the threshold (`held: context ~152k tokens` in status) until
16
+ `unsnooze resume-now`, which always bypasses the guard. The estimate also
17
+ shows per-session in `unsnooze status` (`ctx ~152k tok`). Claude Code only
18
+ for now — the agent-adapter hook (`contextTokens`) is open for Codex, whose
19
+ rollout `token_count` events carry the same data. Prompted by r/ClaudeAI
20
+ feedback on a resume consuming 30% of a 5h quota.
21
+
22
+ ## 1.8.1 — 2026-07-13
23
+
24
+ - **Discovery / SEO**: package description and keywords expanded so npm and
25
+ GitHub surface Qwen Code, Kimi CLI, OpenCode, Antigravity, OpenRouter, and
26
+ Zellij alongside Claude/Codex/Grok. README comparison table updated to match.
27
+
5
28
  ## 1.8.0 — 2026-07-13
6
29
 
7
30
  - **Notification channels** (`notifyChannel`: `auto` | `native` | `osc` |
package/README.md CHANGED
@@ -29,7 +29,7 @@ existing tool solves only a slice of it:
29
29
 
30
30
  | | **unsnooze** | claude-auto-retry | autoclaude | hydra |
31
31
  |---|:---:|:---:|:---:|:---:|
32
- | Multi-CLI (Claude Code + Codex + Grok) | ✅ | ❌ Claude only | ❌ Claude only | ✅ |
32
+ | Multi-CLI (Claude · Codex · Grok · Qwen · Kimi · OpenCode · Antigravity) | ✅ | ❌ Claude only | ❌ Claude only | partial |
33
33
  | GUI sessions (VS Code ext, desktop apps) | ✅ watcher daemon | ❌ | ❌ | ❌ |
34
34
  | Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
35
35
  | All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
@@ -200,6 +200,8 @@ unsnooze help # full command list (also -h / --help)
200
200
  | `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
201
201
  | `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
202
202
  | `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
203
+ | `contextGuard` | `inform` | Big cold context at wake? Waking a session re-reads its **entire context at full uncached price** ([why](#why-did-resuming-a-big-session-eat-so-much-of-my-quota)). `inform` resumes and notifies you of the size; `pause` holds sessions above the threshold for `unsnooze resume-now`; `off` disables. Claude Code only for now. |
204
+ | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
203
205
  | `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. |
204
206
 
205
207
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
@@ -258,6 +260,12 @@ watcher stops (no pane context) always use native.
258
260
  the wake, the resumed agent is told to re-read before acting — or, with
259
261
  `workspaceGuard=pause`, the session is held and `unsnooze resume-now`
260
262
  shows the diff first.
263
+ - **Cold-cache wake guard**: waking a session after hours means the provider's
264
+ prompt cache is long expired — the first message re-reads the entire
265
+ context at full price, and a fat session can eat a real slice of the fresh
266
+ quota window. unsnooze estimates the size from the session transcript
267
+ (shown as `ctx ~152k tok` in `unsnooze status`) and, per `contextGuard`,
268
+ notifies you or holds the session instead of spending it silently.
261
269
 
262
270
  ## Requirements
263
271
 
@@ -327,6 +335,25 @@ continuing"). Set `workspaceGuard` to `pause` to hold such sessions for a
327
335
  manual `unsnooze resume-now` (which prints the diff stat), or `off` to
328
336
  disable the check.
329
337
 
338
+ ### Why did resuming a big session eat so much of my quota?
339
+
340
+ Because of prompt-cache expiry, not because of unsnooze. Providers cache your
341
+ session's context so each turn only pays full price for what's new — but that
342
+ cache lives ~5 minutes. After hours stopped at a limit, the cache is long
343
+ gone, so the **first** wake message (unsnooze's, or a hand-typed "continue" —
344
+ identical cost either way; the wake message itself is ~30 tokens) makes the
345
+ API re-read the entire conversation at full uncached price. A ~150k-token
346
+ session on a top-tier model can cost a meaningful slice of a fresh 5-hour
347
+ window the moment it wakes, before any new work happens.
348
+
349
+ What actually helps: `/compact` (or starting a fresh session with a handoff
350
+ summary) *before* you hit the limit, and keeping overnight sessions lean.
351
+ What unsnooze does: the `contextGuard` setting estimates the context size
352
+ from the session transcript before waking. `inform` (default) resumes and
353
+ notifies you of the price; `pause` holds sessions above `contextGuardTokens`
354
+ (default 100k) until you decide with `unsnooze resume-now`; the estimate is
355
+ also visible per-session in `unsnooze status` (`ctx ~152k tok`).
356
+
330
357
  ### Does it work if my laptop was asleep or the terminal was closed?
331
358
 
332
359
  Yes — reset times are stored as absolute timestamps and checked every 30
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.8.0",
4
- "description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity CLI sessions in tmux or Zellij when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
3
+ "version": "1.9.0",
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",
7
7
  "claude-code",
@@ -10,19 +10,25 @@
10
10
  "openai",
11
11
  "anthropic",
12
12
  "grok",
13
+ "xai",
13
14
  "qwen",
14
15
  "qwen-code",
15
16
  "kimi",
16
17
  "kimi-cli",
18
+ "moonshot",
17
19
  "opencode",
18
20
  "antigravity",
19
21
  "antigravity-cli",
22
+ "gemini",
23
+ "google",
20
24
  "openrouter",
25
+ "chatgpt",
21
26
  "usage-limit",
22
27
  "usage limit",
23
28
  "rate-limit",
24
29
  "rate limit",
25
30
  "5-hour limit",
31
+ "weekly limit",
26
32
  "auto-resume",
27
33
  "auto-retry",
28
34
  "resume",
@@ -31,7 +37,8 @@
31
37
  "zellij",
32
38
  "ai-agents",
33
39
  "agentic-coding",
34
- "cli"
40
+ "cli",
41
+ "developer-tools"
35
42
  ],
36
43
  "author": "Saaransh Menon <saaransh.dev2811@gmail.com>",
37
44
  "license": "MIT",
@@ -3,7 +3,7 @@
3
3
  // session-id lookup, and (claude-only) the interactive limit-menu driver.
4
4
 
5
5
  import { contentLines } from '../patterns.js';
6
- import { latestSessionId } from '../sessions.js';
6
+ import { latestSessionId, transcriptPath, lastUsageTokens } from '../sessions.js';
7
7
 
8
8
  export const patterns = {
9
9
  // Claude Code renders limits across multiple TUI lines, e.g.:
@@ -93,6 +93,13 @@ export default {
93
93
  return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
94
94
  },
95
95
  latestSessionId,
96
+ // Estimated tokens the API re-reads on a cold wake (prompt cache long
97
+ // expired). null → unknown, contextGuard skips. Adapters without this
98
+ // method are unguarded.
99
+ contextTokens(rec) {
100
+ if (!rec?.cwd || !rec?.sessionId) return null; // watcher/scrape records may lack either
101
+ return lastUsageTokens(transcriptPath(rec.cwd, rec.sessionId));
102
+ },
96
103
  // The foreground process for a claude session is `node` (nvm shim) or `claude`.
97
104
  isForegroundCommand(cmd) {
98
105
  return cmd === 'claude' || cmd === 'node' || cmd === 'unsnooze';
package/src/cli.js CHANGED
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { LOG_FILE, MAX_RESUME_ATTEMPTS } from './config.js';
6
6
  import { readState, setStatus, updateState } from './state.js';
7
+ import { getAgent } from './agents/index.js';
8
+ import { approxTokens } from './sessions.js';
7
9
  import { getConfig, setConfigValue, listConfig, CONFIG_FILE } from './settings.js';
8
10
  import { spawnResumerIfNeeded } from './spawn.js';
9
11
 
@@ -35,10 +37,18 @@ export function cmdStatus() {
35
37
  ? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
36
38
  : '';
37
39
  const hold = s.workspaceHold
38
- ? ` · workspace changed (${s.holdReason ?? '?'}) — resume-now to wake`
40
+ ? ` · held: ${s.holdReason ?? '?'} — resume-now to wake`
39
41
  : '';
42
+ // The price of waking: estimated context tokens the API re-reads cold.
43
+ let ctx = '';
44
+ if (s.status === 'stopped') {
45
+ try {
46
+ const t = getAgent(s.agent).contextTokens?.(s);
47
+ if (t != null) ctx = ` · ctx ${approxTokens(t)} tok`;
48
+ } catch { /* estimate unavailable — omit */ }
49
+ }
40
50
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
41
- console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
51
+ console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${ctx}${hold}`);
42
52
  }
43
53
  return 0;
44
54
  }
package/src/resumer.js CHANGED
@@ -17,6 +17,7 @@ import { detectLimit, isBusy } from './patterns.js';
17
17
  import { getAgent } from './agents/index.js';
18
18
  import { parseResetTime, resetAtMs } from './time-parser.js';
19
19
  import { readState, updateState, setStatus, dueSessions, activeStopped } from './state.js';
20
+ import { approxTokens } from './sessions.js';
20
21
  import { getConfig, resolveResumeMessage } from './settings.js';
21
22
  import { workspaceFingerprint, workspaceChanged, describeChange } from './workspace.js';
22
23
  import { notify } from './notify.js';
@@ -90,7 +91,7 @@ async function driveMenu(mux, pane, agent, text) {
90
91
  export async function dispatchOne(rec, {
91
92
  mux = resolveRecordMux(rec), resolveMux = null,
92
93
  resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint,
93
- notifier = notify, matchesLease = leaseMatches,
94
+ notifier = notify, matchesLease = leaseMatches, contextTokens = null,
94
95
  } = {}) {
95
96
  resolveMux ||= () => mux;
96
97
  const key = rec.key;
@@ -108,7 +109,7 @@ export async function dispatchOne(rec, {
108
109
  if (change) {
109
110
  const desc = describeChange(change);
110
111
  if (guardMode === 'pause') {
111
- setStatus(key, 'stopped', { workspaceHold: true, holdReason: desc });
112
+ setStatus(key, 'stopped', { workspaceHold: true, holdReason: `workspace changed (${desc})` });
112
113
  notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`, { context: ctxOf(rec) });
113
114
  log(`${key}: workspace changed (${desc}) — held (workspaceGuard=pause)`);
114
115
  return 'held';
@@ -118,6 +119,36 @@ export async function dispatchOne(rec, {
118
119
  }
119
120
  }
120
121
 
122
+ // Context-size guard: the prompt cache expired hours ago, so the first wake
123
+ // message re-reads the session's entire context at full (uncached) price.
124
+ // Estimate the size from the agent's transcript; `pause` holds big sessions,
125
+ // `inform` notifies the user — but only once the wake actually lands, since
126
+ // dispatchOne re-runs on every busy/retry tick. Manual resumes proceed.
127
+ let contextNote = null;
128
+ const ctxGuardMode = getConfig('contextGuard');
129
+ if (ctxGuardMode !== 'off' && !rec.manual) {
130
+ const estimate = contextTokens
131
+ ?? (typeof agent.contextTokens === 'function' ? r => agent.contextTokens(r) : null);
132
+ let tokens = null;
133
+ try { tokens = estimate ? estimate(rec) : null; } catch { /* unknown size — skip */ }
134
+ if (tokens != null && tokens >= getConfig('contextGuardTokens')) {
135
+ const size = approxTokens(tokens);
136
+ if (ctxGuardMode === 'pause') {
137
+ setStatus(key, 'stopped', { workspaceHold: true, holdReason: `context ${size} tokens` });
138
+ notifier('unsnooze: session held', `${rec.cwd}: waking would re-read ${size} tokens of context at full (uncached) price — run: unsnooze resume-now`, { context: ctxOf(rec) });
139
+ log(`${key}: context ${size} tokens ≥ threshold — held (contextGuard=pause)`);
140
+ return 'held';
141
+ }
142
+ contextNote = `${rec.cwd}: waking a ${size}-token session — its full context is re-read at full (uncached) price`;
143
+ log(`${key}: context ${size} tokens — informing (contextGuard=inform)`);
144
+ }
145
+ }
146
+ const notifyContext = () => {
147
+ if (contextNote) notifier('unsnooze: big-context wake', contextNote, { context: ctxOf(rec) });
148
+ };
149
+ const reopenGuarded = () =>
150
+ reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onDelivered: notifyContext });
151
+
121
152
  // Live-pane path: only if the pane still exists AND the agent CLI is its
122
153
  // foreground command (pane ids get recycled — never inject into a random
123
154
  // program).
@@ -140,7 +171,7 @@ export async function dispatchOne(rec, {
140
171
  const authorized = owned && contentOwned;
141
172
 
142
173
  if (menu) {
143
- if (!authorized) return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd });
174
+ if (!authorized) return reopenGuarded();
144
175
  if (!getConfig('menuAutoAnswer')) return 'held';
145
176
  try {
146
177
  if (await driveMenu(mux, rec.pane, agent, text)) return 'progress';
@@ -154,15 +185,18 @@ export async function dispatchOne(rec, {
154
185
  setStatus(key, 'resuming', { lastAttemptAt: Date.now(), lastError: null, verifyRetries: 0 });
155
186
  await mux.sendText(rec.pane, resumeMessage);
156
187
  log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
188
+ notifyContext();
157
189
  return 'injected';
158
190
  }
159
191
  if (owned) return 'busy';
160
192
  }
161
193
 
162
- return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd });
194
+ return reopenGuarded();
163
195
  }
164
196
 
165
- async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd }) {
197
+ // onDelivered fires only when the resume message actually reached the agent
198
+ // (argv or typed) — not on ready-timeouts or a still-active limit banner.
199
+ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onDelivered = () => {} }) {
166
200
  const key = rec.key;
167
201
  const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
168
202
  const leaseId = createLeaseId();
@@ -193,7 +227,7 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd }) {
193
227
 
194
228
  // The resume prompt traveled in argv (e.g. `codex resume <id> "msg"`) —
195
229
  // nothing to type into the TUI; verifyOne checks the outcome later.
196
- if (!resume.messageViaPane) return 'reopen';
230
+ if (!resume.messageViaPane) { onDelivered(); return 'reopen'; }
197
231
 
198
232
  // Wait for the TUI to be ready (input prompt visible), then send the message.
199
233
  const deadline = Date.now() + READY_TIMEOUT_MS;
@@ -210,6 +244,7 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd }) {
210
244
  if (!isBusy(text, agent.patterns.busyPatterns) && agent.patterns.idleRegex.test(text)) {
211
245
  await mux.sendText(address.pane, resumeMessage);
212
246
  log(`${key}: resume message sent to new pane ${address.pane}`);
247
+ onDelivered();
213
248
  return 'reopen';
214
249
  }
215
250
  }
package/src/sessions.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // Lookup helpers for Claude Code's transcript store (~/.claude/projects/).
2
2
  // Used to backfill sessionId when detection came from pane scraping and the
3
- // StopFailure hook didn't supply one.
3
+ // StopFailure hook didn't supply one, and to estimate a session's context
4
+ // size before waking it (contextGuard).
4
5
 
5
- import { readdirSync, statSync } from 'node:fs';
6
+ import { readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
6
7
  import { join } from 'node:path';
7
8
  import { CLAUDE_DIR } from './config.js';
8
9
 
@@ -37,3 +38,50 @@ export function latestSessionId(cwd, aroundTs = null) {
37
38
  }
38
39
  return best ? best.id : null;
39
40
  }
41
+
42
+ export function transcriptPath(cwd, sessionId) {
43
+ return join(projectDir(cwd), `${sessionId}.jsonl`);
44
+ }
45
+
46
+ export function approxTokens(n) {
47
+ return n >= 1000 ? `~${Math.round(n / 1000)}k` : `~${n}`;
48
+ }
49
+
50
+ // Current context size of a session ≈ the last assistant entry's usage block:
51
+ // everything the API read on that turn (fresh + cached) plus what it wrote.
52
+ // Tail-read only — transcripts reach tens of MB — doubling the window until a
53
+ // usage entry appears or maxWindow is hit. null = unknown (missing file, no
54
+ // usage found); callers skip the guard, like workspaceFingerprint's null.
55
+ export function lastUsageTokens(path, { window = 256 * 1024, maxWindow = 4 * 1024 * 1024 } = {}) {
56
+ let fd;
57
+ try {
58
+ const { size } = statSync(path);
59
+ fd = openSync(path, 'r');
60
+ for (;;) {
61
+ const len = Math.min(window, size);
62
+ const buf = Buffer.alloc(len);
63
+ readSync(fd, buf, 0, len, size - len);
64
+ let text = buf.toString('utf-8');
65
+ if (len < size) text = text.slice(text.indexOf('\n') + 1); // drop the partial first line
66
+ const lines = text.split('\n');
67
+ for (let i = lines.length - 1; i >= 0; i--) {
68
+ const line = lines[i].trim();
69
+ if (!line) continue;
70
+ let entry;
71
+ try { entry = JSON.parse(line); } catch { continue; }
72
+ if (entry.isSidechain === true) continue; // subagent traffic doesn't ride the main context
73
+ const u = entry.message?.usage;
74
+ if (!u || typeof u !== 'object') continue;
75
+ const sum = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0)
76
+ + (u.cache_read_input_tokens || 0) + (u.output_tokens || 0);
77
+ if (sum > 0) return sum; // zero-sum = synthetic/error entry, keep scanning
78
+ }
79
+ if (len >= size || window >= maxWindow) return null;
80
+ window = Math.min(window * 2, maxWindow);
81
+ }
82
+ } catch {
83
+ return null;
84
+ } finally {
85
+ if (fd !== undefined) closeSync(fd);
86
+ }
87
+ }
package/src/settings.js CHANGED
@@ -21,6 +21,8 @@ export const DEFAULTS = {
21
21
  guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
22
22
  updateCheck: true, // daily registry version check + update notices/toast
23
23
  workspaceGuard: 'inform', // repo changed while stopped: off | inform | pause
24
+ contextGuard: 'inform', // wake re-reads a big cold context: off | inform | pause
25
+ contextGuardTokens: 100_000, // contextGuard notify/hold threshold (tokens)
24
26
  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.',
25
27
  resumeMessages: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' }, // per-agent override; '' = use resumeMessage
26
28
  agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false }, // experimental agents default off
@@ -36,6 +38,8 @@ const ENV_NAMES = {
36
38
  guiWatch: 'UNSNOOZE_GUI_WATCH',
37
39
  updateCheck: 'UNSNOOZE_UPDATE_CHECK',
38
40
  workspaceGuard: 'UNSNOOZE_WORKSPACE_GUARD',
41
+ contextGuard: 'UNSNOOZE_CONTEXT_GUARD',
42
+ contextGuardTokens: 'UNSNOOZE_CONTEXT_GUARD_TOKENS',
39
43
  resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
40
44
  'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
41
45
  'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
@@ -59,6 +63,7 @@ const KNOWN_KEYS = Object.keys(ENV_NAMES);
59
63
  const ENUMS = {
60
64
  multiplexer: ['auto', 'tmux', 'zellij'],
61
65
  workspaceGuard: ['off', 'inform', 'pause'],
66
+ contextGuard: ['off', 'inform', 'pause'],
62
67
  notifyChannel: ['auto', 'native', 'osc', 'bell'],
63
68
  };
64
69
 
@@ -90,12 +95,22 @@ export function getConfig(key) {
90
95
  if (typeof def === 'boolean') {
91
96
  const b = parseBool(env);
92
97
  if (b !== null) return b;
98
+ } else if (typeof def === 'number') {
99
+ const n = parseInt(env, 10);
100
+ if (Number.isFinite(n)) return n;
93
101
  } else {
94
102
  return env;
95
103
  }
96
104
  }
97
105
  const fromFile = dig(readFileConfig(), key);
98
- return fromFile !== undefined ? fromFile : def;
106
+ if (fromFile === undefined) return def;
107
+ if (typeof def === 'number') {
108
+ // Hand-edited files may hold anything; a non-numeric value must not
109
+ // silently disable a threshold — fall back to the default instead.
110
+ const n = Number(fromFile);
111
+ return Number.isFinite(n) ? n : def;
112
+ }
113
+ return fromFile;
99
114
  }
100
115
 
101
116
  // The message to send a given agent: its resumeMessages.<id> override when
@@ -117,6 +132,10 @@ export function setConfigValue(key, rawValue) {
117
132
  const b = typeof rawValue === 'boolean' ? rawValue : parseBool(String(rawValue));
118
133
  if (b === null) throw new Error(`unsnooze: "${key}" needs a boolean (on/off, true/false)`);
119
134
  value = b;
135
+ } else if (typeof def === 'number') {
136
+ const n = parseInt(String(rawValue), 10);
137
+ if (!Number.isFinite(n) || n <= 0) throw new Error(`unsnooze: "${key}" needs a positive integer`);
138
+ value = n;
120
139
  } else {
121
140
  value = String(rawValue);
122
141
  if (ENUMS[key] && !ENUMS[key].includes(value)) {