unsnooze 1.18.0 → 1.19.0

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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.0 — 2026-09-10
4
+
5
+ Fish shell support, direct help/version commands, and a fix for Codex sessions
6
+ that stop at a reported 99% usage.
7
+
8
+ ### Fish shell support
9
+
10
+ `unsnooze setup` and `unsnooze install` now install fish wrappers when fish is
11
+ your login shell or its config file already exists. The default location is
12
+ `~/.config/fish/config.fish`; an absolute `XDG_CONFIG_HOME` is respected.
13
+ `unsnooze doctor` checks this file, and `unsnooze uninstall` removes the wrappers.
14
+ Re-running setup replaces the existing block and preserves your other settings.
15
+ Use `--fishrc <path>` with install or uninstall to choose another config file.
16
+
17
+ After upgrading, run `unsnooze setup` once to add the fish wrappers, then open
18
+ a new shell. Thanks to [@duncanmcqueen](https://github.com/duncanmcqueen) for
19
+ [PR #21](https://github.com/saaranshM/unsnooze/pull/21).
20
+
21
+ ### Help and version commands run directly
22
+
23
+ Commands such as `claude --help` and `codex --version` now pass straight to
24
+ the agent. They do not open a multiplexer session, start a pane monitor,
25
+ prepend `launchExtraArgs`, or append an unsnooze update notice. The existing
26
+ print-mode passthrough is unchanged. Thanks to [@chid](https://github.com/chid)
27
+ for [PR #23](https://github.com/saaranshM/unsnooze/pull/23).
28
+
29
+ ### Codex can be detected as stopped at a reported 99%
30
+
31
+ The GUI watcher now recognizes the sequence reported in
32
+ [issue #20](https://github.com/saaranshM/unsnooze/issues/20): a five-hour Codex
33
+ window reaches 99%, then the next rate-limit snapshot switches to an empty
34
+ `premium` bucket with no credits. It records the stop and schedules the session
35
+ using the previous reset time, plus the normal safety margin.
36
+
37
+ This fallback requires snapshots from the same rollout no more than a minute
38
+ apart, no available credits, and a future reset time. It works across watcher
39
+ polls and daemon restarts. An already-exhausted weekly window still takes
40
+ precedence if it resets later. A 99% reading alone does not trigger a resume.
41
+
42
+ The separate report of stale 64% usage was not reproduced; this release fixes
43
+ the missed stop detection. Thanks to [@mio-tsuki](https://github.com/mio-tsuki)
44
+ for the sample. Fixed in [PR #24](https://github.com/saaranshM/unsnooze/pull/24).
45
+
46
+ ### Test fixes
47
+
48
+ - Permission tests now set their required file mode explicitly, so they pass
49
+ with different system umasks. Thanks to
50
+ [@Chang-Jin-Lee](https://github.com/Chang-Jin-Lee) for
51
+ [PR #22](https://github.com/saaranshM/unsnooze/pull/22).
52
+ - Fish install/uninstall tests use temporary profiles and leave existing user
53
+ settings, wrappers, and daemon state untouched.
54
+ - Help/version tests use a portable test executable instead of relying on
55
+ platform-specific `echo` behavior.
56
+
3
57
  ## 1.18.0 — 2026-09-02
4
58
 
5
59
  **Cursor CLI support**, a wrapper change that keeps `cursor .` working, and a
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  [![CI](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml/badge.svg)](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml)
8
8
  [![npm](https://img.shields.io/npm/v/unsnooze?color=f59e0b)](https://www.npmjs.com/package/unsnooze)
9
- [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3fb950)](package.json)
9
+ [![node](https://img.shields.io/badge/node-%E2%89%A5%2020.12-3fb950)](package.json)
10
10
  [![license](https://img.shields.io/badge/license-MIT-8b949e)](LICENSE)
11
11
 
12
12
  **Automatically resume every limit-stopped AI coding session when its usage limit resets.**
@@ -223,7 +223,8 @@ Two things worth knowing:
223
223
 
224
224
  ## GUI surfaces (VS Code extension, desktop apps)
225
225
 
226
- Terminal sessions are watched through the shell wrapper + tmux, Zellij, or herdr. Sessions in
226
+ Terminal sessions are watched through the shell wrapper and tmux, Zellij, herdr,
227
+ or cmux, with headless watching when no multiplexer is available. Sessions in
227
228
  **Claude Code's VS Code extension / desktop app** and **Codex's IDE
228
229
  extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
229
230
  the session files those surfaces already write:
@@ -239,6 +240,10 @@ the session files those surfaces already write:
239
240
  where Codex lives only inside ChatGPT.app (no `codex` on PATH), unsnooze
240
241
  automatically resumes through the app-bundled binary
241
242
  (`/Applications/ChatGPT.app/Contents/Resources/codex`).
243
+ The watcher also recognizes a reported 99% five-hour window followed within
244
+ one minute by an empty `premium` bucket with no credits, using the previous
245
+ future reset time. The snapshots must come from the same rollout; 99% alone
246
+ is not a stop signal. This works across watcher polls and daemon restarts.
242
247
  - **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
243
248
  sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
244
249
  those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
@@ -289,11 +294,11 @@ StopFailure hook (claude, grok) ──────────────┤
289
294
 
290
295
  </details>
291
296
 
292
- Limit events are never persisted by the CLIs themselves; the reset time is
293
- parsed from the banner text, DST-safe. Unparseable banners are never guessed
294
- at — unsnooze probes the pane on a backoff schedule until a real reset time
295
- appears. Every resume is verified afterwards (banner came back → reschedule
296
- from the fresh banner, capped at 5 attempts).
297
+ Detection uses agent hooks, saved session files, and terminal banners. Reset
298
+ times come from the agent's saved timestamp or the banner text, parsed DST-safe.
299
+ When no reset time is available, unsnooze probes on a backoff schedule. Every
300
+ resume is verified afterwards (banner came back → reschedule from the fresh
301
+ banner, capped at 5 attempts).
297
302
 
298
303
  ## Know the wall before you hit it
299
304
 
@@ -680,9 +685,20 @@ watcher stops (no pane context) always use native.
680
685
  [Watching without a multiplexer](#watching-without-a-multiplexer))
681
686
  - tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0 for pane-level watching. Optional:
682
687
  without one, unsnooze runs `headless` and still catches and resumes stops.
683
- - zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), or PowerShell
688
+ - zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), fish (wrappers go
689
+ into `~/.config/fish/config.fish`), or PowerShell
684
690
  (wrappers go into `$PROFILE.CurrentUserAllHosts`)
685
691
 
692
+ Fish wrappers are installed when fish is the login shell or its config already
693
+ exists. An absolute `XDG_CONFIG_HOME` changes the config location to
694
+ `$XDG_CONFIG_HOME/fish/config.fish`; `unsnooze install --fishrc <path>` and
695
+ `unsnooze uninstall --fishrc <path>` override it explicitly. After upgrading an
696
+ existing install, run `unsnooze setup` and open a new shell to load the wrappers.
697
+
698
+ Agent help/version commands, such as `claude --help` and `codex --version`, run
699
+ directly without a multiplexer session, pane monitor, `launchExtraArgs`, or an
700
+ unsnooze update notice. `unsnooze help` still shows unsnooze's own commands.
701
+
686
702
  ### Supported terminals
687
703
 
688
704
  unsnooze drives four terminal multiplexers, and works without one.
package/bin/unsnooze.js CHANGED
@@ -69,7 +69,7 @@ async function maybeLaunchExitNotice(args) {
69
69
  try {
70
70
  if (process.env.TMUX || process.env.ZELLIJ || process.env.UNSNOOZE_ACTIVE === '1') return;
71
71
  if (!process.stderr.isTTY) return; // pipes, CI, scripts
72
- if (args.includes('-p') || args.includes('--print')) return; // non-interactive runs
72
+ if (args.some(a => a === '-p' || a === '--print' || a === '-h' || a === '--help' || a === '-v' || a === '-V' || a === '--version')) return; // non-interactive runs
73
73
  const mod = await safeImport('../src/update-check.js');
74
74
  if (!mod) return;
75
75
  const notice = mod.launchExitNotice();
@@ -340,17 +340,23 @@ Usage:
340
340
  unsnooze usage [--json] account burn rate & time-to-limit forecast
341
341
  (--install-statusline for exact Claude %,
342
342
  --uninstall-statusline to remove it)
343
+ unsnooze design [setup] Claude Design from the terminal: check whether
344
+ the claude-design MCP server is registered and
345
+ signed in, or register it with setup
343
346
  unsnooze logs [-f] show (or follow) the unsnooze log
344
347
  unsnooze update update unsnooze itself to the latest version
345
348
  unsnooze daemon persistent watcher for GUI sessions (VS Code
346
349
  extension, desktop apps) — no live pane needed
347
- to detect; revival opens in tmux or Zellij
350
+ to detect; revival opens in your multiplexer,
351
+ or headless where there is none
348
352
  unsnooze config [list|get|set] view or change settings (toggles, global +
349
353
  per-agent resume messages, notifyChannel
350
354
  auto|native|osc|bell, updateCheck)
351
355
  unsnooze setup interactive setup wizard (agents + toggles)
352
356
  unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
357
+ (--fishrc <path> overrides the fish config)
353
358
  unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
359
+ (--fishrc <path> overrides the fish config)
354
360
  unsnooze report [agent] [pane] capture a pane to report an undetected banner
355
361
  unsnooze help show this help (also -h / --help)`);
356
362
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
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/doctor.js CHANGED
@@ -16,6 +16,7 @@ import { makeLogger } from './logger.js';
16
16
  import { shouldUseTui, formatDoctorTui } from './tui.js';
17
17
  import { designRegisteredOffline } from './design.js';
18
18
  import { powershellProfilePath } from './powershell.js';
19
+ import { fishConfigPath } from './fish.js';
19
20
 
20
21
  const log = makeLogger('doctor');
21
22
 
@@ -116,13 +117,14 @@ function readOrEmpty(path) {
116
117
  // wrappers installed" is a health question — but it has to be asked of the
117
118
  // right file. A PowerShell user has no ~/.zshrc, so checking only the POSIX rc
118
119
  // files made every native-Windows `unsnooze doctor` report the wrappers
119
- // missing and point the user at an install they had already run.
120
+ // missing and point the user at an install they had already run. A fish user
121
+ // has no POSIX rc wrapper either — their block lives in the fish config.
120
122
  function defaultWrappersInstalled({
121
123
  platform = process.platform,
122
124
  rcContent = readOrEmpty,
123
125
  profileContent = null,
124
126
  } = {}) {
125
- const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')];
127
+ const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc'), fishConfigPath()];
126
128
  if (rcs.some(rc => rcContent(rc).includes(WRAPPER_MARK))) return true;
127
129
  if (platform !== 'win32') return false;
128
130
  const readProfile = profileContent
package/src/fish.js ADDED
@@ -0,0 +1,22 @@
1
+ // Where the fish shell looks for its config.
2
+ //
3
+ // A leaf module (node builtins only) on purpose — the same reason
4
+ // powershell.js exists: install.js already imports doctor.js, and doctor.js
5
+ // needs this to answer "are the wrappers installed?" for fish users.
6
+ // Declaring it in install.js would close that loop (see
7
+ // test/module-graph.test.js for what that breaks).
8
+ //
9
+ // fish reads its config from <config-home>/fish/config.fish, where
10
+ // <config-home> honors XDG_CONFIG_HOME (absolute paths only — the XDG spec
11
+ // says a relative value must be ignored, and fish follows that).
12
+
13
+ import { homedir } from 'node:os';
14
+ import { join, isAbsolute } from 'node:path';
15
+
16
+ export function fishConfigPath({ env = process.env, home = homedir() } = {}) {
17
+ // Test/CI override, matching UNSNOOZE_QWEN_DIR and the autostart dir vars.
18
+ if (env.UNSNOOZE_FISH_CONFIG) return env.UNSNOOZE_FISH_CONFIG;
19
+ const xdg = env.XDG_CONFIG_HOME;
20
+ if (xdg && isAbsolute(xdg)) return join(xdg, 'fish', 'config.fish');
21
+ return join(home, '.config', 'fish', 'config.fish');
22
+ }
package/src/install.js CHANGED
@@ -5,8 +5,11 @@
5
5
  // atomic write)
6
6
  // - ~/.zshrc + ~/.bashrc: one fence-marked block with a wrapper function per
7
7
  // enabled agent (claude/codex/grok), routed through `unsnooze _run`
8
+ // - ~/.config/fish/config.fish: the same block in fish syntax (fish shares
9
+ // no syntax with POSIX rc files — a bash function there is a parse error)
8
10
  // - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
9
- // --settings <path> / --zshrc <path> override targets (used by tests).
11
+ // --settings <path> / --zshrc <path> / --fishrc <path> override targets (used
12
+ // by tests).
10
13
 
11
14
  import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
12
15
  import { execFileSync } from 'node:child_process';
@@ -21,6 +24,7 @@ import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
21
24
  import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
22
25
  import { uninstallStatuslineShim } from './usage.js';
23
26
  import { powershellProfilePath } from './powershell.js';
27
+ import { fishConfigPath } from './fish.js';
24
28
 
25
29
  const FENCE_OPEN = '# >>> unsnooze >>>';
26
30
  const FENCE_CLOSE = '# <<< unsnooze <<<';
@@ -33,13 +37,17 @@ const LEGACY_FENCES = [
33
37
  const OLD_FENCE_OPEN = LEGACY_FENCES[0].open;
34
38
 
35
39
  function parseArgs(rest) {
36
- const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false, daemon: false };
40
+ const opts = {
41
+ yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'),
42
+ fishrc: fishConfigPath(), purge: false, daemon: false,
43
+ };
37
44
  for (let i = 0; i < rest.length; i++) {
38
45
  if (rest[i] === '--yes' || rest[i] === '-y') opts.yes = true;
39
46
  else if (rest[i] === '--purge') opts.purge = true;
40
47
  else if (rest[i] === '--daemon') opts.daemon = true;
41
48
  else if (rest[i] === '--settings') opts.settings = rest[++i];
42
49
  else if (rest[i] === '--zshrc') opts.zshrc = rest[++i];
50
+ else if (rest[i] === '--fishrc') opts.fishrc = rest[++i];
43
51
  }
44
52
  return opts;
45
53
  }
@@ -204,6 +212,38 @@ export function installZshrcBlock(content, agents = ['claude']) {
204
212
  return { content: result, oldRemoved };
205
213
  }
206
214
 
215
+ // The fish twin of wrapperBlock(). Fish shares no syntax with the POSIX block:
216
+ // `name() { … }` is a parse error there, `$?` is `$status`, and `"${X}"` is
217
+ // `"${X}"` verbatim text — so the wrapper gets its own body. Same semantics as
218
+ // the zsh/bash wrapper: a recursion guard (UNSNOOZE_ACTIVE), the load-bearing
219
+ // missing-file fallback to the real CLI, and `_run <id>` routing.
220
+ //
221
+ // Redefining a function replaces any earlier one, which covers fish "aliases"
222
+ // (they are functions too) — no `unalias` analogue is needed. Fish comments
223
+ // are `#`, so the same fence markers work and stripFencedBlock() removes it
224
+ // unchanged.
225
+ export function fishWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
226
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `function ${name} --description 'unsnooze-wrapped ${name}'
227
+ if test "$UNSNOOZE_ACTIVE" = "1"; or not test -f '${bin}'
228
+ command ${name} $argv
229
+ return $status
230
+ end
231
+ node '${bin}' _run ${id} $argv
232
+ end`)).join('\n');
233
+ return `${FENCE_OPEN}
234
+ # unsnooze wrappers: route every interactive launch of the CLIs below through
235
+ # unsnooze so limit stops are recorded and auto-resumed.
236
+ ${fns}
237
+ ${FENCE_CLOSE}`;
238
+ }
239
+
240
+ // installZshrcBlock's fish twin. Same fence, same replace-don't-append
241
+ // behaviour, so re-running setup never stacks a second copy of the wrappers.
242
+ export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BIN) {
243
+ const { content: cleaned } = stripFencedBlock(content, FENCE_OPEN, FENCE_CLOSE);
244
+ return cleaned.replace(/\n+$/, '\n') + '\n' + fishWrapperBlock(agents, bin) + '\n';
245
+ }
246
+
207
247
  // --- daemon autostart ---
208
248
  // GUI sessions (VS Code extension, desktop apps) never pass through the shell
209
249
  // wrappers, so their limit stops are only caught while `unsnooze daemon` is
@@ -555,6 +595,22 @@ function rcTargets(opts, explicit) {
555
595
  return existing.length > 0 ? existing : [opts.zshrc];
556
596
  }
557
597
 
598
+ // The fish config is a separate target from the POSIX rc loop: fish syntax
599
+ // shares nothing with bash, so it gets its own block, and fish reads only
600
+ // <config-home>/fish/config.fish. Touch it when the file already exists (the
601
+ // same any-rc rule as zsh/bash), or when fish is the login shell — a fish
602
+ // user may not have created config.fish yet, and a wrapper that would never
603
+ // load is the same silent-miss as a profile written to a guessed path. Empty
604
+ // list = no evidence of fish on this machine; leave it alone.
605
+ function fishTargets(opts, explicit) {
606
+ if (explicit) return [opts.fishrc];
607
+ const cfg = fishConfigPath();
608
+ if (existsSync(cfg)) return [cfg];
609
+ const loginShell = process.env.SHELL || userInfo().shell;
610
+ if (typeof loginShell === 'string' && /(^|\/|\\)fish$/.test(loginShell)) return [cfg];
611
+ return [];
612
+ }
613
+
558
614
  export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
559
615
  const opts = parseArgs(rest);
560
616
  const explicitRc = rest.includes('--zshrc');
@@ -623,6 +679,21 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
623
679
  }
624
680
  }
625
681
 
682
+ // 3c. Fish config. The POSIX loop above is a parse error in fish, and the
683
+ // PowerShell profile never loads there — fish needs its own block or a
684
+ // fish user's `claude` never routes through unsnooze at all.
685
+ for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
686
+ try {
687
+ mkdirSync(dirname(cfg), { recursive: true });
688
+ const before = existsSync(cfg) ? readFileSync(cfg, 'utf-8') : '';
689
+ if (existsSync(cfg)) backupOnce(cfg);
690
+ atomicWrite(cfg, installFishBlock(before, agents, UNSNOOZE_BIN));
691
+ console.log(`unsnooze: fish wrappers (${agents.join(', ')}) installed in ${cfg}`);
692
+ } catch (err) {
693
+ console.log(`unsnooze: could not write the fish config (${err.message})`);
694
+ }
695
+ }
696
+
626
697
  // 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
627
698
  if (opts.daemon) {
628
699
  const target = installDaemonAutostart();
@@ -698,6 +769,17 @@ export function cmdUninstall(rest) {
698
769
  console.log(`unsnooze: could not clean the PowerShell profile (${err.message})`);
699
770
  }
700
771
 
772
+ // The fish config is a wrapper site too, same reason as the PowerShell
773
+ // profile above: a stale block would keep shadowing the real CLI.
774
+ for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
775
+ if (!existsSync(cfg)) continue;
776
+ const { content, found } = stripFencedBlock(readFileSync(cfg, 'utf-8'), FENCE_OPEN, FENCE_CLOSE);
777
+ if (found) {
778
+ atomicWrite(cfg, content);
779
+ console.log(`unsnooze: fish wrappers removed from ${cfg}`);
780
+ }
781
+ }
782
+
701
783
  const autostart = uninstallDaemonAutostart();
702
784
  if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
703
785
 
package/src/launcher.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // - outside a multiplexer: re-exec through its launchWrapped operation
3
3
  // - inside one: spawn a detached per-pane monitor, then run the CLI,
4
4
  // propagating its exit code
5
- // - -p/--print: pure pass-through, no monitor (nothing interactive to scrape)
5
+ // - -p/--print, -h/--help, -v/-V/--version: pure pass-through, no monitor (nothing interactive to scrape)
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
8
  import { getMultiplexer } from './multiplexer.js';
@@ -16,8 +16,12 @@ import { AgentDispatchedError, SessionCreateError, attachHint } from './multiple
16
16
 
17
17
  const log = makeLogger('launcher');
18
18
 
19
- function isPrintMode(args) {
20
- return args.includes('-p') || args.includes('--print');
19
+ export function isPassthrough(args = []) {
20
+ return args.some(a =>
21
+ a === '-p' || a === '--print'
22
+ || a === '-h' || a === '--help'
23
+ || a === '-v' || a === '-V' || a === '--version'
24
+ );
21
25
  }
22
26
 
23
27
  export function resolvePaneOwner(muxName, env = process.env) {
@@ -44,7 +48,12 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
44
48
  // Recursion / nested-launch guard: inside an unsnooze-managed session, a
45
49
  // plain `claude`/`codex`/`unsnooze` call goes straight through. Same for an
46
50
  // agent the user disabled in settings — run it, don't watch it.
47
- if (process.env.UNSNOOZE_ACTIVE === '1' || isPrintMode(args) || !getConfig(`agents.${agent.id}`)) {
51
+ //
52
+ // Non-interactive / informational invocations (-p/--print, -h/--help, -v/--version)
53
+ // also bypass multiplexer wrapping and monitoring — running a new tmux session
54
+ // or spawning a watcher for `claude --help` flashes the screen and slows down
55
+ // simple queries.
56
+ if (process.env.UNSNOOZE_ACTIVE === '1' || isPassthrough(args) || !getConfig(`agents.${agent.id}`)) {
48
57
  const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
49
58
  return r.status ?? 1;
50
59
  }
package/src/watcher.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from './config.js';
23
23
  import { getMultiplexer } from './multiplexer.js';
24
24
  import { parseTranscriptLine } from './watchers/claude.js';
25
- import { parseRolloutLine, rolloutMeta } from './watchers/codex.js';
25
+ import { parseRolloutLines, rolloutMeta } from './watchers/codex.js';
26
26
  import { ROLLOUT_RE } from './agents/codex.js';
27
27
  import { parseResetTime, resetAtMs } from './time-parser.js';
28
28
  import { upsertSession, readState, updateState } from './state.js';
@@ -85,8 +85,8 @@ export function codexSource({ roots }) {
85
85
  roots,
86
86
  enabled: () => getConfig('agents.codex'),
87
87
  match: p => ROLLOUT_RE.test(basename(p)),
88
- parse(lines, path) {
89
- const hits = lines.map(parseRolloutLine).filter(Boolean);
88
+ parse(lines, path, { offset } = {}) {
89
+ const hits = parseRolloutLines(lines, { path, offset });
90
90
  if (hits.length === 0) return [];
91
91
  const last = hits[hits.length - 1]; // the latest snapshot governs
92
92
  const meta = rolloutMeta(path);
@@ -322,7 +322,10 @@ export function createWatcher({
322
322
  const lastNl = buf.lastIndexOf(0x0a);
323
323
  if (lastNl === -1) { setOffset(path, offset); return null; } // partial line — wait
324
324
  setOffset(path, offset + lastNl + 1);
325
- return buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim());
325
+ return {
326
+ offset,
327
+ lines: buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim()),
328
+ };
326
329
  }
327
330
 
328
331
  function walk(dir, depth, cb) {
@@ -353,11 +356,12 @@ export function createWatcher({
353
356
  const usageMatch = source.usageMatch ? source.usageMatch(path) : stopMatch;
354
357
  if (!stopMatch && !usageMatch) return;
355
358
  seen.add(path);
356
- const lines = readAppended(path);
357
- if (!lines || lines.length === 0) return;
359
+ const appended = readAppended(path);
360
+ if (!appended || appended.lines.length === 0) return;
361
+ const { lines, offset } = appended;
358
362
  if (stopMatch) {
359
363
  try {
360
- candidates.push(...source.parse(lines, path));
364
+ candidates.push(...source.parse(lines, path, { offset }));
361
365
  } catch (err) {
362
366
  log(`parse error in ${path}: ${err.message}`);
363
367
  }
@@ -23,17 +23,29 @@ export { extractCodexUsage } from '../usage.js';
23
23
  // must not conflate it with the weekly bucket.
24
24
  import { labelWindow } from '../usage.js';
25
25
 
26
- // One rollout JSONL line → limit-stop candidate or null. A window binds when
27
- // its used_percent hits 100 (or rate_limit_reached_type says the model was
28
- // actually blocked); with several exhausted windows the LATEST reset governs —
29
- // resuming at the earlier one would immediately re-hit the other limit.
30
- export function parseRolloutLine(line) {
26
+ function rolloutSnapshot(line) {
31
27
  if (!line || !line.trim()) return null;
32
28
  let entry;
33
29
  try { entry = JSON.parse(line); } catch { return null; }
34
30
  if (entry?.type !== 'event_msg' || entry.payload?.type !== 'token_count') return null;
35
31
  const rl = entry.payload.rate_limits;
36
32
  if (!rl || typeof rl !== 'object') return null;
33
+ return entry;
34
+ }
35
+
36
+ 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;
41
+ }
42
+
43
+ // With several exhausted windows, the latest reset governs: resuming at an
44
+ // earlier one would immediately hit the other limit again.
45
+ function parseSnapshot(entry, previous = null) {
46
+ if (!entry) return null;
47
+ const rl = entry.payload.rate_limits;
48
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
37
49
 
38
50
  const windows = ['primary', 'secondary']
39
51
  .map(k => rl[k])
@@ -48,9 +60,28 @@ export function parseRolloutLine(line) {
48
60
  ? named
49
61
  : windows.reduce((a, b) => ((b.resets_at || 0) > (a?.resets_at || 0) ? b : a), null);
50
62
  }
63
+ // #20: Codex can stop at a reported 99%, then emit an empty premium bucket
64
+ // instead of a 100% snapshot. Infer a stop only for that transition,
65
+ // in the same rollout, within a minute, with no credits and a future reset.
66
+ // A lone 99% snapshot or an unrelated premium bucket is not a stop.
67
+ if (!binding && emptyPremium(rl) && previous) {
68
+ const prior = previous.payload.rate_limits;
69
+ const elapsed = ts - Date.parse(previous.timestamp);
70
+ const primary = prior.primary;
71
+ if (prior.limit_id === 'codex' && elapsed >= 0 && elapsed <= 60_000
72
+ && primary?.window_minutes === 300
73
+ && Number.isFinite(primary.used_percent) && primary.used_percent >= 99
74
+ && Number.isFinite(primary.resets_at) && primary.resets_at * 1000 > ts) {
75
+ binding = primary;
76
+ const secondary = prior.secondary;
77
+ if (Number.isFinite(secondary?.used_percent) && secondary.used_percent >= 100
78
+ && Number.isFinite(secondary.resets_at) && secondary.resets_at > binding.resets_at) {
79
+ binding = secondary;
80
+ }
81
+ }
82
+ }
51
83
  if (!binding) return null;
52
84
 
53
- const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
54
85
  return {
55
86
  limitType: labelWindow(binding.window_minutes),
56
87
  resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
@@ -59,6 +90,48 @@ export function parseRolloutLine(line) {
59
90
  };
60
91
  }
61
92
 
93
+ export function parseRolloutLine(line) {
94
+ return parseSnapshot(rolloutSnapshot(line));
95
+ }
96
+
97
+ // Look immediately before the appended batch, not at the file's current EOF:
98
+ // it may have grown since the watcher read it. This also works after restart
99
+ // without persisting another cache alongside the watcher's byte offsets.
100
+ function previousSnapshot(path, offset) {
101
+ if (!path || !Number.isSafeInteger(offset) || offset <= 0) return null;
102
+ let fd;
103
+ try {
104
+ fd = openSync(path, 'r');
105
+ const length = Math.min(offset, 4 * 1024 * 1024);
106
+ const buf = Buffer.alloc(length);
107
+ const count = readSync(fd, buf, 0, length, offset - length);
108
+ const lines = buf.subarray(0, count).toString('utf-8').split('\n');
109
+ if (offset > length) lines.shift(); // the first line may be truncated
110
+ for (let i = lines.length - 1; i >= 0; i--) {
111
+ const snapshot = rolloutSnapshot(lines[i]);
112
+ if (snapshot) return snapshot;
113
+ }
114
+ } catch { /* missing context means no inferred stop */ }
115
+ finally { if (fd !== undefined) closeSync(fd); }
116
+ return null;
117
+ }
118
+
119
+ export function parseRolloutLines(lines, { path, offset } = {}) {
120
+ let previous;
121
+ const hits = [];
122
+ for (const line of lines) {
123
+ const snapshot = rolloutSnapshot(line);
124
+ if (!snapshot) continue;
125
+ if (previous === undefined && emptyPremium(snapshot.payload.rate_limits)) {
126
+ previous = previousSnapshot(path, offset);
127
+ }
128
+ const hit = parseSnapshot(snapshot, previous);
129
+ if (hit) hits.push(hit);
130
+ previous = snapshot;
131
+ }
132
+ return hits;
133
+ }
134
+
62
135
  // The session_meta head line can be very long (it embeds the full base
63
136
  // instructions) — read in chunks until the first newline.
64
137
  function readFirstLine(path, maxBytes = 256 * 1024) {