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 +31 -0
- package/README.md +6 -0
- package/bin/unsnooze.js +6 -0
- package/package.json +1 -1
- package/src/config.js +15 -1
- package/src/doctor.js +3 -1
- package/src/hook.js +2 -0
- package/src/prime-learn.js +172 -0
- package/src/prime.js +384 -0
- package/src/resumer.js +7 -0
- package/src/settings.js +20 -0
- package/src/watcher.js +4 -1
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.
|
|
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
|
-
|
|
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;
|