unsnooze 1.19.5 → 1.20.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.0 — 2026-10-08
4
+
5
+ Window priming starts your 5-hour window before you sit down, and `doctor`
6
+ stops flagging a Claude hook you don't need.
7
+
8
+ ### Window priming (experimental)
9
+
10
+ - Claude's 5-hour window starts with your first message, so starting work at
11
+ 9am means a reset at 2pm. `unsnooze config set primeAt.claude 06:00` has the
12
+ daemon send one tiny Haiku prompt at 6am, so the window resets at 11am and a
13
+ fresh one lands mid-morning. Codex works the same on plans with a 5-hour
14
+ window (`primeAt.codex`).
15
+ - `primeAt.claude auto` works out the time for you: it learns when you usually
16
+ start from the last 3 weeks of your own prompts (ignoring tool results,
17
+ subagents, revivals and late nights running past midnight) and primes 3 hours
18
+ before. It waits for 5 days of history and re-learns daily.
19
+ - Every prime checks the reset time in the reply and reports whether it
20
+ started the window, found one already running, or found no 5-hour window on
21
+ the plan. `unsnooze prime` shows the schedule and last result;
22
+ `unsnooze prime now` runs one immediately. `primeDays weekdays` skips
23
+ weekends.
24
+ - A prime that fails (say, the network is not back yet after waking) retries
25
+ up to three times, five minutes apart, and notifies once.
26
+ - A prime that hits a limit is never treated as a session to revive.
27
+
28
+ ### `unsnooze doctor`
29
+
30
+ - No longer reports a missing Claude StopFailure hook when Claude is disabled.
31
+ `unsnooze install --yes` skips that hook for a disabled Claude, so the
32
+ finding could never be cleared (#44).
33
+
3
34
  ## 1.19.5 — 2026-10-05
4
35
 
5
36
  Sessions resume again under psmux, Antigravity no longer wakes early, and
package/README.md CHANGED
@@ -128,6 +128,12 @@ reset, and unsnooze does that for every stopped session automatically.
128
128
  you would, resumes once, and checks the limit actually lifted. It replaces the
129
129
  4am alarm, not the limit.
130
130
 
131
+ **Can it start my 5-hour window before I sit down?** Yes, experimentally:
132
+ `unsnooze config set primeAt.claude auto` learns when you usually start and has
133
+ the daemon send one tiny Haiku prompt 3 hours earlier, so the window resets
134
+ mid-session instead of 5 hours in. A fixed time like `06:00` works too. Claude
135
+ and Codex only. See [window priming](https://unsnooze.dev/docs/commands/#prime).
136
+
131
137
  More questions and troubleshooting: [unsnooze.dev/docs/troubleshooting](https://unsnooze.dev/docs/troubleshooting/).
132
138
 
133
139
  ## Documentation
package/bin/unsnooze.js CHANGED
@@ -159,6 +159,10 @@ async function main() {
159
159
  const { cmdUsage } = await import('../src/usage.js');
160
160
  return cmdUsage(rest);
161
161
  }
162
+ case 'prime': {
163
+ const { cmdPrime } = await import('../src/prime.js');
164
+ return cmdPrime(rest);
165
+ }
162
166
  case 'hosts': {
163
167
  const { cmdHosts } = await import('../src/fleet.js');
164
168
  return cmdHosts(rest);
@@ -364,6 +368,8 @@ Usage:
364
368
  unsnooze prompt remove <id> cancel a queued prompt
365
369
  unsnooze prompt clear cancel all pending queued prompts
366
370
  unsnooze usage [--json] account burn rate & time-to-limit forecast
371
+ unsnooze prime [now [agent]] window priming (experimental): start the 5-hour
372
+ window early — set a time with primeAt.claude
367
373
  (--install-statusline for exact Claude %,
368
374
  --uninstall-statusline to remove it)
369
375
  unsnooze design [setup] Claude Design from the terminal: check whether
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.5",
3
+ "version": "1.20.0",
4
4
  "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
5
5
  "keywords": [
6
6
  "claude",
package/src/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
- import { join, resolve } from 'node:path';
2
+ import { join, resolve, win32 } from 'node:path';
3
3
  import {
4
4
  mkdirSync, chmodSync, writeFileSync, renameSync, lstatSync, statSync, readdirSync,
5
5
  } from 'node:fs';
@@ -14,6 +14,20 @@ export const STATE_FILE = join(STATE_DIR, 'state.json');
14
14
  export const LOCK_DIR = join(STATE_DIR, 'state.lock');
15
15
  export const LOG_FILE = join(STATE_DIR, 'unsnooze.log');
16
16
  export const EVENTS_DIR = join(STATE_DIR, 'events');
17
+ // Working dir for window primes (prime.js) — the watcher ignores stops in it.
18
+ export const PRIME_DIR = join(STATE_DIR, 'prime');
19
+
20
+ // Is this (agent-recorded) cwd the prime dir? Codex may record it with a
21
+ // \\?\ prefix or another drive-letter case on Windows.
22
+ export function isPrimeDir(dir, primeDir = PRIME_DIR, platform = process.platform) {
23
+ if (!dir) return false;
24
+ const norm = p => {
25
+ const raw = String(p).replace(/^\\\\\?\\/, '');
26
+ return platform === 'win32' ? win32.resolve(raw).toLowerCase() : resolve(raw);
27
+ };
28
+ return norm(dir) === norm(primeDir);
29
+ }
30
+
17
31
  export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
18
32
  // High-frequency burn accumulator + warn-dedup (daemon single-writer).
19
33
  export const USAGE_FILE = join(STATE_DIR, 'usage.json');
package/src/doctor.js CHANGED
@@ -238,7 +238,9 @@ export async function runDoctor({
238
238
  });
239
239
  }
240
240
 
241
- if (!hookInstalled()) {
241
+ // install only writes the hook for an enabled claude, so a codex-only
242
+ // setup must not be told to run an install that can never clear this.
243
+ if (enabled('claude') && !hookInstalled()) {
242
244
  findings.push({
243
245
  id: 'hook-missing', kind: 'health',
244
246
  title: 'Claude StopFailure hook is not installed',
package/src/hook.js CHANGED
@@ -62,6 +62,8 @@ export async function runHook(rest = [], { ensureDaemonFn = ensureDaemon } = {})
62
62
  const agentIdx = rest.indexOf('--agent');
63
63
  const agent = getAgent(agentIdx !== -1 ? rest[agentIdx + 1] : 'claude');
64
64
  if (!getConfig(`agents.${agent.id}`)) return 0; // agent disabled in settings
65
+ // A window prime (prime.js) is a throwaway one-shot — never a session to revive.
66
+ if (process.env.UNSNOOZE_PRIME) return 0;
65
67
  // Nothing keeps the daemon running on native Windows (see ensureDaemon),
66
68
  // and the hook is the one entry point GUI Claude sessions pass through —
67
69
  // so it makes sure the daemon is up. A no-op everywhere else.
@@ -0,0 +1,172 @@
1
+ // primeAt.<id> = auto: learn when the user starts work from their own local
2
+ // history, and prime LEAD_MS before it so the reset lands a couple of hours
3
+ // into the session.
4
+ //
5
+ // "Starts work" = the first human prompt of a day that follows IDLE_GAP_MS of
6
+ // no prompts — a window's worth of quiet. That gap rule is what keeps a late
7
+ // night running past midnight from reading as a 00:10 start. Only human
8
+ // prompts count: tool results, subagent turns, unsnooze's own resume messages
9
+ // and primes are activity unsnooze or the agent produced, and a 4am revival
10
+ // must not teach it that the user starts at 4am.
11
+ //
12
+ // Usage outside these agents' local history (claude.ai, the desktop app) is
13
+ // invisible here; a prime that finds the window already running says so.
14
+ //
15
+ // History is large (hundreds of MB of transcripts), so per-file results are
16
+ // cached by size+mtime in ACTIVITY_FILE and only changed files are re-read.
17
+
18
+ import { createReadStream, readdirSync, statSync, readFileSync } from 'node:fs';
19
+ import { createInterface } from 'node:readline';
20
+ import { join, dirname } from 'node:path';
21
+ import { CLAUDE_DIR, CODEX_DIR, STATE_DIR, isPrimeDir, writePrivateFile, ensureStateDir } from './config.js';
22
+ import { resolveResumeMessage } from './settings.js';
23
+
24
+ export const LEARN_DAYS = 21;
25
+ export const MIN_DAYS = 5;
26
+ const IDLE_GAP_MS = 5 * 3_600_000;
27
+ export const LEAD_MS = 3 * 3_600_000;
28
+ const BUCKET_MS = 5 * 60_000;
29
+ const DAY_MS = 86_400_000;
30
+
31
+ export const ACTIVITY_FILE = () => join(STATE_DIR, 'prime-activity.json');
32
+
33
+ function textOf(content) {
34
+ if (typeof content === 'string') return content;
35
+ if (!Array.isArray(content)) return null;
36
+ if (content.some(b => b?.type === 'tool_result')) return null;
37
+ const parts = content.filter(b => b?.type === 'text' || b?.type === 'input_text').map(b => b.text || '');
38
+ return parts.length ? parts.join('\n') : null;
39
+ }
40
+
41
+ // Epoch ms of a human prompt on this line, else null.
42
+ export function promptTimestamp(agentId, line, { resumePrefix = '' } = {}) {
43
+ if (agentId === 'claude' ? !line.includes('"type":"user"') : !line.includes('"role":"user"')) return null;
44
+ let j;
45
+ try { j = JSON.parse(line); } catch { return null; }
46
+ let text;
47
+ if (agentId === 'claude') {
48
+ if (j.type !== 'user' || j.isSidechain || j.isMeta || j.isCompactSummary) return null;
49
+ text = textOf(j.message?.content);
50
+ } else {
51
+ const p = j.payload;
52
+ if (j.type !== 'response_item' || p?.type !== 'message' || p.role !== 'user') return null;
53
+ text = textOf(p.content);
54
+ // Codex injects <environment_context>, <user_instructions> and AGENTS.md
55
+ // as user messages at every session start — revivals included.
56
+ if (text != null && /^\s*(<[a-z_]+>|# AGENTS\.md)/i.test(text)) return null;
57
+ }
58
+ if (text == null) return null;
59
+ if (resumePrefix && text.trimStart().startsWith(resumePrefix)) return null;
60
+ const ts = Date.parse(j.timestamp);
61
+ return Number.isFinite(ts) ? ts : null;
62
+ }
63
+
64
+ // Claude: projects/<dashed-cwd>/<session>.jsonl. Codex: sessions/YYYY/MM/DD/.
65
+ export function historyFiles(agentId, { since, claudeDir = CLAUDE_DIR, codexDir = CODEX_DIR } = {}) {
66
+ const out = [];
67
+ const ls = dir => { try { return readdirSync(dir); } catch { return []; } };
68
+ const add = path => {
69
+ try {
70
+ const st = statSync(path);
71
+ if (st.isFile() && st.mtimeMs >= since) out.push({ path, size: st.size, mtimeMs: st.mtimeMs });
72
+ } catch { /* vanished */ }
73
+ };
74
+ if (agentId === 'claude') {
75
+ const root = join(claudeDir, 'projects');
76
+ for (const d of ls(root)) for (const f of ls(join(root, d))) if (f.endsWith('.jsonl')) add(join(root, d, f));
77
+ } else {
78
+ const root = join(codexDir, 'sessions');
79
+ for (const y of ls(root)) for (const m of ls(join(root, y))) for (const d of ls(join(root, y, m))) {
80
+ // Day dirs older than the window cannot hold anything newer than it.
81
+ if (Date.parse(`${y}-${m}-${d}T00:00:00Z`) < since - DAY_MS) continue;
82
+ for (const f of ls(join(root, y, m, d))) if (f.endsWith('.jsonl')) add(join(root, y, m, d, f));
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ async function scanFile(agentId, path, resumePrefix) {
89
+ const buckets = new Set();
90
+ const rl = createInterface({ input: createReadStream(path, { encoding: 'utf-8' }), crlfDelay: Infinity });
91
+ let first = true;
92
+ for await (const line of rl) {
93
+ if (first && agentId === 'codex') {
94
+ first = false;
95
+ // A prime's own rollout is not the user starting work.
96
+ try { if (isPrimeDir(JSON.parse(line)?.payload?.cwd)) { rl.close(); return []; } } catch { /* no meta */ }
97
+ }
98
+ const ts = promptTimestamp(agentId, line, { resumePrefix });
99
+ if (ts != null) buckets.add(Math.floor(ts / BUCKET_MS) * BUCKET_MS);
100
+ }
101
+ return [...buckets].sort((a, b) => a - b);
102
+ }
103
+
104
+ // Prompt timestamps (5-minute buckets) over the learning window.
105
+ export async function collectPrompts(agentId, {
106
+ now = Date.now(), cacheFile = ACTIVITY_FILE(), files = null, scan = scanFile, ...dirs
107
+ } = {}) {
108
+ const since = now - LEARN_DAYS * DAY_MS;
109
+ const resumePrefix = resolveResumeMessage(agentId).trim().slice(0, 40);
110
+ let cache = {};
111
+ try { cache = JSON.parse(readFileSync(cacheFile, 'utf-8'))?.[agentId] || {}; } catch { /* first run */ }
112
+ // Cached timestamps were filtered with the resume message of their day.
113
+ if (cache.resumePrefix !== resumePrefix) cache = {};
114
+ const cachedFiles = cache.files || {};
115
+ const next = {};
116
+ const all = [];
117
+ for (const f of files || historyFiles(agentId, { since, ...dirs })) {
118
+ const hit = cachedFiles[f.path];
119
+ const ts = hit && hit.size === f.size && hit.mtimeMs === f.mtimeMs
120
+ ? hit.ts
121
+ : await scan(agentId, f.path, resumePrefix).catch(() => []);
122
+ next[f.path] = { size: f.size, mtimeMs: f.mtimeMs, ts };
123
+ for (const t of ts) if (t >= since) all.push(t);
124
+ }
125
+ try {
126
+ let whole = {};
127
+ try { whole = JSON.parse(readFileSync(cacheFile, 'utf-8')) || {}; } catch { /* fresh */ }
128
+ whole[agentId] = { resumePrefix, files: next };
129
+ ensureStateDir(dirname(cacheFile));
130
+ writePrivateFile(cacheFile, `${cacheFile}.tmp.${process.pid}`, JSON.stringify(whole));
131
+ } catch { /* cache is an optimisation */ }
132
+ return all.sort((a, b) => a - b);
133
+ }
134
+
135
+ const minuteOfDay = ms => { const d = new Date(ms); return d.getHours() * 60 + d.getMinutes(); };
136
+
137
+ // The usual start time (minutes after local midnight): for each day, the
138
+ // prompt after the longest quiet of at least IDLE_GAP_MS — the one after
139
+ // sleep, not a stray 00:30 prompt after an evening break — then the median.
140
+ // The earliest prompt on record is never a start: what came before it is
141
+ // unknown. null until MIN_DAYS days qualify.
142
+ export function learnStart(timestamps, { days = 'daily' } = {}) {
143
+ const perDay = new Map();
144
+ for (let i = 1; i < timestamps.length; i++) {
145
+ const t = timestamps[i];
146
+ const gap = t - timestamps[i - 1];
147
+ if (gap < IDLE_GAP_MS) continue;
148
+ const d = new Date(t);
149
+ if (days === 'weekdays' && (d.getDay() === 0 || d.getDay() === 6)) continue;
150
+ const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
151
+ if (!perDay.has(key) || gap > perDay.get(key).gap) perDay.set(key, { gap, minutes: minuteOfDay(t) });
152
+ }
153
+ const mins = [...perDay.values()].map(v => v.minutes).sort((a, b) => a - b);
154
+ if (mins.length < MIN_DAYS) return { minutes: null, days: mins.length };
155
+ const mid = mins.length >> 1;
156
+ const minutes = mins.length % 2 ? mins[mid] : Math.round((mins[mid - 1] + mins[mid]) / 2);
157
+ return { minutes, days: mins.length };
158
+ }
159
+
160
+ export const hhmm = minutes => {
161
+ const m = ((minutes % 1440) + 1440) % 1440;
162
+ return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
163
+ };
164
+
165
+ // Learn and turn into a prime slot: { start, slot, wrap, days } or
166
+ // { slot: null, days }. wrap: the slot falls the evening before (start < 03:00).
167
+ export async function learnSlot(agentId, { days = 'daily', ...opts } = {}) {
168
+ const learned = learnStart(await collectPrompts(agentId, opts), { days });
169
+ if (learned.minutes == null) return { slot: null, start: null, days: learned.days };
170
+ const slotMinutes = learned.minutes - LEAD_MS / 60_000;
171
+ return { start: hhmm(learned.minutes), slot: hhmm(slotMinutes), wrap: slotMinutes < 0, days: learned.days };
172
+ }
package/src/prime.js ADDED
@@ -0,0 +1,384 @@
1
+ // Window priming (experimental). Claude's 5-hour window — and Codex's, on
2
+ // plans that have one — starts on the first message after a reset, not on a
3
+ // clock. One tiny prompt to the cheapest model at a chosen time (primeAt.<id>,
4
+ // or `auto` — learned from history, see prime-learn.js) starts it early, so
5
+ // the reset lands mid-workday instead of mid-afternoon.
6
+ //
7
+ // Only claude and codex: Cursor and Grok reset on a fixed billing clock,
8
+ // Qwen's 5h quota slides per request (and its terms forbid scheduled
9
+ // non-interactive use), and Kimi/Antigravity are unverified.
10
+ //
11
+ // Every prime checks itself: the reply carries the window's reset time, and a
12
+ // reset ~5h after the prime means it started the window. Anything else is
13
+ // reported as it is — already running, no 5-hour window on this plan, failed.
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import { readdirSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { PRIME_DIR, CODEX_DIR } from './config.js';
19
+ import { getConfig } from './settings.js';
20
+ import { getAgent } from './agents/index.js';
21
+ import { updateState, readState } from './state.js';
22
+ import { extractCodexUsage } from './usage.js';
23
+ import { makeLogger } from './logger.js';
24
+ import { learnSlot, MIN_DAYS } from './prime-learn.js';
25
+ import { standaloneEnv } from './spawn.js';
26
+
27
+ const log = makeLogger('prime');
28
+
29
+ export const PRIME_AGENTS = ['claude', 'codex'];
30
+ const PROMPT = 'Reply with just: ok';
31
+ const FIVE_HOURS_MS = 5 * 3_600_000;
32
+ // A window started by this prime resets 5h after it, give or take the
33
+ // run time and the minute the server rounds to.
34
+ const STARTED_SLACK_MS = 10 * 60_000;
35
+ // A machine asleep at the scheduled time still primes on wake, within this.
36
+ // Later than that the day is skipped — the user is likely working by then.
37
+ export const GRACE_MS = 4 * 3_600_000;
38
+ const RUN_TIMEOUT_MS = 3 * 60_000;
39
+
40
+ // "6:00" / "06:00" → "06:00"; "auto"; "" / "off" → ""; anything else → null.
41
+ export function normalizePrimeAt(raw) {
42
+ const value = String(raw ?? '').trim();
43
+ if (value === '' || /^off$/i.test(value)) return '';
44
+ if (/^auto$/i.test(value)) return 'auto';
45
+ const m = value.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
46
+ return m ? `${m[1].padStart(2, '0')}:${m[2]}` : null;
47
+ }
48
+
49
+ export function dayKey(ms) {
50
+ const d = new Date(ms);
51
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
52
+ }
53
+
54
+ // The day a prime is due for right now, as a dayKey, else null. The slot is
55
+ // local time, open for GRACE_MS — yesterday's slot too, so a machine asleep
56
+ // across midnight still primes on wake. `wrap` marks a slot that falls the
57
+ // evening before the day it serves (auto, for a start before 03:00): the
58
+ // weekday check and the once-a-day key belong to that next day.
59
+ export function dueDay({ at, days = 'daily', lastDay = null, now = Date.now(), wrap = false }) {
60
+ const hhmm = normalizePrimeAt(at);
61
+ if (!hhmm || hhmm === 'auto') return null; // auto resolves to a slot first
62
+ const [h, m] = hhmm.split(':').map(Number);
63
+ const d = new Date(now);
64
+ for (const back of [0, 1]) {
65
+ const slot = new Date(d.getFullYear(), d.getMonth(), d.getDate() - back, h, m);
66
+ if (now < slot.getTime() || now - slot.getTime() > GRACE_MS) continue;
67
+ const served = new Date(slot.getFullYear(), slot.getMonth(), slot.getDate() + (wrap ? 1 : 0));
68
+ if (days === 'weekdays' && (served.getDay() === 0 || served.getDay() === 6)) continue;
69
+ const key = dayKey(served.getTime());
70
+ if (key !== lastDay) return key;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ export const primeDue = opts => dueDay(opts) !== null;
76
+
77
+ // Codex model slugs vary by account, so the default is the first "luna" or
78
+ // "mini" model this install has cached, else codex's own default.
79
+ export function codexPrimeModel({ codexDir = CODEX_DIR } = {}) {
80
+ try {
81
+ const cache = JSON.parse(readFileSync(join(codexDir, 'models_cache.json'), 'utf-8'));
82
+ const list = Array.isArray(cache) ? cache : cache.models || [];
83
+ const slug = list.map(m => m?.slug || m?.id).find(s => /luna|mini/i.test(s || ''));
84
+ return slug || '';
85
+ } catch { return ''; }
86
+ }
87
+
88
+ export function primeModel(agentId, opts) {
89
+ const set = getConfig(`primeModel.${agentId}`);
90
+ if (set) return set;
91
+ return agentId === 'claude' ? 'haiku' : codexPrimeModel(opts);
92
+ }
93
+
94
+ export function primeArgs(agentId, { model = '', cwd = PRIME_DIR } = {}) {
95
+ if (agentId === 'claude') {
96
+ // stream-json --verbose is what carries the rate_limit_event readback.
97
+ return ['-p', PROMPT, ...(model ? ['--model', model] : []), '--no-session-persistence',
98
+ '--output-format', 'stream-json', '--verbose'];
99
+ }
100
+ // No --ephemeral: the rollout it suppresses is the readback.
101
+ return ['exec', '--json', ...(model ? ['-m', model] : []), '-c', 'model_reasoning_effort="low"',
102
+ '-s', 'read-only', '--skip-git-repo-check', '-C', cwd, PROMPT];
103
+ }
104
+
105
+ function jsonLines(text) {
106
+ return String(text || '').split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
107
+ }
108
+
109
+ // Claude: the rate_limit_events in the stream-json output. Any one of them
110
+ // may describe another bucket (weekly, overage), so "no 5-hour window" is
111
+ // only the answer when none of them carries one.
112
+ export function parseClaudePrime(stdout) {
113
+ let seen = false;
114
+ for (const m of jsonLines(stdout)) {
115
+ if (m.type !== 'rate_limit_event') continue;
116
+ seen = true;
117
+ const info = m.rate_limit_info || {};
118
+ const five = info.unifiedWindows?.five_hour?.resetsAt
119
+ ?? (info.rateLimitType === 'five_hour' ? info.resetsAt : null);
120
+ if (Number.isFinite(five)) return { resetsAtMs: five * 1000 };
121
+ }
122
+ return seen ? { noWindow: true } : null;
123
+ }
124
+
125
+ // Newest-first day dirs under sessions/YYYY/MM/DD — a prime's rollout is in
126
+ // one of the last couple.
127
+ function recentDayDirs(root, limit = 3) {
128
+ const out = [];
129
+ const desc = dir => { try { return readdirSync(dir).filter(n => /^\d+$/.test(n)).sort().reverse(); } catch { return []; } };
130
+ for (const y of desc(root)) for (const mo of desc(join(root, y))) for (const d of desc(join(root, y, mo))) {
131
+ out.push(join(root, y, mo, d));
132
+ if (out.length >= limit) return out;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ // Codex: thread_id from `exec --json`, then the rollout's last token_count.
138
+ export function parseCodexPrime(stdout, { codexDir = CODEX_DIR } = {}) {
139
+ const started = jsonLines(stdout).find(m => m.type === 'thread.started');
140
+ if (!started?.thread_id) return null;
141
+ for (const dir of recentDayDirs(join(codexDir, 'sessions'))) {
142
+ let name;
143
+ try { name = readdirSync(dir).find(n => n.endsWith(`${started.thread_id}.jsonl`)); } catch { continue; }
144
+ if (!name) continue;
145
+ const lines = readFileSync(join(dir, name), 'utf-8').split('\n');
146
+ let last = null;
147
+ for (const line of lines) last = extractCodexUsage(line) || last;
148
+ if (!last) return null;
149
+ const five = [last.primary, last.secondary].find(w => w?.windowMinutes === 300);
150
+ return five?.resetsAtMs ? { resetsAtMs: five.resetsAtMs } : { noWindow: true, planType: last.planType };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ // stdin closed: `codex exec` with a piped stdin reads it as extra prompt
156
+ // input and exits with no output at all.
157
+ // Resolves on `exit`, not only `close`: a grandchild holding the pipes open
158
+ // must not leave the prime — and with it every later tick — waiting forever.
159
+ function defaultRunner(bin, args, opts) {
160
+ return new Promise(resolve => {
161
+ let stdout = '';
162
+ let stderr = '';
163
+ let child;
164
+ let done = false;
165
+ const timers = [];
166
+ const finish = result => {
167
+ if (done) return;
168
+ done = true;
169
+ timers.forEach(clearTimeout);
170
+ resolve({ stdout, stderr, ...result });
171
+ };
172
+ try {
173
+ child = spawn(bin, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
174
+ } catch (err) { finish({ err }); return; }
175
+ timers.push(setTimeout(() => {
176
+ child.kill();
177
+ // An agent that ignores SIGTERM still has to give the tick back.
178
+ timers.push(setTimeout(() => {
179
+ try { child.kill('SIGKILL'); } catch { /* gone */ }
180
+ finish({ err: Object.assign(new Error('timed out'), { killed: true }) });
181
+ }, 5_000));
182
+ }, RUN_TIMEOUT_MS));
183
+ child.stdout.on('data', d => { stdout += d; });
184
+ child.stderr.on('data', d => { stderr += d; });
185
+ child.on('error', err => finish({ err }));
186
+ const settle = (code, signal) => finish({
187
+ err: code === 0 ? null : Object.assign(new Error(`exit ${code ?? signal}`), { killed: !!signal }),
188
+ });
189
+ // close = all output read; exit + a short drain covers held-open pipes.
190
+ child.on('close', settle);
191
+ child.on('exit', (code, signal) => { timers.push(setTimeout(() => settle(code, signal), 2_000)); });
192
+ });
193
+ }
194
+
195
+ // One prime, verified. Never throws.
196
+ export async function runPrime(agentId, { runner = defaultRunner, now = () => Date.now(), codexDir = CODEX_DIR } = {}) {
197
+ const at = now();
198
+ const model = primeModel(agentId, { codexDir });
199
+ const base = { agent: agentId, at, model: model || null };
200
+ try {
201
+ mkdirSync(PRIME_DIR, { recursive: true, mode: 0o700 });
202
+ const agent = getAgent(agentId);
203
+ const { err, stdout, stderr } = await runner(agent.bin, primeArgs(agentId, { model, cwd: PRIME_DIR }), {
204
+ cwd: PRIME_DIR,
205
+ // standaloneEnv: `prime now` from inside a Claude Code session must not
206
+ // hand its session markers (CLAUDECODE, UNSNOOZE_MUX…) to the prime.
207
+ // The StopFailure hook ignores runs carrying UNSNOOZE_PRIME, so a prime
208
+ // that hits a limit is never recorded as a session to revive.
209
+ env: { ...standaloneEnv(), UNSNOOZE_PRIME: '1' },
210
+ });
211
+ const read = agentId === 'claude' ? parseClaudePrime(stdout) : parseCodexPrime(stdout, { codexDir });
212
+ if (!read) {
213
+ const why = err ? (err.killed ? 'timed out' : (stderr.trim().split('\n').pop() || err.message)) : 'no rate-limit readback in the reply';
214
+ return { ...base, outcome: 'failed', detail: why.slice(0, 200) };
215
+ }
216
+ if (read.noWindow) return { ...base, outcome: 'no-window', planType: read.planType || null };
217
+ const started = Math.abs(read.resetsAtMs - (at + FIVE_HOURS_MS)) <= STARTED_SLACK_MS;
218
+ return { ...base, outcome: started ? 'started' : 'running', resetsAtMs: read.resetsAtMs };
219
+ } catch (e) {
220
+ return { ...base, outcome: 'failed', detail: String(e.message).slice(0, 200) };
221
+ }
222
+ }
223
+
224
+ const hm = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
225
+
226
+ export function formatPrimeResult(r) {
227
+ switch (r.outcome) {
228
+ case 'started': return `${r.agent} 5-hour window started — resets ${hm(r.resetsAtMs)}`;
229
+ case 'running': return `${r.agent} 5-hour window was already running — resets ${hm(r.resetsAtMs)}`;
230
+ case 'no-window': return `${r.agent} has no 5-hour window${r.planType ? ` on the ${r.planType} plan` : ''} — priming does nothing here (unsnooze config set primeAt.${r.agent} off)`;
231
+ case 'pending': return `${r.agent} prime in progress`;
232
+ default: return `${r.agent} prime failed: ${r.detail || 'unknown error'}`;
233
+ }
234
+ }
235
+
236
+ const PRIME_TITLES = {
237
+ started: 'window primed',
238
+ running: 'window already running',
239
+ 'no-window': 'window priming does nothing',
240
+ };
241
+
242
+ // A failed prime (network still down after wake, a 5xx) retries within the
243
+ // slot's grace, a few times and not back to back.
244
+ const MAX_ATTEMPTS = 3;
245
+ const RETRY_GAP_MS = 5 * 60_000;
246
+
247
+ // The day this record has settled, as dueDay's lastDay: a failure with tries
248
+ // left settles nothing once the retry gap has passed.
249
+ function settledDay(rec, now) {
250
+ if (!rec) return null;
251
+ const retry = rec.outcome === 'failed' && !rec.manual
252
+ && (rec.attempts || 1) < MAX_ATTEMPTS && now - rec.at >= RETRY_GAP_MS;
253
+ return retry ? null : rec.day;
254
+ }
255
+
256
+ // Scheduled results keep the day they served and the attempt count; a manual
257
+ // `prime now` keeps whatever the schedule had, so testing at 05:00 does not
258
+ // cancel the 06:00 prime.
259
+ function record(result, { day, attempts, manual = false } = {}) {
260
+ updateState(state => {
261
+ state.prime = state.prime && typeof state.prime === 'object' ? state.prime : {};
262
+ const prev = state.prime[result.agent] || {};
263
+ state.prime[result.agent] = manual
264
+ ? { learned: prev.learned, ...result, day: prev.day ?? null, attempts: prev.attempts, manual: true }
265
+ : { learned: prev.learned, ...result, day, attempts };
266
+ });
267
+ }
268
+
269
+ // The slot to prime at: the fixed time, or for auto the learned one —
270
+ // re-learned once a day and kept in state, so ticks in between cost nothing.
271
+ async function resolveSlot(id, { now, days, last, learn }) {
272
+ const at = normalizePrimeAt(getConfig(`primeAt.${id}`));
273
+ if (at !== 'auto') return { at, wrap: false };
274
+ const known = last[id]?.learned;
275
+ if (known?.day === dayKey(now) && known.primeDays === days) return { at: known.slot, wrap: !!known.wrap };
276
+ const learned = { ...(await learn(id, { days, now })), day: dayKey(now), primeDays: days };
277
+ updateState(state => {
278
+ state.prime = state.prime && typeof state.prime === 'object' ? state.prime : {};
279
+ state.prime[id] = { ...(state.prime[id] || { agent: id }), learned };
280
+ });
281
+ log(learned.slot
282
+ ? `${id}: usually starts ~${learned.start} (${learned.days} days) — priming at ${learned.slot}`
283
+ : `${id}: auto priming still learning (${learned.days} of ${MIN_DAYS} days)`);
284
+ return { at: learned.slot, wrap: !!learned.wrap };
285
+ }
286
+
287
+ let ticking = false;
288
+
289
+ // Daemon tick. The day is claimed before the run starts, so the next tick
290
+ // (or a second daemon) cannot fire the same prime while this one is out.
291
+ export async function tickPrime({ now = Date.now(), run = runPrime, notifyFn = null, learn = learnSlot } = {}) {
292
+ const on = PRIME_AGENTS.filter(id => getConfig(`agents.${id}`) && normalizePrimeAt(getConfig(`primeAt.${id}`)));
293
+ if (!on.length || ticking) return []; // the common case: priming off, no state read
294
+ ticking = true;
295
+ try { return await tickOn(on, { now, run, notifyFn, learn }); } finally { ticking = false; }
296
+ }
297
+
298
+ async function tickOn(on, { now, run, notifyFn, learn }) {
299
+ const days = getConfig('primeDays');
300
+ const last = readState().prime || {};
301
+ const due = new Map();
302
+ for (const id of on) {
303
+ const { at, wrap } = await resolveSlot(id, { now, days, last, learn });
304
+ const day = dueDay({ at, wrap, days, lastDay: settledDay(last[id], now), now });
305
+ if (day) due.set(id, day);
306
+ }
307
+ if (!due.size) return [];
308
+ // Claimed under the state lock, so a second daemon cannot fire it too.
309
+ const claimed = new Map();
310
+ updateState(state => {
311
+ state.prime = state.prime && typeof state.prime === 'object' ? state.prime : {};
312
+ for (const [id, day] of due) {
313
+ const rec = state.prime[id];
314
+ if (settledDay(rec, now) === day) continue;
315
+ const attempts = rec?.day === day && rec.outcome === 'failed' ? (rec.attempts || 1) + 1 : 1;
316
+ claimed.set(id, { day, attempts });
317
+ state.prime[id] = { learned: rec?.learned, agent: id, at: now, day, attempts, outcome: 'pending' };
318
+ }
319
+ });
320
+ const notify = notifyFn || (await import('./notify.js')).notify;
321
+ const results = [];
322
+ for (const [id, claim] of claimed) {
323
+ const result = await run(id);
324
+ record(result, claim);
325
+ const line = formatPrimeResult(result);
326
+ log(line);
327
+ // A failure with retries left is only logged — one toast per prime.
328
+ const final = result.outcome !== 'failed' || claim.attempts >= MAX_ATTEMPTS;
329
+ if (final) {
330
+ try { notify(PRIME_TITLES[result.outcome] || 'window prime failed', line, { priority: 2 }); } catch { /* never break the daemon */ }
331
+ }
332
+ results.push(result);
333
+ }
334
+ return results;
335
+ }
336
+
337
+ // `unsnooze prime` — schedule + last result; `unsnooze prime now [agent…]`.
338
+ export async function cmdPrime(rest = [], { run = runPrime, print = console.log, learn = learnSlot } = {}) {
339
+ if (rest[0] === 'now') {
340
+ const ids = rest.slice(1).length ? rest.slice(1) : PRIME_AGENTS.filter(id => getConfig(`agents.${id}`));
341
+ const bad = ids.filter(id => !PRIME_AGENTS.includes(id));
342
+ if (bad.length) {
343
+ print(`unsnooze: priming supports ${PRIME_AGENTS.join(' and ')} only (not ${bad.join(', ')})`);
344
+ return 1;
345
+ }
346
+ let code = 0;
347
+ for (const id of ids) {
348
+ print(`unsnooze: priming ${id}…`);
349
+ const result = await run(id);
350
+ record(result, { manual: true });
351
+ print(`unsnooze: ${formatPrimeResult(result)}`);
352
+ if (result.outcome === 'failed') code = 1;
353
+ }
354
+ return code;
355
+ }
356
+ if (rest[0] && rest[0] !== 'status') {
357
+ print('usage: unsnooze prime [status] | unsnooze prime now [claude|codex]');
358
+ return 1;
359
+ }
360
+ const days = getConfig('primeDays');
361
+ const last = readState().prime || {};
362
+ print('window priming (experimental) — starts the 5-hour window early with one tiny prompt');
363
+ for (const id of PRIME_AGENTS) {
364
+ const at = normalizePrimeAt(getConfig(`primeAt.${id}`));
365
+ let sched = 'off';
366
+ if (at === 'auto') {
367
+ const l = await learn(id, { days });
368
+ sched = l.slot
369
+ ? `auto — you usually start ~${l.start} (${l.days} days), so it primes at ${l.slot} ${days}, model ${primeModel(id) || 'default'}`
370
+ : `auto — still learning when you start (${l.days} of ${MIN_DAYS} days); no primes until then`;
371
+ } else if (at) sched = `${at} ${days}, model ${primeModel(id) || 'default'}`;
372
+ const prev = last[id]?.at ? ` last: ${dayKey(last[id].at)} ${hm(last[id].at)} — ${formatPrimeResult(last[id])}` : '';
373
+ print(` ${id.padEnd(7)} ${sched}${prev ? `\n ${prev}` : ''}`);
374
+ }
375
+ if (PRIME_AGENTS.some(id => normalizePrimeAt(getConfig(`primeAt.${id}`)))) {
376
+ const { autostartUnitPath } = await import('./install.js');
377
+ if (process.platform !== 'win32' && !existsSync(autostartUnitPath())) {
378
+ print('\n ! scheduled primes run from the daemon, which is not installed: unsnooze install --daemon');
379
+ }
380
+ } else {
381
+ print('\n turn on: unsnooze config set primeAt.claude auto (or a time like 06:00)');
382
+ }
383
+ return 0;
384
+ }
package/src/resumer.js CHANGED
@@ -1153,6 +1153,13 @@ export async function runResumer({
1153
1153
  log(`usage warn tick failed: ${err.message}`);
1154
1154
  }
1155
1155
 
1156
+ // Window priming (experimental) — not awaited: a prime is a model call
1157
+ // of up to minutes, and resumes must not queue behind it. tickPrime
1158
+ // claims the day before running, so later ticks cannot double-fire.
1159
+ import('./prime.js')
1160
+ .then(({ tickPrime }) => tickPrime())
1161
+ .catch(err => log(`prime tick failed: ${err.message}`));
1162
+
1156
1163
  // Scheduled cleanup: age prune + pane-aware sweep + abandon stale stops.
1157
1164
  try {
1158
1165
  updateState(state => { prune(state); return state; });
package/src/settings.js CHANGED
@@ -57,6 +57,12 @@ export const DEFAULTS = {
57
57
  // compacted) is useless if it only applies to revives, because the session
58
58
  // that runs out of context is the one the user started.
59
59
  launchExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' },
60
+ // Window priming (experimental, see prime.js): one tiny prompt at primeAt
61
+ // (local HH:MM, or auto = learned from history) starts the 5-hour window
62
+ // early. '' = off.
63
+ primeAt: { claude: '', codex: '' },
64
+ primeModel: { claude: '', codex: '' }, // '' = claude haiku / codex's cheapest cached model
65
+ primeDays: 'daily', // daily | weekdays
60
66
  agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false, cursor: false }, // experimental agents default off
61
67
  };
62
68
 
@@ -107,6 +113,11 @@ const ENV_NAMES = {
107
113
  'launchExtraArgs.opencode': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_OPENCODE',
108
114
  'launchExtraArgs.agy': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_AGY',
109
115
  'launchExtraArgs.cursor': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CURSOR',
116
+ 'primeAt.claude': 'UNSNOOZE_PRIME_AT_CLAUDE',
117
+ 'primeAt.codex': 'UNSNOOZE_PRIME_AT_CODEX',
118
+ 'primeModel.claude': 'UNSNOOZE_PRIME_MODEL_CLAUDE',
119
+ 'primeModel.codex': 'UNSNOOZE_PRIME_MODEL_CODEX',
120
+ primeDays: 'UNSNOOZE_PRIME_DAYS',
110
121
  'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
111
122
  'agents.codex': 'UNSNOOZE_AGENT_CODEX',
112
123
  'agents.grok': 'UNSNOOZE_AGENT_GROK',
@@ -127,6 +138,7 @@ const ENUMS = {
127
138
  notifyChannel: ['auto', 'native', 'osc', 'bell'],
128
139
  ntfyPrivacy: ['full', 'terse'],
129
140
  usageWarn: ['off', 'notify'],
141
+ primeDays: ['daily', 'weekdays'],
130
142
  };
131
143
 
132
144
  function parseBool(raw) {
@@ -253,6 +265,14 @@ export function setConfigValue(key, rawValue) {
253
265
  value = n;
254
266
  } else {
255
267
  value = String(rawValue);
268
+ if (key.startsWith('primeAt.')) {
269
+ // Inline, not imported from prime.js: that module pulls in usage.js,
270
+ // and settings.js must stay a cheap leaf for the hook path.
271
+ const m = value.trim().match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
272
+ const auto = /^auto$/i.test(value.trim());
273
+ if (!m && !auto && !/^(off)?$/i.test(value.trim())) throw new Error(`unsnooze: "${key}" needs auto, a local time like 06:00, or off`);
274
+ value = auto ? 'auto' : m ? `${m[1].padStart(2, '0')}:${m[2]}` : '';
275
+ }
256
276
  if (ENUMS[key] && !ENUMS[key].includes(value)) {
257
277
  throw new Error(`unsnooze: "${key}" must be one of: ${ENUMS[key].join(', ')}`);
258
278
  }
package/src/watcher.js CHANGED
@@ -18,7 +18,7 @@ import { join, sep, basename, dirname } from 'node:path';
18
18
  import { homedir } from 'node:os';
19
19
  import {
20
20
  CLAUDE_DIR, CODEX_DIR, WATCH_OFFSETS_FILE, WATCH_FRESHNESS_MS,
21
- RESET_MARGIN_MS, PROBE_INTERVAL_MS,
21
+ RESET_MARGIN_MS, PROBE_INTERVAL_MS, isPrimeDir,
22
22
  } from './config.js';
23
23
  import { getMultiplexer } from './multiplexer.js';
24
24
  import { parseTranscriptLine } from './watchers/claude.js';
@@ -170,6 +170,9 @@ export function defaultSources() {
170
170
  // MAX_RESUME_ATTEMPTS cap could never bind — and a cancelled record stays
171
171
  // cancelled.
172
172
  export function dispatchCandidate(c) {
173
+ // A window prime's own rollout (prime.js runs codex in PRIME_DIR) hitting a
174
+ // limit is not a session — reviving it would spend quota on nothing.
175
+ if (isPrimeDir(c.cwd)) return;
173
176
  const detectedAt = c.timestampMs || Date.now();
174
177
  const bannerAt = c.timestampMs || null;
175
178
  let at, source;