unsnooze 1.8.1 → 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 +17 -0
- package/README.md +27 -0
- package/package.json +1 -1
- package/src/agents/claude.js +8 -1
- package/src/cli.js +12 -2
- package/src/resumer.js +41 -6
- package/src/sessions.js +50 -2
- package/src/settings.js +20 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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
|
+
|
|
5
22
|
## 1.8.1 — 2026-07-13
|
|
6
23
|
|
|
7
24
|
- **Discovery / SEO**: package description and keywords expanded so npm and
|
package/README.md
CHANGED
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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/agents/claude.js
CHANGED
|
@@ -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
|
-
? ` ·
|
|
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
|
|
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
|
|
194
|
+
return reopenGuarded();
|
|
163
195
|
}
|
|
164
196
|
|
|
165
|
-
|
|
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
|
-
|
|
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)) {
|