dorkhub

unsnooze

Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity when the 5-hour/weekly usage limi…

saaranshM
JavaScript684 forksMITupdated 5 days ago
visit the demogit clone https://github.com/saaranshM/unsnooze.gitsaaranshM/unsnooze
unsnooze — wakes every limit-stopped AI session the moment the limit resets

CI npm node license

unsnooze.combustortech.in — docs · changelog · feedback

Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity — when they hit the 5-hour or weekly usage limit ("You've hit your usage limit"), your session just… stops.
unsnooze auto-resumes them: it tracks every limit-stopped session across all your projects and wakes each one up in tmux or Zellij the moment the usage limit resets.

npm install -g unsnooze && unsnooze setup
terminal demo: limit banners detected in two sessions, unsnooze waits for the reset, then wakes both — good morning, the work is done

Why unsnooze

Overnight and long-running agent work dies at the 5-hour / weekly limit, and every existing tool solves only a slice of it:

unsnooze claude-auto-retry autoclaude hydra
Multi-CLI (Claude · Codex · Grok · Qwen · Kimi · OpenCode · Antigravity) ❌ Claude only ❌ Claude only partial
GUI sessions (VS Code ext, desktop apps) ✅ watcher daemon
Waits for reset & resumes the same session ❌ switches provider
All sessions at once (shared ledger + one daemon) ❌ one pane
Revives sessions whose pane/process is gone --resume <id>
Survives laptop sleep & weekly-scale waits ✅ epoch polling partial partial n/a
Settings + first-run wizard

Trust & security

