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.
@@ -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
- return {
101
- hour: to24h(parseInt(tryAtMatch[1], 10), tryAtMatch[3].toLowerCase()),
102
- minute: tryAtMatch[2] ? parseInt(tryAtMatch[2], 10) : 0,
103
- timezone: null, ambiguous: false, day: null,
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: minuteRaw ? parseInt(minuteRaw, 10) : 0,
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
- if (t <= anchor) t += 86_400_000;
249
- // Roll forward to the named weekday (in the target tz).
250
- for (let i = 0; i < 7; i++) {
251
- const wd = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short' })
252
- .format(new Date(t)).toLowerCase().slice(0, 3);
253
- if (DAY_INDEX[wd] === parsed.day) break;
254
- t += 86_400_000;
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
@@ -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 {