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 +19 -0
- package/package.json +1 -1
- package/src/agents/codex.js +31 -1
- package/src/monitor.js +11 -6
- package/src/patterns.js +23 -1
- package/src/state.js +15 -0
- package/src/watchers/codex.js +75 -2
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.
|
|
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",
|
package/src/agents/codex.js
CHANGED
|
@@ -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
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
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
|
|
423
|
-
|
|
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]))) {
|
|
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,
|
package/src/watchers/codex.js
CHANGED
|
@@ -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
|
+
}
|