unsnooze 1.19.2 → 1.19.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.2",
3
+ "version": "1.19.4",
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/agents/agy.js CHANGED
@@ -18,23 +18,33 @@ import { homedir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
 
20
20
  const AGY_DIR = () => process.env.UNSNOOZE_AGY_DIR || join(homedir(), '.gemini', 'antigravity-cli');
21
+ const SESSION_RECENCY_MS = 5 * 60 * 60 * 1000;
21
22
 
22
23
  const LIMIT_ANCHORS = [
24
+ /Individual quota reached/i,
23
25
  /Model quota limit exceeded/i,
24
26
  /RESOURCE_EXHAUSTED/,
25
27
  /quota (?:limit )?exceeded/i,
26
28
  ];
27
29
 
28
30
  export const patterns = {
31
+ bannerWraps: true,
29
32
  limitPatterns: LIMIT_ANCHORS,
30
33
  // "Refreshes in 6 days and 18 hours" renders near the quota banner; anchors
31
34
  // double as reset lines for the API-key single-line renders.
32
35
  resetPatterns: [
33
- /Refreshes in/i,
36
+ /Refreshes\s+in/i,
37
+ /Resets\s+in/i,
38
+ /\b(?:Resets|Refreshes)(?:\s+in)?\s*$/i,
34
39
  ...LIMIT_ANCHORS,
35
40
  ],
36
- weeklyPatterns: [/Refreshes in \d+ days?/i], // multi-day refresh = the weekly cap
37
- fiveHourPatterns: [],
41
+ weeklyPatterns: [
42
+ /Refreshes\s+in\s+\d+\s+days?/i,
43
+ /(?:Refreshes|Resets)\s+in\s+\d+(?:\.\d+)?\s*(?:days?|d)(?![a-z])/i,
44
+ ],
45
+ fiveHourPatterns: [
46
+ /(?:Refreshes|Resets)\s+in\s+\d+(?:\.\d+)?\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])/i,
47
+ ],
38
48
  busyPatterns: [
39
49
  /esc to interrupt/i,
40
50
  /attempt \d+\/\d+/i,
@@ -47,9 +57,10 @@ export const patterns = {
47
57
 
48
58
  // ~/.gemini/antigravity-cli/history.jsonl indexes all conversations. Schema is
49
59
  // undocumented (and a SQLite migration is rumored) — parse the tail
50
- // tolerantly: an entry counts if any string value equals the cwd, its id is
51
- // the first conversation-ish field. Null on any doubt (the resumer then opens
52
- // a fresh agy session; the wake message explains the context loss).
60
+ // tolerantly. A workspace can have multiple live conversations, so when the
61
+ // caller supplies a detection time, use an id only if exactly one conversation
62
+ // appears in recent history. Otherwise the live pane can still be resumed in
63
+ // place; if it has disappeared, the adapter falls back to --continue.
53
64
  function tailBytes(path, bytes = 64 * 1024) {
54
65
  let fd;
55
66
  try {
@@ -70,13 +81,49 @@ export function latestSessionId(cwd, aroundTs = null, agyDir = AGY_DIR()) {
70
81
  const text = tailBytes(join(agyDir, 'history.jsonl'));
71
82
  if (!text) return null;
72
83
  const lines = text.split('\n').filter(l => l.trim());
84
+ const recentIds = new Set();
85
+ let latestId = null;
86
+ let hasTimestamp = false;
73
87
  for (let i = lines.length - 1; i >= 0; i--) {
74
88
  let entry;
75
89
  try { entry = JSON.parse(lines[i]); } catch { continue; }
76
90
  if (!entry || typeof entry !== 'object') continue;
77
91
  if (!Object.values(entry).some(v => v === cwd)) continue;
78
92
  const id = entry.conversation_id ?? entry.conversationId ?? entry.id;
79
- if (typeof id === 'string' && id) return id;
93
+ if (typeof id !== 'string' || !id) continue;
94
+ latestId ||= id;
95
+ const timestamp = typeof entry.timestamp === 'number'
96
+ ? entry.timestamp
97
+ : typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
98
+ if (Number.isFinite(timestamp)) {
99
+ hasTimestamp = true;
100
+ if (Number.isFinite(aroundTs) && Math.abs(aroundTs - timestamp) <= SESSION_RECENCY_MS) {
101
+ recentIds.add(id);
102
+ }
103
+ }
104
+ }
105
+ if (!Number.isFinite(aroundTs) || !hasTimestamp) return latestId;
106
+ return recentIds.size === 1 ? recentIds.values().next().value : null;
107
+ }
108
+
109
+ export function latestBannerAt(cwd, aroundTs = null, agyDir = AGY_DIR()) {
110
+ const text = tailBytes(join(agyDir, 'history.jsonl'));
111
+ if (!text) return null;
112
+ const lines = text.split('\n').filter(l => l.trim());
113
+ for (let i = lines.length - 1; i >= 0; i--) {
114
+ let entry;
115
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
116
+ if (!entry || typeof entry !== 'object') continue;
117
+ if (!Object.values(entry).some(v => v === cwd)) continue;
118
+ const timestamp = typeof entry.timestamp === 'number'
119
+ ? entry.timestamp
120
+ : typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
121
+ if (Number.isFinite(timestamp)) {
122
+ if (!Number.isFinite(aroundTs)) return timestamp;
123
+ if (timestamp <= (aroundTs + 5000) && (aroundTs - timestamp) <= SESSION_RECENCY_MS) {
124
+ return timestamp;
125
+ }
126
+ }
80
127
  }
81
128
  return null;
82
129
  }
@@ -96,6 +143,7 @@ export default {
96
143
  // v1: every agent launches the bare TUI and gets the prompt typed once idle.
97
144
  launchArgs(message) { return { args: [], messageViaPane: true }; },
98
145
  latestSessionId,
146
+ latestBannerAt,
99
147
  isForegroundCommand(cmd) {
100
148
  return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
101
149
  },
@@ -78,13 +78,19 @@ const LIMIT_ANCHORS = [
78
78
  /You['’]ve hit your usage limit/i, // the desktop app also emits a curly apostrophe
79
79
  /Your workspace is out of credits/i,
80
80
  /hit your spend cap/i,
81
+ // Plus plan: an out-of-credits picker sits under the banner and can push it
82
+ // past the 12-line scan (#36). This line stays inside it and carries the time.
83
+ /Your rate limit resets on/i,
81
84
  ];
82
85
 
83
86
  export const patterns = {
84
87
  limitPatterns: LIMIT_ANCHORS,
85
88
  // The whole banner is ONE line, so the anchors double as reset lines — the
86
89
  // proximity engine then hands that line to time-parser (which falls back to
87
- // the 5h default for "Try again later.").
90
+ // the 5h default for "Try again later."). A narrow pane soft-wraps that
91
+ // line, which can strand the clock on a later row; bannerWraps tells the
92
+ // engine to rejoin the rows before parsing.
93
+ bannerWraps: true,
88
94
  resetPatterns: [
89
95
  /try again at/i,
90
96
  /try again in \d+/i,
@@ -185,6 +191,30 @@ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_
185
191
  return null;
186
192
  }
187
193
 
194
+ // The rollout file a thread is writing to now: the newest of its files. A
195
+ // reverted thread continues in a second `…_<rollout id>` file, which is where
196
+ // a resumed turn lands.
197
+ export function rolloutPathFor(sessionId, sessionsRoot = join(CODEX_DIR, 'sessions')) {
198
+ if (!sessionId) return null;
199
+ const want = String(sessionId).toLowerCase();
200
+ let best = null;
201
+ const walk = (dir, depth) => {
202
+ let entries;
203
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
204
+ for (const e of entries) {
205
+ const p = join(dir, e.name);
206
+ if (e.isDirectory() && depth < 3) { walk(p, depth + 1); continue; }
207
+ const m = e.isFile() && e.name.match(ROLLOUT_RE);
208
+ if (!m || m[1].toLowerCase() !== want) continue;
209
+ let mtime;
210
+ try { mtime = statSync(p).mtimeMs; } catch { continue; }
211
+ if (!best || mtime > best.mtime) best = { path: p, mtime };
212
+ }
213
+ };
214
+ walk(sessionsRoot, 0);
215
+ return best ? best.path : null;
216
+ }
217
+
188
218
  export default {
189
219
  id: 'codex',
190
220
  name: 'OpenAI Codex CLI',
package/src/monitor.js CHANGED
@@ -25,6 +25,7 @@ import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
25
25
  import { upsertSession, setStatus, readState, updateState } from './state.js';
26
26
  import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
27
27
  import { latestRateLimitFromTranscript } from './watchers/claude.js';
28
+ import { hasCodexProgressAfter } from './watchers/codex.js';
28
29
  import { spawnResumerIfNeeded, spawnDetached, monitorSpawnArgs, UNSNOOZE_BIN } from './spawn.js';
29
30
  import { restartOnVersionSkew, hasVersionSkew, PKG_VERSION } from './update-check.js';
30
31
  import { makeLogger } from './logger.js';
@@ -112,10 +113,13 @@ export function createMonitor({
112
113
  detectedAt,
113
114
  };
114
115
  }
116
+ const bannerAt = typeof agent.latestBannerAt === 'function'
117
+ ? agent.latestBannerAt(cwd, detectedAt)
118
+ : null;
115
119
  return {
116
120
  resetLine: paneResetLine,
117
121
  limitType: paneLimitType,
118
- bannerAt: null,
122
+ bannerAt,
119
123
  sessionId,
120
124
  via: null, // filled by caller (scrape/hook)
121
125
  detectedAt,
@@ -131,6 +135,7 @@ export function createMonitor({
131
135
  // "already past → due now" path, which is margin above wall clock only).
132
136
  function isCorroborated(resolved, at, source) {
133
137
  if (resolved.via === 'transcript' && resolved.bannerAt != null) return true;
138
+ if (resolved.bannerAt != null && at - RESET_MARGIN_MS > Date.now()) return true;
134
139
  if (source === 'absolute' && at - RESET_MARGIN_MS > Date.now()) return true;
135
140
  // A model limit carries no time to corroborate — its own remedy hint is
136
141
  // the evidence, and the record only ever probes (never blind-wakes), so
@@ -410,17 +415,21 @@ export function createMonitor({
410
415
  }
411
416
 
412
417
  // A banner leaving the 12-line scan is not evidence of a resume: ordinary
413
- // output (including background-agent notices) can scroll it away. Claude
414
- // is terminal only after its parent transcript records newer non-error
415
- // assistant usage. Other adapters retain their legacy behavior until they
416
- // expose an equally authoritative progress signal.
418
+ // output (including background-agent notices) can scroll it away, and in
419
+ // Codex a keypress or tab switch can hide it while the session is still
420
+ // stopped. Claude is terminal only after its parent transcript records
421
+ // newer non-error assistant usage; Codex only after its rollout records
422
+ // newer model output (or, with no rollout to read, the legacy rule).
423
+ // Other adapters retain their legacy behavior until they expose an
424
+ // equally authoritative progress signal.
417
425
  if (trackedKey) {
418
426
  const state = readState();
419
427
  const rec = state.sessions[trackedKey];
420
428
  if (rec && rec.status === 'stopped') {
421
429
  const cutoff = rec.bannerAt ?? rec.detectedAt;
422
- const progressed = agent.id !== 'claude'
423
- || hasClaudeParentUsageAfter(rec, cutoff);
430
+ const progressed = agent.id === 'claude' ? hasClaudeParentUsageAfter(rec, cutoff)
431
+ : agent.id === 'codex' ? hasCodexProgressAfter(rec, cutoff) !== false
432
+ : true;
424
433
  if (progressed) {
425
434
  const next = setStatus(trackedKey, 'resumed', {
426
435
  lastAttemptAt: Date.now(), bannerCleared: true, lastError: null,
package/src/patterns.js CHANGED
@@ -96,6 +96,25 @@ function quotedLineFlags(lines) {
96
96
  return flags;
97
97
  }
98
98
 
99
+ // A banner the terminal soft-wraps continues on the next rows until its
100
+ // closing punctuation. Codex writes one long line, so at some widths the
101
+ // reset clock lands on a later row than the phrase that introduces it:
102
+ // "... or try" / "again at 6:02 PM." "... try again at 6:02" / "PM."
103
+ // Rejoin those rows. Stop at a blank row, at the next TUI glyph, or once the
104
+ // sentence has ended, so a footer hint below the banner is never absorbed.
105
+ const WRAP_MAX_ROWS = 3;
106
+ const NEW_BLOCK = /^\s*[›•■⏺⚠>]/;
107
+ function joinWrapped(lines, idx) {
108
+ let joined = lines[idx].trim();
109
+ for (let j = idx + 1; j <= idx + WRAP_MAX_ROWS && j < lines.length; j++) {
110
+ if (/[.!?]$/.test(joined)) break;
111
+ const next = lines[j].trim();
112
+ if (!next || NEW_BLOCK.test(lines[j])) break;
113
+ joined += ` ${next}`;
114
+ }
115
+ return joined;
116
+ }
117
+
99
118
  export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
100
119
  const lines = contentLines(text, tailLines);
101
120
 
@@ -138,7 +157,10 @@ export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
138
157
  // Most recent reset line wins (the TUI never clears old banners from scrollback).
139
158
  let resetLine = null;
140
159
  for (let i = lines.length - 1; i >= 0; i--) {
141
- if (sets.resetPatterns.some(p => p.test(lines[i]))) { resetLine = lines[i].trim(); break; }
160
+ if (sets.resetPatterns.some(p => p.test(lines[i]))) {
161
+ resetLine = sets.bannerWraps ? joinWrapped(lines, i) : lines[i].trim();
162
+ break;
163
+ }
142
164
  }
143
165
  if (!resetLine) {
144
166
  for (let i = lines.length - 1; i >= 0; i--) {
package/src/resumer.js CHANGED
@@ -342,6 +342,10 @@ export async function probeFallback(rec, {
342
342
  return null;
343
343
  }
344
344
 
345
+ if (!bannerAt && typeof agent.latestBannerAt === 'function') {
346
+ bannerAt = agent.latestBannerAt(rec.cwd, now);
347
+ }
348
+
345
349
  // Banner still present. If it now parses to a real time, upgrade off fallback.
346
350
  if (resetLine) {
347
351
  const { at, source } = resetAtMs(parseResetTime(resetLine), {
@@ -955,6 +959,8 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
955
959
  if (fromTx) {
956
960
  resetLine = fromTx.resetLine ?? resetLine;
957
961
  bannerAt = fromTx.timestampMs;
962
+ } else if (typeof agent.latestBannerAt === 'function') {
963
+ bannerAt = agent.latestBannerAt(rec.cwd, Date.now());
958
964
  }
959
965
  const { at, source } = resetAtMs(parseResetTime(resetLine), {
960
966
  marginMs: RESET_MARGIN_MS,
package/src/state.js CHANGED
@@ -249,6 +249,10 @@ export function upsertSession(record, { after = null } = {}) {
249
249
  const fingerprint = needsFingerprint ? workspaceFingerprint(record.cwd) : undefined;
250
250
  return updateState(state => {
251
251
  prune(state);
252
+ // A transcript/GUI detection has no pane, so its address fields are only
253
+ // defaults (see watcher.js). Taken before the closing branch below, which
254
+ // nulls the address on purpose and must stay distinguishable from this.
255
+ const panelessDetection = !record.pane;
252
256
  const closing = state.paneClosures.find(c => c.pane && c.leaseId
253
257
  && c.mux === record.mux && c.paneOwner === record.paneOwner
254
258
  && c.pane === record.pane && c.leaseId === record.leaseId);
@@ -294,6 +298,17 @@ export function upsertSession(record, { after = null } = {}) {
294
298
  const merged = {
295
299
  ...existing,
296
300
  ...record,
301
+ // A pane-less detection does not know where the session runs. When it
302
+ // lands on a record that holds a live pane, keep that pane's address:
303
+ // the defaults would otherwise point the pane id at another
304
+ // multiplexer, and the resumer would reopen the session beside the
305
+ // running one instead of waking it.
306
+ ...(panelessDetection && existing.pane ? {
307
+ mux: existing.mux,
308
+ paneOwner: existing.paneOwner,
309
+ leaseId: existing.leaseId,
310
+ muxSession: existing.muxSession,
311
+ } : {}),
297
312
  ...(keepReset ? {
298
313
  resetAt: existing.resetAt,
299
314
  resetSource: existing.resetSource,
@@ -30,9 +30,9 @@
30
30
  // it carries no reset time, and the pane path files it under transient
31
31
  // overload for the same reason.
32
32
 
33
- import { openSync, readSync, closeSync } from 'node:fs';
33
+ import { openSync, readSync, closeSync, statSync } from 'node:fs';
34
34
  import { basename } from 'node:path';
35
- import { ROLLOUT_RE, patterns as codexPatterns } from '../agents/codex.js';
35
+ import { ROLLOUT_RE, rolloutPathFor, patterns as codexPatterns } from '../agents/codex.js';
36
36
  import { detectLimit } from '../patterns.js';
37
37
  // Usage extractor lives in usage.js (shared cold path + daemon); re-exported
38
38
  // here so the plan's watcher surface is the documented import site.
@@ -351,3 +351,76 @@ export function rolloutMeta(path) {
351
351
  }
352
352
  return { sessionId, cwd, originator };
353
353
  }
354
+
355
+ // Model output the rollout records only when a request succeeded. A turn the
356
+ // usage limit refuses writes the user message and a task_complete error, never
357
+ // one of these.
358
+ const MODEL_OUTPUT = new Set(['reasoning', 'function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call']);
359
+ function isModelOutput(entry) {
360
+ if (entry?.type !== 'response_item') return false;
361
+ const p = entry.payload || {};
362
+ return (p.type === 'message' && p.role === 'assistant') || MODEL_OUTPUT.has(p.type);
363
+ }
364
+
365
+ // Durable evidence that a Codex session made progress after a recorded stop:
366
+ // model output in its rollout, newer than the stop and not followed by a newer
367
+ // limit. The Codex counterpart of hasClaudeParentUsageAfter. A banner leaving
368
+ // the pane's scan window is not evidence; a keypress, tab switch or overlay
369
+ // can do that while the session is still stopped. Returns null when there is
370
+ // no rollout to read (no session id, file not found, read error), so the
371
+ // caller can fall back instead of holding the stop forever.
372
+ //
373
+ // The monitor asks every scrape tick while the banner is hidden, so the path
374
+ // lookup (a walk of ~/.codex/sessions) is reused for a minute. A reverted
375
+ // thread's newer continuation file is still picked up within that minute.
376
+ const PATH_TTL_MS = 60_000;
377
+ const pathCache = new Map();
378
+ function cachedRolloutPath(sessionId, sessionsRoot) {
379
+ const key = `${sessionsRoot ?? ''}|${sessionId}`;
380
+ const hit = pathCache.get(key);
381
+ if (hit && Date.now() - hit.at < PATH_TTL_MS) return hit.path;
382
+ const path = sessionsRoot ? rolloutPathFor(sessionId, sessionsRoot) : rolloutPathFor(sessionId);
383
+ if (path) pathCache.set(key, { path, at: Date.now() });
384
+ else pathCache.delete(key);
385
+ return path;
386
+ }
387
+
388
+ export function hasCodexProgressAfter(rec, afterMs, {
389
+ sessionsRoot,
390
+ window = 256 * 1024,
391
+ maxWindow = 4 * 1024 * 1024,
392
+ } = {}) {
393
+ if (!rec?.sessionId || !Number.isFinite(afterMs)) return null;
394
+ const path = cachedRolloutPath(rec.sessionId, sessionsRoot);
395
+ if (!path) return null;
396
+ let fd;
397
+ try {
398
+ const { size } = statSync(path);
399
+ fd = openSync(path, 'r');
400
+ for (let win = window; ; win = Math.min(win * 2, maxWindow)) {
401
+ const len = Math.min(win, size);
402
+ const buf = Buffer.alloc(len);
403
+ readSync(fd, buf, 0, len, size - len);
404
+ const lines = buf.toString('utf-8').split('\n');
405
+ if (len < size) lines.shift(); // the first line may be truncated
406
+ for (let i = lines.length - 1; i >= 0; i--) {
407
+ if (!lines[i].trim()) continue;
408
+ let entry;
409
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
410
+ const at = Date.parse(entry?.timestamp);
411
+ if (!Number.isFinite(at)) continue;
412
+ // Lines are chronological: nothing older can postdate the stop.
413
+ if (at <= afterMs) return false;
414
+ // Newest first, so a limit written after the last output means the
415
+ // session stopped again.
416
+ if (parseRolloutLine(lines[i])) return false;
417
+ if (isModelOutput(entry)) return true;
418
+ }
419
+ if (len >= size || win >= maxWindow) return false;
420
+ }
421
+ } catch {
422
+ return null;
423
+ } finally {
424
+ if (fd !== undefined) closeSync(fd);
425
+ }
426
+ }