unsnooze 1.19.0 → 1.19.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.
@@ -1,20 +1,39 @@
1
1
  // Codex rollout watcher: parses lines appended to session rollout files
2
2
  // (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) into limit-stop candidates.
3
3
  //
4
- // Codex never persists Error/StreamError events to rollouts (confirmed in
5
- // codex-rs/rollout policy), so the limit banner text is NOT in the file. What
6
- // IS persisted is a token_count event per turn carrying a rate_limits
7
- // snapshot — used_percent per window plus an exact resets_at epoch:
8
- // {"type":"event_msg","payload":{"type":"token_count","rate_limits":{
9
- // "primary":{"used_percent":100,"window_minutes":300,"resets_at":1778672230},
10
- // "secondary":{"used_percent":1,"window_minutes":10080,"resets_at":...},
11
- // "rate_limit_reached_type":null}}}
12
- // That epoch is more precise than any scraped banner, and rollouts are shared
13
- // by every Codex surface (CLI, IDE extension, desktop app).
4
+ // Two signals are persisted, and either one is a stop:
5
+ //
6
+ // 1. A token_count event per turn carrying a rate_limits snapshot —
7
+ // used_percent per window plus an exact resets_at epoch:
8
+ // {"type":"event_msg","payload":{"type":"token_count","rate_limits":{
9
+ // "primary":{"used_percent":100,"window_minutes":300,"resets_at":1778672230},
10
+ // "secondary":{"used_percent":1,"window_minutes":10080,"resets_at":...},
11
+ // "rate_limit_reached_type":null}}}
12
+ // That epoch is more precise than any scraped banner, so it governs
13
+ // whenever it is present.
14
+ //
15
+ // 2. Since codex-cli 0.145 the failed turn's task_complete carries the
16
+ // error itself, with the same banner text the TUI renders:
17
+ // {"type":"event_msg","payload":{"type":"task_complete","error":{
18
+ // "message":"You've hit your usage limit. … try again at Jul 30th, 2026 10:33 AM.",
19
+ // "codex_error_info":"usage_limit_exceeded"}}}
20
+ // This is the ONLY signal when Codex runs behind an OpenAI-compatible
21
+ // proxy (model_providers.<x>.base_url): the proxy answers with its own
22
+ // response, the X-Codex-* rate-limit headers never reach Codex, and every
23
+ // snapshot arrives as {primary:null, secondary:null}. The message goes
24
+ // through the same time-parser as a scraped pane, so the reset lands on
25
+ // the banner's own clock time (or the probe fallback for "Try again later.").
26
+ //
27
+ // Rollouts are shared by every Codex surface (CLI, IDE extension, desktop app).
28
+ // A bare 429 ("exceeded retry limit, last status: 429 Too Many Requests",
29
+ // codex_error_info.response_too_many_failed_attempts) is NOT a stop here —
30
+ // it carries no reset time, and the pane path files it under transient
31
+ // overload for the same reason.
14
32
 
15
33
  import { openSync, readSync, closeSync } from 'node:fs';
16
34
  import { basename } from 'node:path';
17
- import { ROLLOUT_RE } from '../agents/codex.js';
35
+ import { ROLLOUT_RE, patterns as codexPatterns } from '../agents/codex.js';
36
+ import { detectLimit } from '../patterns.js';
18
37
  // Usage extractor lives in usage.js (shared cold path + daemon); re-exported
19
38
  // here so the plan's watcher surface is the documented import site.
20
39
  export { extractCodexUsage } from '../usage.js';
@@ -33,11 +52,84 @@ function rolloutSnapshot(line) {
33
52
  return entry;
34
53
  }
35
54
 
