unsnooze 1.18.0 → 1.19.1
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 +183 -0
- package/README.md +47 -13
- package/bin/unsnooze.js +8 -2
- package/package.json +1 -1
- package/src/agents/codex.js +96 -12
- package/src/cli.js +4 -0
- package/src/config.js +6 -0
- package/src/doctor.js +81 -2
- package/src/fish.js +22 -0
- package/src/install.js +86 -4
- package/src/launcher.js +45 -12
- package/src/multiplexers/headless.js +159 -11
- package/src/resumer.js +65 -7
- package/src/time-parser.js +6 -1
- package/src/usage.js +50 -21
- package/src/watcher.js +56 -19
- package/src/watchers/codex.js +262 -23
- package/src/which.js +63 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,188 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.19.1 — 2026-09-21
|
|
4
|
+
|
|
5
|
+
A Codex usage reading that was averaged down, a headless Codex revival
|
|
6
|
+
that could never have worked — reported as a success — and Codex stops that
|
|
7
|
+
were never seen at all behind a proxy or in a reverted thread.
|
|
8
|
+
|
|
9
|
+
### `unsnooze usage` reports Codex's exact percentage as read
|
|
10
|
+
|
|
11
|
+
The 64% in [issue #20](https://github.com/saaranshM/unsnooze/issues/20) was
|
|
12
|
+
`(29 + 99) / 2`: a jump of more than 15 points was averaged with the reading
|
|
13
|
+
before it and still labelled `exact`. The averaging was meant to soften a
|
|
14
|
+
one-tick spike, but at the moment a session is blocked no further reading
|
|
15
|
+
arrives, so the low number stayed for the rest of the window — and 64 is
|
|
16
|
+
below the 80/95 warning thresholds, so the usage-wall notification never
|
|
17
|
+
fired. Codex's `used_percent` is the server's own number and is now shown
|
|
18
|
+
unchanged (still clamped to 0–100).
|
|
19
|
+
|
|
20
|
+
Two things could put a much lower reading in front of the latest one: Codex
|
|
21
|
+
writes one snapshot per rate-limit bucket per response, and `unsnooze usage`
|
|
22
|
+
read every bucket (`premium`, `codex_other`, …) as the account window; and
|
|
23
|
+
Codex Desktop tabs and sub-agents each write their own rollout, so "the
|
|
24
|
+
previous reading" could be another thread's minutes-old snapshot. Only the
|
|
25
|
+
account bucket feeds the 5h/weekly lines now, and burn is measured between
|
|
26
|
+
readings of the same rollout. Thanks to
|
|
27
|
+
[@mio-tsuki](https://github.com/mio-tsuki) for working out the arithmetic.
|
|
28
|
+
|
|
29
|
+
### Headless Codex revivals run, and a revival that dies says so
|
|
30
|
+
|
|
31
|
+
[Issue #25](https://github.com/saaranshM/unsnooze/issues/25): on Windows the
|
|
32
|
+
daemon's Scheduled Task could not find `codex`, every revival died with
|
|
33
|
+
`spawn codex ENOENT`, and unsnooze logged `verified resumed` anyway.
|
|
34
|
+
|
|
35
|
+
- **`codex exec resume`.** Under the headless backend a revival ran the Codex
|
|
36
|
+
TUI, which exits before touching the session when stdin is not a terminal —
|
|
37
|
+
on every OS, since the backend shipped in 1.16.0. Headless Codex revivals
|
|
38
|
+
now run `codex exec resume <id> "<prompt>"`, which continues the same
|
|
39
|
+
conversation non-interactively, the way Claude's headless revival already
|
|
40
|
+
passed its prompt in argv. It is given `--skip-git-repo-check`: `exec`
|
|
41
|
+
refuses any directory that is not a git repository, which the TUI never
|
|
42
|
+
did, and the session being revived already ran there. `resumeExtraArgs.codex`
|
|
43
|
+
goes right after `exec`, where `-s`, `-p` and `--add-dir` parse; after
|
|
44
|
+
`resume` they were rejected outright.
|
|
45
|
+
- **A dead revival is a failed attempt.** With no pane to capture, an empty
|
|
46
|
+
capture used to count as a cleared banner. The headless backend now records
|
|
47
|
+
each revival's exit, and a non-zero one puts the stop back on the ledger
|
|
48
|
+
with backoff and a `last error` that carries the child's own words — visible
|
|
49
|
+
in `unsnooze status`, and kept when unsnooze finally gives up on the session.
|
|
50
|
+
A revival still running at the 20-second check is watched for up to two
|
|
51
|
+
minutes before it counts as resumed, long enough to catch the failures that
|
|
52
|
+
take a while (an auth refresh giving up, a retry loop); exit 0 counts as
|
|
53
|
+
resumed. A revival started by a resumer the Claude hook spawned no longer
|
|
54
|
+
inherits Claude's nested-launch marker, which had made it drop
|
|
55
|
+
`launchExtraArgs` and fail with a bare "exit 1".
|
|
56
|
+
- **A revival into a deleted directory no longer takes the daemon down.** When
|
|
57
|
+
a session's directory was gone (a removed worktree, say), the headless
|
|
58
|
+
launch failed with an error nothing was listening for, and the daemon
|
|
59
|
+
crashed. It is now an ordinary failed attempt that names the reason.
|
|
60
|
+
- **`unsnooze preview` shows the headless command.** It described a typed
|
|
61
|
+
resume and the Codex TUI for sessions that dispatch revives through argv
|
|
62
|
+
and `codex exec resume`.
|
|
63
|
+
- **Windows finds the Codex runtime.** The Desktop/Store install keeps
|
|
64
|
+
`codex.exe` under a versioned directory that changes on update, and a daemon
|
|
65
|
+
started at logon keeps the PATH it was born with. Codex is now resolved at
|
|
66
|
+
launch: `UNSNOOZE_CODEX_BIN`, then `codex.exe` on PATH, then the newest
|
|
67
|
+
runtime under `%LOCALAPPDATA%\OpenAI\Codex\bin`. A `.cmd`/`.bat` shim
|
|
68
|
+
cannot be launched directly by Node; the launcher now says so and names the
|
|
69
|
+
variable to point at the `.exe` instead of failing with `EINVAL`.
|
|
70
|
+
- **`unsnooze doctor` names each agent's binary** as the launcher would resolve
|
|
71
|
+
it, and reports an agent that cannot be launched as a health problem — on
|
|
72
|
+
Windows with the note that the daemon's environment can lag the shell's and
|
|
73
|
+
how to refresh it. Only for agents that have run on the machine: Claude and
|
|
74
|
+
Codex are both on by default, and a Claude-only machine is not unhealthy for
|
|
75
|
+
having no `codex`. The lookup follows spawn's own Windows rules: an `.exe`
|
|
76
|
+
anywhere on PATH wins over an npm shim earlier on it, quoted PATH entries
|
|
77
|
+
count, and a path given without its extension finds the `.exe`.
|
|
78
|
+
- **Codex stops bind to the right reset.** A `rate_limit_reached` at a reported
|
|
79
|
+
99.x% was scheduled for the *weekly* reset (the latest one), days out; it now
|
|
80
|
+
binds the window nearest exhaustion. A workspace wall — credits depleted or
|
|
81
|
+
a spend cap, with no window spent — has no reset to wait for: it is announced
|
|
82
|
+
as needing you rather than "resumes when the limit resets", probed, and then
|
|
83
|
+
held with the remedy (add credits or raise the cap, then continue the
|
|
84
|
+
session in Codex) instead of a blind wake. A spent window (99% or more)
|
|
85
|
+
still governs when a workspace reason rides along, since its reset is what
|
|
86
|
+
brings the plan allowance back. `unsnooze status --json` shows the reason as
|
|
87
|
+
`limitReason`.
|
|
88
|
+
- **A weaker reading no longer undoes an exact reset.** A re-detected stop
|
|
89
|
+
whose only evidence is a banner ("Try again later.") used to replace the
|
|
90
|
+
exact reset the rollout snapshot had given, turning a known wake into a
|
|
91
|
+
15-minute probe; the stronger schedule now stands until it passes, the rule
|
|
92
|
+
pane detection already followed. `unsnooze usage` in the dashboard also
|
|
93
|
+
stops reading Codex burn as idle whenever the daemon is running.
|
|
94
|
+
|
|
95
|
+
The Windows resolution follows the layout in the report and wants a Windows
|
|
96
|
+
run before it is trusted; continuing an open Codex Desktop thread in place
|
|
97
|
+
(`codex queue`) is a separate follow-up. Thanks to
|
|
98
|
+
[@nwn900](https://github.com/nwn900) for the precise report.
|
|
99
|
+
|
|
100
|
+
### Codex stops behind a proxy, and in reverted threads
|
|
101
|
+
|
|
102
|
+
[Issue #27](https://github.com/saaranshM/unsnooze/issues/27) found two ways
|
|
103
|
+
a Codex desktop-app or IDE session went untracked:
|
|
104
|
+
|
|
105
|
+
- **Reverted threads are watched.** An edited, regenerated or `/undo`-ed
|
|
106
|
+
thread continues in `rollout-<ts>-<thread id>_<rollout id>.jsonl`, a name
|
|
107
|
+
the rollout watcher never opened. Those files are now watched, counted by
|
|
108
|
+
`unsnooze usage`, and resumed by the stable thread id.
|
|
109
|
+
- **A stop recorded only as an error is a stop.** Behind an OpenAI-compatible
|
|
110
|
+
proxy the rate-limit headers never reach Codex, every snapshot has empty
|
|
111
|
+
windows, and no stop was ever detected. Since codex-cli 0.145 the failed
|
|
112
|
+
turn's `task_complete` carries the limit error itself; unsnooze now reads
|
|
113
|
+
it and dates it from the banner the way a scraped pane is dated ("Try again
|
|
114
|
+
later." is probed). When the same turn also wrote an exhausted snapshot,
|
|
115
|
+
its exact reset still wins, and a workspace wall — in the snapshot or in
|
|
116
|
+
the banner's own words — stays a wall. Only the banner counts: Codex marks
|
|
117
|
+
billing and plan errors ("Quota exceeded…", "upgrade to Plus…") with the
|
|
118
|
+
same `usage_limit_exceeded` code, and those are not stops, nor is a bare 429,
|
|
119
|
+
which carries no reset time.
|
|
120
|
+
- **Codex's current banner is recognized.** Codex now writes "You’ve hit your
|
|
121
|
+
usage limit" with a typographic apostrophe; the anchor accepts both, in
|
|
122
|
+
rollouts and in panes.
|
|
123
|
+
- **A reset in the banner's own minute is not tomorrow.** Codex prints the
|
|
124
|
+
reset time without seconds, so a limit hit at 7:36:15 that resets at
|
|
125
|
+
7:36:40 says "try again at 7:36 AM" — which was read as 7:36 the next
|
|
126
|
+
morning, a day late. It now wakes at 7:37.
|
|
127
|
+
|
|
128
|
+
Thanks to [@d-jiao](https://github.com/d-jiao) for the report, the fix in
|
|
129
|
+
[PR #28](https://github.com/saaranshM/unsnooze/pull/28), and checking it
|
|
130
|
+
against 523 real rollouts.
|
|
131
|
+
|
|
132
|
+
## 1.19.0 — 2026-09-10
|
|
133
|
+
|
|
134
|
+
Fish shell support, direct help/version commands, and a fix for Codex sessions
|
|
135
|
+
that stop at a reported 99% usage.
|
|
136
|
+
|
|
137
|
+
### Fish shell support
|
|
138
|
+
|
|
139
|
+
`unsnooze setup` and `unsnooze install` now install fish wrappers when fish is
|
|
140
|
+
your login shell or its config file already exists. The default location is
|
|
141
|
+
`~/.config/fish/config.fish`; an absolute `XDG_CONFIG_HOME` is respected.
|
|
142
|
+
`unsnooze doctor` checks this file, and `unsnooze uninstall` removes the wrappers.
|
|
143
|
+
Re-running setup replaces the existing block and preserves your other settings.
|
|
144
|
+
Use `--fishrc <path>` with install or uninstall to choose another config file.
|
|
145
|
+
|
|
146
|
+
After upgrading, run `unsnooze setup` once to add the fish wrappers, then open
|
|
147
|
+
a new shell. Thanks to [@duncanmcqueen](https://github.com/duncanmcqueen) for
|
|
148
|
+
[PR #21](https://github.com/saaranshM/unsnooze/pull/21).
|
|
149
|
+
|
|
150
|
+
### Help and version commands run directly
|
|
151
|
+
|
|
152
|
+
Commands such as `claude --help` and `codex --version` now pass straight to
|
|
153
|
+
the agent. They do not open a multiplexer session, start a pane monitor,
|
|
154
|
+
prepend `launchExtraArgs`, or append an unsnooze update notice. The existing
|
|
155
|
+
print-mode passthrough is unchanged. Thanks to [@chid](https://github.com/chid)
|
|
156
|
+
for [PR #23](https://github.com/saaranshM/unsnooze/pull/23).
|
|
157
|
+
|
|
158
|
+
### Codex can be detected as stopped at a reported 99%
|
|
159
|
+
|
|
160
|
+
The GUI watcher now recognizes the sequence reported in
|
|
161
|
+
[issue #20](https://github.com/saaranshM/unsnooze/issues/20): a five-hour Codex
|
|
162
|
+
window reaches 99%, then the next rate-limit snapshot switches to an empty
|
|
163
|
+
`premium` bucket with no credits. It records the stop and schedules the session
|
|
164
|
+
using the previous reset time, plus the normal safety margin.
|
|
165
|
+
|
|
166
|
+
This fallback requires snapshots from the same rollout no more than a minute
|
|
167
|
+
apart, no available credits, and a future reset time. It works across watcher
|
|
168
|
+
polls and daemon restarts. An already-exhausted weekly window still takes
|
|
169
|
+
precedence if it resets later. A 99% reading alone does not trigger a resume.
|
|
170
|
+
|
|
171
|
+
The separate report of stale 64% usage was not reproduced; this release fixes
|
|
172
|
+
the missed stop detection. Thanks to [@mio-tsuki](https://github.com/mio-tsuki)
|
|
173
|
+
for the sample. Fixed in [PR #24](https://github.com/saaranshM/unsnooze/pull/24).
|
|
174
|
+
|
|
175
|
+
### Test fixes
|
|
176
|
+
|
|
177
|
+
- Permission tests now set their required file mode explicitly, so they pass
|
|
178
|
+
with different system umasks. Thanks to
|
|
179
|
+
[@Chang-Jin-Lee](https://github.com/Chang-Jin-Lee) for
|
|
180
|
+
[PR #22](https://github.com/saaranshM/unsnooze/pull/22).
|
|
181
|
+
- Fish install/uninstall tests use temporary profiles and leave existing user
|
|
182
|
+
settings, wrappers, and daemon state untouched.
|
|
183
|
+
- Help/version tests use a portable test executable instead of relying on
|
|
184
|
+
platform-specific `echo` behavior.
|
|
185
|
+
|
|
3
186
|
## 1.18.0 — 2026-09-02
|
|
4
187
|
|
|
5
188
|
**Cursor CLI support**, a wrapper change that keeps `cursor .` working, and a
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml)
|
|
8
8
|
[](https://www.npmjs.com/package/unsnooze)
|
|
9
|
-
[](package.json)
|
|
9
|
+
[](package.json)
|
|
10
10
|
[](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
|
|
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,19 @@ 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.
|
|
247
|
+
Since codex-cli 0.145 the failed turn's `task_complete` also carries the
|
|
248
|
+
limit error itself (`codex_error_info: "usage_limit_exceeded"` plus the
|
|
249
|
+
banner text), and unsnooze reads that too — it is the only signal when
|
|
250
|
+
Codex runs behind an OpenAI-compatible proxy (`model_providers.<x>.base_url`),
|
|
251
|
+
where the rate-limit headers never reach Codex and every snapshot has empty
|
|
252
|
+
windows. The banner's time is parsed like a scraped pane; "Try again later."
|
|
253
|
+
takes the probe path. Reverted threads (the desktop app's edit/regenerate,
|
|
254
|
+
`/undo`) continue in `rollout-<ts>-<thread id>_<rollout id>.jsonl`; those
|
|
255
|
+
files are watched and resumed by the stable thread id.
|
|
242
256
|
- **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
|
|
243
257
|
sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
|
|
244
258
|
those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
|
|
@@ -289,11 +303,11 @@ StopFailure hook (claude, grok) ──────────────┤
|
|
|
289
303
|
|
|
290
304
|
</details>
|
|
291
305
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
306
|
+
Detection uses agent hooks, saved session files, and terminal banners. Reset
|
|
307
|
+
times come from the agent's saved timestamp or the banner text, parsed DST-safe.
|
|
308
|
+
When no reset time is available, unsnooze probes on a backoff schedule. Every
|
|
309
|
+
resume is verified afterwards (banner came back → reschedule from the fresh
|
|
310
|
+
banner, capped at 5 attempts).
|
|
297
311
|
|
|
298
312
|
## Know the wall before you hit it
|
|
299
313
|
|
|
@@ -680,9 +694,20 @@ watcher stops (no pane context) always use native.
|
|
|
680
694
|
[Watching without a multiplexer](#watching-without-a-multiplexer))
|
|
681
695
|
- tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0 for pane-level watching. Optional:
|
|
682
696
|
without one, unsnooze runs `headless` and still catches and resumes stops.
|
|
683
|
-
- zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`),
|
|
697
|
+
- zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), fish (wrappers go
|
|
698
|
+
into `~/.config/fish/config.fish`), or PowerShell
|
|
684
699
|
(wrappers go into `$PROFILE.CurrentUserAllHosts`)
|
|
685
700
|
|
|
701
|
+
Fish wrappers are installed when fish is the login shell or its config already
|
|
702
|
+
exists. An absolute `XDG_CONFIG_HOME` changes the config location to
|
|
703
|
+
`$XDG_CONFIG_HOME/fish/config.fish`; `unsnooze install --fishrc <path>` and
|
|
704
|
+
`unsnooze uninstall --fishrc <path>` override it explicitly. After upgrading an
|
|
705
|
+
existing install, run `unsnooze setup` and open a new shell to load the wrappers.
|
|
706
|
+
|
|
707
|
+
Agent help/version commands, such as `claude --help` and `codex --version`, run
|
|
708
|
+
directly without a multiplexer session, pane monitor, `launchExtraArgs`, or an
|
|
709
|
+
unsnooze update notice. `unsnooze help` still shows unsnooze's own commands.
|
|
710
|
+
|
|
686
711
|
### Supported terminals
|
|
687
712
|
|
|
688
713
|
unsnooze drives four terminal multiplexers, and works without one.
|
|
@@ -703,7 +728,10 @@ herdr restores saved agent panes itself, so a revival on top would resume the
|
|
|
703
728
|
same conversation twice.
|
|
704
729
|
² Into a fresh workspace; cmux has no joinable named-session model.
|
|
705
730
|
³ As a detached process. Its output goes to `~/.unsnooze/headless/<session>.log`,
|
|
706
|
-
since there is no scrollback to read it out of later
|
|
731
|
+
since there is no scrollback to read it out of later; the prompt rides in argv
|
|
732
|
+
(`claude --resume <id> "…"`, `codex exec resume <id> "…"`), and a revival that
|
|
733
|
+
exits non-zero is retried, with its last output as the `last error` in
|
|
734
|
+
`unsnooze status`.
|
|
707
735
|
⁴ Nothing to wrap into — your own terminal is the session.
|
|
708
736
|
|
|
709
737
|
Pick one explicitly with `unsnooze config set multiplexer tmux|zellij|herdr|cmux|headless`.
|
|
@@ -742,12 +770,18 @@ Native Windows works: PowerShell wrappers, a cmd-safe StopFailure hook, and a
|
|
|
742
770
|
logon-triggered Scheduled Task for the daemon. Install as usual and run
|
|
743
771
|
`unsnooze doctor` to confirm.
|
|
744
772
|
|
|
745
|
-
|
|
773
|
+
Three honest caveats. The daemon is started at logon but not restarted if it
|
|
746
774
|
dies — Task Scheduler is not a supervisor the way launchd and systemd are — so
|
|
747
775
|
after upgrading unsnooze, log out and back in (or run `unsnooze daemon`
|
|
748
|
-
yourself).
|
|
749
|
-
|
|
750
|
-
|
|
776
|
+
yourself). The daemon also keeps the `PATH` it was born with: an agent whose
|
|
777
|
+
install directory changes on update (the Codex Desktop/Store runtime lives
|
|
778
|
+
under `%LOCALAPPDATA%\OpenAI\Codex\bin\<hash>`) can vanish from the daemon's
|
|
779
|
+
view while your shell still finds it — unsnooze looks that directory up at
|
|
780
|
+
launch time, `unsnooze doctor` prints what each agent resolves to, and
|
|
781
|
+
`UNSNOOZE_CODEX_BIN` (or `UNSNOOZE_CLAUDE_BIN`, …) pins a path; point it at an
|
|
782
|
+
`.exe`, since Node cannot launch a `.cmd` shim directly. And stored-password
|
|
783
|
+
fleet hosts still need Git-for-Windows or WSL `ssh`; native `ssh.exe` requires
|
|
784
|
+
an `.exe` askpass helper unsnooze does not yet ship.
|
|
751
785
|
|
|
752
786
|
WSL remains the richer option, because that is where the Unix multiplexers live
|
|
753
787
|
— and where the agent CLIs often already are:
|
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.
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.19.1",
|
|
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
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
// the overload path, never the ledger.
|
|
12
12
|
|
|
13
13
|
import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
14
|
+
import { join, basename } from 'node:path';
|
|
15
15
|
import { CODEX_DIR } from '../config.js';
|
|
16
|
+
import { findOnPath } from '../which.js';
|
|
16
17
|
|
|
17
18
|
// Since the 2026 unified ChatGPT desktop app absorbed the Codex app, the codex
|
|
18
19
|
// binary ships INSIDE the app bundle and many machines have no standalone
|
|
@@ -21,21 +22,59 @@ import { CODEX_DIR } from '../config.js';
|
|
|
21
22
|
// codex-cli 0.144 from ChatGPT.app).
|
|
22
23
|
export const CHATGPT_CODEX_BIN = '/Applications/ChatGPT.app/Contents/Resources/codex';
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Windows keeps the Desktop/Store install's CLI under a versioned runtime
|
|
26
|
+
// directory — %LOCALAPPDATA%\OpenAI\Codex\bin\<hash>\codex.exe — whose name
|
|
27
|
+
// changes on every update. The interactive shell finds it through a PATH the
|
|
28
|
+
// app rewrote; a daemon started at logon keeps the PATH it was born with,
|
|
29
|
+
// which after an update names a directory that no longer exists (#25). So
|
|
30
|
+
// look the directory up at launch time and take the newest runtime, the way
|
|
31
|
+
// CHATGPT_CODEX_BIN covers the macOS bundle. Layout as reported by the
|
|
32
|
+
// issue's Microsoft Store install; a machine without it just gets null.
|
|
33
|
+
export function windowsBundledCodex({ env = process.env, readdir = readdirSync, stat = statSync } = {}) {
|
|
34
|
+
if (!env.LOCALAPPDATA) return null;
|
|
35
|
+
const base = join(env.LOCALAPPDATA, 'OpenAI', 'Codex', 'bin');
|
|
36
|
+
let entries;
|
|
37
|
+
try { entries = readdir(base, { withFileTypes: true }); } catch { return null; }
|
|
38
|
+
let best = null;
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
if (!e.isDirectory()) continue;
|
|
41
|
+
const exe = join(base, e.name, 'codex.exe');
|
|
42
|
+
let mtime;
|
|
43
|
+
try { mtime = stat(exe).mtimeMs; } catch { continue; }
|
|
44
|
+
if (!best || mtime > best.mtime) best = { exe, mtime };
|
|
45
|
+
}
|
|
46
|
+
return best ? best.exe : null;
|
|
28
47
|
}
|
|
29
48
|
|
|
30
|
-
|
|
49
|
+
// What to spawn for codex. Order, on Windows: an explicit UNSNOOZE_CODEX_BIN,
|
|
50
|
+
// a codex.exe on PATH (spawn resolves the bare name itself), the newest
|
|
51
|
+
// Desktop/Store runtime, then a .cmd/.bat shim on PATH by full path — Node
|
|
52
|
+
// refuses to spawn those without a shell, and a full path makes the launcher's
|
|
53
|
+
// error say which file it was. Elsewhere: PATH, then the macOS app bundle.
|
|
54
|
+
export function resolveCodexBin({
|
|
55
|
+
env = process.env,
|
|
56
|
+
platform = process.platform,
|
|
57
|
+
exists = existsSync,
|
|
58
|
+
onPath = null,
|
|
59
|
+
bundled = () => windowsBundledCodex({ env }),
|
|
60
|
+
} = {}) {
|
|
31
61
|
if (env.UNSNOOZE_CODEX_BIN) return env.UNSNOOZE_CODEX_BIN;
|
|
32
|
-
if (
|
|
62
|
+
if (platform === 'win32') {
|
|
63
|
+
if (findOnPath(['codex.exe'], { env, exists, platform })) return 'codex';
|
|
64
|
+
const runtime = bundled();
|
|
65
|
+
if (runtime) return runtime;
|
|
66
|
+
const shim = findOnPath(['codex.cmd', 'codex.bat'], { env, exists, platform });
|
|
67
|
+
if (shim) return shim.path;
|
|
68
|
+
return 'codex';
|
|
69
|
+
}
|
|
70
|
+
const found = onPath ? onPath() : !!findOnPath(['codex'], { env, exists, platform });
|
|
71
|
+
if (found) return 'codex';
|
|
33
72
|
if (exists(CHATGPT_CODEX_BIN)) return CHATGPT_CODEX_BIN;
|
|
34
73
|
return 'codex'; // neither — the launcher degrades gracefully on spawn error
|
|
35
74
|
}
|
|
36
75
|
|
|
37
76
|
const LIMIT_ANCHORS = [
|
|
38
|
-
/You've hit your usage limit/i,
|
|
77
|
+
/You['’]ve hit your usage limit/i, // the desktop app also emits a curly apostrophe
|
|
39
78
|
/Your workspace is out of credits/i,
|
|
40
79
|
/hit your spend cap/i,
|
|
41
80
|
];
|
|
@@ -62,11 +101,28 @@ export const patterns = {
|
|
|
62
101
|
transientPatterns: [/stream error/i, /exceeded retry limit/i],
|
|
63
102
|
};
|
|
64
103
|
|
|
65
|
-
// Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{
|
|
104
|
+
// Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{THREAD}.jsonl;
|
|
66
105
|
// the first JSONL line carries the session cwd. Conservative: no cwd match →
|
|
67
106
|
// null (the resumer then uses `codex resume --last`, which codex itself scopes
|
|
68
107
|
// to the launch cwd).
|
|
69
|
-
|
|
108
|
+
//
|
|
109
|
+
// A reverted thread (the desktop app's edit/regenerate, `/undo`) continues in
|
|
110
|
+
// rollout-{ts}-{THREAD}_{ROLLOUT}.jsonl — codex-rs/rollout/src/rollout_file_name.rs:
|
|
111
|
+
// "filenames for reverted threads append an underscore and a distinct rollout
|
|
112
|
+
// ID after the stable thread ID". Group 1 is always the thread id, which is
|
|
113
|
+
// the id `codex resume` takes; the rollout id after the underscore is not.
|
|
114
|
+
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
115
|
+
export const ROLLOUT_RE = new RegExp(`^rollout-.*-(${UUID})(?:_${UUID})?\\.jsonl$`, 'i');
|
|
116
|
+
|
|
117
|
+
// The thread uuid a rollout path names, or null for anything else. Cheaper than
|
|
118
|
+
// rolloutMeta() (no read) and the same id in every rollout seen so far. A
|
|
119
|
+
// reverted thread's `…_<rollout id>` file names the same thread, so usage
|
|
120
|
+
// readings compare across the two files of one conversation.
|
|
121
|
+
export function rolloutId(path) {
|
|
122
|
+
if (typeof path !== 'string') return null;
|
|
123
|
+
const m = basename(path).match(ROLLOUT_RE);
|
|
124
|
+
return m ? m[1].toLowerCase() : null;
|
|
125
|
+
}
|
|
70
126
|
|
|
71
127
|
function fileHead(path, bytes = 4096) {
|
|
72
128
|
let fd;
|
|
@@ -115,12 +171,40 @@ export default {
|
|
|
115
171
|
experimental: false,
|
|
116
172
|
patterns,
|
|
117
173
|
menu: null, // no interactive limit menu
|
|
174
|
+
// What to do about a workspace wall (credits depleted, a spend cap): no
|
|
175
|
+
// window reset clears it, so the resumer's ceiling notification names this.
|
|
176
|
+
// Not `unsnooze resume-now`: the hold leaves the record failed, and
|
|
177
|
+
// resume-now only wakes stopped ones — it would answer "no matching stopped
|
|
178
|
+
// sessions" to the one command the notification told the user to run.
|
|
179
|
+
modelRemedy: 'add credits or raise the spend cap in the ChatGPT workspace (or ask its owner to), then continue the session in Codex',
|
|
118
180
|
// Resume takes the prompt in argv — `codex resume <id> "msg"` starts the turn
|
|
119
181
|
// immediately, nothing to type into the TUI.
|
|
120
|
-
|
|
182
|
+
//
|
|
183
|
+
// canType: false (headless — no pane): the TUI cannot run there at all. It
|
|
184
|
+
// refuses a non-TTY stdin before it looks at the session ("Error: stdin is
|
|
185
|
+
// not a terminal", exit 1 — reproduced against codex-cli 0.150 with the
|
|
186
|
+
// headless backend's exact stdio), so `codex exec resume <id> "msg"` carries
|
|
187
|
+
// the same conversation forward non-interactively instead (#25). With
|
|
188
|
+
// --last, codex reads a lone positional as the prompt, not a session id.
|
|
189
|
+
//
|
|
190
|
+
// `exec` also refuses any directory that is not a git repository unless
|
|
191
|
+
// told --skip-git-repo-check ("Not inside a trusted directory…", exit 1),
|
|
192
|
+
// a guard the TUI never had: without the flag, a session that ran in a
|
|
193
|
+
// plain folder could never be revived headless. It is the user's own
|
|
194
|
+
// session, resumed where it already ran, so the flag goes on.
|
|
195
|
+
//
|
|
196
|
+
// extraArgsAt: resumeExtraArgs go right after `exec`, not at the end. The
|
|
197
|
+
// `resume` subcommand rejects -s/--sandbox, -p/--profile and --add-dir
|
|
198
|
+
// ("unexpected argument", exit 2) — exactly what a user sets to match
|
|
199
|
+
// their normal launch — while `exec` takes them. (TUI-only flags such as
|
|
200
|
+
// -a or --search have no exec equivalent and still fail, visibly.)
|
|
201
|
+
resumeArgs(sessionId, message, { canType = true } = {}) {
|
|
202
|
+
const tail = sessionId ? [sessionId, message] : ['--last', message];
|
|
203
|
+
if (canType) return { args: ['resume', ...tail], messageViaPane: false };
|
|
121
204
|
return {
|
|
122
|
-
args:
|
|
205
|
+
args: ['exec', '--skip-git-repo-check', 'resume', ...tail],
|
|
123
206
|
messageViaPane: false,
|
|
207
|
+
extraArgsAt: 1,
|
|
124
208
|
};
|
|
125
209
|
},
|
|
126
210
|
// v1: every agent launches the bare TUI and gets the prompt typed once idle.
|
package/src/cli.js
CHANGED
|
@@ -90,6 +90,10 @@ export async function cmdStatus(args = []) {
|
|
|
90
90
|
sessions: Object.values(state.sessions).map(s => ({
|
|
91
91
|
key: s.key, sessionId: s.sessionId ?? null, agent: s.agent ?? 'claude',
|
|
92
92
|
cwd: s.cwd ?? null, status: s.status, limitType: s.limitType ?? null,
|
|
93
|
+
// Why the provider refused, when it said (Codex's rate_limit_reached_type,
|
|
94
|
+
// e.g. workspace_member_credits_depleted) — the reason a stop is probed
|
|
95
|
+
// and held rather than scheduled.
|
|
96
|
+
limitReason: s.limitReason ?? null,
|
|
93
97
|
resetAt: s.resetAt ?? null, resetSource: s.resetSource ?? null,
|
|
94
98
|
mux: s.mux ?? null, pane: s.pane ?? null, muxSession: s.muxSession ?? null,
|
|
95
99
|
attempts: s.attempts ?? 0, lastError: s.lastError ?? null,
|
package/src/config.js
CHANGED
|
@@ -229,6 +229,10 @@ export const PROBE_INTERVAL_MS = envInt('UNSNOOZE_PROBE_INTERVAL_MS', 15 * 60_00
|
|
|
229
229
|
export const PROBE_MAX_MS = envInt('UNSNOOZE_PROBE_MAX_MS', 60 * 60_000);
|
|
230
230
|
export const STAGGER_MS = envInt('UNSNOOZE_STAGGER_MS', 8_000);
|
|
231
231
|
export const VERIFY_DELAY_MS = envInt('UNSNOOZE_VERIFY_DELAY_MS', 20_000);
|
|
232
|
+
// A headless revival still running at verify time is watched this long before
|
|
233
|
+
// it counts as resumed: the failures worth catching (an auth refresh that gives
|
|
234
|
+
// up, a retry loop on a bad network) take longer than VERIFY_DELAY_MS to exit.
|
|
235
|
+
export const HEADLESS_SETTLE_MS = envInt('UNSNOOZE_HEADLESS_SETTLE_MS', 2 * 60_000);
|
|
232
236
|
export const BUSY_DEFER_MS = envInt('UNSNOOZE_BUSY_DEFER_MS', 60_000);
|
|
233
237
|
export const READY_TIMEOUT_MS = envInt('UNSNOOZE_READY_TIMEOUT_MS', 60_000);
|
|
234
238
|
export const EVENT_MARKER_TTL_MS = envInt('UNSNOOZE_EVENT_MARKER_TTL_MS', 120_000);
|
|
@@ -254,6 +258,8 @@ export const MUX_NAMES = ['tmux', 'zellij', 'herdr', 'cmux', 'headless'];
|
|
|
254
258
|
// Where a headless revive tees the agent's output. There is no pane to scroll
|
|
255
259
|
// back through, so the log is the only record of what an unattended run did.
|
|
256
260
|
export const HEADLESS_LOG_DIR = join(STATE_DIR, 'headless');
|
|
261
|
+
// The daemon's Windows Task Scheduler entry (install.js creates it; doctor names it).
|
|
262
|
+
export const WINDOWS_TASK_NAME = 'unsnooze';
|
|
257
263
|
|
|
258
264
|
// Pane scanning
|
|
259
265
|
export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
|
package/src/doctor.js
CHANGED
|
@@ -16,6 +16,11 @@ 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';
|
|
20
|
+
import { listAgents } from './agents/index.js';
|
|
21
|
+
import { getConfig } from './settings.js';
|
|
22
|
+
import { resolveBin } from './which.js';
|
|
23
|
+
import { WINDOWS_TASK_NAME, CLAUDE_DIR, CODEX_DIR } from './config.js';
|
|
19
24
|
|
|
20
25
|
const log = makeLogger('doctor');
|
|
21
26
|
|
|
@@ -116,13 +121,14 @@ function readOrEmpty(path) {
|
|
|
116
121
|
// wrappers installed" is a health question — but it has to be asked of the
|
|
117
122
|
// right file. A PowerShell user has no ~/.zshrc, so checking only the POSIX rc
|
|
118
123
|
// files made every native-Windows `unsnooze doctor` report the wrappers
|
|
119
|
-
// missing and point the user at an install they had already run.
|
|
124
|
+
// missing and point the user at an install they had already run. A fish user
|
|
125
|
+
// has no POSIX rc wrapper either — their block lives in the fish config.
|
|
120
126
|
function defaultWrappersInstalled({
|
|
121
127
|
platform = process.platform,
|
|
122
128
|
rcContent = readOrEmpty,
|
|
123
129
|
profileContent = null,
|
|
124
130
|
} = {}) {
|
|
125
|
-
const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')];
|
|
131
|
+
const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc'), fishConfigPath()];
|
|
126
132
|
if (rcs.some(rc => rcContent(rc).includes(WRAPPER_MARK))) return true;
|
|
127
133
|
if (platform !== 'win32') return false;
|
|
128
134
|
const readProfile = profileContent
|
|
@@ -185,6 +191,11 @@ export async function runDoctor({
|
|
|
185
191
|
profileContent = undefined,
|
|
186
192
|
autostartDir = null,
|
|
187
193
|
stateDir = STATE_DIR,
|
|
194
|
+
env = process.env,
|
|
195
|
+
exists = existsSync,
|
|
196
|
+
agents = listAgents(),
|
|
197
|
+
enabled = id => !!getConfig(`agents.${id}`),
|
|
198
|
+
inUse = agentInUse,
|
|
188
199
|
} = {}) {
|
|
189
200
|
const findings = [];
|
|
190
201
|
|
|
@@ -286,6 +297,14 @@ export async function runDoctor({
|
|
|
286
297
|
}
|
|
287
298
|
} catch { /* best-effort: a PATH check must never break doctor */ }
|
|
288
299
|
|
|
300
|
+
// What each enabled agent would actually be launched as — resolved the way
|
|
301
|
+
// the launcher resolves it, from this environment. #25 was a daemon whose
|
|
302
|
+
// logon-time PATH no longer named the Codex runtime directory; every revival
|
|
303
|
+
// died with ENOENT and nothing said so until the headless log was read.
|
|
304
|
+
// Doctor cannot see the daemon's environment, only its own, so on Windows
|
|
305
|
+
// it also says how the two can differ.
|
|
306
|
+
for (const f of agentBinFindings({ platform, env, exists, agents, enabled, inUse })) findings.push(f);
|
|
307
|
+
|
|
289
308
|
findings.push({
|
|
290
309
|
id: 'daemon', kind: 'info',
|
|
291
310
|
title: daemonRunning() ? 'resumer/daemon: running' : 'resumer/daemon: not running (starts on the next limit stop)',
|
|
@@ -308,6 +327,66 @@ export async function runDoctor({
|
|
|
308
327
|
return { findings, healthy };
|
|
309
328
|
}
|
|
310
329
|
|
|
330
|
+
// claude and codex are enabled by default, so "enabled" does not mean "used
|
|
331
|
+
// here": a machine that has only ever run one of them has nothing of the
|
|
332
|
+
// other's to revive, and must not be reported unhealthy for lacking its
|
|
333
|
+
// binary. Their own data directories say whether they have run here. The
|
|
334
|
+
// other agents are opt-in; the user switching one on is evidence enough.
|
|
335
|
+
const AGENT_HOMES = { claude: CLAUDE_DIR, codex: CODEX_DIR };
|
|
336
|
+
export function agentInUse(id, { exists = existsSync } = {}) {
|
|
337
|
+
const home = AGENT_HOMES[id];
|
|
338
|
+
return home ? exists(home) : true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function agentBinFindings({
|
|
342
|
+
platform = process.platform, env = process.env, exists = existsSync,
|
|
343
|
+
agents = listAgents(), enabled = id => !!getConfig(`agents.${id}`),
|
|
344
|
+
inUse = agentInUse,
|
|
345
|
+
} = {}) {
|
|
346
|
+
const findings = [];
|
|
347
|
+
const envVar = id => `UNSNOOZE_${String(id).toUpperCase()}_BIN`;
|
|
348
|
+
// Two commands, not `a && b`: && only exists from PowerShell 7, and the
|
|
349
|
+
// Windows PowerShell 5.1 that ships with the OS is a supported shell.
|
|
350
|
+
const windowsNote = platform === 'win32'
|
|
351
|
+
? `\n the daemon's Scheduled Task keeps the PATH it had at logon; after an agent update, run\n`
|
|
352
|
+
+ ` schtasks /end /tn ${WINDOWS_TASK_NAME}\n`
|
|
353
|
+
+ ` schtasks /run /tn ${WINDOWS_TASK_NAME} (or sign out and in)`
|
|
354
|
+
: '';
|
|
355
|
+
for (const agent of agents) {
|
|
356
|
+
if (!agent?.id || !enabled(agent.id)) continue;
|
|
357
|
+
const bin = agent.bin;
|
|
358
|
+
let resolved = null;
|
|
359
|
+
try { resolved = resolveBin(bin, { env, exists, platform }); } catch { resolved = null; }
|
|
360
|
+
if (resolved?.launchable) {
|
|
361
|
+
findings.push({
|
|
362
|
+
id: `agent-bin-${agent.id}`, kind: 'info',
|
|
363
|
+
title: `${agent.id}: ${resolved.path}`,
|
|
364
|
+
detail: '',
|
|
365
|
+
});
|
|
366
|
+
} else if (resolved) {
|
|
367
|
+
findings.push({
|
|
368
|
+
id: `agent-bin-${agent.id}`, kind: 'health',
|
|
369
|
+
title: `${agent.id} resolves to ${resolved.path}, a .cmd/.bat shim that cannot be launched directly`,
|
|
370
|
+
detail: ` set ${envVar(agent.id)} to the agent's .exe`,
|
|
371
|
+
});
|
|
372
|
+
} else if (!inUse(agent.id)) {
|
|
373
|
+
findings.push({
|
|
374
|
+
id: `agent-bin-${agent.id}`, kind: 'info',
|
|
375
|
+
title: `${agent.id}: not installed (no ${agent.id} sessions here to revive)`,
|
|
376
|
+
detail: '',
|
|
377
|
+
});
|
|
378
|
+
} else {
|
|
379
|
+
findings.push({
|
|
380
|
+
id: `agent-bin-${agent.id}`, kind: 'health',
|
|
381
|
+
title: `${agent.id} is not launchable from this environment (${bin} not found)`,
|
|
382
|
+
detail: ` every revival of a ${agent.id} session will fail with ENOENT\n`
|
|
383
|
+
+ ` install it, put it on PATH, or set ${envVar(agent.id)}${windowsNote}`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return findings;
|
|
388
|
+
}
|
|
389
|
+
|
|
311
390
|
// --- fixes ------------------------------------------------------------------------
|
|
312
391
|
|
|
313
392
|
function defaultKill(pid) {
|