unsnooze 1.19.2 → 1.19.3

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.3 — 2026-09-30
4
+
5
+ Codex fixes from a single herdr report: stops that were missed, woken late,
6
+ or woken in the wrong place.
7
+
8
+ - The out-of-credits screen on Plus plans no longer hides a usage limit. Its
9
+ Upgrade / Add Credits picker pushed the banner off the part of the pane
10
+ unsnooze reads; the "Your rate limit resets on" line is now recognised
11
+ instead (#36).
12
+ - A reset time that a narrow pane wraps onto the next row is read correctly,
13
+ instead of falling back to guessing and probing (#33).
14
+ - A Codex stop is only marked resumed once its session log shows new output.
15
+ A keypress or tab switch that briefly hides the banner no longer drops the
16
+ stop and its exact reset time. Stops with no session log keep the old
17
+ behaviour (#34).
18
+ - A session running in herdr (or any non-default multiplexer) keeps its pane
19
+ address when a second detection of the same stop comes in, so it is woken
20
+ in its own pane instead of reopened beside it (#35).
21
+
3
22
  ## 1.19.2 — 2026-09-27
4
23
 
5
24
  Setup on Windows no longer creates a Scheduled Task. That task is what
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.2",
3
+ "version": "1.19.3",
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",
@@ -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';
@@ -410,17 +411,21 @@ export function createMonitor({
410
411
  }
411
412
 
412
413
  // 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.
414
+ // output (including background-agent notices) can scroll it away, and in
415
+ // Codex a keypress or tab switch can hide it while the session is still
416
+ // stopped. Claude is terminal only after its parent transcript records
417
+ // newer non-error assistant usage; Codex only after its rollout records
418
+ // newer model output (or, with no rollout to read, the legacy rule).
419
+ // Other adapters retain their legacy behavior until they expose an
420
+ // equally authoritative progress signal.
417
421
  if (trackedKey) {
418
422
  const state = readState();
419
423
  const rec = state.sessions[trackedKey];
420
424
  if (rec && rec.status === 'stopped') {
421
425
  const cutoff = rec.bannerAt ?? rec.detectedAt;
422
- const progressed = agent.id !== 'claude'
423
- || hasClaudeParentUsageAfter(rec, cutoff);
426
+ const progressed = agent.id === 'claude' ? hasClaudeParentUsageAfter(rec, cutoff)
427
+ : agent.id === 'codex' ? hasCodexProgressAfter(rec, cutoff) !== false
428
+ : true;
424
429
  if (progressed) {
425
430
  const next = setStatus(trackedKey, 'resumed', {
426
431
  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/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
+ }