55
+ // The failed turn's task_complete error, when it is a usage limit. The turn
56
+ // also ends in task_complete for stream errors, retry exhaustion and
57
+ // cancellations, so only the banner itself qualifies — not the structured
58
+ // marker alone: codex-rs (protocol/src/error.rs, to_codex_protocol_error)
59
+ // sends codex_error_info "usage_limit_exceeded" for "Quota exceeded. Check
60
+ // your plan and billing details." and "To use Codex with your ChatGPT plan,
61
+ // upgrade to Plus…" too, which no amount of waiting clears. Every real usage
62
+ // limit it words ("You’ve hit your usage limit…", "Your workspace is out of
63
+ // credits…", "You hit your spend cap…") carries a banner anchor.
64
+ // codex-rs words the workspace walls (UsageLimitReachedError's Display):
65
+ // "Your workspace is out of credits. …" and "You hit your spend cap set …".
66
+ const WORKSPACE_WALL_BANNER = /Your workspace is out of credits|hit your spend cap/i;
67
+
68
+ function rolloutLimitError(line) {
69
+ // Every line that is not a snapshot lands here, and rollout lines can be
70
+ // large (tool output): skip the second JSON.parse unless it can be a match.
71
+ if (!line || !line.includes('"task_complete"')) return null;
72
+ let entry;
73
+ try { entry = JSON.parse(line); } catch { return null; }
74
+ if (entry?.type !== 'event_msg' || entry.payload?.type !== 'task_complete') return null;
75
+ const error = entry.payload.error;
76
+ if (!error || typeof error !== 'object') return null;
77
+ const message = typeof error.message === 'string' ? error.message.trim() : '';
78
+ if (!message) return null;
79
+ // The message as pane text, with the same anchors and the same reset-line
80
+ // selection as the scraped TUI — so the two paths cannot disagree about a
81
+ // banner. All of it (tail 0): a message that wraps its reset time onto a
82
+ // second line must still be read whole.
83
+ const detected = detectLimit(message, 0, codexPatterns);
84
+ if (!detected.hit) return null;
85
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
86
+ // A workspace wall says what it is in words, with no time to try again at.
87
+ // Behind a proxy no snapshot is there to classify it, so the banner files
88
+ // it the way parseSnapshot files a workspace reason with no spent window:
89
+ // a model limit — probed, then held for a human, never woken into.
90
+ const wall = WORKSPACE_WALL_BANNER.test(message);
91
+ return {
92
+ limitType: wall ? 'model' : detected.limitType,
93
+ resetAt: null,
94
+ resetLine: wall ? null : (detected.resetLine || message),
95
+ reachedType: null,
96
+ timestampMs: Number.isFinite(ts) ? ts : null,
97
+ };
98
+ }
99
+
36
100
  function emptyPremium(rl) {
37
- return rl.limit_id === 'premium' && rl.primary === null && rl.secondary === null
38
- && rl.credits?.has_credits === false && rl.credits?.unlimited === false
39
- && rl.credits?.balance != null && rl.credits.balance !== ''
40
- && Number(rl.credits.balance) === 0;
101
+ if (!(rl.limit_id === 'premium' && rl.primary === null && rl.secondary === null
102
+ && rl.credits?.has_credits === false && rl.credits?.unlimited === false)) return false;
103
+ if (rl.credits?.balance != null && rl.credits.balance !== '') {
104
+ return Number(rl.credits.balance) === 0;
105
+ }
106
+ // Workspace plans (Business/Team) send the same empty bucket with no balance
107
+ // at all, and say why in rate_limit_reached_type instead.
108
+ return workspaceWall(rl.rate_limit_reached_type);
109
+ }
110
+
111
+ // rate_limit_reached_type names WHY the server refused, never a window. The
112
+ // enum (codex-rs/protocol) is rate_limit_reached and four workspace_* values:
113
+ // {owner,member}_credits_depleted and {owner,member}_usage_limit_reached. The
114
+ // workspace ones are a wall that no window reset takes down by itself: Codex
115
+ // words them "Your workspace is out of credits…" / "You hit your spend cap…",
116
+ // with no time to try again at. A window that is spent as well is another
117
+ // matter — its reset brings the plan's own allowance back (parseSnapshot).
118
+ function workspaceWall(reachedType) {
119
+ return typeof reachedType === 'string' && reachedType.startsWith('workspace_');
120
+ }
121
+
122
+ // A window counts as spent from 99%: the server reports fractions, and #20's
123
+ // real stop read 99.0 (the same line the empty-premium inference draws).
124
+ const SPENT_PERCENT = 99;
125
+
126
+ // The window a "limit reached" is about when none reads 100: the one nearest
127
+ // exhaustion, primary on a tie. The server reports fractional percentages and
128
+ // #20's real stop read 99.0, so ">= 100" alone is not the whole story. (The
129
+ // previous rule took the window with the LATEST reset, which for any
130
+ // rate_limit_reached under 100% was the weekly one — days out.)
131
+ function nearestExhausted(windows) {
132
+ return windows.reduce((a, b) => ((b.used_percent ?? 0) > (a?.used_percent ?? -1) ? b : a), null);
41
133
  }
