unsnooze 1.19.3 → 1.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.4 — 2026-09-30
4
+
5
+ Antigravity's new quota banner, and a shorter README.
6
+
7
+ ### Antigravity (`agy`)
8
+
9
+ - The current `Individual quota reached … Resets in 2h52m46s` banner is now
10
+ detected, including short countdowns (`45m10s`, `30s`) and multi-day ones
11
+ (`6d18h`, the weekly cap). A banner a narrow pane wrapped onto two rows is
12
+ read whole (#38).
13
+ - The countdown is dated from the prompt that hit the limit, not from when
14
+ unsnooze noticed the banner, so a late scrape no longer pushes the wake late.
15
+ - When a folder has more than one recent Antigravity conversation, unsnooze no
16
+ longer guesses which one stopped. It wakes the live pane, and uses
17
+ `--continue` if that pane is gone.
18
+
19
+ ### Docs
20
+
21
+ - The README is now a short overview. Per-agent details, herdr and Windows
22
+ notes, and the FAQ moved to the website, including a new
23
+ [Supported agents](https://unsnooze.dev/docs/agents/) page (#39).
24
+
3
25
  ## 1.19.3 — 2026-09-30
4
26
 
5
27
  Codex fixes from a single herdr report: stops that were missed, woken late,
package/README.md CHANGED
@@ -43,232 +43,46 @@ existing tool solves only a slice of it:
43
43
  | Survives laptop sleep & weekly-scale waits | ✅ epoch polling | partial | partial | n/a |
44
44
  | Settings + first-run wizard | ✅ | ❌ | ❌ | ❌ |
45
45
 
46
- ## Trust & security
47
-
48
- unsnooze is a **scheduler that presses your keys — not an auto-approver.** It waits
49
- for your usage limit to reset, then resumes the *same* session. It never changes how
50
- your agent handles permissions.
46
+ ## Quick start
51
47
 
52
- - **Types only after proving the pane is yours.** Every keystroke requires both
53
- *identity* (a tmux `@unsnooze_owner` stamp, else a lease = process id + birth
54
- time — a mismatch vetoes, because pane ids get recycled) and *liveness* (your
55
- agent is still running there). Pane closes require proven identity (idle
56
- threshold too for `resumed` panes). Ownership unprovable → it reopens a fresh
57
- session instead of typing.
58
- - **See before you trust: `unsnooze preview`.** A true dry-run — it prints exactly
59
- what would be typed, where, and why (or what's holding it back), and sends
60
- nothing. Preview shares its decision code with the real dispatcher, so it cannot
61
- drift from what dispatch actually does.
62
- - **Answers Claude's limit menu safely — never a blind Enter.** It locates the
63
- cursor and the **"Stop and wait for limit to reset"** option and computes the
64
- exact moves. Unreadable menu → it presses nothing. It will never select
65
- "Upgrade your plan." Toggle with `menuAutoAnswer`.
66
- - **No `--dangerously-skip-permissions`, no auto-trust, no auto-approve.** unsnooze
67
- never passes bypass flags, never presses "Yes, I trust this folder," and never
68
- touches MCP config. Whatever your agent does *after* resuming is governed by
69
- **its own** permission model — the same as if you'd typed the message yourself.
70
- - **Nearly zero network, zero telemetry.** One version check to
71
- `registry.npmjs.org` (nothing identifying; `updateCheck=false` turns it off), and
72
- push notifications only if *you* configure an ntfy topic. State stays local under
73
- `~/.unsnooze`.
74
- - **Reversible install.** The settings hook and rc-file wrappers are backed up
75
- first (`*.unsnooze-orig` pristine + `*.unsnooze-bak` rolling); `unsnooze
76
- uninstall` removes every change. Releases are published with npm provenance.
77
- - **Fleet adds no network surface.** No listening ports, no custom auth, no
78
- tokens. Transport is your own OpenSSH (`~/.ssh/config`, keys, agent);
79
- unsnooze never sets `StrictHostKeyChecking=no` and fails fast on unknown
80
- hosts instead of auto-trusting them.
81
- - **The remote is the authority.** Fleet resume only marks a session due —
82
- the remote daemon types under its own gates (foreground, idle, your
83
- configured message only). A compromised viewer cannot make a remote type
84
- anything else.
85
- - **Remote output is untrusted.** Everything a host returns is
86
- control-character-stripped and length-capped before it touches your
87
- terminal (ANSI-injection defense), and parsed fields are extracted —
88
- never merged — into fresh objects.
89
- - **Lock a key to unsnooze (optional, recommended for automation):**
90
- `command="unsnooze _remote",restrict ssh-ed25519 AAAA… unsnooze-fleet`
91
- in the remote `authorized_keys` confines that key to the fleet
92
- entrypoint — it can never open a shell.
93
-
94
- **Honest limits:** unsnooze *does* inject keystrokes into your live terminal on
95
- your behalf, and it does not sandbox your agent or defend against prompt
96
- injection / malicious repos — that's your agent's job. Full threat model, residual
97
- risks, and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
98
-
99
- ## Supported CLIs
100
-
101
- - **Claude Code** — dual-channel detection: the `StopFailure` hook (authoritative,
102
- carries `session_id`) plus multiplexer pane scraping for banners and the interactive
103
- limit menu (always answered with *"Stop and wait for limit to reset"*, never a
104
- blind Enter). Dead sessions revive via `claude --resume <id>`.
105
- - **OpenAI Codex CLI** — scrape-based (Codex fires no event on limits). Detects
106
- the exact `■ You've hit your usage limit …` banner strings from the Codex
107
- source, parses `try again at 3:51 PM` / `Feb 23rd, 2026 9:01 PM` /
108
- `in 4 days 20 hours 9 minutes`. Dead sessions revive via
109
- `codex resume <id> "<message>"` — the prompt travels in argv.
110
- - **Grok Build (xAI)** — ⚠️ *experimental*. Hook channel works (Grok reads
111
- Claude-compatible hooks, including `StopFailure`); the limit banner text is
112
- not publicly documented, so detection uses generic patterns with a safe
113
- fallback. Hit a banner unsnooze missed? Run `unsnooze report` and paste the
114
- capture into an issue — that's how this adapter gets good.
115
- - **Qwen Code** — ⚠️ *experimental*. Dual-channel: a Claude-shaped `StopFailure`
116
- hook installed into `~/.qwen/settings.json` (fires with `error: rate_limit`)
117
- plus scraping for the verbatim quota renders (`Qwen OAuth quota exceeded`,
118
- Coding Plan `Allocated quota exceeded`, and OpenRouter `Rate limit exceeded:
119
- limit_…` passthroughs). Qwen never shows a reset time, so waits use the
120
- 5-hour fallback and self-correct on verify. Dead sessions revive via
121
- `qwen --resume <id>` (session ids come from the `*.runtime.json` sidecars
122
- qwen writes for exactly this purpose).
123
- - **Kimi CLI (Moonshot)** — ⚠️ *experimental*. Scrape-based: kimi retries a 429
124
- three times within seconds, then stops with a red `LLM provider error: Error
125
- code: 429 … rate_limit_reached_error` line — that's the detection anchor.
126
- The 429 body carries no reset time (5h fallback + verify). Dead sessions
127
- revive via `kimi -r <id> -p "<message>"` — with a guard: kimi silently starts
128
- a *new* session for unknown ids, so the id is verified on disk first
129
- (`--continue` otherwise). `Membership expired` (402) is notify-only.
130
- - **OpenCode** — ⚠️ *experimental*, and a different shape: OpenCode *retries
131
- rate limits itself, forever*, honoring `retry-after` (it will sleep hours
132
- until the reset, showing `Rate Limited [retrying in 2h5m attempt #4]`). So
133
- unsnooze records the stop but never touches a live self-retrying pane; its
134
- job is reviving sessions whose process died mid-wait (laptop slept, tmux
135
- gone) via `opencode -s <ses_id>`, with the reset time parsed straight from
136
- the banner countdown. Zen plan banners (`5 hour/weekly/monthly usage limit
137
- reached…`) and OpenRouter passthroughs are detected too; `insufficient
138
- credits` (402) is notify-only.
139
- - **Antigravity CLI (Google, `agy`)** — ⚠️ *experimental*. The Gemini-CLI
140
- successor. Scrapes the forum-reported quota strings (`Model quota limit
141
- exceeded`, `Refreshes in 6 days and 18 hours` — parsed, multi-day refresh =
142
- the weekly cap) and treats `503 MODEL_CAPACITY_EXHAUSTED` as a transient
143
- overload, not a limit. Dead sessions revive via `agy --conversation=<id>`
144
- (ids from `~/.gemini/antigravity-cli/history.jsonl`). Like Grok: closed
145
- source, so `unsnooze report` captures make this adapter better.
146
- - **Cursor CLI (`cursor-agent`)** — ⚠️ *experimental*, and the only adapter
147
- whose limit is **not waitable**. Cursor's included usage resets on your
148
- monthly *billing cycle*, not a rolling window, so unsnooze deliberately never
149
- schedules a wake for it: the banner is classified as a model limit, which
150
- probes every 15/30/60 min and resumes the moment it clears (you switch to
151
- Auto, enable on-demand, or the cycle rolls) instead of sleeping for weeks.
152
- The banner text is server-provided — the CLI renders `Error: <title>` plus a
153
- detail line and `key: value` extras — and the patterns are pinned against a
154
- real captured limit (whose own reset date was a month out, which is the point).
155
- `unsnooze report cursor` is how the rest gets better. Transport errors take the transient-overload ladder and
156
- `Authentication required` is notify-only. Dead sessions revive via
157
- `cursor-agent --resume=<id>` — ids come from `~/.cursor/chats/<md5 of the
158
- cwd>/<chatId>/meta.json`, which records the `cwd` so every candidate is
159
- verified before use — and `--continue` otherwise, which is workspace-scoped.
160
-
161
- The wrapper shadows **`cursor-agent`** only. The bare `cursor` command is the
162
- IDE launcher (`cursor .`) and is never touched; the newer `agent` alias is
163
- left alone too, being too generic a name to shadow safely.
164
-
165
- **OpenRouter** (the API gateway) isn't a separate agent: its 429 bodies
166
- (`Rate limit exceeded: limit_rpd/…`, free-models-per-day) are detected inside
167
- the CLIs that use it (OpenCode, Qwen Code), and credit exhaustion (402) is
168
- surfaced as a notification — there's no reset to wait for, only a top-up.
169
-
170
- ### Proxy launchers such as Headroom
171
-
172
- `headroom wrap claude` and `headroom wrap codex` resolve and launch the real
173
- agent executable directly. That bypasses Unsnooze's shell-function launcher,
174
- so no same-pane monitor is attached. Claude's installed `StopFailure` hook can
175
- still record stops, as can the transcript/rollout watcher while the daemon,
176
- `guiWatch`, and that agent are enabled. For full pane monitoring too, configure
177
- Headroom's provider-scope persistent routing, then start `claude` / `codex`
178
- normally so Unsnooze remains the outer launcher. With Headroom v0.34, one
179
- explicit setup is:
180
-
181
- ```bash
182
- headroom install apply --scope provider --providers manual --target claude --target codex
48
+ ```sh
49
+ npm install -g unsnooze
50
+ unsnooze setup # pick agents; installs shell wrappers + the Claude hook
183
51
  ```
184
52
 
185
- ## Claude Design
186
-
187
- Claude Design shares your usual 5-hour and weekly limits with chat, Cowork and
188
- Claude Code — so a long design run stops the same way everything else does, and
189
- unsnooze resumes it the same way. What it needs first is a terminal to run in.
190
-
191
- Claude Design has three surfaces. The canvas at `claude.ai/design` and the
192
- Claude Desktop sidebar are web apps. The third is an official MCP server that
193
- Claude Code drives from your terminal, and that is the one unsnooze supports:
53
+ Then use `claude`, `codex` and the rest exactly as before — the wrappers run them
54
+ in a watched pane. When one hits its limit, unsnooze records the stop and wakes it
55
+ the moment the limit resets.
194
56
 
195
57
  ```sh
196
- unsnooze design setup # registers the claude-design MCP server
197
- # then, inside Claude Code:
198
- /design-login
199
- unsnooze design # confirms it is registered and signed in
58
+ unsnooze status # tracked sessions + reset countdowns (live dashboard on a TTY)
59
+ unsnooze preview [id] # dry-run: what would be typed, where, and why — sends nothing
60
+ unsnooze resume-now [id|--all]
61
+ unsnooze usage # burn rate and time-to-limit, before you hit the wall
62
+ unsnooze doctor [--fix] # install health check
200
63
  ```
201
64
 
202
- For long design runs, give the session room to compact rather than stall:
65
+ Needs Node ≥ 20.12 on macOS, Linux or Windows. tmux ≥ 3.2, Zellij, herdr ≥ 0.8
66
+ or cmux give pane-level watching; without one, unsnooze runs headless and still
67
+ catches and resumes stops. Every command: [command reference](https://unsnooze.dev/docs/commands/).
203
68
 
204
- ```sh
205
- unsnooze config set launchExtraArgs.claude "--autocompact 400000"
206
- ```
69
+ ## Supported agents
207
70
 
208
- **unsnooze does not automate the web canvas, and will not.** Anthropic's
209
- Consumer Terms bar accessing Claude "through automated or non-human means,
210
- whether through a bot, script, or otherwise", and accounts have been terminated
211
- for it. Claude Code is the documented exemption — which is exactly what
212
- unsnooze drives. A browser-driving "Design adapter" is not a missing feature
213
- here; it is one this project declines.
214
-
215
- Two things worth knowing:
216
-
217
- - A signed-out `/design-login` is **not** a usage limit. Waiting will never
218
- clear it, so unsnooze reports it separately instead of letting it look like a
219
- stop that will resolve itself.
220
- - Design used to have its own weekly allowance. It does not now — everything
221
- draws from one shared pool, which is why `unsnooze usage` already accounts
222
- for design work without knowing it is design work.
223
-
224
- ## GUI surfaces (VS Code extension, desktop apps)
225
-
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
228
- **Claude Code's VS Code extension / desktop app** and **Codex's IDE
229
- extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
230
- the session files those surfaces already write:
231
-
232
- - **Claude Code** records every rate-limit stop as a structured entry in its
233
- `~/.claude/projects/**.jsonl` transcript (session id, cwd, reset time) —
234
- shared by the CLI and the VS Code extension.
235
- - **Codex** writes a `rate_limits` snapshot (usage %, **exact epoch reset
236
- time**) into every rollout under `~/.codex/sessions/` — shared by the CLI,
237
- IDE extension, and the **unified ChatGPT desktop app** (July 2026: the Codex
238
- app became the ChatGPT app; its bundled `codex app-server` writes the same
239
- rollouts to the same store — verified against a real install). On machines
240
- where Codex lives only inside ChatGPT.app (no `codex` on PATH), unsnooze
241
- automatically resumes through the app-bundled binary
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.
256
- - **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
257
- sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
258
- those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
259
- (plus the keychain-scope override that keeps auth working — verified
260
- against a real desktop session).
261
-
262
- When the limit resets, the session is revived **in a multiplexer pane** with
263
- `claude --resume <id>` / `codex resume <id>` — it's the same session file, so
264
- the continued conversation stays visible in the GUI's own history. (Resuming
265
- *inside* the GUI panel isn't possible today: no extension/app exposes an
266
- IPC/URI that can send a prompt.)
267
-
268
- Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit;
269
- on Windows the daemon is started on demand instead — see [Windows](#windows)),
270
- or run `unsnooze install --daemon` / `unsnooze daemon` yourself. Turn it off
271
- anytime with `unsnooze config set guiWatch off`.
71
+ | Agent | Detection | Revival |
72
+ |---|---|---|
73
+ | **Claude Code** | `StopFailure` hook + pane + transcripts; answers the limit menu safely | `claude --resume <id>` |
74
+ | **Codex CLI** / ChatGPT app | pane banner + rollout files (exact reset time) | `codex resume <id>` |
75
+ | Grok Build *(experimental)* | hook + pane | `grok --resume <id>` |
76
+ | Qwen Code *(experimental)* | hook + pane | `qwen --resume <id>` |
77
+ | Kimi CLI *(experimental)* | pane | `kimi -r <id>` |
78
+ | OpenCode *(experimental)* | pane (it retries by itself; unsnooze revives it if it dies) | `opencode -s <id>` |
79
+ | Antigravity `agy` *(experimental)* | pane | `agy --conversation=<id>` |
80
+ | Cursor `cursor-agent` *(experimental)* | pane; billing-cycle limit, so it probes instead of scheduling | `cursor-agent --resume=<id>` |
81
+
82
+ GUI sessions (Claude Code's VS Code extension, the ChatGPT/Codex desktop app,
83
+ Claude desktop) are watched through the files they already write, by the optional
84
+ daemon. Per-agent details, OpenRouter, proxy launchers like Headroom, and Claude
85
+ Design: [supported agents](https://unsnooze.dev/docs/agents/).
272
86
 
273
87
  ## How it works
274
88
 
@@ -276,654 +90,55 @@ anytime with `unsnooze config set guiWatch off`.
276
90
  <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"/>
277
91
  </div>
278
92
 
279
- <details>
280
- <summary>text version</summary>
281
-
282
- ```
283
- claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in mux pane
284
- │
285
- ├─ per-pane monitor (scrapes for limit
286
- │ banners, drives Claude's limit menu,
287
- │ seconds-scale retry on 5xx/overload)
288
- │
289
- StopFailure hook (claude, grok) ──────────────┤
290
- ▼
291
- ~/.unsnooze/state.json
292
- { agent, sessionId, cwd, pane, resetAt, status }
293
- │
294
- ▼
295
- resumer daemon (singleton, epoch-polling —
296
- survives laptop sleep and weekly-scale waits)
297
- │
298
- ┌───────────────────┴────────────────────┐
299
- pane still alive? pane gone?
300
- send resume message into it new multiplexer pane,
301
- (only if the CLI is foreground `unsnooze _run <agent>
302
- and not mid-stream) --resume <id>`, verify
303
- ```
304
-
305
- </details>
306
-
307
- Detection uses agent hooks, saved session files, and terminal banners. Reset
308
- times come from the agent's saved timestamp or the banner text, parsed DST-safe.
309
- When no reset time is available, unsnooze probes on a backoff schedule. Every
310
- resume is verified afterwards (banner came back → reschedule from the fresh
311
- banner, capped at 5 attempts).
312
-
313
- ## Know the wall before you hit it
314
-
315
- Recovery (auto-resume) is only half the job. **Prevention** is knowing when the
316
- wall is coming — so you can `/compact`, pause, or switch models *before* a stop.
317
-
318
- ```sh
319
- unsnooze usage # burn rate, % used, time-to-limit
320
- unsnooze usage --json # stable machine shape (exit 2 past warn %)
321
- unsnooze usage --install-statusline # opt-in: exact Claude % via statusline
322
- unsnooze usage --uninstall-statusline # remove the shim, restore your statusLine
323
- ```
324
-
325
- ```text
326
- $ unsnooze usage
327
- unsnooze usage — account burn & time-to-limit (daemon: running · warnings at 80,95%)
328
-
329
- claude 5h [█████████████░░░░░░░] ~64% (calibrated from 4 stops)
330
- burn ~31k weighted tok/min over last 42 active min
331
- wall ~1h 10m at this pace · window resets 8:00 pm (absolute)
332
-
333
- codex 5h [███░░░░░░░░░░░░░░░░░] 5% used (exact)
334
- monthly [██░░░░░░░░░░░░░░░░░░] 5% used (exact) · resets Aug 11
335
- burn idle — no active burn
93
+ Stops are detected from agent hooks, saved session files and terminal banners,
94
+ and recorded in `~/.unsnooze/state.json` with the reset time. A single daemon
95
+ checks the clock every 30 seconds — so a laptop that slept through the reset still
96
+ wakes on time — then types your resume message into the live pane, or reopens the
97
+ session by id if its pane is gone. Every wake is verified; if the limit is still
98
+ there it reschedules, up to 5 attempts.
336
99
 
337
- Estimates are a lower bound: Claude quotas are account-pooled with claude.ai/Desktop.
338
- Exact Claude percentages available via: unsnooze usage --install-statusline
339
- ```
340
-
341
- | What you get | How |
342
- |---|---|
343
- | **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated — percentage unavailable)`. Never a bare %. |
344
- | **Account-wide burn** | Sums all active sessions **and** subagents (the limit is per-account, not per-pane). Idle gaps >5 min are excluded so a quiet hour doesn't hide a fast burn. |
345
- | **Time-to-wall** | ETA as a band (not a false-precision minute), cross-checked against known reset times. Weekly shown info-only — no unstable weekly ETA. |
346
- | **Pre-wall warnings** | Daemon notifies at `%` bands (`usageWarnAt`, default `80,95`) **and** time tiers (30 / 10 min). Deduped once per window. |
347
- | **`/compact` nudge only** | Warnings may *suggest* `/compact` so the eventual wake is cheap. unsnooze **never auto-types it** (same security rule as resume: types only your configured message). |
348
-
349
- **Provenance ladder** (why this isn't another guessed-quota dashboard):
350
-
351
- 1. **`(exact)`** — Codex when a recent local rollout contains `token_count.rate_limits` (`used_percent` + epoch reset). Claude only with the opt-in statusline shim (server-authoritative `rate_limits` from [Claude Code's statusline JSON](https://code.claude.com/docs/en/statusline)).
352
- 2. **`(calibrated from N stops)`** — Claude 5-hour token burn vs a ceiling learned from **your matching usable calibration samples**: a stop classified as `5h`, with a nonzero local token sample from the same model pool when known, otherwise an unpooled fallback sample. A stop can still be valid for revival without meeting those calibration requirements. Not plan presets, not circular P90-of-history.
353
- 3. **`(estimated — percentage unavailable)`** — used tokens + burn are shown when available, but there is no exact percentage (and a Claude 5-hour row has no matching usable calibration ceiling yet).
354
-
355
- **Honest limits:** Claude transcript sums are a **lower bound** — subscription quotas are account-pooled with claude.ai and the Desktop app. Without the statusline shim, Claude tops out at calibrated/estimated. Exact Claude % needs Pro/Max after the first API response of a session (absent after `/clear` and on API auth).
356
-
357
- ## Usage
358
-
359
- On an interactive TTY, `status` / `usage` / `sessions` open a **live dashboard**
360
- (`unsnooze dashboard`) with the brand logo (`❯` + rising `zzz`), tabs, live
361
- refresh until you press **`q`** — and mouse support: click tabs and rows,
362
- wheel to scroll (toggle with `m`; hold Shift — Option in iTerm2 — to select text). Pipes, `NO_COLOR`, `CI`, and `--json` stay plain.
363
-
364
- ```sh
365
- claude / codex / grok # normal usage — wrapped automatically
366
- unsnooze status # tracked sessions + reset countdowns
367
- unsnooze dashboard [tab] # live TUI (status|usage|sessions|doctor|logs|fleet|prompts)
368
- unsnooze usage [--json] # account burn & time-to-limit forecast
369
- unsnooze usage --install-statusline # opt-in exact Claude % (chains your statusLine)
370
- unsnooze resume-now [id|--all] # don't wait for the reset time
371
- unsnooze cancel [id|--all] # stop tracking a session
372
- unsnooze message <id> "text" # per-session wake message (--clear to reset)
373
- unsnooze preview [id] # dry-run: what WOULD happen right now, and why —
374
- # nothing is typed or opened
375
- unsnooze sessions # list unsnooze-owned mux sessions + panes
376
- unsnooze reap [--dry-run|--yes] # close finished panes / empty sessions (default dry-run)
377
- unsnooze doctor [--fix] # install health check + retire old csg leftovers
378
- unsnooze prompt add [--agent id] [--project path] [--at time|--now] "text"
379
- # queue a prompt for a NEW session (see Queued prompts)
380
- unsnooze prompt list [--json] # list queued prompts
381
- unsnooze prompt remove <id> # cancel a queued prompt
382
- unsnooze prompt clear # cancel all pending queued prompts
383
- unsnooze config list # settings (see below)
384
- unsnooze config set <k> <v> # e.g. autoResume off
385
- unsnooze logs [-f] # what unsnooze has been doing
386
- unsnooze update # update unsnooze itself
387
- unsnooze daemon # persistent GUI-session watcher (usually run
388
- # by launchd/systemd via `install --daemon`;
389
- # on Windows, started on demand)
390
- unsnooze report [agent] # capture a pane to report an undetected banner
391
- unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
392
- unsnooze help # full command list (also -h / --help)
393
- ```
394
-
395
- ## Queued prompts
396
-
397
- Queue a prompt now; unsnooze types it into a **brand-new** agent session — a
398
- fresh window in a project directory — once a usage limit clears (or at a time
399
- you choose). It's one-shot: each entry is delivered at most once.
400
-
401
- ```sh
402
- unsnooze prompt add "run the full test suite and fix any failures"
403
- # → interactive agent picker on a TTY; queued for next-reset in the cwd
404
-
405
- unsnooze prompt add --agent codex --project ~/code/api --now "ship the release"
406
- unsnooze prompt add --at "+2h30m" "rebase onto main" # relative duration
407
- unsnooze prompt add --at "9pm" "nightly cleanup pass" # next occurrence of a clock time
408
- unsnooze prompt add --at 1755000000 "..." # epoch (seconds or ms), or an ISO-8601 timestamp
409
-
410
- unsnooze prompt list [--json] # id, agent, due, status, cwd, prompt preview
411
- unsnooze prompt remove <id> # cancel one pending/launching entry
412
- unsnooze prompt clear # cancel every pending/launching entry
413
- ```
414
-
415
- Modes:
416
-
417
- - **next-reset** (default) — delivered once no future reset time is known for
418
- that agent, i.e. once the current limit clears. If unsnooze holds **no
419
- reset signal at all** for that agent when you add the entry, there's
420
- nothing to wait on — it delivers on the very next daemon tick, and
421
- `prompt add` prints a notice to that effect right away. Use `--at` if you
422
- want a specific time instead.
423
- - `--now` — deliver on the next daemon tick, no reset wait at all.
424
- - `--at <time>` — deliver at a specific time: epoch (seconds or
425
- milliseconds), an ISO-8601 timestamp, a `+2h30m`-style relative duration,
426
- or a bare clock time (`9pm`, `2:05pm`, `14:30`) rolled to its next local
427
- occurrence.
428
-
429
- If a delivery attempt lands on a pane that's still limited — the reset
430
- hadn't actually cleared, or a fresh `--now`/`--at` session hits the wall
431
- immediately — the entry goes back to pending behind a backoff floor before
432
- it's retried. That floor applies to every mode, so a failing `--now`/`--at`
433
- entry can't burn through every retry in the first few seconds the way an
434
- unthrottled retry loop would. Verified delivery (capped at 5 attempts,
435
- same as resume) marks it `failed` and sends a notification.
436
-
437
- `autoResume` does **not** gate prompt delivery — the queue runs independently
438
- of session tracking, even with `autoResume off`.
439
-
440
- A far-future `--at` entry is expected to keep a transient resumer process
441
- alive (checking in each tick) until its delivery time arrives, however long
442
- that is — this is intended, not a leak.
443
-
444
- **Fleet:** `--host <name>` queues on a registered host instead of locally.
445
- `--project` (an absolute remote path) and `--agent` are both required — there's
446
- no local cwd to default to and no interactive picker over an ssh round-trip.
447
- The remote host re-validates everything server-side. Delivery feedback comes
448
- from `unsnooze fleet` / the dashboard's Fleet tab (per-host queued count) and
449
- that host's own notifications (ntfy reaches your phone from any host, not
450
- just the one you're sitting at). A host can refuse all queue traffic with
451
- `remoteQueue: false` (`unsnooze config set remoteQueue off`, env
452
- `UNSNOOZE_REMOTE_QUEUE=0`, **set on the host being controlled**) — the queue
453
- verbs then answer a typed "disabled" instead of silently dropping; a remote
454
- that predates this feature reports a clear "too old" error instead of
455
- failing silently.
456
-
457
- ```sh
458
- unsnooze prompt add --host gpu-box --project /home/me/repo --agent claude --now "..."
459
- unsnooze prompt list --host gpu-box
460
- ```
461
-
462
- **Dashboard:** the **Prompts** tab (`7`) lists queued entries; `a` opens an
463
- add form (path → agent → when — with a time prompt if you pick "at" — → a
464
- host step if you have hosts registered → prompt text), `d`/`x` removes the
465
- selected entry. The Status tab shows a `<n> prompt(s) queued — tab 7` hint
466
- whenever entries are pending or launching.
467
-
468
- ## Fleet: sessions on every machine
469
-
470
- See and act on unsnooze sessions on other machines from one terminal — over
471
- your own SSH, no new service to run.
472
-
473
- ```sh
474
- unsnooze hosts add work you@work-box.local # register an ssh destination
475
- unsnooze hosts list # registered hosts
476
- unsnooze hosts rm work # forget a host
477
- unsnooze fleet [--json] # every host's sessions, fanned out over ssh
478
- unsnooze dashboard fleet # live Fleet tab — R resumes a selected stopped session (C cancels)
479
- ```
480
-
481
- A registered host needs **unsnooze installed** and **ssh key auth** (no
482
- passwords) — nothing new to open or configure; fleet rides your existing
483
- `~/.ssh/config`, keys, and agent. **Before adding a host, connect to it the
484
- normal way once** (`ssh <host>`) so OpenSSH pins the host key itself —
485
- unsnooze never weakens host-key checking to skip that step, so an unknown or
486
- changed host fails fast instead of being silently trusted.
487
-
488
- Fleet is *see and mark*, not *type remotely*: `unsnooze fleet` and the
489
- dashboard's **Fleet** tab list every reachable host's tracked sessions
490
- (state, reset countdown, attach hint). Selecting a stopped remote session and
491
- pressing **`R`** (uppercase — lowercase `r` refreshes) only marks it due — the
492
- remote's own daemon does the actual keystrokes, under the same
493
- ownership/liveness/menu gates as a local session.
494
- Resumed and stopped sessions print an attach hint
495
- (`ssh -t <host> 'tmux new -A -s <session>'` / `zellij attach <session>` /
496
- `ssh -t <host> 'herdr session attach <session>'`) so
497
- you can hop over and watch. An unreachable or out-of-date host shows
498
- `unreachable`/`skew` rather than blocking the rest of the fleet, and falls
499
- back to its last-known state (marked `stale`) for up to 24h.
500
-
501
- ### Auth: keys (default) or password
502
-
503
- Every host picks its own auth, per host — unsnooze imposes no policy:
504
-
505
- - **`key`** (default, unchanged) — the BatchMode-hardened path above. Nothing
506
- about a key host changes.
507
- - **`password`, interactive** (`--source prompt`, the default source when you
508
- pass `--auth password` with nothing else) — typed at run time, no-echo, on
509
- whichever terminal is running `unsnooze fleet`/`hosts test`. Nothing is
510
- stored. There's no terminal under the background daemon, so a `prompt`
511
- host is naturally interactive-only — it shows `needs-auth` when the daemon
512
- or a piped/non-TTY caller tries it.
513
- - **`password`, stored** (`--source env|keychain|command`) — resolved
514
- automatically, so the daemon and `unsnooze fleet` can reach it unattended.
515
-
516
- ```sh
517
- unsnooze hosts add <name> <dest>
518
- [--auth key|password] # default: key
519
- [--source prompt|env|keychain|command] # default: prompt, once --auth password is set
520
- [--env <VARNAME>] # env source: var to read (default UNSNOOZE_PW_<NAME>)
521
- [--service <s> --account <a>] # keychain source (macOS only)
522
- [--cmd '<command>'] # command source: program whose stdout is the password
523
- unsnooze hosts test <name> # pre-flight: resolves the source (never prints the
524
- # secret) then a real ssh reachability probe
525
- ```
526
-
527
- Examples:
528
-
529
- ```sh
530
- unsnooze hosts add laptop me@laptop.local --auth password
531
- # → prompts for a password every time it's used from a real terminal
532
-
533
- unsnooze hosts add gpu ubuntu@gpu.example.com --auth password --source env --env UNSNOOZE_PW_GPU
534
- # → export UNSNOOZE_PW_GPU=... in your shell/session first
535
-
536
- unsnooze hosts add mac me@mac-mini.local --auth password --source keychain --service unsnooze-mac --account me
537
- # → macOS only; reads via `security find-generic-password`
538
-
539
- unsnooze hosts add ci ci@build.example.com --auth password --source command --cmd 'op read op://vault/ci/password'
540
- # → any OS; runs your own secret manager and reads its stdout
541
-
542
- unsnooze hosts test gpu # resolves the credential + probes reachability; prints "auth ok" or a hint, never the secret
543
- ```
544
-
545
- **`command` is the portable, first-class source** — plug in whatever secret
546
- manager you already run. Per-OS one-liners for `--cmd`:
547
-
548
- | OS | Example `--cmd` |
549
- |---|---|
550
- | macOS | `security find-generic-password -s <service> -a <account> -w` |
551
- | Linux | `pass show <path>` &nbsp;or&nbsp; `secret-tool lookup <attr> <value> ...` |
552
- | Windows | `powershell -Command "..."` (e.g. wrapping `Get-StoredCredential`) &nbsp;or&nbsp; `op read op://vault/item/password` |
553
- | any OS | `op read op://vault/item/password` (1Password CLI), or any other manager's read command |
554
-
555
- `keychain` is a convenience built-in for **macOS only** (`security
556
- find-generic-password`) — there's no dependable dependency-free store to
557
- shell out to on Windows or Linux, so those use `--source command` with the
558
- recipes above instead.
559
-
560
- **Security posture:** the password reaches `ssh` through OpenSSH's own
561
- `SSH_ASKPASS` hook — it never appears on `argv`, in `ps`, or in unsnooze's
562
- own environment; it flows helper-stdout → ssh, in-process, for that one ssh
563
- child only. unsnooze itself **stores no plaintext**: `keychain`/`command`
564
- delegate entirely to the OS store or your own manager, and `env` is
565
- user-managed (readable by your own shell already — positioned as a
566
- low-friction/CI convenience, not secret-manager-grade). Keys remain the
567
- default and their BatchMode-hardened path is untouched by any of this.
568
-
569
- **Windows note:** native `ssh.exe` (Windows' built-in OpenSSH client) prompts
570
- fine out of the box for an **interactive** `prompt`-source host — no shim
571
- needed, it's the zero-friction path. **Stored** sources (`env`/`keychain`/
572
- `command`) need a non-interactive askpass helper, and native `ssh.exe`
573
- requires that to be a real launchable `.exe` — it won't reliably run a `.js`
574
- or npm's `.cmd` shim. Until unsnooze ships that native launcher, use a
575
- **Git-for-Windows or WSL `ssh`** (both accept the script-based helper the
576
- same way macOS/Linux do — common on dev machines already) for stored
577
- password hosts on Windows; a plain `ssh <host>` prompt works with native
578
- `ssh.exe` regardless.
579
-
580
- ## Settings
581
-
582
- `unsnooze setup` writes `~/.unsnooze/config.json`; change anything later with
583
- `unsnooze config set`:
584
-
585
- | key | default | meaning |
586
- |---|---|---|
587
- | `multiplexer` | `auto` | Backend to use: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, or `headless`. `auto` prefers the current multiplexer (tmux/zellij ambient env, then `CMUX_SOCKET_PATH`); with no ambient env it falls back to whichever of tmux/zellij is installed, tmux as the tie-breaker — cmux is only selected via its ambient env or an explicit setting, never by this installed-backend fallback, since it has no attach-from-outside path to wrap into. cmux support covers resume-in-place (detect, capture, send); it has no joinable named-session model, so idle-session reaping (`unsnooze reap`) does not cover cmux panes, and no attach hint is shown for cmux sessions. `headless` is the no-multiplexer mode (see [Watching without a multiplexer](#watching-without-a-multiplexer)) — `auto` falls back to it only when no multiplexer is installed at all, never ahead of one. |
588
- | `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
589
- | `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
590
- | `notifications` | `true` | Master switch for all notifications (limit detected / session resumed / gave up). Off = silence every channel. |
591
- | `notifyChannel` | `auto` | How to deliver: `auto`, `native`, `osc`, or `bell` (see [Notification channels](#notification-channels)). Env: `UNSNOOZE_NOTIFY_CHANNEL`. |
592
- | `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
593
- | `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`. |
594
- | `resumeMessages.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
595
- | `resumeExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Extra flags for launches unsnooze performs itself. A revival spawns the agent binary directly, so flags you normally get from a shell alias or wrapper (`--dangerously-skip-permissions`, `--model …`) do not apply unless you put them here. Quoting is respected: `--append-system-prompt "stay in this repo"` is two arguments, not five. `config.json` may also hold an array (`["--append-system-prompt", "stay in this repo"]`), which skips parsing entirely. |
596
- | `launchExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Extra flags for the sessions **you** start through the shell wrapper — the launch-side twin of `resumeExtraArgs`. Use it for flags that have to hold for the whole session: `unsnooze config set launchExtraArgs.claude "--autocompact 400000"` keeps a long, context-heavy run compacting instead of stalling. Flags are placed before your own arguments, since `claude "do the thing"` puts a positional prompt there. Revivals inherit them too, so a flag survives the wake. |
597
- | `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
598
- | `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` / `agents.cursor` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
599
- | `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. |
600
- | `contextGuard` | `inform` | Big cold context at wake? Waking a session re-reads its **entire context at full uncached price** ([why](#why-did-resuming-a-big-session-eat-so-much-of-my-quota)). `inform` resumes and notifies you of the size; `pause` holds sessions above the threshold for `unsnooze resume-now`; `off` disables. Claude Code only for now. |
601
- | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
602
- | `usageWarn` | `notify` | Pre-wall usage warnings from the daemon (`unsnooze usage`): `notify` or `off`. Needs `notifications` on and the daemon running. Env: `UNSNOOZE_USAGE_WARN`. |
603
- | `usageWarnAt` | `80,95` | Percent thresholds for usage warnings (comma-separated). Non-numeric values fall back to the default — never silently disable. Time tiers (30 / 10 min) are constants, not config keys. Env: `UNSNOOZE_USAGE_WARN_AT`. |
604
- | `mouse` | `true` | Mouse support in `unsnooze dashboard`: click tabs and session rows, wheel-scroll lists/logs, clickable footer hints. Toggle live with `m`. Hold Shift (Option in iTerm2) to select text while it's on. Env: `UNSNOOZE_MOUSE`. |
605
- | `reapResumed` | `false` | Opt-in: auto-close `resumed` panes idle longer than `reapIdleAfter`. Off by default — use `unsnooze reap --yes` for explicit cleanup. |
606
- | `reapIdleAfter` | `604800000` (7d) | Idle age (ms) before an opt-in auto-reap closes a `resumed` pane. |
607
- | `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. |
608
- | `ntfyTopic` | `""` | [ntfy](https://ntfy.sh) push notifications — **off until set**. Fires *alongside* the local channel on limit-hit / resumed / gave-up. ⚠️ ntfy.sh topics are public — the name is the password. Use an unguessable one, e.g. `unsnooze-$(openssl rand -hex 8)`, a token, or a self-hosted server. |
609
- | `ntfyServer` | `https://ntfy.sh` | ntfy server base URL (self-hosted servers welcome). |
610
- | `ntfyToken` | `""` | Optional `tk_…` access token (`Authorization: Bearer`) for reserved topics / authed servers. |
611
- | `ntfyPrivacy` | `full` | `terse` keeps directory paths out of pushed bodies (titles only) — recommended on public ntfy.sh topics. |
612
- | `remoteQueue` | `true` | Set **on the host being controlled**: may other hosts queue prompts on this one (`unsnooze prompt add --host <this>`)? Off = the queue verbs answer a typed `disabled` instead of silently dropping. Env: `UNSNOOZE_REMOTE_QUEUE`. |
613
-
614
- Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
615
- all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
616
-
617
- ### Multiplexer session names
618
-
619
- Interactive launches own the base session name (default **`unsnooze`**); a second
620
- concurrent terminal takes `unsnooze-2`, etc. The resumer daemon may **join** a
621
- live session but only ever **creates** `unsnooze-resumed`, so a revived agent
622
- never steals the interactive name.
623
-
624
- | env | default | meaning |
625
- |---|---|---|
626
- | `UNSNOOZE_SESSION_NAME` | `unsnooze` | Interactive base session name (also accepts legacy `UNSNOOZE_TMUX_SESSION`). |
627
- | `UNSNOOZE_RESUME_SESSION` | `<base>-resumed` | Session the daemon creates when the pane's original session is gone. |
628
-
629
- Find revived agents with `unsnooze status` (prints an attach hint when the
630
- session is live), `unsnooze sessions`, or e.g. `tmux attach -t unsnooze-resumed`.
631
-
632
- ### Notification channels
633
-
634
- When a limit is hit, a session resumes, or unsnooze gives up, it can alert you
635
- via an OS toast, a terminal OSC sequence, a BEL, or a mix — controlled by
636
- `notifyChannel` (default `auto`). `notifications=false` (or
637
- `UNSNOOZE_NOTIFICATIONS=off`) turns every channel off.
638
-
639
- | channel | what it does |
640
- |---|---|
641
- | `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. |
642
- | `native` | OS toast (macOS `osascript`, Linux `notify-send`, WSL/Windows PowerShell toast); tmux `display-message` as a last-resort statusline fallback on other platforms. |
643
- | `osc` | Force OSC to attached client ttys; native if zero deliveries. |
644
- | `bell` | BEL to the pane tty; native if undeliverable. |
645
-
646
- **Terminal support (OSC)** — detection uses `TERM_PROGRAM` / termname / known
647
- env markers:
648
-
649
- | terminals | sequence |
650
- |---|---|
651
- | iTerm2, kitty, WezTerm, Ghostty, Warp | OSC 9 (`\x1b]9;title: body\x07`) |
652
- | rxvt / urxvt | OSC 777 (`\x1b]777;notify;title;body\x07`) |
653
- | Apple Terminal, VS Code, Alacritty, Zed | denylisted — OSC skipped (prefer `native`; force `osc` does not unlock these) |
654
- | unknown | skipped in `auto`; OSC 9 when `notifyChannel=osc` |
655
-
656
- **tmux only for OSC/BEL.** Those paths write to the client's tty (OSC) or the
657
- pane's tty (BEL), which requires tmux's client/pane tty APIs. **Zellij has no
658
- equivalent**, so OSC/BEL are not attempted under Zellij — notifications fall
659
- back to native (and Zellij has no statusline-inject equivalent either). GUI
660
- watcher stops (no pane context) always use native.
661
-
662
- ## Safety properties
663
-
664
- - **Never injects blind**: keys are only sent when the pane's foreground
665
- process is the agent CLI and no "esc to interrupt"-style busy footer is
666
- visible. Recycled pane ids can't receive stray messages.
667
- - **Never picks a menu option blind**: if Claude's limit menu layout can't be
668
- read, unsnooze does not press Enter (that could confirm "Upgrade your plan").
669
- - **Sleep-safe waits**: the resumer polls wall-clock against the target epoch
670
- every 30s instead of one long timer — a laptop asleep past the reset fires
671
- on the next tick. Weekly limits are just a bigger epoch.
672
- - **Verified resumes**: after dispatch it re-captures the pane; if the limit
673
- banner reappears (reset time misparsed / not actually reset), it reschedules
674
- from the fresh banner, capped at 5 attempts.
675
- - **Concurrent-writer safe**: state updates go through a mkdir lock + atomic
676
- rename; corrupt state is quarantined, never fatal (the hook path must never
677
- block the CLI).
678
- - **Overload ≠ limit**: 5xx/529/429 transient errors take a seconds-scale
679
- backoff path ([30,60,120,240,300]s ± jitter) and never enter the ledger.
680
- - **Stale-workspace guard**: the repo's HEAD + dirty state are fingerprinted
681
- when a session stops. If another session (or you) changed the repo before
682
- the wake, the resumed agent is told to re-read before acting — or, with
683
- `workspaceGuard=pause`, the session is held and `unsnooze resume-now`
684
- shows the diff first.
685
- - **Cold-cache wake guard**: waking a session after hours means the provider's
686
- prompt cache is long expired — the first message re-reads the entire
687
- context at full price, and a fat session can eat a real slice of the fresh
688
- quota window. unsnooze estimates the size from the session transcript
689
- (shown as `ctx ~152k tok` in `unsnooze status`) and, per `contextGuard`,
690
- notifies you or holds the session instead of spending it silently.
691
-
692
- ## Requirements
693
-
694
- - Node ≥ 20.12
695
- - macOS, Linux, or Windows — natively on all three (see
696
- [Watching without a multiplexer](#watching-without-a-multiplexer))
697
- - tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0 for pane-level watching. Optional:
698
- without one, unsnooze runs `headless` and still catches and resumes stops.
699
- - zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), fish (wrappers go
700
- into `~/.config/fish/config.fish`), or PowerShell
701
- (wrappers go into `$PROFILE.CurrentUserAllHosts`)
702
-
703
- Fish wrappers are installed when fish is the login shell or its config already
704
- exists. An absolute `XDG_CONFIG_HOME` changes the config location to
705
- `$XDG_CONFIG_HOME/fish/config.fish`; `unsnooze install --fishrc <path>` and
706
- `unsnooze uninstall --fishrc <path>` override it explicitly. After upgrading an
707
- existing install, run `unsnooze setup` and open a new shell to load the wrappers.
708
-
709
- Agent help/version commands, such as `claude --help` and `codex --version`, run
710
- directly without a multiplexer session, pane monitor, `launchExtraArgs`, or an
711
- unsnooze update notice. `unsnooze help` still shows unsnooze's own commands.
712
-
713
- ### Supported terminals
714
-
715
- unsnooze drives four terminal multiplexers, and works without one.
716
-
717
- | | [tmux](https://github.com/tmux/tmux) ≥ 3.2 | [Zellij](https://zellij.dev) | [herdr](https://herdr.dev) ≥ 0.8.0 | [cmux](https://cmux.dev) | `headless` |
718
- |---|:---:|:---:|:---:|:---:|:---:|
719
- | Detect a limit stop | pane + hook + transcript | pane + hook + transcript | pane + hook + transcript | pane + hook + transcript | hook + transcript |
720
- | Resume a live session in place | ✅ | ✅ | ✅ | ✅ | — |
721
- | Answer Claude's limit menu | ✅ | ✅ | ✅ | ✅ | — |
722
- | Revive a session whose pane died | ✅ | ✅ | ✅¹ | ✅² | ✅³ |
723
- | Wrap a bare `claude` into a managed session | ✅ | ✅ | ✅ | — | n/a⁴ |
724
- | `unsnooze reap` idle sessions | ✅ | ✅ | ✅ | — | — |
725
- | `attach:` hint in `unsnooze status` | ✅ | ✅ | ✅ | — | — |
726
- | OSC notifications to your terminal | ✅ | — | — | — | — |
727
-
728
- ¹ Into a fresh session name (`unsnooze-2`), never by restarting the stopped one —
729
- herdr restores saved agent panes itself, so a revival on top would resume the
730
- same conversation twice.
731
- ² Into a fresh workspace; cmux has no joinable named-session model.
732
- ³ As a detached process. Its output goes to `~/.unsnooze/headless/<session>.log`,
733
- since there is no scrollback to read it out of later; the prompt rides in argv
734
- (`claude --resume <id> "…"`, `codex exec resume <id> "…"`), and a revival that
735
- exits non-zero is retried, with its last output as the `last error` in
736
- `unsnooze status`.
737
- ⁴ Nothing to wrap into — your own terminal is the session.
738
-
739
- Pick one explicitly with `unsnooze config set multiplexer tmux|zellij|herdr|cmux|headless`.
740
- On `auto`, unsnooze uses the multiplexer you are currently inside; failing that,
741
- the only one installed (tmux breaks ties); failing that, `headless`.
742
-
743
- **Install:** `brew install tmux` or `brew install zellij` on macOS,
744
- `sudo apt install tmux` on Linux/WSL. herdr comes from
745
- [herdr.dev](https://herdr.dev) — Homebrew still ships 0.7.3, which `available()`
746
- refuses, so take the release binary.
747
-
748
- ### Watching without a multiplexer
749
-
750
- A terminal pane is only one of unsnooze's three detection channels. The Claude
751
- Code **StopFailure hook** and the **session transcript watcher** need no pane at
752
- all — which is what makes native Windows, bare servers and CI work.
753
-
754
- When no multiplexer is installed, `multiplexer: auto` resolves to `headless`:
755
-
756
- | | with tmux/Zellij/herdr | `headless` |
757
- |---|---|---|
758
- | detects limit stops | pane scrape + hook + transcript | hook + transcript |
759
- | resumes at reset | yes | yes |
760
- | answers Claude's limit menu | yes | no |
761
- | waits out a busy pane | yes | no |
762
- | revived session | live pane you can attach to | detached process, output in `~/.unsnooze/headless/` |
763
-
764
- `headless` is never chosen ahead of a real multiplexer — only when none is
765
- installed, or when you ask for it with
766
- `unsnooze config set multiplexer headless`. If you want the full behaviour on
767
- Windows, WSL is still the better home.
768
-
769
- ### Windows
770
-
771
- Native Windows works: PowerShell wrappers, a cmd-safe StopFailure hook, and a
772
- daemon started on demand. Install as usual and run `unsnooze doctor` to confirm.
773
-
774
- unsnooze registers nothing to run at sign-in on Windows — no Scheduled Task, Run
775
- key or Startup-folder entry. (Up to 1.19.1 it created a logon Scheduled Task,
776
- and Microsoft Defender flagged setup as `Trojan:Win32/Commando.A!ml` for it;
777
- `unsnooze uninstall` removes one left behind.)
778
- Setup starts the daemon instead, and every agent you launch from PowerShell and
779
- every Claude Code StopFailure hook starts it again whenever it is not running,
780
- so after a restart it is back as soon as you use an agent. If you only use the
781
- Codex app or IDE, nothing you do passes through a wrapper or the hook: run
782
- `unsnooze install --daemon` after signing in to start it.
783
-
784
- Three honest caveats. Nothing supervises the daemon the way launchd and systemd
785
- do, and a running daemon keeps the code it started with, so after upgrading
786
- unsnooze re-run `unsnooze install --daemon` (or sign out and back in). The
787
- daemon also keeps the `PATH` it was born with: an agent whose install directory
788
- changes on update (the Codex Desktop/Store runtime lives under
789
- `%LOCALAPPDATA%\OpenAI\Codex\bin\<hash>`) can vanish from the daemon's view
790
- while your shell still finds it — unsnooze looks that directory up at launch
791
- time, `unsnooze doctor` prints what each agent resolves to, and
792
- `UNSNOOZE_CODEX_BIN` (or `UNSNOOZE_CLAUDE_BIN`, …) pins a path; point it at an
793
- `.exe`, since Node cannot launch a `.cmd` shim directly. And stored-password
794
- fleet hosts still need Git-for-Windows or WSL `ssh`; native `ssh.exe` requires
795
- an `.exe` askpass helper unsnooze does not yet ship.
796
-
797
- WSL remains the richer option, because that is where the Unix multiplexers live
798
- — and where the agent CLIs often already are:
100
+ ## Trust & security
799
101
 
800
- ```sh
801
- # inside your WSL distro (Ubuntu etc.)
802
- sudo apt install tmux # or install Zellij: https://zellij.dev/documentation/installation
803
- # install herdr from https://herdr.dev (requires herdr >= 0.8.0)
804
- npm install -g unsnooze && unsnooze setup
805
- ```
102
+ unsnooze is a **scheduler that presses your keys — not an auto-approver.**
806
103
 
807
- On macOS, install either backend with `brew install tmux` or
808
- `brew install zellij`; install herdr from [herdr.dev](https://herdr.dev).
809
- herdr >= 0.8.0, which is the version the backend is verified against; older 0.x
810
- releases are refused by available(). Homebrew currently ships 0.7.3, so install
811
- the release binary from herdr.dev or GitHub rather than `brew install herdr`.
812
- In `auto` mode unsnooze uses the multiplexer you are currently inside; choose one
813
- explicitly with `unsnooze config set multiplexer tmux`, `zellij`, or `herdr`.
814
-
815
- #### herdr specifics
816
-
817
- herdr differs from tmux and Zellij in four ways that are visible in use:
818
-
819
- - **unsnooze never restarts a stopped herdr session.** herdr restores saved
820
- agent panes when a session restarts (`resume_agents_on_restore` is on by
821
- default), so restarting one can bring your agent back by itself — and a
822
- revival on top of that would resume the same conversation twice. Reviving
823
- into a stopped session therefore creates `unsnooze-2` (and so on) rather than
824
- reusing the name. If you restart a stopped session yourself and herdr brings
825
- the agent back, unsnooze may still open a fresh pane for it; that case is not
826
- yet detected.
827
- - **A revived session opens a tab, not a workspace.** A herdr workspace is the
828
- project — per repo, the way a tmux *session* is — while a tab is what tmux
829
- calls a window. So a revival opens a new tab in whichever workspace herdr
830
- already has open on that directory, and only creates a workspace when there
831
- is none. (A tab rather than a split: a revived agent draws a full-screen TUI,
832
- and halving the pane you are looking at would leave two terminals too narrow
833
- to use. Neither steals focus.)
834
- - **Multi-line resume messages are carried, not typed.** herdr starts a pane
835
- command by typing it into the pane's shell, where a newline would submit half
836
- a command and a tab would be a completion request. Any such argument is passed
837
- through the new tab's environment instead and referenced by the typed line, so
838
- a multi-paragraph `resumeMessage` reaches the agent as one argument, byte for
839
- byte. (A NUL byte is still refused — an environment value cannot hold one.)
840
- - **A custom `HERDR_SOCKET_PATH` that does not match the session being
841
- addressed disables watching for that pane.** herdr pane ids are per-server,
842
- so acting on one from the wrong server could type into an unrelated terminal.
843
- The agent still runs; unsnooze says why it is not watching.
844
-
845
- Everything works as on Linux, including desktop notifications: inside WSL,
846
- unsnooze raises **native Windows toasts** through `powershell.exe` (no
847
- `notify-send` or X server needed).
104
+ - **Types only after proving the pane is yours** — an ownership stamp or process
105
+ lease, plus your agent running and idle there. Unprovable → it opens a fresh
106
+ session instead of typing.
107
+ - **Never presses a menu option blind**, and never selects "Upgrade your plan".
108
+ - **No `--dangerously-skip-permissions`, no auto-trust, no auto-approve.** What
109
+ your agent does after the wake is governed by its own permission model.
110
+ - **No telemetry.** One daily version check to npm (`updateCheck=false` turns it
111
+ off) and ntfy push only if you set it up. State stays in `~/.unsnooze`.
112
+ - **Reversible.** Every file it edits is backed up; `unsnooze uninstall` removes
113
+ every change. Releases are published with npm provenance.
114
+ - **Fleet (multi-machine) uses your own SSH** — no ports, no tokens, no weakened
115
+ host-key checking.
116
+
117
+ It does inject keystrokes into your terminal, and it does not sandbox your agent.
118
+ Full threat model and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
848
119
 
849
120
  ## FAQ
850
121
 
851
- ### What does "You've hit your usage limit" mean in Claude Code?
852
-
853
- Claude subscription plans meter usage in a rolling 5-hour window plus a weekly
854
- cap. When either runs out, Claude Code stops mid-task and shows the banner with
855
- a reset time ("resets 3pm"). Nothing is lost — the session can be resumed with
856
- `claude --resume <id>` once the limit resets. unsnooze does exactly that,
857
- automatically, for every stopped session.
858
-
859
- ### What about Codex — "You've hit your usage limit. Try again at …"?
860
-
861
- Same idea: ChatGPT-plan Codex has 5-hour and weekly windows. The Codex TUI
862
- stays open at the composer after the banner, so unsnooze either types into the
863
- live pane or reopens the session with `codex resume <id>` at the reset time it
864
- parsed from the banner.
865
-
866
- ### Does this get around the rate limit?
867
-
868
- No. unsnooze waits for the reset exactly like you would, resumes once, and
869
- verifies the limit actually lifted. It replaces the 4am alarm, not the limit.
122
+ **What does "You've hit your usage limit" mean?** Claude and ChatGPT plans meter
123
+ usage in a rolling 5-hour window plus a weekly cap. When either runs out, the
124
+ agent stops mid-task. Nothing is lost — the session can be resumed after the
125
+ reset, and unsnooze does that for every stopped session automatically.
870
126
 
871
- ### How do I update, and how do I know when to?
127
+ **Does this get around the rate limit?** No. It waits for the reset exactly like
128
+ you would, resumes once, and checks the limit actually lifted. It replaces the
129
+ 4am alarm, not the limit.
872
130
 
873
- `unsnooze update` (or `npm i -g unsnooze`). unsnooze checks the npm registry at most once a day and
874
- tells you when a newer version exists (a line after CLI commands, plus one
875
- desktop notification per version); after updating, the next command shows a
876
- short "what's new" from the changelog. It's a plain registry GET with nothing
877
- identifying — turn it off with `unsnooze config set updateCheck off`.
131
+ More questions and troubleshooting: [unsnooze.dev/docs/troubleshooting](https://unsnooze.dev/docs/troubleshooting/).
878
132
 
879
- ### What if another session changed the repo while one was stopped?
880
-
881
- unsnooze fingerprints the workspace (HEAD + uncommitted state) at stop time
882
- and re-checks at wake. By default the session still resumes, but the wake
883
- message includes what changed ("HEAD abc1234 → def5678 — re-read before
884
- continuing"). Set `workspaceGuard` to `pause` to hold such sessions for a
885
- manual `unsnooze resume-now` (which prints the diff stat), or `off` to
886
- disable the check.
887
-
888
- ### Why did resuming a big session eat so much of my quota?
889
-
890
- Because of prompt-cache expiry, not because of unsnooze. Providers cache your
891
- session's context so each turn only pays full price for what's new — but that
892
- cache lives ~5 minutes. After hours stopped at a limit, the cache is long
893
- gone, so the **first** wake message (unsnooze's, or a hand-typed "continue" —
894
- identical cost either way; the wake message itself is ~30 tokens) makes the
895
- API re-read the entire conversation at full uncached price. A ~150k-token
896
- session on a top-tier model can cost a meaningful slice of a fresh 5-hour
897
- window the moment it wakes, before any new work happens.
898
-
899
- What actually helps: `/compact` (or starting a fresh session with a handoff
900
- summary) *before* you hit the limit, and keeping overnight sessions lean.
901
- What unsnooze does: the `contextGuard` setting estimates the context size
902
- from the session transcript before waking. `inform` (default) resumes and
903
- notifies you of the price; `pause` holds sessions above `contextGuardTokens`
904
- (default 100k) until you decide with `unsnooze resume-now`; the estimate is
905
- also visible per-session in `unsnooze status` (`ctx ~152k tok`).
906
-
907
- ### Does it work if my laptop was asleep or the terminal was closed?
908
-
909
- Yes — reset times are stored as absolute timestamps and checked every 30
910
- seconds, so a laptop that slept through the reset resumes on the next tick,
911
- and dead panes are reopened by session id in a fresh multiplexer pane.
912
-
913
- ## Development
914
-
915
- ```sh
916
- npm test # unit tests (node:test)
917
- ./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
918
- # scratch tmux session (no real limits needed)
919
- bash -n scripts/e2e-zellij.sh # syntax-check the reserved-session Zellij smoke test
920
- node scripts/e2e-herdr.mjs # drives src/multiplexers/herdr.js against a real herdr (not run by CI)
921
- vhs demo/demo.tape # regenerate assets/demo.gif (brew install vhs)
922
- ```
133
+ ## Documentation
923
134
 
924
- Releases are tagged (`git tag v<version> && git push origin v<version>`) and
925
- published to npm by CI with [provenance](https://docs.npmjs.com/generating-provenance-statements)
926
- via trusted publishing — see `.github/workflows/release.yml`.
135
+ - [Install & setup](https://unsnooze.dev/docs/) — terminals, multiplexers, Windows and WSL
136
+ - [Supported agents](https://unsnooze.dev/docs/agents/)
137
+ - [Commands](https://unsnooze.dev/docs/commands/) — usage forecast, queued prompts
138
+ - [Settings & guards](https://unsnooze.dev/docs/settings/) — every config key, notifications
139
+ - [Fleet](https://unsnooze.dev/docs/fleet/) — sessions on every machine over SSH, GUI surfaces
140
+ - [Troubleshooting & security](https://unsnooze.dev/docs/troubleshooting/) — including development setup
141
+ - [Changelog](CHANGELOG.md)
927
142
 
928
143
  ## License
929
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.3",
3
+ "version": "1.19.4",
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/agy.js CHANGED
@@ -18,23 +18,33 @@ import { homedir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
 
20
20
  const AGY_DIR = () => process.env.UNSNOOZE_AGY_DIR || join(homedir(), '.gemini', 'antigravity-cli');
21
+ const SESSION_RECENCY_MS = 5 * 60 * 60 * 1000;
21
22
 
22
23
  const LIMIT_ANCHORS = [
24
+ /Individual quota reached/i,
23
25
  /Model quota limit exceeded/i,
24
26
  /RESOURCE_EXHAUSTED/,
25
27
  /quota (?:limit )?exceeded/i,
26
28
  ];
27
29
 
28
30
  export const patterns = {
31
+ bannerWraps: true,
29
32
  limitPatterns: LIMIT_ANCHORS,
30
33
  // "Refreshes in 6 days and 18 hours" renders near the quota banner; anchors
31
34
  // double as reset lines for the API-key single-line renders.
32
35
  resetPatterns: [
33
- /Refreshes in/i,
36
+ /Refreshes\s+in/i,
37
+ /Resets\s+in/i,
38
+ /\b(?:Resets|Refreshes)(?:\s+in)?\s*$/i,
34
39
  ...LIMIT_ANCHORS,
35
40
  ],
36
- weeklyPatterns: [/Refreshes in \d+ days?/i], // multi-day refresh = the weekly cap
37
- fiveHourPatterns: [],
41
+ weeklyPatterns: [
42
+ /Refreshes\s+in\s+\d+\s+days?/i,
43
+ /(?:Refreshes|Resets)\s+in\s+\d+(?:\.\d+)?\s*(?:days?|d)(?![a-z])/i,
44
+ ],
45
+ fiveHourPatterns: [
46
+ /(?:Refreshes|Resets)\s+in\s+\d+(?:\.\d+)?\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])/i,
47
+ ],
38
48
  busyPatterns: [
39
49
  /esc to interrupt/i,
40
50
  /attempt \d+\/\d+/i,
@@ -47,9 +57,10 @@ export const patterns = {
47
57
 
48
58
  // ~/.gemini/antigravity-cli/history.jsonl indexes all conversations. Schema is
49
59
  // 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).
60
+ // tolerantly. A workspace can have multiple live conversations, so when the
61
+ // caller supplies a detection time, use an id only if exactly one conversation
62
+ // appears in recent history. Otherwise the live pane can still be resumed in
63
+ // place; if it has disappeared, the adapter falls back to --continue.
53
64
  function tailBytes(path, bytes = 64 * 1024) {
54
65
  let fd;
55
66
  try {
@@ -70,13 +81,49 @@ export function latestSessionId(cwd, aroundTs = null, agyDir = AGY_DIR()) {
70
81
  const text = tailBytes(join(agyDir, 'history.jsonl'));
71
82
  if (!text) return null;
72
83
  const lines = text.split('\n').filter(l => l.trim());
84
+ const recentIds = new Set();
85
+ let latestId = null;
86
+ let hasTimestamp = false;
73
87
  for (let i = lines.length - 1; i >= 0; i--) {
74
88
  let entry;
75
89
  try { entry = JSON.parse(lines[i]); } catch { continue; }
76
90
  if (!entry || typeof entry !== 'object') continue;
77
91
  if (!Object.values(entry).some(v => v === cwd)) continue;
78
92
  const id = entry.conversation_id ?? entry.conversationId ?? entry.id;
79
- if (typeof id === 'string' && id) return id;
93
+ if (typeof id !== 'string' || !id) continue;
94
+ latestId ||= id;
95
+ const timestamp = typeof entry.timestamp === 'number'
96
+ ? entry.timestamp
97
+ : typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
98
+ if (Number.isFinite(timestamp)) {
99
+ hasTimestamp = true;
100
+ if (Number.isFinite(aroundTs) && Math.abs(aroundTs - timestamp) <= SESSION_RECENCY_MS) {
101
+ recentIds.add(id);
102
+ }
103
+ }
104
+ }
105
+ if (!Number.isFinite(aroundTs) || !hasTimestamp) return latestId;
106
+ return recentIds.size === 1 ? recentIds.values().next().value : null;
107
+ }
108
+
109
+ export function latestBannerAt(cwd, aroundTs = null, agyDir = AGY_DIR()) {
110
+ const text = tailBytes(join(agyDir, 'history.jsonl'));
111
+ if (!text) return null;
112
+ const lines = text.split('\n').filter(l => l.trim());
113
+ for (let i = lines.length - 1; i >= 0; i--) {
114
+ let entry;
115
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
116
+ if (!entry || typeof entry !== 'object') continue;
117
+ if (!Object.values(entry).some(v => v === cwd)) continue;
118
+ const timestamp = typeof entry.timestamp === 'number'
119
+ ? entry.timestamp
120
+ : typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
121
+ if (Number.isFinite(timestamp)) {
122
+ if (!Number.isFinite(aroundTs)) return timestamp;
123
+ if (timestamp <= (aroundTs + 5000) && (aroundTs - timestamp) <= SESSION_RECENCY_MS) {
124
+ return timestamp;
125
+ }
126
+ }
80
127
  }
81
128
  return null;
82
129
  }
@@ -96,6 +143,7 @@ export default {
96
143
  // v1: every agent launches the bare TUI and gets the prompt typed once idle.
97
144
  launchArgs(message) { return { args: [], messageViaPane: true }; },
98
145
  latestSessionId,
146
+ latestBannerAt,
99
147
  isForegroundCommand(cmd) {
100
148
  return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
101
149
  },
package/src/monitor.js CHANGED
@@ -113,10 +113,13 @@ export function createMonitor({
113
113
  detectedAt,
114
114
  };
115
115
  }
116
+ const bannerAt = typeof agent.latestBannerAt === 'function'
117
+ ? agent.latestBannerAt(cwd, detectedAt)
118
+ : null;
116
119
  return {
117
120
  resetLine: paneResetLine,
118
121
  limitType: paneLimitType,
119
- bannerAt: null,
122
+ bannerAt,
120
123
  sessionId,
121
124
  via: null, // filled by caller (scrape/hook)
122
125
  detectedAt,
@@ -132,6 +135,7 @@ export function createMonitor({
132
135
  // "already past → due now" path, which is margin above wall clock only).
133
136
  function isCorroborated(resolved, at, source) {
134
137
  if (resolved.via === 'transcript' && resolved.bannerAt != null) return true;
138
+ if (resolved.bannerAt != null && at - RESET_MARGIN_MS > Date.now()) return true;
135
139
  if (source === 'absolute' && at - RESET_MARGIN_MS > Date.now()) return true;
136
140
  // A model limit carries no time to corroborate — its own remedy hint is
137
141
  // the evidence, and the record only ever probes (never blind-wakes), so
package/src/resumer.js CHANGED
@@ -342,6 +342,10 @@ export async function probeFallback(rec, {
342
342
  return null;
343
343
  }
344
344
 
345
+ if (!bannerAt && typeof agent.latestBannerAt === 'function') {
346
+ bannerAt = agent.latestBannerAt(rec.cwd, now);
347
+ }
348
+
345
349
  // Banner still present. If it now parses to a real time, upgrade off fallback.
346
350
  if (resetLine) {
347
351
  const { at, source } = resetAtMs(parseResetTime(resetLine), {
@@ -955,6 +959,8 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
955
959
  if (fromTx) {
956
960
  resetLine = fromTx.resetLine ?? resetLine;
957
961
  bannerAt = fromTx.timestampMs;
962
+ } else if (typeof agent.latestBannerAt === 'function') {
963
+ bannerAt = agent.latestBannerAt(rec.cwd, Date.now());
958
964
  }
959
965
  const { at, source } = resetAtMs(parseResetTime(resetLine), {
960
966
  marginMs: RESET_MARGIN_MS,