unsnooze is a scheduler that presses your keys — not an auto-approver. It waits for your usage limit to reset, then resumes the same session. It never changes how your agent handles permissions.

  • Types only after proving the pane is yours. Every keystroke requires both identity (a tmux @unsnooze_owner stamp, else a lease = process id + birth time — a mismatch vetoes, because pane ids get recycled) and liveness (your agent is still running there). Pane closes require proven identity (idle threshold too for resumed panes). Ownership unprovable → it reopens a fresh session instead of typing.
  • See before you trust: unsnooze preview. A true dry-run — it prints exactly what would be typed, where, and why (or what's holding it back), and sends nothing. Preview shares its decision code with the real dispatcher, so it cannot drift from what dispatch actually does.
  • Answers Claude's limit menu safely — never a blind Enter. It locates the cursor and the "Stop and wait for limit to reset" option and computes the exact moves. Unreadable menu → it presses nothing. It will never select "Upgrade your plan." Toggle with menuAutoAnswer.
  • No --dangerously-skip-permissions, no auto-trust, no auto-approve. unsnooze never passes bypass flags, never presses "Yes, I trust this folder," and never touches MCP config. Whatever your agent does after resuming is governed by its own permission model — the same as if you'd typed the message yourself.
  • Nearly zero network, zero telemetry. One version check to registry.npmjs.org (nothing identifying; updateCheck=false turns it off), and push notifications only if you configure an ntfy topic. State stays local under ~/.unsnooze.
  • Reversible install. The settings hook and rc-file wrappers are backed up first (*.unsnooze-orig pristine + *.unsnooze-bak rolling); unsnooze uninstall removes every change. Releases are published with npm provenance.
  • Fleet adds no network surface. No listening ports, no custom auth, no tokens. Transport is your own OpenSSH (~/.ssh/config, keys, agent); unsnooze never sets StrictHostKeyChecking=no and fails fast on unknown hosts instead of auto-trusting them.
  • The remote is the authority. Fleet resume only marks a session due — the remote daemon types under its own gates (foreground, idle, your configured message only). A compromised viewer cannot make a remote type anything else.
  • Remote output is untrusted. Everything a host returns is control-character-stripped and length-capped before it touches your terminal (ANSI-injection defense), and parsed fields are extracted — never merged — into fresh objects.
  • Lock a key to unsnooze (optional, recommended for automation): command="unsnooze _remote",restrict ssh-ed25519 AAAA… unsnooze-fleet in the remote authorized_keys confines that key to the fleet entrypoint — it can never open a shell.

Honest limits: unsnooze does inject keystrokes into your live terminal on your behalf, and it does not sandbox your agent or defend against prompt injection / malicious repos — that's your agent's job. Full threat model, residual risks, and vulnerability reporting: SECURITY.md.

Supported CLIs

  • Claude Code — dual-channel detection: the StopFailure hook (authoritative, carries session_id) plus multiplexer pane scraping for banners and the interactive limit menu (always answered with "Stop and wait for limit to reset", never a blind Enter). Dead sessions revive via claude --resume <id>.
  • OpenAI Codex CLI — scrape-based (Codex fires no event on limits). Detects the exact ■ You've hit your usage limit … banner strings from the Codex source, parses try again at 3:51 PM / Feb 23rd, 2026 9:01 PM / in 4 days 20 hours 9 minutes. Dead sessions revive via codex resume <id> "<message>" — the prompt travels in argv.
  • Grok Build (xAI) — ⚠️ experimental. Hook channel works (Grok reads Claude-compatible hooks, including StopFailure); the limit banner text is not publicly documented, so detection uses generic patterns with a safe fallback. Hit a banner unsnooze missed? Run unsnooze report and paste the capture into an issue — that's how this adapter gets good.
  • Qwen Code — ⚠️ experimental. Dual-channel: a Claude-shaped StopFailure hook installed into ~/.qwen/settings.json (fires with error: rate_limit) plus scraping for the verbatim quota renders (Qwen OAuth quota exceeded, Coding Plan Allocated quota exceeded, and OpenRouter Rate limit exceeded: limit_… passthroughs). Qwen never shows a reset time, so waits use the 5-hour fallback and self-correct on verify. Dead sessions revive via qwen --resume <id> (session ids come from the *.runtime.json sidecars qwen writes for exactly this purpose).
  • Kimi CLI (Moonshot) — ⚠️ experimental. Scrape-based: kimi retries a 429 three times within seconds, then stops with a red LLM provider error: Error code: 429 … rate_limit_reached_error line — that's the detection anchor. The 429 body carries no reset time (5h fallback + verify). Dead sessions revive via kimi -r <id> -p "<message>" — with a guard: kimi silently starts a new session for unknown ids, so the id is verified on disk first (--continue otherwise). Membership expired (402) is notify-only.
  • OpenCode — ⚠️ experimental, and a different shape: OpenCode retries rate limits itself, forever, honoring retry-after (it will sleep hours until the reset, showing Rate Limited [retrying in 2h5m attempt #4]). So unsnooze records the stop but never touches a live self-retrying pane; its job is reviving sessions whose process died mid-wait (laptop slept, tmux gone) via opencode -s <ses_id>, with the reset time parsed straight from the banner countdown. Zen plan banners (5 hour/weekly/monthly usage limit reached…) and OpenRouter passthroughs are detected too; insufficient credits (402) is notify-only.
  • Antigravity CLI (Google, agy) — ⚠️ experimental. The Gemini-CLI successor. Scrapes the forum-reported quota strings (Model quota limit exceeded, Refreshes in 6 days and 18 hours — parsed, multi-day refresh = the weekly cap) and treats 503 MODEL_CAPACITY_EXHAUSTED as a transient overload, not a limit. Dead sessions revive via agy --conversation=<id> (ids from ~/.gemini/antigravity-cli/history.jsonl). Like Grok: closed source, so unsnooze report captures make this adapter better.

OpenRouter (the API gateway) isn't a separate agent: its 429 bodies (Rate limit exceeded: limit_rpd/…, free-models-per-day) are detected inside the CLIs that use it (OpenCode, Qwen Code), and credit exhaustion (402) is surfaced as a notification — there's no reset to wait for, only a top-up.

GUI surfaces (VS Code extension, desktop apps)

Terminal sessions are watched through the shell wrapper + tmux or Zellij. Sessions in Claude Code's VS Code extension / desktop app and Codex's IDE extension / desktop app have no pane to scrape — so unsnooze daemon tails the session files those surfaces already write:

  • Claude Code records every rate-limit stop as a structured entry in its ~/.claude/projects/**.jsonl transcript (session id, cwd, reset time) — shared by the CLI and the VS Code extension.
  • Codex writes a rate_limits snapshot (usage %, exact epoch reset time) into every rollout under ~/.codex/sessions/ — shared by the CLI, IDE extension, and the unified ChatGPT desktop app (July 2026: the Codex app became the ChatGPT app; its bundled codex app-server writes the same rollouts to the same store — verified against a real install). On machines where Codex lives only inside ChatGPT.app (no codex on PATH), unsnooze automatically resumes through the app-bundled binary (/Applications/ChatGPT.app/Contents/Resources/codex).
  • Claude desktop (cowork) sessions (experimental, macOS) run in sandboxes under ~/Library/Application Support/Claude; unsnooze watches those too and revives with the session's isolated CLAUDE_CONFIG_DIR (plus the keychain-scope override that keeps auth working — verified against a real desktop session).

When the limit resets, the session is revived in a multiplexer pane with claude --resume <id> / codex resume <id> — it's the same session file, so the continued conversation stays visible in the GUI's own history. (Resuming inside the GUI panel isn't possible today: no extension/app exposes an IPC/URI that can send a prompt.)

Enable it in unsnooze setup (installs a launchd agent / systemd user unit), or run unsnooze install --daemon / unsnooze daemon yourself. Turn it off anytime with unsnooze config set guiWatch off.

How it works

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
text version
claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in mux pane
                                              │
                                              ├─ per-pane monitor (scrapes for limit
                                              │  banners, drives Claude's limit menu,
                                              │  seconds-scale retry on 5xx/overload)
                                              │
StopFailure hook (claude, grok) ──────────────┤
                                              ▼
                                ~/.unsnooze/state.json
                                { agent, sessionId, cwd, pane, resetAt, status }
                                              │
                                              ▼
                                resumer daemon (singleton, epoch-polling —
                                survives laptop sleep and weekly-scale waits)
                                              │
                          ┌───────────────────┴────────────────────┐
                 pane still alive?                          pane gone?
                 send resume message into it                new multiplexer pane,
                 (only if the CLI is foreground             `unsnooze _run <agent>
                 and not mid-stream)                        --resume <id>`, verify

Limit events are never persisted by the CLIs themselves; the reset time is parsed from the banner text, DST-safe. Unparseable banners are never guessed at — unsnooze probes the pane on a backoff schedule until a real reset time appears. Every resume is verified afterwards (banner came back → reschedule from the fresh banner, capped at 5 attempts).

Know the wall before you hit it

Recovery (auto-resume) is only half the job. Prevention is knowing when the wall is coming — so you can /compact, pause, or switch models before a stop.

unsnooze usage                         # burn rate, % used, time-to-limit
unsnooze usage --json                  # stable machine shape (exit 2 past warn %)
unsnooze usage --install-statusline    # opt-in: exact Claude % via statusline
unsnooze usage --uninstall-statusline  # remove the shim, restore your statusLine
$ unsnooze usage
unsnooze usage — account burn & time-to-limit  (daemon: running · warnings at 80,95%)

  claude  5h      [█████████████░░░░░░░]  ~64%  (calibrated from 4 stops)
          burn    ~31k weighted tok/min over last 42 active min
          wall    ~1h 10m at this pace · window resets 8:00 pm (absolute)

  codex   5h      [███░░░░░░░░░░░░░░░░░]  5% used  (exact)
          monthly [██░░░░░░░░░░░░░░░░░░]  5% used  (exact) · resets Aug 11
          burn    idle — no active burn

  Estimates are a lower bound: Claude quotas are account-pooled with claude.ai/Desktop.
  Exact Claude percentages available via: unsnooze usage --install-statusline
What you get How
Honest labels Every figure is tagged (exact), (calibrated from N stops), or (estimated — calibrating…). Never a bare %.
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.
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.
Pre-wall warnings Daemon notifies at % bands (usageWarnAt, default 80,95) and time tiers (30 / 10 min). Deduped once per window.
/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).

Provenance ladder (why this isn't another guessed-quota dashboard):

  1. (exact) — Codex always (local used_percent + epoch reset). Claude only with the opt-in statusline shim (server-authoritative rate_limits from Claude Code's statusline JSON).
  2. (calibrated from N stops) — Claude token burn vs a ceiling learned from your limit-stop ledger (the same stops unsnooze already records for resume). Not plan presets, not circular P90-of-history.
  3. (estimated) — used tokens + burn shown; ceiling unknown until the first observed stop.

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).

Usage

On an interactive TTY, status / usage / sessions open a live dashboard (unsnooze dashboard) with the brand logo ( + rising zzz), tabs, live refresh until you press q — and mouse support: click tabs and rows, wheel to scroll (toggle with m; hold Shift — Option in iTerm2 — to select text). Pipes, NO_COLOR, CI, and --json stay plain.

claude / codex / grok                  # normal usage — wrapped automatically
unsnooze status                        # tracked sessions + reset countdowns
unsnooze dashboard [tab]               # live TUI (status|usage|sessions|doctor|logs|fleet|prompts)
unsnooze usage [--json]                # account burn & time-to-limit forecast
unsnooze usage --install-statusline    # opt-in exact Claude % (chains your statusLine)
unsnooze resume-now [id|--all]         # don't wait for the reset time
unsnooze cancel [id|--all]             # stop tracking a session
unsnooze message <id> "text"           # per-session wake message (--clear to reset)
unsnooze preview [id]                  # dry-run: what WOULD happen right now, and why —
                                       # nothing is typed or opened
unsnooze sessions                      # list unsnooze-owned mux sessions + panes
unsnooze reap [--dry-run|--yes]        # close finished panes / empty sessions (default dry-run)
unsnooze doctor [--fix]                # install health check + retire old csg leftovers
unsnooze prompt add [--agent id] [--project path] [--at time|--now] "text"
                                       # queue a prompt for a NEW session (see Queued prompts)
unsnooze prompt list [--json]          # list queued prompts
unsnooze prompt remove <id>            # cancel a queued prompt
unsnooze prompt clear                  # cancel all pending queued prompts
unsnooze config list                   # settings (see below)
unsnooze config set <k> <v>            # e.g. autoResume off
unsnooze logs [-f]                     # what unsnooze has been doing
unsnooze update                        # update unsnooze itself
unsnooze daemon                        # persistent GUI-session watcher (usually run
                                       # by launchd/systemd via `install --daemon`)
unsnooze report [agent]                # capture a pane to report an undetected banner
unsnooze uninstall [--purge]           # remove wrappers + hooks (+ state with --purge)
unsnooze help                          # full command list (also -h / --help)

Queued prompts

Queue a prompt now; unsnooze types it into a brand-new agent session — a fresh window in a project directory — once a usage limit clears (or at a time you choose). It's one-shot: each entry is delivered at most once.

unsnooze prompt add "run the full test suite and fix any failures"
# → interactive agent picker on a TTY; queued for next-reset in the cwd

unsnooze prompt add --agent codex --project ~/code/api --now "ship the release"
unsnooze prompt add --at "+2h30m" "rebase onto main"   # relative duration
unsnooze prompt add --at "9pm" "nightly cleanup pass"  # next occurrence of a clock time
unsnooze prompt add --at 1755000000 "..."              # epoch (seconds or ms), or an ISO-8601 timestamp

unsnooze prompt list [--json]   # id, agent, due, status, cwd, prompt preview
unsnooze prompt remove <id>     # cancel one pending/launching entry
unsnooze prompt clear           # cancel every pending/launching entry

Modes:

  • next-reset (default) — delivered once no future reset time is known for that agent, i.e. once the current limit clears. If unsnooze holds no reset signal at all for that agent when you add the entry, there's nothing to wait on — it delivers on the very next daemon tick, and prompt add prints a notice to that effect right away. Use --at if you want a specific time instead.
  • --now — deliver on the next daemon tick, no reset wait at all.
  • --at <time> — deliver at a specific time: epoch (seconds or milliseconds), an ISO-8601 timestamp, a +2h30m-style relative duration, or a bare clock time (9pm, 2:05pm, 14:30) rolled to its next local occurrence.

If a delivery attempt lands on a pane that's still limited — the reset hadn't actually cleared, or a fresh --now/--at session hits the wall immediately — the entry goes back to pending behind a backoff floor before it's retried. That floor applies to every mode, so a failing --now/--at entry can't burn through every retry in the first few seconds the way an unthrottled retry loop would. Verified delivery (capped at 5 attempts, same as resume) marks it failed and sends a notification.

autoResume does not gate prompt delivery — the queue runs independently of session tracking, even with autoResume off.

A far-future --at entry is expected to keep a transient resumer process alive (checking in each tick) until its delivery time arrives, however long that is — this is intended, not a leak.

Fleet: --host <name> queues on a registered host instead of locally. --project (an absolute remote path) and --agent are both required — there's no local cwd to default to and no interactive picker over an ssh round-trip. The remote host re-validates everything server-side. Delivery feedback comes from unsnooze fleet / the dashboard's Fleet tab (per-host queued count) and that host's own notifications (ntfy reaches your phone from any host, not just the one you're sitting at). A host can refuse all queue traffic with remoteQueue: false (unsnooze config set remoteQueue off, env UNSNOOZE_REMOTE_QUEUE=0, set on the host being controlled) — the queue verbs then answer a typed "disabled" instead of silently dropping; a remote that predates this feature reports a clear "too old" error instead of failing silently.

unsnooze prompt add --host gpu-box --project /home/me/repo --agent claude --now "..."
unsnooze prompt list --host gpu-box

Dashboard: the Prompts tab (7) lists queued entries; a opens an add form (path → agent → when — with a time prompt if you pick "at" — → a host step if you have hosts registered → prompt text), d/x removes the selected entry. The Status tab shows a <n> prompt(s) queued — tab 7 hint whenever entries are pending or launching.

Fleet: sessions on every machine

See and act on unsnooze sessions on other machines from one terminal — over your own SSH, no new service to run.

unsnooze hosts add work you@work-box.local   # register an ssh destination
unsnooze hosts list                          # registered hosts
unsnooze hosts rm work                       # forget a host
unsnooze fleet [--json]                      # every host's sessions, fanned out over ssh
unsnooze dashboard fleet                     # live Fleet tab — R resumes a selected stopped session (C cancels)

A registered host needs unsnooze installed and ssh key auth (no passwords) — nothing new to open or configure; fleet rides your existing ~/.ssh/config, keys, and agent. Before adding a host, connect to it the normal way once (ssh <host>) so OpenSSH pins the host key itself — unsnooze never weakens host-key checking to skip that step, so an unknown or changed host fails fast instead of being silently trusted.

Fleet is see and mark, not type remotely: unsnooze fleet and the dashboard's Fleet tab list every reachable host's tracked sessions (state, reset countdown, attach hint). Selecting a stopped remote session and pressing R (uppercase — lowercase r refreshes) only marks it due — the remote's own daemon does the actual keystrokes, under the same ownership/liveness/menu gates as a local session. Resumed and stopped sessions print an attach hint (ssh -t <host> 'tmux new -A -s <session>' / zellij attach <session>) so you can hop over and watch. An unreachable or out-of-date host shows unreachable/skew rather than blocking the rest of the fleet, and falls back to its last-known state (marked stale) for up to 24h.

Auth: keys (default) or password

Every host picks its own auth, per host — unsnooze imposes no policy:

  • key (default, unchanged) — the BatchMode-hardened path above. Nothing about a key host changes.
  • password, interactive (--source prompt, the default source when you pass --auth password with nothing else) — typed at run time, no-echo, on whichever terminal is running unsnooze fleet/hosts test. Nothing is stored. There's no terminal under the background daemon, so a prompt host is naturally interactive-only — it shows needs-auth when the daemon or a piped/non-TTY caller tries it.
  • password, stored (--source env|keychain|command) — resolved automatically, so the daemon and unsnooze fleet can reach it unattended.
unsnooze hosts add <name> <dest>
    [--auth key|password]                  # default: key
    [--source prompt|env|keychain|command] # default: prompt, once --auth password is set
    [--env <VARNAME>]                      # env source: var to read (default UNSNOOZE_PW_<NAME>)
    [--service <s> --account <a>]          # keychain source (macOS only)
    [--cmd '<command>']                    # command source: program whose stdout is the password
unsnooze hosts test <name>                 # pre-flight: resolves the source (never prints the
                                            #   secret) then a real ssh reachability probe

Examples:

unsnooze hosts add laptop me@laptop.local --auth password
# → prompts for a password every time it's used from a real terminal

unsnooze hosts add gpu ubuntu@gpu.example.com --auth password --source env --env UNSNOOZE_PW_GPU
# → export UNSNOOZE_PW_GPU=... in your shell/session first

unsnooze hosts add mac me@mac-mini.local --auth password --source keychain --service unsnooze-mac --account me
# → macOS only; reads via `security find-generic-password`

unsnooze hosts add ci ci@build.example.com --auth password --source command --cmd 'op read op://vault/ci/password'
# → any OS; runs your own secret manager and reads its stdout

unsnooze hosts test gpu   # resolves the credential + probes reachability; prints "auth ok" or a hint, never the secret

command is the portable, first-class source — plug in whatever secret manager you already run. Per-OS one-liners for --cmd:

OS Example --cmd
macOS security find-generic-password -s <service> -a <account> -w
Linux pass show <path>  or  secret-tool lookup <attr> <value> ...
Windows powershell -Command "..." (e.g. wrapping Get-StoredCredential)  or  op read op://vault/item/password
any OS op read op://vault/item/password (1Password CLI), or any other manager's read command

keychain is a convenience built-in for macOS only (security find-generic-password) — there's no dependable dependency-free store to shell out to on Windows or Linux, so those use --source command with the recipes above instead.

Security posture: the password reaches ssh through OpenSSH's own SSH_ASKPASS hook — it never appears on argv, in ps, or in unsnooze's own environment; it flows helper-stdout → ssh, in-process, for that one ssh child only. unsnooze itself stores no plaintext: keychain/command delegate entirely to the OS store or your own manager, and env is user-managed (readable by your own shell already — positioned as a low-friction/CI convenience, not secret-manager-grade). Keys remain the default and their BatchMode-hardened path is untouched by any of this.

Windows note: native ssh.exe (Windows' built-in OpenSSH client) prompts fine out of the box for an interactive prompt-source host — no shim needed, it's the zero-friction path. Stored sources (env/keychain/ command) need a non-interactive askpass helper, and native ssh.exe requires that to be a real launchable .exe — it won't reliably run a .js or npm's .cmd shim. Until unsnooze ships that native launcher, use a Git-for-Windows or WSL ssh (both accept the script-based helper the same way macOS/Linux do — common on dev machines already) for stored password hosts on Windows; a plain ssh <host> prompt works with native ssh.exe regardless.

Settings

unsnooze setup writes ~/.unsnooze/config.json; change anything later with unsnooze config set:

key default meaning
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.
autoResume true Master switch. Off = stops are still tracked, but nothing is resumed until you run unsnooze resume-now or turn it back on.
menuAutoAnswer true May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only.
notifications true Master switch for all notifications (limit detected / session resumed / gave up). Off = silence every channel.
notifyChannel auto How to deliver: auto, native, osc, or bell (see Notification channels). Env: UNSNOOZE_NOTIFY_CHANNEL.
guiWatch true May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (unsnooze install --daemon).
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.
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 "".
agents.claude / agents.codex true Which CLIs are guarded.
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.
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.
contextGuard inform Big cold context at wake? Waking a session re-reads its entire context at full uncached price (why). 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.
contextGuardTokens 100000 Context-size threshold (tokens) at which contextGuard notifies or holds.
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.
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.
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.
reapResumed false Opt-in: auto-close resumed panes idle longer than reapIdleAfter. Off by default — use unsnooze reap --yes for explicit cleanup.
reapIdleAfter 604800000 (7d) Idle age (ms) before an opt-in auto-reap closes a resumed pane.
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.
ntfyTopic "" ntfy 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.
ntfyServer https://ntfy.sh ntfy server base URL (self-hosted servers welcome).
ntfyToken "" Optional tk_… access token (Authorization: Bearer) for reserved topics / authed servers.
ntfyPrivacy full terse keeps directory paths out of pushed bodies (titles only) — recommended on public ntfy.sh topics.
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.

Every setting also has a UNSNOOZE_* env override (see src/settings.js), and all timings/paths are tunable via UNSNOOZE_* env vars (see src/config.js).

Multiplexer session names

Interactive launches own the base session name (default unsnooze); a second concurrent terminal takes unsnooze-2, etc. The resumer daemon may join a live session but only ever creates unsnooze-resumed, so a revived agent never steals the interactive name.

env default meaning
UNSNOOZE_SESSION_NAME unsnooze Interactive base session name (also accepts legacy UNSNOOZE_TMUX_SESSION).
UNSNOOZE_RESUME_SESSION <base>-resumed Session the daemon creates when the pane's original session is gone.

Find revived agents with unsnooze status (prints an attach hint when the session is live), unsnooze sessions, or e.g. tmux attach -t unsnooze-resumed.

Notification channels

When a limit is hit, a session resumes, or unsnooze gives up, it can alert you via an OS toast, a terminal OSC sequence, a BEL, or a mix — controlled by notifyChannel (default auto). notifications=false (or UNSNOOZE_NOTIFICATIONS=off) turns every channel off.

channel what it does
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.
native OS toast (macOS osascript, Linux notify-send, WSL/Windows PowerShell toast); tmux display-message as a last-resort statusline fallback on other platforms.
osc Force OSC to attached client ttys; native if zero deliveries.
bell BEL to the pane tty; native if undeliverable.

Terminal support (OSC) — detection uses TERM_PROGRAM / termname / known env markers:

terminals sequence
iTerm2, kitty, WezTerm, Ghostty, Warp OSC 9 (\x1b]9;title: body\x07)
rxvt / urxvt OSC 777 (\x1b]777;notify;title;body\x07)
Apple Terminal, VS Code, Alacritty, Zed denylisted — OSC skipped (prefer native; force osc does not unlock these)
unknown skipped in auto; OSC 9 when notifyChannel=osc

tmux only for OSC/BEL. Those paths write to the client's tty (OSC) or the pane's tty (BEL), which requires tmux's client/pane tty APIs. Zellij has no equivalent, so OSC/BEL are not attempted under Zellij — notifications fall back to native (and Zellij has no statusline-inject equivalent either). GUI watcher stops (no pane context) always use native.

Safety properties

  • Never injects blind: keys are only sent when the pane's foreground process is the agent CLI and no "esc to interrupt"-style busy footer is visible. Recycled pane ids can't receive stray messages.
  • Never picks a menu option blind: if Claude's limit menu layout can't be read, unsnooze does not press Enter (that could confirm "Upgrade your plan").
  • Sleep-safe waits: the resumer polls wall-clock against the target epoch every 30s instead of one long timer — a laptop asleep past the reset fires on the next tick. Weekly limits are just a bigger epoch.
  • Verified resumes: after dispatch it re-captures the pane; if the limit banner reappears (reset time misparsed / not actually reset), it reschedules from the fresh banner, capped at 5 attempts.
  • Concurrent-writer safe: state updates go through a mkdir lock + atomic rename; corrupt state is quarantined, never fatal (the hook path must never block the CLI).
  • Overload ≠ limit: 5xx/529/429 transient errors take a seconds-scale backoff path ([30,60,120,240,300]s ± jitter) and never enter the ledger.
  • Stale-workspace guard: the repo's HEAD + dirty state are fingerprinted when a session stops. If another session (or you) changed the repo before the wake, the resumed agent is told to re-read before acting — or, with workspaceGuard=pause, the session is held and unsnooze resume-now shows the diff first.
  • Cold-cache wake guard: waking a session after hours means the provider's prompt cache is long expired — the first message re-reads the entire context at full price, and a fat session can eat a real slice of the fresh quota window. unsnooze estimates the size from the session transcript (shown as ctx ~152k tok in unsnooze status) and, per contextGuard, notifies you or holds the session instead of spending it silently.

Requirements

  • Node ≥ 20 and tmux ≥ 3.2 or Zellij
  • macOS, Linux, or Windows via WSL (see below)
  • zsh or bash (the wrappers are installed into ~/.zshrc / ~/.bashrc)

Windows / WSL

tmux and Zellij are Unix tools, so on Windows unsnooze runs inside WSL — which is where the agent CLIs live on Windows anyway:

# inside your WSL distro (Ubuntu etc.)
sudo apt install tmux             # or install Zellij: https://zellij.dev/documentation/installation
npm install -g unsnooze && unsnooze setup

On macOS, install either backend with brew install tmux or brew install zellij. In auto mode unsnooze uses the multiplexer you are currently inside; choose one explicitly with unsnooze config set multiplexer tmux or zellij.

Everything works as on Linux, including desktop notifications: inside WSL, unsnooze raises native Windows toasts through powershell.exe (no notify-send or X server needed). Native Windows (PowerShell/cmd, no WSL) is not supported — without tmux or Zellij there is no terminal pane to watch; unsnooze will tell you so and run your CLI unwatched instead of breaking it.

FAQ

What does "You've hit your usage limit" mean in Claude Code?

Claude subscription plans meter usage in a rolling 5-hour window plus a weekly cap. When either runs out, Claude Code stops mid-task and shows the banner with a reset time ("resets 3pm"). Nothing is lost — the session can be resumed with claude --resume <id> once the limit resets. unsnooze does exactly that, automatically, for every stopped session.

What about Codex — "You've hit your usage limit. Try again at …"?

Same idea: ChatGPT-plan Codex has 5-hour and weekly windows. The Codex TUI stays open at the composer after the banner, so unsnooze either types into the live pane or reopens the session with codex resume <id> at the reset time it parsed from the banner.

Does this get around the rate limit?

No. unsnooze waits for the reset exactly like you would, resumes once, and verifies the limit actually lifted. It replaces the 4am alarm, not the limit.

How do I update, and how do I know when to?

unsnooze update (or npm i -g unsnooze). unsnooze checks the npm registry at most once a day and tells you when a newer version exists (a line after CLI commands, plus one desktop notification per version); after updating, the next command shows a short "what's new" from the changelog. It's a plain registry GET with nothing identifying — turn it off with unsnooze config set updateCheck off.

What if another session changed the repo while one was stopped?

unsnooze fingerprints the workspace (HEAD + uncommitted state) at stop time and re-checks at wake. By default the session still resumes, but the wake message includes what changed ("HEAD abc1234 → def5678 — re-read before continuing"). Set workspaceGuard to pause to hold such sessions for a manual unsnooze resume-now (which prints the diff stat), or off to disable the check.

Why did resuming a big session eat so much of my quota?

Because of prompt-cache expiry, not because of unsnooze. Providers cache your session's context so each turn only pays full price for what's new — but that cache lives ~5 minutes. After hours stopped at a limit, the cache is long gone, so the first wake message (unsnooze's, or a hand-typed "continue" — identical cost either way; the wake message itself is ~30 tokens) makes the API re-read the entire conversation at full uncached price. A ~150k-token session on a top-tier model can cost a meaningful slice of a fresh 5-hour window the moment it wakes, before any new work happens.

What actually helps: /compact (or starting a fresh session with a handoff summary) before you hit the limit, and keeping overnight sessions lean. What unsnooze does: the contextGuard setting estimates the context size from the session transcript before waking. inform (default) resumes and notifies you of the price; pause holds sessions above contextGuardTokens (default 100k) until you decide with unsnooze resume-now; the estimate is also visible per-session in unsnooze status (ctx ~152k tok).

Does it work if my laptop was asleep or the terminal was closed?

Yes — reset times are stored as absolute timestamps and checked every 30 seconds, so a laptop that slept through the reset resumes on the next tick, and dead panes are reopened by session id in a fresh multiplexer pane.

Development

npm test                     # unit tests (node:test)
./scripts/e2e-simulate.sh    # full detect → wait → re-open cycle in a
                             # scratch tmux session (no real limits needed)
bash -n scripts/e2e-zellij.sh # syntax-check the reserved-session Zellij smoke test
vhs demo/demo.tape           # regenerate assets/demo.gif (brew install vhs)

Releases are tagged (git tag v<version> && git push origin v<version>) and published to npm by CI with provenance via trusted publishing — see .github/workflows/release.yml.

License

MIT

more like this

agentlytics

Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravi…

JavaScript555
@f

meine

meine 🌒 - A CLI file manager and system utility built with Textual. It combines intuitive command parsing with rich t…

Python50

veille-techno

Skill Claude Code de veille tech francophone. Agrège les flux RSS (Journal du Hacker, Human Coders News…) et produit un…

Python51

xylocopa

A to-do list that runs your Claude Code agents — capture anywhere, dispatch in parallel, review from your phone.

Python51

search

search projects, people, and tags