unsnooze 1.6.0 → 1.8.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 +61 -0
- package/README.md +96 -16
- package/bin/unsnooze.js +7 -6
- package/package.json +11 -2
- package/src/agents/agy.js +100 -0
- package/src/agents/codex.js +22 -2
- package/src/agents/index.js +5 -1
- package/src/agents/kimi.js +120 -0
- package/src/agents/opencode.js +105 -0
- package/src/agents/qwen.js +108 -0
- package/src/cli.js +2 -1
- package/src/config.js +3 -2
- package/src/hook.js +20 -7
- package/src/install.js +45 -5
- package/src/launcher.js +40 -21
- package/src/lease.js +87 -0
- package/src/monitor.js +47 -19
- package/src/multiplexer.js +58 -0
- package/src/multiplexers/tmux.js +200 -0
- package/src/multiplexers/zellij.js +193 -0
- package/src/notify-tty.js +311 -0
- package/src/notify.js +131 -14
- package/src/report.js +11 -4
- package/src/resumer.js +172 -73
- package/src/settings.js +16 -2
- package/src/state.js +29 -6
- package/src/time-parser.js +47 -11
- package/src/watcher.js +4 -2
- package/src/wizard.js +7 -6
- package/src/tmux.js +0 -95
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 1.8.0 — 2026-07-13
|
|
6
|
+
|
|
7
|
+
- **Notification channels** (`notifyChannel`: `auto` | `native` | `osc` |
|
|
8
|
+
`bell`, env `UNSNOOZE_NOTIFY_CHANNEL`): terminal-branded alerts via OSC 9
|
|
9
|
+
(iTerm2, kitty, WezTerm, Ghostty, Warp) or OSC 777 (rxvt), plus BEL on the
|
|
10
|
+
pane tty. `auto` sends OSC+BEL when tmux can reach client/pane ttys and
|
|
11
|
+
falls back to the OS toast only if OSC delivered nothing; denylisted
|
|
12
|
+
terminals (Apple Terminal, VS Code, Alacritty, Zed) skip OSC in auto.
|
|
13
|
+
OSC/BEL require tmux — Zellij and GUI-watcher stops use native. Existing
|
|
14
|
+
`notifications` remains the master off-switch.
|
|
15
|
+
- **Unified ChatGPT desktop app support** (July 2026: the Codex app became the
|
|
16
|
+
ChatGPT app). Verified against a real install: the app's bundled
|
|
17
|
+
`codex app-server` still writes rollouts to `~/.codex/sessions/` in the same
|
|
18
|
+
format (now with additive `limit_id`/`credits`/`plan_type` fields and
|
|
19
|
+
reason-string `rate_limit_reached_type` values — both handled), and
|
|
20
|
+
`codex resume <uuid>` works for app-originated sessions. New: when `codex`
|
|
21
|
+
is not on PATH but ChatGPT.app is installed, unsnooze resolves the
|
|
22
|
+
app-bundled binary (`ChatGPT.app/Contents/Resources/codex`) for wrappers,
|
|
23
|
+
wizard detection, and revival. Rollouts older than 7 days are now
|
|
24
|
+
zstd-compressed by codex; irrelevant for detection (freshness window is
|
|
25
|
+
minutes) but noted for `unsnooze report` archaeology.
|
|
26
|
+
- Added a dual tmux/Zellij multiplexer backend. The new `multiplexer` setting
|
|
27
|
+
accepts `auto`, `tmux`, or `zellij`; status output identifies the backend,
|
|
28
|
+
qualified pane address, and revival session. Zellij revival uses structured
|
|
29
|
+
pane ownership and a reserved-session smoke test without adding a statusline
|
|
30
|
+
notification path.
|
|
31
|
+
|
|
32
|
+
## 1.7.0 — 2026-07-12
|
|
33
|
+
|
|
34
|
+
- **Four new agent adapters** (all ⚠️ experimental, off by default — enable in
|
|
35
|
+
`unsnooze setup`):
|
|
36
|
+
- **Qwen Code** (`qwen`): Claude-shaped `StopFailure` hook installed into
|
|
37
|
+
`~/.qwen/settings.json` + verbatim quota-banner scraping (legacy OAuth,
|
|
38
|
+
Coding Plan `Allocated quota exceeded` → 5h window, OpenRouter
|
|
39
|
+
passthroughs). Resumes via `qwen --resume <id>`, ids from qwen's
|
|
40
|
+
`*.runtime.json` sidecars.
|
|
41
|
+
- **Kimi CLI** (`kimi`): detects the terminal red
|
|
42
|
+
`Error code: 429 … rate_limit_reached_error` line; resumes via
|
|
43
|
+
`kimi -r <id> -p "<msg>"` with an on-disk id check (kimi silently starts a
|
|
44
|
+
NEW session for unknown ids). `Membership expired` is notify-only.
|
|
45
|
+
- **OpenCode** (`opencode`): OpenCode self-retries limits forever (sleeping
|
|
46
|
+
until reset), so unsnooze records the stop, never touches a live
|
|
47
|
+
self-retrying pane, and revives dead panes mid-wait via
|
|
48
|
+
`opencode -s <ses_id>` — reset time parsed from the
|
|
49
|
+
`[retrying in 2h5m attempt #N]` countdown.
|
|
50
|
+
- **Antigravity CLI** (`agy`, Google's Gemini-CLI successor): scrapes
|
|
51
|
+
`Model quota limit exceeded` / `Refreshes in 6 days and 18 hours`
|
|
52
|
+
(multi-day refresh = weekly cap); `503 MODEL_CAPACITY_EXHAUSTED` is treated
|
|
53
|
+
as transient overload. Resumes via `agy --conversation=<id>`.
|
|
54
|
+
- **OpenRouter awareness**: 429 bodies (`Rate limit exceeded: limit_…`,
|
|
55
|
+
free-models-per-day) are detected inside OpenCode/Qwen sessions; credit
|
|
56
|
+
exhaustion (402) notifies instead of snoozing.
|
|
57
|
+
- **Terminal-error channel**: non-resetting errors (credits exhausted,
|
|
58
|
+
membership expired, discontinued tiers) now raise a single desktop
|
|
59
|
+
notification instead of being retried against a reset that will never come.
|
|
60
|
+
- **time-parser**: understands `Refreshes in 6 days and 18 hours`,
|
|
61
|
+
`It will reset in 2 hours 5 minutes`, `Retry in 45 minutes`, and Go-style
|
|
62
|
+
countdowns (`2h5m`, `2m 5s`, `~2 days`).
|
|
63
|
+
|
|
3
64
|
## 1.6.0 — 2026-07-12
|
|
4
65
|
|
|
5
66
|
- **Stale-workspace guard** (`workspaceGuard`: `off` | `inform` | `pause`,
|
package/README.md
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
[](package.json)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
|
-
**Claude Code · Codex CLI · Grok** — when they hit the 5-hour or weekly usage limit
|
|
12
|
+
**Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
|
|
13
13
|
("You've hit your usage limit"), your session just… stops.<br/>
|
|
14
14
|
unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
|
|
15
|
-
your projects and **wakes each one up in tmux the moment the usage limit resets.**
|
|
15
|
+
your projects and **wakes each one up in tmux or Zellij the moment the usage limit resets.**
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
18
|
npm install -g unsnooze && unsnooze setup
|
|
@@ -40,7 +40,7 @@ existing tool solves only a slice of it:
|
|
|
40
40
|
## Supported CLIs
|
|
41
41
|
|
|
42
42
|
- **Claude Code** — dual-channel detection: the `StopFailure` hook (authoritative,
|
|
43
|
-
carries `session_id`) plus
|
|
43
|
+
carries `session_id`) plus multiplexer pane scraping for banners and the interactive
|
|
44
44
|
limit menu (always answered with *"Stop and wait for limit to reset"*, never a
|
|
45
45
|
blind Enter). Dead sessions revive via `claude --resume <id>`.
|
|
46
46
|
- **OpenAI Codex CLI** — scrape-based (Codex fires no event on limits). Detects
|
|
@@ -53,10 +53,46 @@ existing tool solves only a slice of it:
|
|
|
53
53
|
not publicly documented, so detection uses generic patterns with a safe
|
|
54
54
|
fallback. Hit a banner unsnooze missed? Run `unsnooze report` and paste the
|
|
55
55
|
capture into an issue — that's how this adapter gets good.
|
|
56
|
+
- **Qwen Code** — ⚠️ *experimental*. Dual-channel: a Claude-shaped `StopFailure`
|
|
57
|
+
hook installed into `~/.qwen/settings.json` (fires with `error: rate_limit`)
|
|
58
|
+
plus scraping for the verbatim quota renders (`Qwen OAuth quota exceeded`,
|
|
59
|
+
Coding Plan `Allocated quota exceeded`, and OpenRouter `Rate limit exceeded:
|
|
60
|
+
limit_…` passthroughs). Qwen never shows a reset time, so waits use the
|
|
61
|
+
5-hour fallback and self-correct on verify. Dead sessions revive via
|
|
62
|
+
`qwen --resume <id>` (session ids come from the `*.runtime.json` sidecars
|
|
63
|
+
qwen writes for exactly this purpose).
|
|
64
|
+
- **Kimi CLI (Moonshot)** — ⚠️ *experimental*. Scrape-based: kimi retries a 429
|
|
65
|
+
three times within seconds, then stops with a red `LLM provider error: Error
|
|
66
|
+
code: 429 … rate_limit_reached_error` line — that's the detection anchor.
|
|
67
|
+
The 429 body carries no reset time (5h fallback + verify). Dead sessions
|
|
68
|
+
revive via `kimi -r <id> -p "<message>"` — with a guard: kimi silently starts
|
|
69
|
+
a *new* session for unknown ids, so the id is verified on disk first
|
|
70
|
+
(`--continue` otherwise). `Membership expired` (402) is notify-only.
|
|
71
|
+
- **OpenCode** — ⚠️ *experimental*, and a different shape: OpenCode *retries
|
|
72
|
+
rate limits itself, forever*, honoring `retry-after` (it will sleep hours
|
|
73
|
+
until the reset, showing `Rate Limited [retrying in 2h5m attempt #4]`). So
|
|
74
|
+
unsnooze records the stop but never touches a live self-retrying pane; its
|
|
75
|
+
job is reviving sessions whose process died mid-wait (laptop slept, tmux
|
|
76
|
+
gone) via `opencode -s <ses_id>`, with the reset time parsed straight from
|
|
77
|
+
the banner countdown. Zen plan banners (`5 hour/weekly/monthly usage limit
|
|
78
|
+
reached…`) and OpenRouter passthroughs are detected too; `insufficient
|
|
79
|
+
credits` (402) is notify-only.
|
|
80
|
+
- **Antigravity CLI (Google, `agy`)** — ⚠️ *experimental*. The Gemini-CLI
|
|
81
|
+
successor. Scrapes the forum-reported quota strings (`Model quota limit
|
|
82
|
+
exceeded`, `Refreshes in 6 days and 18 hours` — parsed, multi-day refresh =
|
|
83
|
+
the weekly cap) and treats `503 MODEL_CAPACITY_EXHAUSTED` as a transient
|
|
84
|
+
overload, not a limit. Dead sessions revive via `agy --conversation=<id>`
|
|
85
|
+
(ids from `~/.gemini/antigravity-cli/history.jsonl`). Like Grok: closed
|
|
86
|
+
source, so `unsnooze report` captures make this adapter better.
|
|
87
|
+
|
|
88
|
+
**OpenRouter** (the API gateway) isn't a separate agent: its 429 bodies
|
|
89
|
+
(`Rate limit exceeded: limit_rpd/…`, free-models-per-day) are detected inside
|
|
90
|
+
the CLIs that use it (OpenCode, Qwen Code), and credit exhaustion (402) is
|
|
91
|
+
surfaced as a notification — there's no reset to wait for, only a top-up.
|
|
56
92
|
|
|
57
93
|
## GUI surfaces (VS Code extension, desktop apps)
|
|
58
94
|
|
|
59
|
-
Terminal sessions are watched through the shell wrapper + tmux. Sessions in
|
|
95
|
+
Terminal sessions are watched through the shell wrapper + tmux or Zellij. Sessions in
|
|
60
96
|
**Claude Code's VS Code extension / desktop app** and **Codex's IDE
|
|
61
97
|
extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
|
|
62
98
|
the session files those surfaces already write:
|
|
@@ -66,14 +102,19 @@ the session files those surfaces already write:
|
|
|
66
102
|
shared by the CLI and the VS Code extension.
|
|
67
103
|
- **Codex** writes a `rate_limits` snapshot (usage %, **exact epoch reset
|
|
68
104
|
time**) into every rollout under `~/.codex/sessions/` — shared by the CLI,
|
|
69
|
-
IDE extension, and desktop app
|
|
105
|
+
IDE extension, and the **unified ChatGPT desktop app** (July 2026: the Codex
|
|
106
|
+
app became the ChatGPT app; its bundled `codex app-server` writes the same
|
|
107
|
+
rollouts to the same store — verified against a real install). On machines
|
|
108
|
+
where Codex lives only inside ChatGPT.app (no `codex` on PATH), unsnooze
|
|
109
|
+
automatically resumes through the app-bundled binary
|
|
110
|
+
(`/Applications/ChatGPT.app/Contents/Resources/codex`).
|
|
70
111
|
- **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
|
|
71
112
|
sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
|
|
72
113
|
those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
|
|
73
114
|
(plus the keychain-scope override that keeps auth working — verified
|
|
74
115
|
against a real desktop session).
|
|
75
116
|
|
|
76
|
-
When the limit resets, the session is revived **in a
|
|
117
|
+
When the limit resets, the session is revived **in a multiplexer pane** with
|
|
77
118
|
`claude --resume <id>` / `codex resume <id>` — it's the same session file, so
|
|
78
119
|
the continued conversation stays visible in the GUI's own history. (Resuming
|
|
79
120
|
*inside* the GUI panel isn't possible today: no extension/app exposes an
|
|
@@ -93,7 +134,7 @@ anytime with `unsnooze config set guiWatch off`.
|
|
|
93
134
|
<summary>text version</summary>
|
|
94
135
|
|
|
95
136
|
```
|
|
96
|
-
claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in
|
|
137
|
+
claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in mux pane
|
|
97
138
|
│
|
|
98
139
|
├─ per-pane monitor (scrapes for limit
|
|
99
140
|
│ banners, drives Claude's limit menu,
|
|
@@ -110,7 +151,7 @@ StopFailure hook (claude, grok) ──────────────┤
|
|
|
110
151
|
│
|
|
111
152
|
┌───────────────────┴────────────────────┐
|
|
112
153
|
pane still alive? pane gone?
|
|
113
|
-
send resume message into it
|
|
154
|
+
send resume message into it new multiplexer pane,
|
|
114
155
|
(only if the CLI is foreground `unsnooze _run <agent>
|
|
115
156
|
and not mid-stream) --resume <id>`, verify
|
|
116
157
|
```
|
|
@@ -148,19 +189,52 @@ unsnooze help # full command list (also -h / --help)
|
|
|
148
189
|
|
|
149
190
|
| key | default | meaning |
|
|
150
191
|
|---|---|---|
|
|
192
|
+
| `multiplexer` | `auto` | Backend to use: `auto`, `tmux`, or `zellij`. `auto` prefers the current multiplexer, then the only installed backend, with tmux as the tie-breaker. |
|
|
151
193
|
| `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
|
|
152
194
|
| `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
|
|
153
|
-
| `notifications` | `true` |
|
|
195
|
+
| `notifications` | `true` | Master switch for all notifications (limit detected / session resumed / gave up). Off = silence every channel. |
|
|
196
|
+
| `notifyChannel` | `auto` | How to deliver: `auto`, `native`, `osc`, or `bell` (see [Notification channels](#notification-channels)). Env: `UNSNOOZE_NOTIFY_CHANNEL`. |
|
|
154
197
|
| `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
|
|
155
198
|
| `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. Override it for a single session with `unsnooze message <id> "…"` — visible in `unsnooze status`. |
|
|
156
|
-
| `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
157
|
-
| `agents.claude` / `agents.codex`
|
|
199
|
+
| `resumeMessages.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
200
|
+
| `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
|
|
201
|
+
| `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
|
|
158
202
|
| `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
|
|
159
203
|
| `updateCheck` | `true` | Daily new-version check (a plain GET to the npm registry, nothing identifying is sent). Notices after commands + one desktop toast per version. |
|
|
160
204
|
|
|
161
205
|
Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
|
|
162
206
|
all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
|
|
163
207
|
|
|
208
|
+
### Notification channels
|
|
209
|
+
|
|
210
|
+
When a limit is hit, a session resumes, or unsnooze gives up, it can alert you
|
|
211
|
+
via an OS toast, a terminal OSC sequence, a BEL, or a mix — controlled by
|
|
212
|
+
`notifyChannel` (default `auto`). `notifications=false` (or
|
|
213
|
+
`UNSNOOZE_NOTIFICATIONS=off`) turns every channel off.
|
|
214
|
+
|
|
215
|
+
| channel | what it does |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `auto` | OSC (when the terminal supports it) **plus** BEL on the pane tty; falls back to native only if OSC delivered nothing (avoids double banners). No pane / non-tmux mux → native. |
|
|
218
|
+
| `native` | OS toast (macOS `osascript`, Linux `notify-send`, WSL/Windows PowerShell toast); tmux `display-message` as a last-resort statusline fallback on other platforms. |
|
|
219
|
+
| `osc` | Force OSC to attached client ttys; native if zero deliveries. |
|
|
220
|
+
| `bell` | BEL to the pane tty; native if undeliverable. |
|
|
221
|
+
|
|
222
|
+
**Terminal support (OSC)** — detection uses `TERM_PROGRAM` / termname / known
|
|
223
|
+
env markers:
|
|
224
|
+
|
|
225
|
+
| terminals | sequence |
|
|
226
|
+
|---|---|
|
|
227
|
+
| iTerm2, kitty, WezTerm, Ghostty, Warp | OSC 9 (`\x1b]9;title: body\x07`) |
|
|
228
|
+
| rxvt / urxvt | OSC 777 (`\x1b]777;notify;title;body\x07`) |
|
|
229
|
+
| Apple Terminal, VS Code, Alacritty, Zed | denylisted — OSC skipped (prefer `native`; force `osc` does not unlock these) |
|
|
230
|
+
| unknown | skipped in `auto`; OSC 9 when `notifyChannel=osc` |
|
|
231
|
+
|
|
232
|
+
**tmux only for OSC/BEL.** Those paths write to the client's tty (OSC) or the
|
|
233
|
+
pane's tty (BEL), which requires tmux's client/pane tty APIs. **Zellij has no
|
|
234
|
+
equivalent**, so OSC/BEL are not attempted under Zellij — notifications fall
|
|
235
|
+
back to native (and Zellij has no statusline-inject equivalent either). GUI
|
|
236
|
+
watcher stops (no pane context) always use native.
|
|
237
|
+
|
|
164
238
|
## Safety properties
|
|
165
239
|
|
|
166
240
|
- **Never injects blind**: keys are only sent when the pane's foreground
|
|
@@ -187,26 +261,31 @@ all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
|
|
|
187
261
|
|
|
188
262
|
## Requirements
|
|
189
263
|
|
|
190
|
-
- Node ≥ 20 and tmux
|
|
264
|
+
- Node ≥ 20 and tmux ≥ 3.2 **or** Zellij
|
|
191
265
|
- macOS, Linux, or **Windows via WSL** (see below)
|
|
192
266
|
- zsh or bash (the wrappers are installed into `~/.zshrc` / `~/.bashrc`)
|
|
193
267
|
|
|
194
268
|
### Windows / WSL
|
|
195
269
|
|
|
196
|
-
|
|
270
|
+
tmux and Zellij are Unix tools, so on Windows unsnooze runs inside
|
|
197
271
|
[WSL](https://learn.microsoft.com/windows/wsl/install) — which is where the
|
|
198
272
|
agent CLIs live on Windows anyway:
|
|
199
273
|
|
|
200
274
|
```sh
|
|
201
275
|
# inside your WSL distro (Ubuntu etc.)
|
|
202
|
-
sudo apt install tmux
|
|
276
|
+
sudo apt install tmux # or install Zellij: https://zellij.dev/documentation/installation
|
|
203
277
|
npm install -g unsnooze && unsnooze setup
|
|
204
278
|
```
|
|
205
279
|
|
|
280
|
+
On macOS, install either backend with `brew install tmux` or
|
|
281
|
+
`brew install zellij`. In `auto` mode unsnooze uses the multiplexer you are
|
|
282
|
+
currently inside; choose one explicitly with
|
|
283
|
+
`unsnooze config set multiplexer tmux` or `zellij`.
|
|
284
|
+
|
|
206
285
|
Everything works as on Linux, including desktop notifications: inside WSL,
|
|
207
286
|
unsnooze raises **native Windows toasts** through `powershell.exe` (no
|
|
208
287
|
`notify-send` or X server needed). Native Windows (PowerShell/cmd, no WSL) is
|
|
209
|
-
not supported — without tmux there is
|
|
288
|
+
not supported — without tmux or Zellij there is no terminal pane to watch; unsnooze will tell you
|
|
210
289
|
so and run your CLI unwatched instead of breaking it.
|
|
211
290
|
|
|
212
291
|
## FAQ
|
|
@@ -252,7 +331,7 @@ disable the check.
|
|
|
252
331
|
|
|
253
332
|
Yes — reset times are stored as absolute timestamps and checked every 30
|
|
254
333
|
seconds, so a laptop that slept through the reset resumes on the next tick,
|
|
255
|
-
and dead panes are reopened by session id in a fresh
|
|
334
|
+
and dead panes are reopened by session id in a fresh multiplexer pane.
|
|
256
335
|
|
|
257
336
|
## Development
|
|
258
337
|
|
|
@@ -260,6 +339,7 @@ and dead panes are reopened by session id in a fresh tmux window.
|
|
|
260
339
|
npm test # unit tests (node:test)
|
|
261
340
|
./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
|
|
262
341
|
# scratch tmux session (no real limits needed)
|
|
342
|
+
bash -n scripts/e2e-zellij.sh # syntax-check the reserved-session Zellij smoke test
|
|
263
343
|
```
|
|
264
344
|
|
|
265
345
|
## License
|
package/bin/unsnooze.js
CHANGED
|
@@ -69,9 +69,9 @@ async function main() {
|
|
|
69
69
|
return runHook(rest);
|
|
70
70
|
}
|
|
71
71
|
case '_monitor': {
|
|
72
|
-
if (!rest[0]) { console.error('unsnooze _monitor: pane
|
|
72
|
+
if (!rest[0] || !rest[2]) { console.error('unsnooze _monitor: mux owner pane required'); return 2; }
|
|
73
73
|
const { runMonitor } = await import('../src/monitor.js');
|
|
74
|
-
return runMonitor(rest[0], rest[1]);
|
|
74
|
+
return runMonitor(rest[0], rest[1], rest[2], rest[3], rest[4]);
|
|
75
75
|
}
|
|
76
76
|
case '_run': {
|
|
77
77
|
if (!rest[0]) { console.error('unsnooze _run: agent id required'); return 2; }
|
|
@@ -93,7 +93,7 @@ async function main() {
|
|
|
93
93
|
case 'daemon': {
|
|
94
94
|
// Persistent resumer + transcript watcher: detects and revives limit
|
|
95
95
|
// stops from GUI surfaces (VS Code extension, desktop apps) where no
|
|
96
|
-
// shell wrapper or
|
|
96
|
+
// shell wrapper or multiplexer pane exists. Run via launchd/systemd or a shell.
|
|
97
97
|
const { runResumer } = await import('../src/resumer.js');
|
|
98
98
|
const { createWatcher } = await import('../src/watcher.js');
|
|
99
99
|
const controller = new AbortController();
|
|
@@ -122,10 +122,11 @@ Usage:
|
|
|
122
122
|
unsnooze logs [-f] show (or follow) the unsnooze log
|
|
123
123
|
unsnooze update update unsnooze itself to the latest version
|
|
124
124
|
unsnooze daemon persistent watcher for GUI sessions (VS Code
|
|
125
|
-
extension, desktop apps) — no
|
|
126
|
-
detect; revival
|
|
125
|
+
extension, desktop apps) — no live pane needed
|
|
126
|
+
to detect; revival opens in tmux or Zellij
|
|
127
127
|
unsnooze config [list|get|set] view or change settings (toggles, global +
|
|
128
|
-
per-agent resume messages,
|
|
128
|
+
per-agent resume messages, notifyChannel
|
|
129
|
+
auto|native|osc|bell, updateCheck)
|
|
129
130
|
unsnooze setup interactive setup wizard (agents + toggles)
|
|
130
131
|
unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
|
|
131
132
|
unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Auto-resume Claude Code, Codex CLI &
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity CLI sessions in tmux or Zellij when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
7
7
|
"claude-code",
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
"openai",
|
|
11
11
|
"anthropic",
|
|
12
12
|
"grok",
|
|
13
|
+
"qwen",
|
|
14
|
+
"qwen-code",
|
|
15
|
+
"kimi",
|
|
16
|
+
"kimi-cli",
|
|
17
|
+
"opencode",
|
|
18
|
+
"antigravity",
|
|
19
|
+
"antigravity-cli",
|
|
20
|
+
"openrouter",
|
|
13
21
|
"usage-limit",
|
|
14
22
|
"usage limit",
|
|
15
23
|
"rate-limit",
|
|
@@ -20,6 +28,7 @@
|
|
|
20
28
|
"resume",
|
|
21
29
|
"retry",
|
|
22
30
|
"tmux",
|
|
31
|
+
"zellij",
|
|
23
32
|
"ai-agents",
|
|
24
33
|
"agentic-coding",
|
|
25
34
|
"cli"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Google Antigravity CLI adapter (`agy`) — EXPERIMENTAL.
|
|
2
|
+
//
|
|
3
|
+
// agy is the closed-source Go successor to Gemini CLI (which stopped serving
|
|
4
|
+
// individual accounts 2026-06-18). Limits: rolling ~5h "sprint" window plus a
|
|
5
|
+
// weekly cap, metered per model. The banner strings here come from Google
|
|
6
|
+
// forum reports, not source ("Model quota limit exceeded", "Refreshes in
|
|
7
|
+
// 6 days and 18 hours") — grok-bar experimental; improve via `unsnooze report`.
|
|
8
|
+
//
|
|
9
|
+
// 503 MODEL_CAPACITY_EXHAUSTED is provider capacity (transient), NOT a user
|
|
10
|
+
// quota — it takes the overload path.
|
|
11
|
+
//
|
|
12
|
+
// Deferred by design: the hooks.json channel (schema drifted between builds)
|
|
13
|
+
// and the loopback RetrieveUserQuotaSummary endpoint (authoritative per-meter
|
|
14
|
+
// resetTime) — the latter is the natural future resetProbe seam.
|
|
15
|
+
|
|
16
|
+
import { openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const AGY_DIR = () => process.env.UNSNOOZE_AGY_DIR || join(homedir(), '.gemini', 'antigravity-cli');
|
|
21
|
+
|
|
22
|
+
const LIMIT_ANCHORS = [
|
|
23
|
+
/Model quota limit exceeded/i,
|
|
24
|
+
/RESOURCE_EXHAUSTED/,
|
|
25
|
+
/quota (?:limit )?exceeded/i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const patterns = {
|
|
29
|
+
limitPatterns: LIMIT_ANCHORS,
|
|
30
|
+
// "Refreshes in 6 days and 18 hours" renders near the quota banner; anchors
|
|
31
|
+
// double as reset lines for the API-key single-line renders.
|
|
32
|
+
resetPatterns: [
|
|
33
|
+
/Refreshes in/i,
|
|
34
|
+
...LIMIT_ANCHORS,
|
|
35
|
+
],
|
|
36
|
+
weeklyPatterns: [/Refreshes in \d+ days?/i], // multi-day refresh = the weekly cap
|
|
37
|
+
fiveHourPatterns: [],
|
|
38
|
+
busyPatterns: [
|
|
39
|
+
/esc to interrupt/i,
|
|
40
|
+
/attempt \d+\/\d+/i,
|
|
41
|
+
],
|
|
42
|
+
idleRegex: /[›❯>]/,
|
|
43
|
+
overloadPatterns: [/MODEL_CAPACITY_EXHAUSTED/i, /503.*(?:capacity|exhausted)/i],
|
|
44
|
+
transientPatterns: [/MODEL_CAPACITY_EXHAUSTED/i],
|
|
45
|
+
terminalPatterns: [/not logged into Antigravity/i], // auth, not quota — notify only
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ~/.gemini/antigravity-cli/history.jsonl indexes all conversations. Schema is
|
|
49
|
+
// undocumented (and a SQLite migration is rumored) — parse the tail
|
|
50
|
+
// tolerantly: an entry counts if any string value equals the cwd, its id is
|
|
51
|
+
// the first conversation-ish field. Null on any doubt (the resumer then opens
|
|
52
|
+
// a fresh agy session; the wake message explains the context loss).
|
|
53
|
+
function tailBytes(path, bytes = 64 * 1024) {
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
const size = statSync(path).size;
|
|
57
|
+
fd = openSync(path, 'r');
|
|
58
|
+
const start = Math.max(0, size - bytes);
|
|
59
|
+
const buf = Buffer.alloc(size - start);
|
|
60
|
+
const n = readSync(fd, buf, 0, buf.length, start);
|
|
61
|
+
return buf.toString('utf-8', 0, n);
|
|
62
|
+
} catch {
|
|
63
|
+
return '';
|
|
64
|
+
} finally {
|
|
65
|
+
if (fd !== undefined) closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function latestSessionId(cwd, aroundTs = null, agyDir = AGY_DIR()) {
|
|
70
|
+
const text = tailBytes(join(agyDir, 'history.jsonl'));
|
|
71
|
+
if (!text) return null;
|
|
72
|
+
const lines = text.split('\n').filter(l => l.trim());
|
|
73
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
74
|
+
let entry;
|
|
75
|
+
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
76
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
77
|
+
if (!Object.values(entry).some(v => v === cwd)) continue;
|
|
78
|
+
const id = entry.conversation_id ?? entry.conversationId ?? entry.id;
|
|
79
|
+
if (typeof id === 'string' && id) return id;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default {
|
|
85
|
+
id: 'agy',
|
|
86
|
+
name: 'Antigravity CLI (Google)',
|
|
87
|
+
bin: process.env.UNSNOOZE_AGY_BIN || 'agy',
|
|
88
|
+
experimental: true,
|
|
89
|
+
patterns,
|
|
90
|
+
menu: null,
|
|
91
|
+
// `agy --conversation=<id>` resumes (printed by agy itself on exit);
|
|
92
|
+
// `--continue` reopens the most recent conversation (verified in agy --help).
|
|
93
|
+
resumeArgs(sessionId) {
|
|
94
|
+
return { args: sessionId ? [`--conversation=${sessionId}`] : ['--continue'], messageViaPane: true };
|
|
95
|
+
},
|
|
96
|
+
latestSessionId,
|
|
97
|
+
isForegroundCommand(cmd) {
|
|
98
|
+
return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
|
|
99
|
+
},
|
|
100
|
+
};
|
package/src/agents/codex.js
CHANGED
|
@@ -10,10 +10,30 @@
|
|
|
10
10
|
// Transient errors render "stream error: … retrying 4/5 in 1.4s" and must take
|
|
11
11
|
// the overload path, never the ledger.
|
|
12
12
|
|
|
13
|
-
import { openSync, readSync, closeSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { CODEX_DIR } from '../config.js';
|
|
16
16
|
|
|
17
|
+
// Since the 2026 unified ChatGPT desktop app absorbed the Codex app, the codex
|
|
18
|
+
// binary ships INSIDE the app bundle and many machines have no standalone
|
|
19
|
+
// `codex` on PATH at all — yet ~/.codex/sessions rollouts (and the resume
|
|
20
|
+
// command) work identically through the bundled binary (verified live against
|
|
21
|
+
// codex-cli 0.144 from ChatGPT.app).
|
|
22
|
+
export const CHATGPT_CODEX_BIN = '/Applications/ChatGPT.app/Contents/Resources/codex';
|
|
23
|
+
|
|
24
|
+
function codexOnPath(env = process.env) {
|
|
25
|
+
return (env.PATH || '').split(':').some(dir => {
|
|
26
|
+
try { return dir && existsSync(join(dir, 'codex')); } catch { return false; }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveCodexBin({ env = process.env, onPath = () => codexOnPath(env), exists = existsSync } = {}) {
|
|
31
|
+
if (env.UNSNOOZE_CODEX_BIN) return env.UNSNOOZE_CODEX_BIN;
|
|
32
|
+
if (onPath()) return 'codex';
|
|
33
|
+
if (exists(CHATGPT_CODEX_BIN)) return CHATGPT_CODEX_BIN;
|
|
34
|
+
return 'codex'; // neither — the launcher degrades gracefully on spawn error
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
const LIMIT_ANCHORS = [
|
|
18
38
|
/You've hit your usage limit/i,
|
|
19
39
|
/Your workspace is out of credits/i,
|
|
@@ -91,7 +111,7 @@ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_
|
|
|
91
111
|
export default {
|
|
92
112
|
id: 'codex',
|
|
93
113
|
name: 'OpenAI Codex CLI',
|
|
94
|
-
bin:
|
|
114
|
+
bin: resolveCodexBin(),
|
|
95
115
|
experimental: false,
|
|
96
116
|
patterns,
|
|
97
117
|
menu: null, // no interactive limit menu
|
package/src/agents/index.js
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
import claude from './claude.js';
|
|
5
5
|
import codex from './codex.js';
|
|
6
6
|
import grok from './grok.js';
|
|
7
|
+
import qwen from './qwen.js';
|
|
8
|
+
import kimi from './kimi.js';
|
|
9
|
+
import opencode from './opencode.js';
|
|
10
|
+
import agy from './agy.js';
|
|
7
11
|
|
|
8
|
-
const REGISTRY = { claude, codex, grok };
|
|
12
|
+
const REGISTRY = { claude, codex, grok, qwen, kimi, opencode, agy };
|
|
9
13
|
|
|
10
14
|
export function getAgent(id) {
|
|
11
15
|
return REGISTRY[id] || claude;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Kimi CLI adapter (MoonshotAI/kimi-cli, command `kimi`) — EXPERIMENTAL.
|
|
2
|
+
//
|
|
3
|
+
// Limits (researched 2026-07): rolling 5-hour window + weekly quota (7 days
|
|
4
|
+
// from subscription date); a concurrency throttle returns the SAME 429. The
|
|
5
|
+
// CLI retries 429/5xx three times within seconds, then stops with a red
|
|
6
|
+
// "LLM provider error: Error code: 429 - {...rate_limit_reached_error...}"
|
|
7
|
+
// line. The 429 body carries NO reset time → 5h fallback + verify loop.
|
|
8
|
+
// Known limitation: a weekly limit burns fallback attempts until the resumer
|
|
9
|
+
// gives up (a future resetProbe against GET api.kimi.com/coding/v1/usages
|
|
10
|
+
// would fix that).
|
|
11
|
+
//
|
|
12
|
+
// kimi-cli is Python today (pane_current_command may be python3); its
|
|
13
|
+
// kimi-code successor is TypeScript (node) with the same `kimi` command and
|
|
14
|
+
// auto-migrated ~/.kimi state — both are covered.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
const KIMI_DIR = () => process.env.UNSNOOZE_KIMI_DIR || join(homedir(), '.kimi');
|
|
22
|
+
|
|
23
|
+
const LIMIT_ANCHORS = [
|
|
24
|
+
/rate_limit_reached_error/i,
|
|
25
|
+
/LLM provider error:.*429/i,
|
|
26
|
+
/receiving too many requests at the moment/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const patterns = {
|
|
30
|
+
limitPatterns: LIMIT_ANCHORS,
|
|
31
|
+
// The 429 render is one line with no reset text — anchors double as reset
|
|
32
|
+
// lines and time-parser falls back to the 5h default.
|
|
33
|
+
resetPatterns: [...LIMIT_ANCHORS],
|
|
34
|
+
weeklyPatterns: [],
|
|
35
|
+
fiveHourPatterns: [],
|
|
36
|
+
// Fast in-CLI retries ("Retrying after rate limit · attempt 2/3 · 1.2s")
|
|
37
|
+
// resolve within seconds — never inject keys during them.
|
|
38
|
+
busyPatterns: [
|
|
39
|
+
/Retrying after rate limit/i,
|
|
40
|
+
/attempt \d+\/\d+/i,
|
|
41
|
+
/esc to interrupt/i,
|
|
42
|
+
],
|
|
43
|
+
idleRegex: /[›❯>]/,
|
|
44
|
+
overloadPatterns: [/LLM provider error:.*5\d\d/i, /Retrying after rate limit/i],
|
|
45
|
+
transientPatterns: [/Retrying after rate limit/i],
|
|
46
|
+
// 402: no reset exists — notify-only.
|
|
47
|
+
terminalPatterns: [/Membership expired/i],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
51
|
+
|
|
52
|
+
// Sessions live in ~/.kimi/sessions/<md5(cwd)>/<uuid>/.
|
|
53
|
+
function sessionsDirFor(cwd, kimiDir) {
|
|
54
|
+
return join(kimiDir, 'sessions', createHash('md5').update(cwd).digest('hex'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sessionExists(sessionId, kimiDir) {
|
|
58
|
+
let hashes;
|
|
59
|
+
try { hashes = readdirSync(join(kimiDir, 'sessions')); } catch { return false; }
|
|
60
|
+
return hashes.some(h => existsSync(join(kimiDir, 'sessions', h, sessionId)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ~/.kimi/kimi.json maps each workdir to its last-used session. The exact
|
|
64
|
+
// entry shape is undocumented — accept a bare id or any uuid-valued field.
|
|
65
|
+
function sessionFromKimiJson(cwd, kimiDir) {
|
|
66
|
+
try {
|
|
67
|
+
const meta = JSON.parse(readFileSync(join(kimiDir, 'kimi.json'), 'utf-8'));
|
|
68
|
+
const entry = meta?.work_dirs?.[cwd];
|
|
69
|
+
if (typeof entry === 'string' && UUID_RE.test(entry)) return entry;
|
|
70
|
+
if (entry && typeof entry === 'object') {
|
|
71
|
+
for (const v of Object.values(entry)) {
|
|
72
|
+
if (typeof v === 'string' && UUID_RE.test(v)) return v;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch { /* missing or unreadable — fall through */ }
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function latestSessionId(cwd, aroundTs = null, kimiDir = KIMI_DIR()) {
|
|
80
|
+
const fromMeta = sessionFromKimiJson(cwd, kimiDir);
|
|
81
|
+
if (fromMeta) return fromMeta;
|
|
82
|
+
|
|
83
|
+
const dir = sessionsDirFor(cwd, kimiDir);
|
|
84
|
+
let entries;
|
|
85
|
+
try { entries = readdirSync(dir); } catch { return null; }
|
|
86
|
+
let best = null;
|
|
87
|
+
for (const name of entries) {
|
|
88
|
+
if (!UUID_RE.test(name)) continue;
|
|
89
|
+
let mtime;
|
|
90
|
+
try { mtime = statSync(join(dir, name)).mtimeMs; } catch { continue; }
|
|
91
|
+
if (aroundTs != null && Math.abs(mtime - aroundTs) > 30 * 60_000) continue;
|
|
92
|
+
if (!best || mtime > best.mtime) best = { id: name, mtime };
|
|
93
|
+
}
|
|
94
|
+
return best ? best.id : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
id: 'kimi',
|
|
99
|
+
name: 'Kimi CLI',
|
|
100
|
+
bin: process.env.UNSNOOZE_KIMI_BIN || 'kimi',
|
|
101
|
+
experimental: true,
|
|
102
|
+
patterns,
|
|
103
|
+
menu: null,
|
|
104
|
+
// `kimi -r <id> -p "<msg>"` carries the prompt in argv. Guard: a missing id
|
|
105
|
+
// makes kimi silently create a NEW session, so verify it exists on disk and
|
|
106
|
+
// otherwise use --continue (kimi scopes that to the cwd itself).
|
|
107
|
+
resumeArgs(sessionId, message) {
|
|
108
|
+
const valid = sessionId && sessionExists(sessionId, KIMI_DIR());
|
|
109
|
+
return {
|
|
110
|
+
args: valid ? ['-r', sessionId, '-p', message] : ['--continue', '-p', message],
|
|
111
|
+
messageViaPane: false,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
latestSessionId,
|
|
115
|
+
isForegroundCommand(cmd) {
|
|
116
|
+
// /i: macOS framework builds report "Python" (verified via tmux
|
|
117
|
+
// pane_current_command against the real pipx install).
|
|
118
|
+
return cmd === 'kimi' || cmd === 'node' || cmd === 'unsnooze' || /^python(\d(\.\d+)?)?$/i.test(cmd);
|
|
119
|
+
},
|
|
120
|
+
};
|