unsnooze 1.0.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 +65 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/bin/unsnooze.js +92 -0
- package/package.json +47 -0
- package/src/agents/claude.js +99 -0
- package/src/agents/codex.js +112 -0
- package/src/agents/grok.js +98 -0
- package/src/agents/index.js +16 -0
- package/src/cli.js +114 -0
- package/src/config.js +46 -0
- package/src/hook.js +105 -0
- package/src/install.js +218 -0
- package/src/launcher.js +71 -0
- package/src/logger.js +23 -0
- package/src/monitor.js +199 -0
- package/src/notify.js +63 -0
- package/src/patterns.js +109 -0
- package/src/report.js +46 -0
- package/src/resumer.js +211 -0
- package/src/sessions.js +39 -0
- package/src/settings.js +105 -0
- package/src/spawn.js +41 -0
- package/src/state.js +147 -0
- package/src/time-parser.js +172 -0
- package/src/tmux.js +95 -0
- package/src/wizard.js +109 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-07-10
|
|
4
|
+
|
|
5
|
+
First public release (previously the private `claude-session-guard`/`csg`).
|
|
6
|
+
|
|
7
|
+
### Multi-CLI auto-resume
|
|
8
|
+
|
|
9
|
+
- **Claude Code and OpenAI Codex CLI fully supported; xAI Grok Build
|
|
10
|
+
experimental** (generic patterns + `unsnooze report` to contribute real
|
|
11
|
+
banner captures).
|
|
12
|
+
- **Agent adapters** (`src/agents/`): per-CLI banner regexes, busy/idle
|
|
13
|
+
markers, resume invocation, session-store lookup, hook wiring.
|
|
14
|
+
- **Codex specifics**: verbatim banner strings from the Codex source; parses
|
|
15
|
+
`try again at 3:51 PM`, `Feb 23rd, 2026 9:01 PM`, and
|
|
16
|
+
`in 4 days 20 hours 9 minutes`; dead sessions revive via
|
|
17
|
+
`codex resume <id> "<message>"` — the prompt travels in argv, nothing is
|
|
18
|
+
typed into the pane.
|
|
19
|
+
|
|
20
|
+
### Settings & UX
|
|
21
|
+
|
|
22
|
+
- **Settings**: `~/.unsnooze/config.json`, `unsnooze config list/get/set`,
|
|
23
|
+
toggles for autoResume, menuAutoAnswer, notifications, resumeMessage, and
|
|
24
|
+
per-agent enablement (env > file > default).
|
|
25
|
+
- **Setup wizard**: `unsnooze setup` — detects installed CLIs, warns when
|
|
26
|
+
`grok` is the community CLI rather than Grok Build, installs wrappers
|
|
27
|
+
(zsh + bash) and hooks.
|
|
28
|
+
- **Desktop notifications** on limit detected / session resumed / gave up
|
|
29
|
+
(macOS osascript, Linux notify-send, tmux fallback).
|
|
30
|
+
|
|
31
|
+
### Windows / WSL
|
|
32
|
+
|
|
33
|
+
- **Windows via WSL**: native Windows toast notifications from inside WSL via
|
|
34
|
+
`powershell.exe` (no notify-send/X server needed), `where`-based CLI
|
|
35
|
+
detection, and a friendly "install tmux / use WSL" message instead of a
|
|
36
|
+
hard failure when tmux is missing (the agent CLI still runs, just
|
|
37
|
+
unwatched). The tmux-independent core is exercised on Windows in CI.
|
|
38
|
+
|
|
39
|
+
### Safety hardening
|
|
40
|
+
|
|
41
|
+
- **Wrappers and hooks can never brick the wrapped CLI**: the shell wrapper
|
|
42
|
+
falls through to the real `claude`/`codex`/`grok` when the unsnooze entry
|
|
43
|
+
point is missing, and hook commands no-op (`exit 0`) instead of erroring on
|
|
44
|
+
every turn.
|
|
45
|
+
- **Menus are answered from the visible screen only**, never from tmux
|
|
46
|
+
scrollback — an already-answered menu in history can no longer trigger
|
|
47
|
+
stray keystrokes (relevant for non-alt-screen TUIs like
|
|
48
|
+
`codex --no-alt-screen`).
|
|
49
|
+
- **Session reopen uses absolute node + entry-point paths** instead of a PATH
|
|
50
|
+
lookup — a tmux server started without npm globals (or nvm's node) on PATH
|
|
51
|
+
can no longer break revival with command-not-found.
|
|
52
|
+
- Migrates cleanly off `claude-auto-retry` and the pre-release csg install
|
|
53
|
+
(fenced rc blocks and settings.json hook entries are replaced, with
|
|
54
|
+
backups).
|
|
55
|
+
|
|
56
|
+
### Testing
|
|
57
|
+
|
|
58
|
+
- 104 unit tests plus a **12-scenario end-to-end suite**
|
|
59
|
+
(`scripts/e2e-simulate.sh`) that exercises every agent and safety path in
|
|
60
|
+
real tmux with real monitor/hook/resumer processes — banner detection for
|
|
61
|
+
all three CLIs, raw-key menu driving with selection verification, hook
|
|
62
|
+
ingestion, dead-pane and live-pane resume, both toggles, and the
|
|
63
|
+
529-overload retry ladder (now env-tunable via
|
|
64
|
+
`UNSNOOZE_OVERLOAD_BACKOFF_S`).
|
|
65
|
+
- CI: Ubuntu + macOS (Node 20/22) and Windows (Node 22).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saaransh Menon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="assets/banner.svg" alt="unsnooze — wakes every limit-stopped AI session the moment the limit resets" width="880"/>
|
|
4
|
+
|
|
5
|
+
<br/>
|
|
6
|
+
|
|
7
|
+
[](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml)
|
|
8
|
+
[](https://www.npmjs.com/package/unsnooze)
|
|
9
|
+
[](package.json)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
**Claude Code · Codex CLI · Grok** — when they hit a usage limit, your session just… stops.<br/>
|
|
13
|
+
unsnooze tracks **every** limit-stopped session across all your projects and
|
|
14
|
+
**wakes each one up in tmux the moment the limit resets.**
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install -g unsnooze && unsnooze setup
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
<img src="assets/demo.svg" alt="terminal demo: limit banners detected in two sessions, unsnooze waits for the reset, then wakes both — good morning, the work is done" width="880"/>
|
|
21
|
+
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
## Why unsnooze
|
|
25
|
+
|
|
26
|
+
Overnight and long-running agent work dies at the 5-hour / weekly limit, and every
|
|
27
|
+
existing tool solves only a slice of it:
|
|
28
|
+
|
|
29
|
+
| | **unsnooze** | claude-auto-retry | autoclaude | hydra |
|
|
30
|
+
|---|:---:|:---:|:---:|:---:|
|
|
31
|
+
| Multi-CLI (Claude Code + Codex + Grok) | ✅ | ❌ Claude only | ❌ Claude only | ✅ |
|
|
32
|
+
| Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
|
|
33
|
+
| All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
|
|
34
|
+
| Revives sessions whose pane/process is **gone** | ✅ `--resume <id>` | ❌ | ❌ | ❌ |
|
|
35
|
+
| Survives laptop sleep & weekly-scale waits | ✅ epoch polling | partial | partial | n/a |
|
|
36
|
+
| Settings + first-run wizard | ✅ | ❌ | ❌ | ❌ |
|
|
37
|
+
|
|
38
|
+
## Supported CLIs
|
|
39
|
+
|
|
40
|
+
- **Claude Code** — dual-channel detection: the `StopFailure` hook (authoritative,
|
|
41
|
+
carries `session_id`) plus tmux pane scraping for banners and the interactive
|
|
42
|
+
limit menu (always answered with *"Stop and wait for limit to reset"*, never a
|
|
43
|
+
blind Enter). Dead sessions revive via `claude --resume <id>`.
|
|
44
|
+
- **OpenAI Codex CLI** — scrape-based (Codex fires no event on limits). Detects
|
|
45
|
+
the exact `■ You've hit your usage limit …` banner strings from the Codex
|
|
46
|
+
source, parses `try again at 3:51 PM` / `Feb 23rd, 2026 9:01 PM` /
|
|
47
|
+
`in 4 days 20 hours 9 minutes`. Dead sessions revive via
|
|
48
|
+
`codex resume <id> "<message>"` — the prompt travels in argv.
|
|
49
|
+
- **Grok Build (xAI)** — ⚠️ *experimental*. Hook channel works (Grok reads
|
|
50
|
+
Claude-compatible hooks, including `StopFailure`); the limit banner text is
|
|
51
|
+
not publicly documented, so detection uses generic patterns with a safe
|
|
52
|
+
fallback. Hit a banner unsnooze missed? Run `unsnooze report` and paste the
|
|
53
|
+
capture into an issue — that's how this adapter gets good.
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
<div align="center">
|
|
58
|
+
<img src="assets/how-it-works.png" alt="architecture: claude/codex/grok panes are watched by unsnooze via hooks and banner scraping; stops land in state.json; the resumer daemon sleeps until the limit resets, then types into live panes or reopens dead ones until every session is running again" width="880"/>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<details>
|
|
62
|
+
<summary>text version</summary>
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in tmux pane
|
|
66
|
+
│
|
|
67
|
+
├─ per-pane monitor (scrapes for limit
|
|
68
|
+
│ banners, drives Claude's limit menu,
|
|
69
|
+
│ seconds-scale retry on 5xx/overload)
|
|
70
|
+
│
|
|
71
|
+
StopFailure hook (claude, grok) ──────────────┤
|
|
72
|
+
▼
|
|
73
|
+
~/.unsnooze/state.json
|
|
74
|
+
{ agent, sessionId, cwd, pane, resetAt, status }
|
|
75
|
+
│
|
|
76
|
+
▼
|
|
77
|
+
resumer daemon (singleton, epoch-polling —
|
|
78
|
+
survives laptop sleep and weekly-scale waits)
|
|
79
|
+
│
|
|
80
|
+
┌───────────────────┴────────────────────┐
|
|
81
|
+
pane still alive? pane gone?
|
|
82
|
+
send resume message into it tmux new-window,
|
|
83
|
+
(only if the CLI is foreground `unsnooze _run <agent>
|
|
84
|
+
and not mid-stream) --resume <id>`, verify
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
</details>
|
|
88
|
+
|
|
89
|
+
Limit events are never persisted by the CLIs themselves; the reset time is
|
|
90
|
+
parsed from the banner text, DST-safe, with a 5-hour fallback when unparseable
|
|
91
|
+
— and every resume is verified afterwards (banner came back → reschedule from
|
|
92
|
+
the fresh banner, capped at 5 attempts).
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
claude / codex / grok # normal usage — wrapped automatically
|
|
98
|
+
unsnooze status # tracked sessions + reset countdowns
|
|
99
|
+
unsnooze resume-now [id|--all] # don't wait for the reset time
|
|
100
|
+
unsnooze cancel [id|--all] # stop tracking a session
|
|
101
|
+
unsnooze config list # settings (see below)
|
|
102
|
+
unsnooze config set <k> <v> # e.g. autoResume off
|
|
103
|
+
unsnooze logs [-f] # what unsnooze has been doing
|
|
104
|
+
unsnooze report [agent] # capture a pane to report an undetected banner
|
|
105
|
+
unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Settings
|
|
109
|
+
|
|
110
|
+
`unsnooze setup` writes `~/.unsnooze/config.json`; change anything later with
|
|
111
|
+
`unsnooze config set`:
|
|
112
|
+
|
|
113
|
+
| key | default | meaning |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
|
|
116
|
+
| `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
|
|
117
|
+
| `notifications` | `true` | Desktop notification on limit detected / session resumed / gave up. |
|
|
118
|
+
| `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. |
|
|
119
|
+
| `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
|
|
120
|
+
|
|
121
|
+
Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
|
|
122
|
+
all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
|
|
123
|
+
|
|
124
|
+
## Safety properties
|
|
125
|
+
|
|
126
|
+
- **Never injects blind**: keys are only sent when the pane's foreground
|
|
127
|
+
process is the agent CLI and no "esc to interrupt"-style busy footer is
|
|
128
|
+
visible. Recycled pane ids can't receive stray messages.
|
|
129
|
+
- **Never picks a menu option blind**: if Claude's limit menu layout can't be
|
|
130
|
+
read, unsnooze does not press Enter (that could confirm "Upgrade your plan").
|
|
131
|
+
- **Sleep-safe waits**: the resumer polls wall-clock against the target epoch
|
|
132
|
+
every 30s instead of one long timer — a laptop asleep past the reset fires
|
|
133
|
+
on the next tick. Weekly limits are just a bigger epoch.
|
|
134
|
+
- **Verified resumes**: after dispatch it re-captures the pane; if the limit
|
|
135
|
+
banner reappears (reset time misparsed / not actually reset), it reschedules
|
|
136
|
+
from the fresh banner, capped at 5 attempts.
|
|
137
|
+
- **Concurrent-writer safe**: state updates go through a mkdir lock + atomic
|
|
138
|
+
rename; corrupt state is quarantined, never fatal (the hook path must never
|
|
139
|
+
block the CLI).
|
|
140
|
+
- **Overload ≠ limit**: 5xx/529/429 transient errors take a seconds-scale
|
|
141
|
+
backoff path ([30,60,120,240,300]s ± jitter) and never enter the ledger.
|
|
142
|
+
|
|
143
|
+
## Requirements
|
|
144
|
+
|
|
145
|
+
- Node ≥ 20 and tmux
|
|
146
|
+
- macOS, Linux, or **Windows via WSL** (see below)
|
|
147
|
+
- zsh or bash (the wrappers are installed into `~/.zshrc` / `~/.bashrc`)
|
|
148
|
+
|
|
149
|
+
### Windows / WSL
|
|
150
|
+
|
|
151
|
+
unsnooze is built on tmux, so on Windows it runs inside
|
|
152
|
+
[WSL](https://learn.microsoft.com/windows/wsl/install) — which is where the
|
|
153
|
+
agent CLIs live on Windows anyway:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
# inside your WSL distro (Ubuntu etc.)
|
|
157
|
+
sudo apt install tmux
|
|
158
|
+
npm install -g unsnooze && unsnooze setup
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Everything works as on Linux, including desktop notifications: inside WSL,
|
|
162
|
+
unsnooze raises **native Windows toasts** through `powershell.exe` (no
|
|
163
|
+
`notify-send` or X server needed). Native Windows (PowerShell/cmd, no WSL) is
|
|
164
|
+
not supported — without tmux there is nothing to watch; unsnooze will tell you
|
|
165
|
+
so and run your CLI unwatched instead of breaking it.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
npm test # unit tests (node:test)
|
|
171
|
+
./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
|
|
172
|
+
# scratch tmux session (no real limits needed)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[MIT](LICENSE)
|
package/bin/unsnooze.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// unsnooze — subcommand router; anything unrecognized is treated as claude
|
|
3
|
+
// args and passed through the launcher (back-compat with the zsh wrapper).
|
|
4
|
+
|
|
5
|
+
const [, , cmd, ...rest] = process.argv;
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
switch (cmd) {
|
|
9
|
+
case 'status': {
|
|
10
|
+
const { cmdStatus } = await import('../src/cli.js');
|
|
11
|
+
return cmdStatus();
|
|
12
|
+
}
|
|
13
|
+
case 'resume-now': {
|
|
14
|
+
const { cmdResumeNow } = await import('../src/cli.js');
|
|
15
|
+
return cmdResumeNow(rest[0]);
|
|
16
|
+
}
|
|
17
|
+
case 'cancel': {
|
|
18
|
+
const { cmdCancel } = await import('../src/cli.js');
|
|
19
|
+
return cmdCancel(rest[0]);
|
|
20
|
+
}
|
|
21
|
+
case 'logs': {
|
|
22
|
+
const { cmdLogs } = await import('../src/cli.js');
|
|
23
|
+
return cmdLogs(rest.includes('-f'));
|
|
24
|
+
}
|
|
25
|
+
case 'setup': {
|
|
26
|
+
const { runWizard } = await import('../src/wizard.js');
|
|
27
|
+
return runWizard();
|
|
28
|
+
}
|
|
29
|
+
case 'report': {
|
|
30
|
+
const { cmdReport } = await import('../src/report.js');
|
|
31
|
+
return cmdReport(rest);
|
|
32
|
+
}
|
|
33
|
+
case 'config': {
|
|
34
|
+
const { cmdConfig } = await import('../src/cli.js');
|
|
35
|
+
return cmdConfig(rest);
|
|
36
|
+
}
|
|
37
|
+
case 'install': {
|
|
38
|
+
const { cmdInstall } = await import('../src/install.js');
|
|
39
|
+
return cmdInstall(rest);
|
|
40
|
+
}
|
|
41
|
+
case 'uninstall': {
|
|
42
|
+
const { cmdUninstall } = await import('../src/install.js');
|
|
43
|
+
return cmdUninstall(rest);
|
|
44
|
+
}
|
|
45
|
+
case '_hook-stopfailure': {
|
|
46
|
+
const { runHook } = await import('../src/hook.js');
|
|
47
|
+
return runHook(rest);
|
|
48
|
+
}
|
|
49
|
+
case '_monitor': {
|
|
50
|
+
if (!rest[0]) { console.error('unsnooze _monitor: pane id required'); return 2; }
|
|
51
|
+
const { runMonitor } = await import('../src/monitor.js');
|
|
52
|
+
return runMonitor(rest[0], rest[1]);
|
|
53
|
+
}
|
|
54
|
+
case '_run': {
|
|
55
|
+
if (!rest[0]) { console.error('unsnooze _run: agent id required'); return 2; }
|
|
56
|
+
const { runLauncher } = await import('../src/launcher.js');
|
|
57
|
+
return runLauncher(rest.slice(1), rest[0]);
|
|
58
|
+
}
|
|
59
|
+
case '_resumer': {
|
|
60
|
+
const { runResumer } = await import('../src/resumer.js');
|
|
61
|
+
return runResumer();
|
|
62
|
+
}
|
|
63
|
+
case 'help':
|
|
64
|
+
case '--help-unsnooze': {
|
|
65
|
+
console.log(`unsnooze — wakes every limit-stopped AI coding session when the limit resets
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
unsnooze [claude args...] run claude under limit-watch (default)
|
|
69
|
+
unsnooze _run <agent> [args...] run a specific agent CLI under limit-watch
|
|
70
|
+
unsnooze status list tracked sessions + reset countdowns
|
|
71
|
+
unsnooze resume-now [id|--all] resume stopped session(s) immediately
|
|
72
|
+
unsnooze cancel [id|--all] stop tracking session(s)
|
|
73
|
+
unsnooze logs [-f] show (or follow) the unsnooze log
|
|
74
|
+
unsnooze config [list|get|set] view or change settings (toggles, message)
|
|
75
|
+
unsnooze setup interactive setup wizard (agents + toggles)
|
|
76
|
+
unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
|
|
77
|
+
unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
|
|
78
|
+
unsnooze report [agent] [pane] capture a pane to report an undetected banner`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
default: {
|
|
82
|
+
// Everything else (including no args, --resume, -c, plain prompts) is a
|
|
83
|
+
// claude invocation — back-compat for the plain wrapper.
|
|
84
|
+
const { runLauncher } = await import('../src/launcher.js');
|
|
85
|
+
const args = cmd === undefined ? [] : [cmd, ...rest];
|
|
86
|
+
return runLauncher(args, 'claude');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
main().then(code => { process.exitCode = typeof code === 'number' ? code : 0; })
|
|
92
|
+
.catch(err => { console.error(`unsnooze: ${err.stack || err}`); process.exitCode = 1; });
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "unsnooze",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Wakes every limit-stopped AI coding session (Claude Code, Codex CLI, Grok) in tmux the moment the usage limit resets.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"codex",
|
|
8
|
+
"codex-cli",
|
|
9
|
+
"grok",
|
|
10
|
+
"usage-limit",
|
|
11
|
+
"rate-limit",
|
|
12
|
+
"auto-resume",
|
|
13
|
+
"tmux",
|
|
14
|
+
"ai-agents",
|
|
15
|
+
"cli"
|
|
16
|
+
],
|
|
17
|
+
"author": "Saaransh Menon <saaransh.dev2811@gmail.com>",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/saaranshM/unsnooze.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/saaranshM/unsnooze/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/saaranshM/unsnooze#readme",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"bin": {
|
|
29
|
+
"unsnooze": "bin/unsnooze.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"src/",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node --test"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@clack/prompts": "^1.7.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Claude Code adapter: everything unsnooze needs to know about one CLI lives
|
|
2
|
+
// in its adapter — banner regexes, busy/idle markers, resume invocation,
|
|
3
|
+
// session-id lookup, and (claude-only) the interactive limit-menu driver.
|
|
4
|
+
|
|
5
|
+
import { contentLines } from '../patterns.js';
|
|
6
|
+
import { latestSessionId } from '../sessions.js';
|
|
7
|
+
|
|
8
|
+
export const patterns = {
|
|
9
|
+
// Claude Code renders limits across multiple TUI lines, e.g.:
|
|
10
|
+
// "⚠ You've hit your 5-hour limit"
|
|
11
|
+
// "· resets 3pm (UTC)"
|
|
12
|
+
limitPatterns: [
|
|
13
|
+
/(?:hit|exceeded|reached).*(?:your|the)\s*(?:[\w-]+\s+){0,3}limit/i,
|
|
14
|
+
/\d+-hour limit/i,
|
|
15
|
+
/limit reached/i,
|
|
16
|
+
/usage limit/i,
|
|
17
|
+
/out of.*usage/i,
|
|
18
|
+
/rate limit/i,
|
|
19
|
+
/try again in/i,
|
|
20
|
+
],
|
|
21
|
+
resetPatterns: [
|
|
22
|
+
/resets?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?/i,
|
|
23
|
+
/resets?\s+in[:\s]\s*\d/i,
|
|
24
|
+
/try again in \d+\s*(?:hours?|minutes?|h|m)/i,
|
|
25
|
+
/resets?\s+(?:on\s+)?(?:mon|tue|wed|thu|fri|sat|sun)/i, // weekly: "resets Tuesday 9am"
|
|
26
|
+
],
|
|
27
|
+
weeklyPatterns: [
|
|
28
|
+
/week(?:ly)?\s+limit/i,
|
|
29
|
+
/resets?\s+(?:on\s+)?(?:mon|tue|wed|thu|fri|sat|sun)/i,
|
|
30
|
+
/limit.*(?:this|per)\s+week/i,
|
|
31
|
+
],
|
|
32
|
+
fiveHourPatterns: [/\d+-hour limit/i, /session limit/i],
|
|
33
|
+
// While Claude is streaming ("esc to interrupt") or running its own internal
|
|
34
|
+
// retries ("Retrying in 5s · attempt 3/10"), never inject keys.
|
|
35
|
+
busyPatterns: [
|
|
36
|
+
/esc to interrupt/i,
|
|
37
|
+
/\besc\b.*\binterrupt\b/i,
|
|
38
|
+
/Retrying in\b/i,
|
|
39
|
+
/\battempt\s+\d+\/\d+/i,
|
|
40
|
+
],
|
|
41
|
+
idleRegex: /[❯>]/,
|
|
42
|
+
// Anchored to Claude Code's error render ("API Error: 529") — never bare digits.
|
|
43
|
+
overloadPatterns: [
|
|
44
|
+
/API Error:?\s*\(?5\d\d/i,
|
|
45
|
+
/overloaded_error/i,
|
|
46
|
+
/API Error:?\s*\(?429/i,
|
|
47
|
+
],
|
|
48
|
+
transientPatterns: [], // claude's transient errors are the overload set
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// --- Interactive /rate-limit-options menu (Claude Code only) ---
|
|
52
|
+
// Newer Claude Code shows a selectable menu on limit hit:
|
|
53
|
+
// What do you want to do?
|
|
54
|
+
// ❯ 1. Upgrade your plan
|
|
55
|
+
// 2. Stop and wait for limit to reset
|
|
56
|
+
// Option order varies between versions — never assume a position; locate the
|
|
57
|
+
// cursor and target option and compute the moves. Never blind-Enter (could
|
|
58
|
+
// confirm "Upgrade your plan").
|
|
59
|
+
|
|
60
|
+
const MENU_CURSOR = '❯';
|
|
61
|
+
const WAIT_OPTION_REGEX = /stop and wait for limit to reset/i;
|
|
62
|
+
const MENU_OPTION_REGEX = /^\s*❯?\s*\d+\.\s/;
|
|
63
|
+
|
|
64
|
+
export function isRateLimitOptionsPrompt(text, tailLines = 12) {
|
|
65
|
+
const t = contentLines(text, tailLines).join('\n');
|
|
66
|
+
return /what do you want to do\?/i.test(t)
|
|
67
|
+
&& WAIT_OPTION_REGEX.test(t)
|
|
68
|
+
&& (/enter to confirm/i.test(t) || /esc to cancel/i.test(t) || t.includes(MENU_CURSOR));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Steps (in options) from cursor to the "Stop and wait" option. Positive =>
|
|
72
|
+
// Down N times, negative => Up. null => layout unreadable, caller MUST NOT Enter.
|
|
73
|
+
export function menuStepsToWaitOption(text, tailLines = 12) {
|
|
74
|
+
const optionLines = contentLines(text, tailLines).filter(l => MENU_OPTION_REGEX.test(l));
|
|
75
|
+
if (optionLines.length === 0) return null;
|
|
76
|
+
const cursorPos = optionLines.findIndex(l => l.includes(MENU_CURSOR));
|
|
77
|
+
const waitPos = optionLines.findIndex(l => WAIT_OPTION_REGEX.test(l));
|
|
78
|
+
if (cursorPos === -1 || waitPos === -1) return null;
|
|
79
|
+
return waitPos - cursorPos;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default {
|
|
83
|
+
id: 'claude',
|
|
84
|
+
name: 'Claude Code',
|
|
85
|
+
bin: process.env.UNSNOOZE_CLAUDE_BIN || 'claude',
|
|
86
|
+
experimental: false,
|
|
87
|
+
patterns,
|
|
88
|
+
menu: { isPrompt: isRateLimitOptionsPrompt, stepsToWait: menuStepsToWaitOption },
|
|
89
|
+
// How to reopen a dead session. messageViaPane: the resume prompt is typed
|
|
90
|
+
// into the TUI once it's ready (claude has no resume-with-prompt argv form).
|
|
91
|
+
resumeArgs(sessionId) {
|
|
92
|
+
return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
|
|
93
|
+
},
|
|
94
|
+
latestSessionId,
|
|
95
|
+
// The foreground process for a claude session is `node` (nvm shim) or `claude`.
|
|
96
|
+
isForegroundCommand(cmd) {
|
|
97
|
+
return cmd === 'claude' || cmd === 'node' || cmd === 'unsnooze';
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// OpenAI Codex CLI adapter.
|
|
2
|
+
//
|
|
3
|
+
// Codex has no hook that fires on usage limits (its `notify` config only emits
|
|
4
|
+
// agent-turn-complete), so detection is scrape-only. The TUI does NOT exit on
|
|
5
|
+
// a hard limit — it renders one red transcript line and sits at the composer:
|
|
6
|
+
// ■ You've hit your usage limit. …{ or try again at 3:51 PM.}
|
|
7
|
+
// Reset-time tails vary by plan: "try again at 3:51 PM." (same day),
|
|
8
|
+
// "try again at Feb 23rd, 2026 9:01 PM." (cross-day), "Try again in 4 days
|
|
9
|
+
// 20 hours 9 minutes." (older builds), or "Try again later." (no timestamp).
|
|
10
|
+
// Transient errors render "stream error: … retrying 4/5 in 1.4s" and must take
|
|
11
|
+
// the overload path, never the ledger.
|
|
12
|
+
|
|
13
|
+
import { openSync, readSync, closeSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
|
|
18
|
+
|
|
19
|
+
const LIMIT_ANCHORS = [
|
|
20
|
+
/You've hit your usage limit/i,
|
|
21
|
+
/Your workspace is out of credits/i,
|
|
22
|
+
/hit your spend cap/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const patterns = {
|
|
26
|
+
limitPatterns: LIMIT_ANCHORS,
|
|
27
|
+
// The whole banner is ONE line, so the anchors double as reset lines — the
|
|
28
|
+
// proximity engine then hands that line to time-parser (which falls back to
|
|
29
|
+
// the 5h default for "Try again later.").
|
|
30
|
+
resetPatterns: [
|
|
31
|
+
/try again at/i,
|
|
32
|
+
/try again in \d+/i,
|
|
33
|
+
/try again later/i,
|
|
34
|
+
...LIMIT_ANCHORS,
|
|
35
|
+
],
|
|
36
|
+
weeklyPatterns: [/weekly limit/i],
|
|
37
|
+
fiveHourPatterns: [/5h limit/i],
|
|
38
|
+
busyPatterns: [
|
|
39
|
+
/esc to interrupt/i, // "• Working (12s • esc to interrupt)"
|
|
40
|
+
/retrying\s+\d+\/\d+/i, // internal stream-error retry — don't inject
|
|
41
|
+
],
|
|
42
|
+
idleRegex: /›/, // composer: "› Ask Codex to do anything"
|
|
43
|
+
overloadPatterns: [/stream error/i, /exceeded retry limit/i],
|
|
44
|
+
transientPatterns: [/stream error/i, /exceeded retry limit/i],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{UUID}.jsonl;
|
|
48
|
+
// the first JSONL line carries the session cwd. Conservative: no cwd match →
|
|
49
|
+
// null (the resumer then uses `codex resume --last`, which codex itself scopes
|
|
50
|
+
// to the launch cwd).
|
|
51
|
+
const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
52
|
+
|
|
53
|
+
function fileHead(path, bytes = 4096) {
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
fd = openSync(path, 'r');
|
|
57
|
+
const buf = Buffer.alloc(bytes);
|
|
58
|
+
const n = readSync(fd, buf, 0, bytes, 0);
|
|
59
|
+
return buf.toString('utf-8', 0, n);
|
|
60
|
+
} catch {
|
|
61
|
+
return '';
|
|
62
|
+
} finally {
|
|
63
|
+
if (fd !== undefined) closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_DIR, 'sessions')) {
|
|
68
|
+
const files = [];
|
|
69
|
+
const walk = (dir, depth) => {
|
|
70
|
+
let entries;
|
|
71
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
72
|
+
for (const e of entries) {
|
|
73
|
+
const p = join(dir, e.name);
|
|
74
|
+
if (e.isDirectory() && depth < 3) { walk(p, depth + 1); continue; }
|
|
75
|
+
const m = e.isFile() && e.name.match(ROLLOUT_RE);
|
|
76
|
+
if (!m) continue;
|
|
77
|
+
let mtime;
|
|
78
|
+
try { mtime = statSync(p).mtimeMs; } catch { continue; }
|
|
79
|
+
if (aroundTs != null && Math.abs(mtime - aroundTs) > 30 * 60_000) continue;
|
|
80
|
+
files.push({ path: p, id: m[1], mtime });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk(sessionsRoot, 0);
|
|
84
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
85
|
+
// JSON-escape the cwd the way it appears inside the meta line.
|
|
86
|
+
const needle = cwd ? JSON.stringify(cwd).slice(1, -1) : null;
|
|
87
|
+
for (const f of files.slice(0, 20)) {
|
|
88
|
+
if (!needle || fileHead(f.path).includes(needle)) return f.id;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default {
|
|
94
|
+
id: 'codex',
|
|
95
|
+
name: 'OpenAI Codex CLI',
|
|
96
|
+
bin: process.env.UNSNOOZE_CODEX_BIN || 'codex',
|
|
97
|
+
experimental: false,
|
|
98
|
+
patterns,
|
|
99
|
+
menu: null, // no interactive limit menu
|
|
100
|
+
// Resume takes the prompt in argv — `codex resume <id> "msg"` starts the turn
|
|
101
|
+
// immediately, nothing to type into the TUI.
|
|
102
|
+
resumeArgs(sessionId, message) {
|
|
103
|
+
return {
|
|
104
|
+
args: sessionId ? ['resume', sessionId, message] : ['resume', '--last', message],
|
|
105
|
+
messageViaPane: false,
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
latestSessionId,
|
|
109
|
+
isForegroundCommand(cmd) {
|
|
110
|
+
return cmd === 'codex' || cmd === 'node' || cmd === 'unsnooze';
|
|
111
|
+
},
|
|
112
|
+
};
|