42
134
 
43
135
  // With several exhausted windows, the latest reset governs: resuming at an
@@ -46,19 +138,45 @@ function parseSnapshot(entry, previous = null) {
46
138
  if (!entry) return null;
47
139
  const rl = entry.payload.rate_limits;
48
140
  const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
141
+ const reachedType = rl.rate_limit_reached_type || null;
49
142
 
50
143
  const windows = ['primary', 'secondary']
51
144
  .map(k => rl[k])
52
145
  .filter(w => w && typeof w === 'object');
53
146
  let binding = null;
147
+ // The rate-limit bucket the binding window belongs to (Codex defaults a
148
+ // missing limit_id to "codex"). parseRolloutLines pairs errors by it.
149
+ let bucket = rl.limit_id ?? 'codex';
54
150
  const exhausted = windows.filter(w => (w.used_percent ?? 0) >= 100);
55
151
  if (exhausted.length > 0) {
56
152
  binding = exhausted.reduce((a, b) => ((b.resets_at || 0) > (a.resets_at || 0) ? b : a));
57
- } else if (rl.rate_limit_reached_type) {
58
- const named = rl[rl.rate_limit_reached_type];
59
- binding = (named && typeof named === 'object')
60
- ? named
61
- : windows.reduce((a, b) => ((b.resets_at || 0) > (a?.resets_at || 0) ? b : a), null);
153
+ } else if (workspaceWall(reachedType) && windows.length > 0) {
154
+ const spent = windows.filter(w => (w.used_percent ?? 0) >= SPENT_PERCENT);
155
+ if (spent.length > 0) {
156
+ // The plan's window is spent too (99.x% is as spent as 100): its reset
157
+ // brings the allowance back, so it governs exactly like an exhausted
158
+ // one — otherwise the same stop reads as a waitable 5h stop at 100.0
159
+ // and as a wall to hold for a human at 99.9.
160
+ binding = spent.reduce((a, b) => ((b.resets_at || 0) > (a.resets_at || 0) ? b : a));
161
+ } else {
162
+ // Out of credits (or over the workspace cap) with no window spent:
163
+ // there is no reset to sleep until. Record it the way a model limit is
164
+ // recorded — no reset time, so the resumer probes and, at the ceiling,
165
+ // makes the stall visible with the adapter's remedy instead of waking
166
+ // into the same wall (#25 saw one of these scheduled as a 5h stop).
167
+ // Only from a snapshot that describes windows: a credits-only bucket
168
+ // (premium/null) carrying the reason must not re-file the 5h stop the
169
+ // account bucket recorded a moment earlier as a probe.
170
+ return {
171
+ limitType: 'model',
172
+ resetAt: null,
173
+ reachedType,
174
+ timestampMs: Number.isFinite(ts) ? ts : null,
175
+ bucket,
176
+ };
177
+ }
178
+ } else if (reachedType) {
179
+ binding = nearestExhausted(windows);
62
180
  }
63
181
  // #20: Codex can stop at a reported 99%, then emit an empty premium bucket
64
182
  // instead of a 100% snapshot. Infer a stop only for that transition,
@@ -73,6 +191,7 @@ function parseSnapshot(entry, previous = null) {
73
191
  && Number.isFinite(primary.used_percent) && primary.used_percent >= 99
74
192
  && Number.isFinite(primary.resets_at) && primary.resets_at * 1000 > ts) {
75
193
  binding = primary;
194
+ bucket = prior.limit_id;
76
195
  const secondary = prior.secondary;
77
196
  if (Number.isFinite(secondary?.used_percent) && secondary.used_percent >= 100
78
197
  && Number.isFinite(secondary.resets_at) && secondary.resets_at > binding.resets_at) {
@@ -85,13 +204,14 @@ function parseSnapshot(entry, previous = null) {
85
204
  return {
86
205
  limitType: labelWindow(binding.window_minutes),
87
206
  resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
88
- reachedType: rl.rate_limit_reached_type || null,
207
+ reachedType,
89
208
  timestampMs: Number.isFinite(ts) ? ts : null,
209
+ bucket,
90
210
  };
91
211
  }
92
212
 
93
213
  export function parseRolloutLine(line) {
94
- return parseSnapshot(rolloutSnapshot(line));
214
+ return parseSnapshot(rolloutSnapshot(line)) || rolloutLimitError(line);
95
215
  }
96
216
 
97
217
  // Look immediately before the appended batch, not at the file's current EOF:
@@ -116,18 +236,72 @@ function previousSnapshot(path, offset) {
116
236
  return null;
117
237
  }
118
238
 
239
+ // A snapshot stop and the task_complete error of the same turn describe one
240
+ // event: keep the epoch (exact) and let the banner only fill in what the
241
+ // snapshot lacks. Bound the pairing to the same batch and a short window so a
242
+ // stale exhausted snapshot never lends its epoch to a later, unrelated stop —
243
+ // and to a stop that still stands when the error is written: its epoch must
244
+ // not have passed yet, and no later reading of the same bucket may show it
245
+ // cleared. Only the same bucket: Codex writes one token_count line per
246
+ // rate-limit bucket per response, so a healthy `codex_other` line right after
247
+ // an exhausted account line says nothing about the account.
248
+ const SNAPSHOT_PAIR_WINDOW_MS = 5 * 60_000;
249
+
250
+ function describesWindows(rl) {
251
+ return ['primary', 'secondary'].some(k => rl[k] && typeof rl[k] === 'object');
252
+ }
253
+
119
254
  export function parseRolloutLines(lines, { path, offset } = {}) {
120
255
  let previous;
256
+ let lastSnapshotHit = null;
121
257
  const hits = [];
122
258
  for (const line of lines) {
123
259
  const snapshot = rolloutSnapshot(line);
124
- if (!snapshot) continue;
125
- if (previous === undefined && emptyPremium(snapshot.payload.rate_limits)) {
126
- previous = previousSnapshot(path, offset);
260
+ if (snapshot) {
261
+ if (previous === undefined && emptyPremium(snapshot.payload.rate_limits)) {
262
+ previous = previousSnapshot(path, offset);
263
+ }
264
+ const hit = parseSnapshot(snapshot, previous);
265
+ if (hit) {
266
+ hits.push(hit);
267
+ lastSnapshotHit = hit;
268
+ } else if (lastSnapshotHit) {
269
+ const rl = snapshot.payload.rate_limits;
270
+ if ((rl.limit_id ?? 'codex') === lastSnapshotHit.bucket && describesWindows(rl)) {
271
+ lastSnapshotHit = null;
272
+ }
273
+ }
274
+ previous = snapshot;
275
+ continue;
276
+ }
277
+ const error = rolloutLimitError(line);
278
+ if (!error) continue;
279
+ const stop = lastSnapshotHit;
280
+ const paired = stop
281
+ && Number.isFinite(error.timestampMs) && Number.isFinite(stop.timestampMs)
282
+ && error.timestampMs >= stop.timestampMs
283
+ && error.timestampMs - stop.timestampMs <= SNAPSHOT_PAIR_WINDOW_MS
284
+ // A workspace wall has no epoch to lend, and pairs anyway (below).
285
+ && (stop.resetAt ? stop.resetAt > error.timestampMs : stop.limitType === 'model');
286
+ if (paired) {
287
+ error.resetAt = stop.resetAt;
288
+ error.reachedType = stop.reachedType;
289
+ if (stop.limitType === 'model') {
290
+ // The snapshot already said what this is: a workspace wall (credits
291
+ // depleted, workspace cap) that no reset takes down. The banner must
292
+ // not turn it back into a waitable stop — probe, then hold for a
293
+ // human, exactly as parseSnapshot filed it.
294
+ error.limitType = 'model';
295
+ error.resetLine = null;
296
+ } else if (error.limitType === 'unknown'
297
+ // A workspace-wall banner over a spent window: the snapshot already
298
+ // weighed the reason against the window, and the window's reset
299
+ // brings the allowance back. Other model limits keep their label.
300
+ || (error.limitType === 'model' && workspaceWall(stop.reachedType))) {
301
+ error.limitType = stop.limitType;
302
+ }
127
303
  }
128
- const hit = parseSnapshot(snapshot, previous);
129
- if (hit) hits.push(hit);
130
- previous = snapshot;
304
+ hits.push(error);
131
305
  }
132
306
  return hits;
133
307
  }
package/src/which.js ADDED
@@ -0,0 +1,63 @@
1
+ // Where a bare command name would be found: the first PATH entry holding one
2
+ // of `names`, as {dir, name, path}, or null. Path rules follow the platform
3
+ // being resolved for, not the host: on Windows the delimiter is ';' and every
4
+ // absolute entry contains a ':' (C:\...), so a ':' split shreds the list into
5
+ // nonsense and always answers "not on PATH" there (#25).
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import { win32, posix } from 'node:path';
9
+
10
+ export function findOnPath(names, { env = process.env, exists = existsSync, platform = process.platform } = {}) {
11
+ const api = platform === 'win32' ? win32 : posix;
12
+ for (const entry of (env.PATH || '').split(api.delimiter)) {
13
+ // Windows PATH entries may be quoted ("C:\Program Files\x"); spawn's own
14
+ // search (libuv) strips the quotes, so this one must too.
15
+ const dir = platform === 'win32' ? entry.replace(/^(["'])(.*)\1$/, '$2') : entry;
16
+ if (!dir) continue;
17
+ for (const name of names) {
18
+ const path = api.join(dir, name);
19
+ try { if (exists(path)) return { dir, name, path }; } catch { /* unreadable entry */ }
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+
25
+ // The spellings a bare name can take on this platform, launchable first.
26
+ // Node's spawn resolves .exe/.com itself; .cmd/.bat need a shell it is not
27
+ // given, so they are "found" in the sense that explains a failure.
28
+ export function candidateNames(name, platform = process.platform) {
29
+ if (platform !== 'win32') return [name];
30
+ if (/\.(exe|com|cmd|bat)$/i.test(name)) return [name];
31
+ return [`${name}.exe`, `${name}.com`, `${name}.cmd`, `${name}.bat`];
32
+ }
33
+
34
+ // Resolve what spawn(bin) would actually run: an absolute or relative path is
35
+ // checked as-is; a bare name is searched on PATH. Returns
36
+ // { path, launchable } or null when nothing is found.
37
+ export function resolveBin(bin, { env = process.env, exists = existsSync, platform = process.platform } = {}) {
38
+ if (typeof bin !== 'string' || bin === '') return null;
39
+ const shim = p => /\.(cmd|bat)$/i.test(p);
40
+ // Anything with a separator of either flavour is a path, checked as given —
41
+ // spawn does the same, and a Windows-shaped path handed to a POSIX lookup
42
+ // (doctor's own tests simulate one platform on another) must not be
43
+ // searched on PATH as if it were a bare name.
44
+ if (win32.isAbsolute(bin) || posix.isAbsolute(bin) || bin.includes('/') || bin.includes('\\')) {
45
+ // On Windows spawn tries an extensionless path as .com then .exe
46
+ // (UNSNOOZE_CODEX_BIN=C:\tools\codex runs C:\tools\codex.exe). The literal
47
+ // path stays the fallback, for a POSIX-shaped path checked under Windows
48
+ // rules (doctor's tests simulate one platform on the other).
49
+ if (platform === 'win32' && !win32.extname(bin)) {
50
+ const hit = [`${bin}.com`, `${bin}.exe`].find(p => exists(p));
51
+ if (hit) return { path: hit, launchable: true };
52
+ }
53
+ return exists(bin) ? { path: bin, launchable: !shim(bin) } : null;
54
+ }
55
+ // Two passes, because spawn never tries a .cmd/.bat (libuv only appends
56
+ // .com and .exe): an .exe anywhere on PATH is what runs, even behind a shim
57
+ // in an earlier directory — the npm shim a native install left behind.
58
+ // Only when nothing launchable exists is a shim the answer, as the reason.
59
+ const names = candidateNames(bin, platform);
60
+ const opts = { env, exists, platform };
61
+ const hit = findOnPath(names.filter(n => !shim(n)), opts) || findOnPath(names.filter(shim), opts);
62
+ return hit ? { path: hit.path, launchable: !shim(hit.path) } : null;
63
+ }
package/src/wizard.js CHANGED
@@ -77,14 +77,18 @@ export async function runWizard() {
77
77
  });
78
78
  if (p.isCancel(notifications)) return cancelled();
79
79
 
80
- // GUI watching: only meaningful where an autostart daemon can run. Windows
81
- // joined that list with the Task Scheduler unit — and needs it most, since
82
- // headless has no pane monitor and the transcript watcher lives in the daemon.
80
+ // GUI watching: only meaningful where the daemon can run. Windows needs it
81
+ // most, since headless has no pane monitor and the transcript watcher lives
82
+ // in the daemon — and there it is started on demand, with nothing registered
83
+ // to run at sign-in (see DAEMON_ON_DEMAND in config.js).
83
84
  let guiWatch = false;
84
85
  if (['darwin', 'linux', 'win32'].includes(process.platform)) {
86
+ const how = process.platform === 'win32'
87
+ ? 'started on demand by your agents — nothing runs at sign-in'
88
+ : (process.platform === 'darwin' ? 'launchd' : 'systemd');
85
89
  const answer = await p.confirm({
86
90
  message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
87
- + ' Installs a small background daemon (launchd/systemd/Task Scheduler) that watches session\n'
91
+ + ` Runs a small background daemon (${how}) that watches session\n`
88
92
  + ' files for limit stops; revived sessions open in the configured multiplexer and stay visible in\n'
89
93
  + ' the GUI\'s own history.',
90
94
  initialValue: DEFAULTS.guiWatch,
package/src/workspace.js CHANGED
@@ -7,9 +7,12 @@
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { createHash } from 'node:crypto';
9
9
 
10
+ // windowsHide: this runs in the daemon and the resumer, which have no console
11
+ // on Windows, so git would otherwise flash a console window of its own at
12
+ // every stop and every wake.
10
13
  function git(cwd, args) {
11
14
  return execFileSync('git', ['-C', cwd, ...args], {
12
- stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
15
+ stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, windowsHide: true,
13
16
  }).toString().trim();
14
17
  }
15
18