unsnooze 1.10.0 → 1.12.2
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 +143 -0
- package/README.md +49 -0
- package/bin/unsnooze.js +151 -16
- package/package.json +1 -1
- package/src/cli.js +71 -0
- package/src/doctor.js +310 -0
- package/src/install.js +74 -5
- package/src/launcher.js +5 -0
- package/src/lease.js +22 -0
- package/src/logger.js +40 -1
- package/src/monitor.js +19 -1
- package/src/multiplexers/tmux.js +61 -2
- package/src/multiplexers/zellij.js +20 -3
- package/src/notify-ntfy.js +76 -0
- package/src/notify.js +9 -0
- package/src/reap.js +43 -2
- package/src/resumer.js +289 -72
- package/src/settings.js +13 -0
- package/src/state.js +39 -9
- package/src/time-parser.js +31 -15
- package/src/update-check.js +34 -0
package/src/time-parser.js
CHANGED
|
@@ -22,7 +22,7 @@ const TRY_AT_DATE_REGEX = /try again at\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|o
|
|
|
22
22
|
// Capture the duration phrase and sum every token via parseGoDuration.
|
|
23
23
|
const RELATIVE_IN_REGEX = /(?:try again|resets?|refreshes|retry|wait)\s+in[:\s]+(.+?)(?:\.\s|\.\s*$|attempt\s*#?\d+|$)/i;
|
|
24
24
|
// "wait 5 minutes" / "wait for 5 minutes" without a leading "in".
|
|
25
|
-
const WAIT_DURATION_REGEX = /\bwait\s+(?:for\s+)?(?!in\b)(.+?)(?:\.\s|\.\s*$|$)/i;
|
|
25
|
+
const WAIT_DURATION_REGEX = /\bwait\s+(?:for\s+)?(?!in\b)(?=~?\s*\d)(.+?)(?:\.\s|\.\s*$|$)/i;
|
|
26
26
|
// opencode status line: "Rate Limited [retrying in 2h5m attempt #4]" — the
|
|
27
27
|
// duration is Go-style ("2h5m", "2m 5s", "~2 days").
|
|
28
28
|
const RETRY_BANNER_REGEX = /retrying in\s+([^\]\n]*?)\s*attempt\s*#?\d+/i;
|
|
@@ -67,6 +67,14 @@ function to24h(hour, ampm) {
|
|
|
67
67
|
return h;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// A mangled banner can regex-match a nonsense clock ("resets 45:99"); letting
|
|
71
|
+
// it through builds an Invalid Date whose Intl formatting throws RangeError.
|
|
72
|
+
// Reject at parse time so callers degrade to the probe/fallback ladder.
|
|
73
|
+
function validClock(hour, minute) {
|
|
74
|
+
return Number.isFinite(hour) && hour >= 0 && hour <= 23
|
|
75
|
+
&& Number.isFinite(minute) && minute >= 0 && minute <= 59;
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
function toMs(t) {
|
|
71
79
|
if (t instanceof Date) return t.getTime();
|
|
72
80
|
return Number(t);
|
|
@@ -97,11 +105,10 @@ export function parseResetTime(text) {
|
|
|
97
105
|
|
|
98
106
|
const tryAtMatch = text.match(TRY_AT_TIME_REGEX);
|
|
99
107
|
if (tryAtMatch) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
};
|
|
108
|
+
const hour = to24h(parseInt(tryAtMatch[1], 10), tryAtMatch[3].toLowerCase());
|
|
109
|
+
const minute = tryAtMatch[2] ? parseInt(tryAtMatch[2], 10) : 0;
|
|
110
|
+
if (!validClock(hour, minute)) return null;
|
|
111
|
+
return { hour, minute, timezone: null, ambiguous: false, day: null };
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
const resetDateMatch = text.match(RESET_DATE_REGEX);
|
|
@@ -111,11 +118,13 @@ export function parseResetTime(text) {
|
|
|
111
118
|
let hour = parseInt(hourRaw, 10);
|
|
112
119
|
if (ampm === 'pm' && hour !== 12) hour += 12;
|
|
113
120
|
if (ampm === 'am' && hour === 12) hour = 0;
|
|
121
|
+
const minute = minuteRaw ? parseInt(minuteRaw, 10) : 0;
|
|
122
|
+
if (!validClock(hour, minute)) return null;
|
|
114
123
|
return {
|
|
115
124
|
month: MONTH_INDEX[mon.toLowerCase()],
|
|
116
125
|
dayOfMonth: parseInt(dayOfMonth, 10),
|
|
117
126
|
hour,
|
|
118
|
-
minute
|
|
127
|
+
minute,
|
|
119
128
|
timezone: timezone || null,
|
|
120
129
|
ambiguous: !ampm && hour >= 1 && hour <= 12,
|
|
121
130
|
day: null,
|
|
@@ -134,6 +143,7 @@ export function parseResetTime(text) {
|
|
|
134
143
|
if (ampm === 'am' && hour === 12) hour = 0;
|
|
135
144
|
|
|
136
145
|
const ambiguous = !ampm && hour >= 1 && hour <= 12;
|
|
146
|
+
if (!validClock(hour, minute)) return null;
|
|
137
147
|
return {
|
|
138
148
|
hour, minute, timezone, ambiguous,
|
|
139
149
|
day: dayMatch ? DAY_INDEX[dayMatch[1].toLowerCase()] : null,
|
|
@@ -245,15 +255,21 @@ export function resetAtMs(parsed, {
|
|
|
245
255
|
function nextOccurrence(h, m) {
|
|
246
256
|
let t = targetTimestamp(h, m, anchorDate);
|
|
247
257
|
if (parsed.day != null) {
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
258
|
+
// Roll the REFERENCE day forward and re-derive h:m for each candidate
|
|
259
|
+
// day: stepping the timestamp itself by fixed 24h units drifts by ±1h
|
|
260
|
+
// when the roll crosses a DST boundary (observed: "resets Tuesday 9am"
|
|
261
|
+
// landing at 10am after a spring-forward weekend).
|
|
262
|
+
const weekdayOf = ts => new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short' })
|
|
263
|
+
.format(new Date(ts)).toLowerCase().slice(0, 3);
|
|
264
|
+
let ref = anchor;
|
|
265
|
+
for (let i = 0; i < 9; i++) {
|
|
266
|
+
const candidate = targetTimestamp(h, m, new Date(ref));
|
|
267
|
+
if (DAY_INDEX[weekdayOf(candidate)] === parsed.day && candidate > anchor) {
|
|
268
|
+
return candidate;
|
|
269
|
+
}
|
|
270
|
+
ref += 86_400_000;
|
|
255
271
|
}
|
|
256
|
-
return t;
|
|
272
|
+
return t; // unreachable in practice — 9 steps cover a week + DST repeat
|
|
257
273
|
}
|
|
258
274
|
if (t <= anchor) {
|
|
259
275
|
// Dated banner, or ambiguous (need both am/pm candidates as real
|
package/src/update-check.js
CHANGED
|
@@ -52,6 +52,24 @@ export function isCacheStale(now = Date.now()) {
|
|
|
52
52
|
return now - (readCache().lastCheckedAt || 0) > CHECK_INTERVAL_MS;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Version-skew guard for long-lived processes: after `npm i -g` swaps the
|
|
56
|
+
// package out underneath a running daemon, the loaded code (PKG_VERSION,
|
|
57
|
+
// read at module load) no longer matches the on-disk package.json. The
|
|
58
|
+
// daemon exits cleanly on skew so launchd/systemd restart it on fresh code —
|
|
59
|
+
// never again a zombie daemon running deleted code.
|
|
60
|
+
//
|
|
61
|
+
// A missing/unreadable/garbage package.json is NOT skew: mid-upgrade the file
|
|
62
|
+
// may briefly not exist, and exiting then would race the installer. Only a
|
|
63
|
+
// readable, *different* version is proof the upgrade finished.
|
|
64
|
+
export function hasVersionSkew({ root = ROOT, current = PKG_VERSION } = {}) {
|
|
65
|
+
try {
|
|
66
|
+
const disk = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
|
|
67
|
+
return typeof disk === 'string' && /^\d+\.\d+\.\d+$/.test(disk) && disk !== current;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
export async function fetchLatest({ fetcher = globalThis.fetch, timeoutMs = 3000 } = {}) {
|
|
56
74
|
try {
|
|
57
75
|
const ctrl = new AbortController();
|
|
@@ -79,6 +97,22 @@ export function updateNotice(pkgVersion = PKG_VERSION) {
|
|
|
79
97
|
return `unsnooze ${latest} is available (you have ${pkgVersion}) — run: unsnooze update · ${RELEASES_URL}`;
|
|
80
98
|
}
|
|
81
99
|
|
|
100
|
+
// Post-session-exit notice for the agent-launch path (the update-notifier
|
|
101
|
+
// pattern: non-blocking, from cache, AFTER the run — never a pre-launch
|
|
102
|
+
// prompt, which the tmux alt-screen would wipe anyway). Wrapper-only users
|
|
103
|
+
// never run `unsnooze status`, so the moment the agent exits and the screen
|
|
104
|
+
// is restored is the one moment a notice reliably reaches them. Throttled to
|
|
105
|
+
// once per day via lastNoticeAt; stamps only when it actually fires.
|
|
106
|
+
const NOTICE_INTERVAL_MS = 24 * 3_600_000;
|
|
107
|
+
|
|
108
|
+
export function launchExitNotice({ now = Date.now(), pkgVersion = PKG_VERSION } = {}) {
|
|
109
|
+
const notice = updateNotice(pkgVersion);
|
|
110
|
+
if (!notice) return null;
|
|
111
|
+
if (now - (readCache().lastNoticeAt || 0) < NOTICE_INTERVAL_MS) return null;
|
|
112
|
+
writeCache({ lastNoticeAt: now });
|
|
113
|
+
return notice;
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
// The "## <version>" block from the CHANGELOG.md that ships in the tarball.
|
|
83
117
|
export function changelogSection(version, maxLines = 6) {
|
|
84
118
|
try {
|