release history

Changelog

Rendered straight from the repository's CHANGELOG.md at build time. Install any version with npm i -g unsnooze@<version>.

v1.19.0

latest

Fish shell support, direct help/version commands, and a fix for Codex sessions that stop at a reported 99% usage.

Fish shell support

unsnooze setup and unsnooze install now install fish wrappers when fish is your login shell or its config file already exists. The default location is ~/.config/fish/config.fish; an absolute XDG_CONFIG_HOME is respected. unsnooze doctor checks this file, and unsnooze uninstall removes the wrappers. Re-running setup replaces the existing block and preserves your other settings. Use --fishrc <path> with install or uninstall to choose another config file.

After upgrading, run unsnooze setup once to add the fish wrappers, then open a new shell. Thanks to @duncanmcqueen for PR #21.

Help and version commands run directly

Commands such as claude --help and codex --version now pass straight to the agent. They do not open a multiplexer session, start a pane monitor, prepend launchExtraArgs, or append an unsnooze update notice. The existing print-mode passthrough is unchanged. Thanks to @chid for PR #23.

Codex can be detected as stopped at a reported 99%

The GUI watcher now recognizes the sequence reported in issue #20: a five-hour Codex window reaches 99%, then the next rate-limit snapshot switches to an empty premium bucket with no credits. It records the stop and schedules the session using the previous reset time, plus the normal safety margin.

This fallback requires snapshots from the same rollout no more than a minute apart, no available credits, and a future reset time. It works across watcher polls and daemon restarts. An already-exhausted weekly window still takes precedence if it resets later. A 99% reading alone does not trigger a resume.

The separate report of stale 64% usage was not reproduced; this release fixes the missed stop detection. Thanks to @mio-tsuki for the sample. Fixed in PR #24.

Test fixes

  • Permission tests now set their required file mode explicitly, so they pass with different system umasks. Thanks to @Chang-Jin-Lee for PR #22.
  • Fish install/uninstall tests use temporary profiles and leave existing user settings, wrappers, and daemon state untouched.
  • Help/version tests use a portable test executable instead of relying on platform-specific echo behavior.

v1.18.0

Cursor CLI support, a wrapper change that keeps cursor . working, and a model-limit notification that finally names a remedy the CLI actually has.

Cursor CLI (cursor-agent)

⚠️ Experimental, off by default — turn it on in unsnooze setup, or:

unsnooze config set agents.cursor on && unsnooze setup
Detection pane scrape
Limit type model — probed, never a scheduled wake
Revive cursor-agent --resume=<id>, else --continue
Chat ids ~/.cursor/chats/<md5 of cwd>/<id>/meta.json, verified against the cwd
Wraps cursor-agent only

Cursor's limit is the first one not worth waiting for. Its usage resets on your monthly billing cycle, not a rolling window. From a real limit:

Error: You've hit your usage limit
Get Cursor Pro for more Agent usage, unlimited Tab, and more.
fallbackModel:
spendLimitHit: false
chatMessage: *…Your usage limits will reset when your monthly cycle ends on 10/2/2026.*
spendLimits: [50,100,200]

A month out. Sleeping on that would mean a monitor waking in October to type into a wall that never moved. So unsnooze treats it as a model limit:

  • records the stop and tells you it needs a decision
  • probes at 15/30/60 min, resuming the moment the banner clears — you change plan, or the cycle rolls
  • marks the record failed at the probe ceiling instead of sending a futile wake

resetPatterns is empty on purpose (a test pins it): any reset pattern there would silently re-route Cursor onto the 5-hour fallback ladder.

Transport errors take the transient-overload ladder and Authentication required is notify-only — neither reaches the ledger. Banner text is server-provided and plan-dependent, so unsnooze report cursor is how it improves.

cursor . still opens the IDE

Cursor ships its CLI as both cursor-agent and agent, while the bare cursor is the editor launcher. Adapters can now declare wrapperNames, so unsnooze wraps cursor-agent and leaves the other two alone — agent is far too generic to shadow safely. Wrapper output for the existing seven adapters is unchanged, byte for byte.

Model limits now suggest something you can actually do

Both the "needs you" and probe-ceiling notifications hardcoded Claude's wording, so a Cursor user was told to run /usage-credits — a command Cursor does not have. The hint moves onto the adapter, and since a model limit has no reset time to wait for, that one sentence is the whole remedy.

v1.17.0

  • A state directory unsnooze cannot write to no longer pins a CPU core forever. The lock around ~/.unsnooze/state.json treated every mkdir failure as contention. EEXIST really is contention — someone else holds the lock — but EACCES on an unwritable state directory, EROFS on a read-only filesystem and ENOSPC on a full one are not, and that branch retried with neither a sleep nor a deadline check. Since mkdir -p on a directory that already exists succeeds as a no-op, there was nothing to break the cycle: the loop spun at around 70% of a core, ignored UNSNOOZE_LOCK_TIMEOUT_MS entirely, and never returned. It took every writer with it — the daemon, any CLI command, and the StopFailure hook that runs inside your agent. unsnooze now repairs a state directory it owns and otherwise fails on the deadline with the errno that caused it.

  • Everything under ~/.unsnooze is now owner-only (0700 / 0600). The directory was 0755 and every file inside it 0644, so on a shared machine any other local user could read them. That directory holds your ntfy bearer token (config.json), the text of queued prompts and the paths they run in (state.json), your fleet's ssh destinations and the commands that fetch their passwords (hosts.json), a mirror of every remote host's sessions (fleet-cache.json), a lease file per pane carrying its working directory (leases/), and — for a headless revival — the entire stdout and stderr of an unattended agent run (headless/*.log). Every writer now creates its file owner-only and its directory 0700.

    Upgrading an existing install needed more than that, because none of it is something a writer can fix: mkdir's mode is ignored for a directory that already exists, appending to a log reuses the file's inode, and config.json and hosts.json are written only by config set and hosts add, so an install that never touches them again would have kept their old mode — and the token in one of them — indefinitely. So a state write repairs the modes directly, walking the events/, leases/, mux-sessions/ and headless/ subdirectories as well as the top-level files. It re-checks on an interval rather than once per process, because daemon.log is created by launchd/systemd redirecting the daemon's stdout and can appear after a first pass has already run — in a process that then lives for days. It strips group and other access without touching the owner's own bits, so an executable in there stays executable; it never follows a symlink or a hardlink into a file it is about to change; and it only ever touches unsnooze's own state directory. unsnooze doctor reports from the same walk the repair acts on, so the two cannot disagree about what counts as exposed. It is inert on Windows, which has no POSIX mode bits — libuv synthesises them by mirroring the owner's, so every file there reads as world-readable and no chmod can change it; access is governed by the profile ACL instead. The same is true of a state directory on a filesystem that does not implement permissions at all — a CIFS or vfat mount, WSL's drvfs, a bind mount from a Windows host — and the platform name does not identify those, so unsnooze finds out by attempting the change and looking: a chmod that succeeds while the bits stay put means the filesystem has nothing to set, and the directory is left alone rather than rewritten every minute forever. unsnooze doctor --fix reports those three outcomes separately, so "could not" is never printed as "did".

    The statusline shim's own drop directory, ~/.claude/unsnooze, gets the same treatment: it holds your live rate-limit numbers and was created 0755. It is repaired on the shim's next run rather than only for new installs.

    unsnooze doctor now also reports anything under the state directory that other users can read, and doctor --fix narrows it. That check exists because the repair deliberately ignores a failed chmod — crashing every state write on a filesystem that has no usable one would be the worse bug — so something has to be able to tell you it did not take.

    No credential was ever stored in plaintext: a password source is a reference to your keychain, environment or secret tool, never the secret. On a single-user machine nothing here was reachable by anyone new.

  • A hostile session_id can no longer steer where unsnooze reads or writes. Three places took that value — which arrives in a payload from the agent — and used it directly as a filename. The opt-in Claude Code statusline shim built its drop filename by concatenating it, so a / or a .. walked the write out of ~/.claude/unsnooze and over any .json file you can write. transcriptPath, on the StopFailure hook path that everyone has enabled, composed a transcript path the same way, turning a read of your own transcript into a read of any .jsonl you name. And the Kimi adapter probed for a session directory by the same concatenation, which leaked whether an arbitrary path exists. Claude Code sends a uuid, so all three needed a malicious or compromised agent to reach, and the two reads are only ever mined for a timestamp or a boolean — never echoed back. None of them was in a position to know that. All three now require a filename-shaped id and fall back to unknown / no-transcript / not-found. If you have the statusline shim installed, re-run unsnooze usage --install-statusline to pick up the fix; the other two need nothing.

  • A corrupt state.json no longer prints its own first bytes into the log. The quarantine message included V8's JSON.parse error, and V8 embeds a snippet of the input it choked on — so a state file that had been replaced with something else echoed that file's opening characters into unsnooze.log. The quarantined file is still kept on disk, and the log still names it and says why.

  • Three narrower lock fixes. A writer whose stale-lock steal was itself stolen no longer deletes the thief's lock on the way out — it only removes a lock still stamped with its own pid. The steal itself now re-checks that the directory it is about to remove is still the one it judged stale, narrowing a window where two contenders could both decide to steal and the slower one would delete the winner's fresh lock. And a lock is now stolen on age alone once it is very old: liveness was decided by signalling the recorded pid, which cannot tell "unsnooze is still working" from "that number now belongs to some unrelated long-lived process", so a leaked lock whose pid was later recycled wedged every writer permanently, with no recovery but deleting the directory by hand.

    That last one is a trade rather than a free win — stealing from a holder that really is still working puts two writers in the critical section, and one overwrites the other. It is chosen because a permanent wedge is the worse failure, and the exposure is kept small by making sure the lock is only ever held for in-memory work plus a single write: upsertSession used to compute its workspace fingerprint inside the lock, which shells out to git, and execFileSync's timeout is a soft bound — it signals the child and then waits for it to actually exit, which a git wedged on a hung network mount never does. That call now happens before the lock is taken, so nothing unbounded runs inside it.

    Raised in #17. The report also flagged the askpass command source as command injection and reading state.json as a symlink-following bug; neither is a vulnerability. A command source is your own configured command, in the shape git's credential.helper = !cmd has always taken, and it can only be set by someone who can already write to your home directory. As for the symlink: reads do follow one — point state.json at a file holding valid JSON and its contents are read as state — but planting that link needs write access to a directory that is now 0700, which is not a boundary anyone crosses without already owning the account. The write side is genuinely safe: the quarantine renames the link rather than the file it points at, and a state write replaces the link with a real file, so the target is never truncated or overwritten.

v1.16.3

  • A dashboard left open overnight no longer runs the machine out of memory. (#16) unsnooze status in a spare pane is exactly what it is for, and after nine or ten hours it was dying on "FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript heap out of memory". The dashboard itself was innocent: React's development build files a performance.measure() entry for every render, commit and setState — the performance track it publishes for devtools — and Node buffers user-timing entries forever with nothing to drain them. The status tab repaints about three times a second (a one-second data tick plus the 450ms logo animation), each repaint left roughly 40 entries behind, and a night of that walks into Node's 2GB heap ceiling. Measured at ~12kB retained per render, surviving a forced garbage collection. The dashboard now drains that buffer while it is mounted; heap use went from climbing without limit to flat. Nothing else in unsnooze records user timing, and no other command renders long enough to have been affected.

  • Every release now creates its GitHub release automatically. They were written by hand afterwards, so 1.16.2 shipped to npm and to the website while the repository's Releases tab still showed 1.16.1. The publish workflow now creates the release from that version's CHANGELOG section, and waits for the npm registry to actually serve the new version before rebuilding the website — npm publish returns before the registry does, which is why 1.16.2's own changelog entry was briefly invisible on unsnooze.dev.

v1.16.2

  • A herdr revival now opens a tab in your project's workspace, not a new workspace of its own. (#15) newWindow means "one more terminal for this work" — a window in tmux, a tab in Zellij — and it was mapped to herdr workspace create. But a herdr workspace is the project: it is per repo, the way a tmux session is. So every reset left another top-level workspace for the same repo, and a week of them was a sidebar full of duplicates. unsnooze now asks herdr which workspace is already open on that directory and adds a tab there, creating a workspace only when there is genuinely none — a session it just started, or a directory herdr has nothing open on. A tab rather than a split, because a revived agent draws a full-screen TUI and halving the pane you are looking at would leave two terminals too narrow to use; neither takes focus away from what you are doing.

v1.16.1

  • unsnooze status no longer offers an attach command for headless sessions. A headless "pane" is a detached pid, but the attach hint fell through to the default tmux attach -t <session> — a command that at best does nothing and at worst attaches to an unrelated tmux session that happens to share the name. Headless output lives in ~/.unsnooze/headless/<session>.log, and status now says nothing rather than something wrong. Found while building the capability table for the new Supported terminals docs section, which sets out exactly what each of tmux, Zellij, herdr, cmux and headless does and does not do.

v1.16.0

  • Claude Design, and native Windows along the way. (#13) Claude Design shares your 5-hour and weekly limits with everything else, so a long design run stops exactly like any other session — the obstacle was never the stop, it was that Design's canvas is a web app with no pane to watch, and that the reporter was on Windows, where unsnooze watched nothing at all. unsnooze design setup wires up the official claude-design MCP server so the work runs inside Claude Code, where it is watched like anything else; unsnooze design reports whether it is registered and signed in, keeping those two apart because an expired /design-login is not a usage limit and waiting will never clear it. unsnooze does not automate the web canvas and will not: Anthropic's Consumer Terms bar automated access to claude.ai, and accounts have been terminated over it.

  • Watching without a multiplexer (headless). A pane was only one of three detection channels — the StopFailure hook and the transcript watcher never needed one. What was missing was somewhere to put a revived agent, so there is now a backend where a "pane" is a pid and a revive is a detached process logging to ~/.unsnooze/headless/. That makes native Windows, bare servers and CI work. It is picked only when no multiplexer is installed, never ahead of one, and it is honestly weaker: no limit-menu answering, no busy detection, no live pane to attach to.

  • Native Windows is supported. Previously unsnooze printed "native Windows is not supported; run inside WSL" and ran your CLI unwatched. Two things were broken beneath that: the StopFailure hook command was POSIX (test -f …), which cmd.exe has no test for, so the hook failed on every turn; and the shell wrapper only ever reached ~/.zshrc and ~/.bashrc, files a PowerShell user does not have — so nothing routed through unsnooze in the first place. Both are fixed, the daemon autostarts from a logon-triggered Scheduled Task, and unsnooze doctor stops reporting wrappers as missing when they are installed. WSL remains the richer option.

  • A server throttle is no longer mistaken for your usage limit. Claude Code says "API Error: Server is temporarily limiting requests (not your usage limit)" — and usage limit matched the parenthetical. Since the TUI never clears old banners from scrollback, any stale "resets 3pm" nearby was enough to turn a throttle that clears in seconds into an hours-long scheduled wait. It is now read as what it is and handled on the transient overload ladder.

  • Non-resetting Claude stops are notified, not scheduled. The claude adapter gained the terminalPatterns every other adapter already had. An expired Claude Design credential and an exhausted credit balance are stops no amount of waiting clears, so unsnooze says so once instead of booking a wake and burning the attempt cap. This matters most for unattended runs: a revived headless session has no interactive terminal, so /design-login cannot even be offered there. All patterns are anchored to the CLI's real error phrasings rather than to the bare command name, so an agent explaining /design-login is not a stop.

  • Claude's own auto-continue is recognised, not raced. Claude Code shipped in-process auto-continue on 2026-08-14 (desktop; the CLI carries it behind a gate that currently defaults off). unsnooze was already guarded — every resume path checks whether the session moved on its own, and the 60s reset margin means Claude gets there first — but a session that woke itself was recorded identically to one unsnooze woke, so the deliberate stand-aside was invisible. Records now carry who resumed them and unsnooze status says "resumed itself (unsnooze stood aside)". Note the native feature covers only the 5-hour limit: weekly stops, closed apps, headless boxes and every non-Claude CLI remain unsnooze's job.

  • launchExtraArgs.<agent>. The launch-side twin of resumeExtraArgs, for flags that must hold for a whole session rather than just a revival — unsnooze config set launchExtraArgs.claude "--autocompact 400000" keeps a context-heavy run compacting instead of stalling. Revivals inherit them, so the flag survives the wake.

v1.15.0

  • Two new multiplexer backends: herdr and cmux. unsnooze now watches and revives sessions in herdr and cmux alongside tmux and Zellij, chosen automatically from the multiplexer you are already inside or explicitly with unsnooze config set multiplexer herdr. Both are terminals built for running coding agents, and both start a command by typing it into a pane rather than exec'ing it, which is a materially different contract from tmux and Zellij — a revival's arguments are now shell-quoted for them, and an argument that cannot be typed at all (a multi-line resume message, say) travels through the pane's environment instead of being mangled or refused. herdr needs 0.8.0 or newer; Homebrew still ships 0.7.3, so install the release binary. Original herdr backend by Wave Consulting (@walt-verweij) with follow-up work by @gaoflow; cmux backend by @echarrod.

  • Per-model limits are no longer invisible. Claude Code can stop with "You've reached your Fable 5 limit. Run /usage-credits to continue or switch models with /model" — a banner with no reset time, which the previous detection (a limit phrase paired with a time) could never match, and which fires no StopFailure hook. The session simply stopped, untracked and unannounced. It is now detected and tracked. Because only a human clears it, unsnooze never types at such a pane: it probes until the banner goes, and at the ceiling records a visible failure naming both remedies rather than pretending a wake is coming. Detection is deliberately conservative — an agent quoting the banner in its own output is not a stop. (@gaoflow)

  • Four reset-scheduling bugs found in production. A banner left on screen after its reset passed was re-parsed every few seconds as "now + margin", so the wake slid forward forever and the resumer never fired. An undated "resets 10:30pm" re-read after midnight resolved to tomorrow, 22 hours out. Accumulated pane-snapshot records with no session id each revived their own copy of the same conversation — 23 records produced roughly 8 clones in one repo at a weekly reset; at most one now revives per agent and project. And a stop arriving after an old record was abandoned is no longer held back by it. (@gaoflow)

  • New setting: resumeExtraArgs.<agent>. A revival spawns the agent binary directly, so flags that normally come from a shell alias or wrapper do not apply — a user who always runs claude --dangerously-skip-permissions got a permission-prompting revival, which is about as useful as none. Set them per agent and they are appended to launches unsnooze performs itself. Quoting is respected (--append-system-prompt "stay in this repo" is two arguments, not five), and config.json accepts an array if you would rather not think about quoting at all.

  • A failed launch no longer leaves a monitor scraping forever. The monitor starts before the agent does, and when the agent failed to start at all there was no lease to notice its absence — so the monitor watched an ordinary shell prompt indefinitely, one more process per failed launch. It now writes the lease unconditionally (it was previously skipped wherever process start times are unavailable, including Windows) and gives up if none appears. Reported by @echarrod.

  • Node 20.12 is now the minimum. engines promised >=20 while a dependency needed styleText from node:util, which arrived in 20.12 — so installs on 20.0–20.11 succeeded and then crashed on startup. The floor is honest now, and CI tests that exact version so it cannot drift again.

v1.14.4

  • Windows: the daemon PATH check split on the wrong separator. The check added in 1.14.3 split PATH on :, which is right on macOS and Linux and wrong on Windows — there the separator is ; and every absolute path contains a colon (C:\...), so entries were shredded into fragments. The predicate now uses the host's own delimiter. No behavior changed on macOS or Linux, and Windows has no autostart unit for the check to reach, so this is a correctness and test-portability fix rather than a user-visible one.

v1.14.3

  • Upgrades now reach running watchers. A per-pane monitor lives as long as the agent and a transient resumer lives until the reset; neither has a supervisor, and npm i -g unsnooze cannot change code a running process has already loaded. A session started before an upgrade therefore kept applying the old rules indefinitely — which is why 1.14.2's resume-lifecycle fix could be installed and still change nothing on a long-lived pane. Both processes now notice that the package changed underneath them, hand off to a replacement on the fresh code, and stand down. Sessions launched under 1.14.2 or earlier need one restart to pick this up; after that, upgrades propagate on their own.
  • Hand-offs are verified, never assumed. spawn resolves the node binary, which exists regardless of the package, so a tree caught mid-swap returns a healthy-looking pid for a child that is already dead. The hand-off now checks that the entry point exists before starting anything, and that the replacement survived startup before standing down. Either check failing keeps the old process watching — a stale watcher beats no watcher.
  • A manually run daemon no longer dies on upgrade. unsnooze daemon started from a shell has nothing to restart it, so exiting on version skew ended GUI watching silently. It now detects whether launchd/systemd is actually supervising it: supervised, it exits and returns on fresh code; unsupervised, it warns once and keeps watching. The 15-minute detection interval is unchanged — a tighter loop raises the odds of restarting into a half-installed package.
  • The daemon PATH self-heal actually heals. The 1.12 repair regenerated the autostart unit from the PATH of whichever process called it, and the only caller is the daemon itself — whose PATH is the minimal one launchd and systemd provide. It wrote the broken PATH straight back, then, because the check only asked whether a PATH block was present, treated the job as done forever. Observed live: a daemon running with PATH=/usr/bin:/bin:/usr/sbin:/sbin while tmux sat in /opt/homebrew/bin, so every GUI revival failed with ENOENT. The heal now asks the login shell for a usable PATH and rewrites the unit only when the result can actually find the multiplexer and differs from what is already there — rewriting reloads the unit, which kills the calling daemon, so a heal that cannot improve anything must do nothing.
  • unsnooze doctor reports a daemon that cannot reach its multiplexer, and --fix repairs it. This was previously invisible, because doctor probes tmux from your shell's PATH rather than the daemon's.
  • Version stamps in the log. The monitor and resumer record which build they are running as they start, so a pasted log answers "is this process even on the new code" without further diagnosis.
  • Internal: one shared pidAlive now backs the resumer's lock hygiene, the dashboard, and the hand-off. It rejects pid 0, which kill(0, 0) reports as alive because that signals the caller's own process group — a lock file holding 0 could otherwise be honored forever. Writing an autostart unit outside the live location no longer activates it: every unit carries the same label, so loading a copy hijacks the real job instead of adding a second one.

v1.14.2

  • Resume lifecycle: a limit banner scrolling out of the pane no longer marks a Claude session resumed without a wake. Unsnooze now requires a newer non-error parent-assistant usage record, rechecks that evidence immediately before dispatch, ignores subagent StopFailure records, follows the canonical key retained by state dedupe, and never treats a persistently busy pane as proof of completion.
  • Usage diagnostics: estimated usage now says that its percentage is unavailable, rather than claiming no stop was observed. Proxy launcher documentation explains that headroom wrap bypasses Unsnooze's shell launcher and same-pane monitor while hook detection and enabled daemon file watching remain available.

v1.14.1

  • Package discovery: refreshed the npm description and README links, and moved the published package homepage from the legacy Combustor Tech hostname to unsnooze.dev. No runtime behavior changed.

v1.14.0

  • Queued prompts: unsnooze prompt add [--agent id] [--project path] [--at time|--now] <text...> (plus list/remove/clear) queues a one-shot prompt that spawns a brand-new agent session in a project directory once a usage limit clears. --now/--at (epoch, ISO-8601, +2h30m, or a bare clock time) skip the reset wait; with no reset signal at all, a next-reset entry delivers on the very next daemon tick and prompt add prints a notice to that effect. Delivery is verified against a fresh limit banner and backs off on failure — the same backoff floor applies to every mode, capped at 5 attempts (same as resume) before an entry is marked failed. autoResume does not gate delivery. Dashboard: a new Prompts tab (7) — list, a to add, d/x to remove — plus a queued-count hint on the Status tab. Fleet: --host <name> relays the same subcommands to a registered host's own queue (--project/--agent required, everything re-validated server-side); the new remoteQueue setting (default on) lets a host opt out of all queue traffic, answering a typed disabled instead of silently dropping it.
  • Fleet password auth: hosts can now use a password instead of an ssh key — unsnooze hosts add <name> <dest> --auth password --source prompt|env|keychain|command (prompt is interactive no-echo and the default; env/keychain/command are daemon-capable). keychain is a macOS-only built-in; Windows and Linux use --source command with a per-OS recipe (Linux pass/secret-tool, Windows powershell/op read; security is the macOS recipe — see README's per-OS table). unsnooze hosts test <name> pre-flights a host without ever printing its secret. Auth-gapped hosts render as needs-auth in fleet/the dashboard, distinct from unreachable. Security: the password never touches argv, ps, or unsnooze's own environment — it flows through OpenSSH's SSH_ASKPASS hook, helper-stdout → ssh, in-process; unsnooze stores no plaintext itself; keys stay the unchanged, BatchMode-hardened default.
  • Fleet — sessions on every machine, over your own SSH: unsnooze hosts [add|rm|list] registers ssh destinations; unsnooze fleet [--json] and the dashboard's new Fleet tab fan out to every host in parallel and show each one's tracked sessions (state, reset countdown, attach hint), with a bounded-concurrency ssh pool, per-host timeouts, and a 24h stale cache so one dead box never blocks the rest. unsnooze _remote is the single remote entrypoint (status/resume/cancel), safe to lock to an authorized_keys forced command; unsnooze status --json and a shared resume core back both the local and remote paths. Security posture: no listening ports, no custom auth, no tokens — transport is plain OpenSSH with host-key checking never weakened; the remote is always the one that types, under its own gates; and every field a remote returns is control-character-stripped, length-capped, and extracted into fresh objects before it touches your terminal or state.
  • Dashboard mouse support: click tabs and session rows, wheel-scroll the status/sessions lists and a real scrollback window in Logs, clickable footer hints (refresh / help / quit). Full keyboard parity kept; m (or mouse config / UNSNOOZE_MOUSE) toggles it live so terminal text selection is one keypress away. Tracking modes are always cleared on exit, crash, and Ctrl-Z — no hijacked mouse after quit.

v1.13.0

  • unsnooze usage — know the wall before you hit it: burn-rate & time-to-limit forecast per agent × window. Every figure carries its provenance — (exact) from Codex rollouts or the opt-in Claude statusline shim, (calibrated from N stops) learned from unsnooze's own recorded limit stops, or (estimated) while calibrating. Weighted token burn (cache reads ×0.1) over active minutes, account-wide including subagent transcripts, per Opus/Sonnet bucket on Max plans. ETA shown as a band, cross-checked against the observed reset time — never a false-precision minute, never a blind now+5h.
  • Pre-wall warnings from the daemon: hybrid thresholds (80/95% and ≤30/≤10 min to the wall at current pace), deduped per window instance, with a /compact now nudge sized from the same context estimator as unsnooze status. Notify-only — unsnooze still never types anything you didn't configure.
  • unsnooze usage --install-statusline: opt-in shim that persists Claude's exact server-side percentages; chains your existing statusline command (backed up once, restored on uninstall).
  • Live dashboard: unsnooze dashboard (also status/usage on an interactive TTY) — full-screen Ink TUI with Status, Usage, Sessions, Doctor and Logs tabs, animated ❯ z z z brand mark, help overlay (?), and a compact layout down to 80×24. Pipes and --json stay plain.
  • Fix: undated limit banners crossing midnight ("resets 12:04am" seen at 9pm) parsed as already-past and were dropped or resumed due-now into a still-live limit; they now roll to tomorrow when the announced time is within a window's reach, while genuinely stale banners still resolve due-now.
  • Codex windows are labeled from window_minutes everywhere (300 → 5h, 10080 → weekly, 43200 → 30d on the go plan) — stop records no longer conflate the monthly window with the weekly bucket.

v1.12.2

  • Daemon autostart self-heal: updating via npm never touches the launchd/systemd unit file, so pre-1.12 users would keep the PATH-less unit (daemon can't find tmux → every revival dies) until they manually re-ran unsnooze install --daemon. The daemon now detects a PATH-less unit on startup, regenerates it, and reloads itself — every affected user is fixed automatically on their first daemon restart after updating (at the latest, the next reboot). Manual unsnooze install --daemon still works and is no longer required.

v1.12.1

  • CI-only: pin the platform in a doctor test whose launchctl assertion failed on Linux runners. No runtime changes — this is 1.12.0 plus a green release pipeline (1.12.0 was tagged but never published).

v1.12.0

  • Daemon PATH fix (fix: every launchd-daemon revival on Homebrew Macs died silently with spawn tmux ENOENT — launchd gives daemons a bare /usr/bin:/bin:/usr/sbin:/sbin and tmux lives in /opt/homebrew/bin): the launchd plist and systemd unit now bake the install-time PATH. Re-run unsnooze install --daemon once after updating to regenerate the unit. Revival new-window failures are now logged (they were silent), and failed records survive the sweeper so unsnooze status can show why a session gave up (age-based prune still expires them).
  • unsnooze preview [id] — a true dry-run: per session, exactly what the resumer WOULD do right now (type into which pane, drive the menu, reopen in which session, probe, defer) and why — every gate (paused, not due, held by workspace/context guard, backoff, attempt cap) spelled out, including the final wake message with any guard suffix. Sends nothing, mutates nothing. Preview shares its decision code (planFor/assessPane/guard evaluators) with the real dispatcher, so it cannot drift. Exit codes: 0 nothing would wake now, 2 at least one actionable wake, 1 error.
  • ntfy push notifications (ntfyTopic / ntfyServer / ntfyToken / ntfyPrivacy) — off until a topic is set; fires alongside the local channel on limit-hit / resumed / gave-up (gave-up pushes at high priority). JSON-to-root publishing (emoji-safe titles), Bearer-token support for authed or self-hosted servers, bodies capped, fire-and-forget with a 5s timeout. ntfyPrivacy=terse keeps directory paths out of pushed bodies — ntfy.sh topics are a public namespace, so the docs push unguessable topic names.
  • Trust & security docs: a "Trust & security" section at the top of the README (what unsnooze types and never types, grounded in the actual mechanisms) and a full SECURITY.md (threat model, honest residual risks, private vulnerability reporting, supported versions). Enable GitHub Private Vulnerability Reporting in the repo settings to activate the report button.
  • Release provenance: .github/workflows/release.yml publishes to npm on v* tags via trusted publishing (OIDC, token-less) with --provenance — after a one-time trusted-publisher setup on npmjs.com (repo saaranshM/unsnooze, workflow release.yml).
  • Reproducible demo: demo/demo.tape (VHS) renders assets/demo.gif — staged fixture ledger + stub agent, but the unsnooze status beat is real.
  • Note: the pre-release name claude-session-guard was never published to npm, so there is nothing to deprecate on the registry; unsnooze doctor --fix remains the migration path for local installs.

v1.11.0

  • unsnooze doctor [--fix] — install health check + migration sweep for the pre-release claude-session-guard (csg) install. Detects zombie csg monitors/resumers, old launchd/systemd units, the orphaned ~/.claude-session-guard state dir, and the stale global package (even when its csg bin symlink dangles). --fix stops the processes, unloads and removes the units, and archives the state dir (never deletes data, never runs npm — the npm rm -g claude-session-guard step stays yours). unsnooze install now runs the detection and points at doctor when leftovers exist.
  • Pane identity & ownership (fix: tmux pane ids are server-global and recycled — a stale monitor or reap could type into or close somebody else's pane): managed panes are stamped with a @unsnooze_owner pane option at launch and revival; every message-injection, menu-drive, reap, and auto-reap decision now answers two independent questions first — is this pane ours (stamp/lease; a mismatched stamp vetoes even a matching foreground command) and is our agent still running in it (lease pid+birth or foreground command; the stamp alone never counts, since it outlives the agent and the pane may now be the user's shell). Monitors exit once their lease disappears instead of scraping whatever the pane becomes. Legacy records without leases are never force-closed.
  • Launch failures degrade instead of dying (fix: a tmux-level session-start failure — duplicate session, open terminal failed, dead socket, nesting refusal, socket permission errors — used to exit with tmux's status and no agent): launchWrapped now reads tmux's stderr, distinguishes "session never started" from "session ran and ended", and falls back to the unwatched agent CLI with a message.
  • Zellij detection fix: capturePane now dumps scrollback (dump-screen --full) — a limit banner that scrolled between polls was previously invisible on Zellij. Pre-0.35 zellij rejects the flag; capture learns that once and degrades to the viewport-only form instead of failing every poll.
  • Reset-time correctness: weekly "resets Tuesday 9am" wakes land on the exact wall-clock time across DST boundaries (day-stepping used to drift ±1h); a mangled banner clock ("resets 45:99") is rejected instead of throwing; "wait " only parses when a duration actually follows (no more summing stray durations out of prose).
  • State safety: sweepers use compare-and-set so markStaleAbandoned can no longer clobber a record that resumed mid-sweep; the state lock records its holder pid and is only ever stolen from a dead process; user-invoked reap skips resumed panes that were active within reapIdleAfter (closing a live working agent contradicted its own contract).
  • Install backups: the first-ever run snapshots your pristine settings/rc as .unsnooze-orig (kept forever); .unsnooze-bak keeps rolling per run.

v1.10.1

  • Upgrade-window fail-safe (fix: npm install -g briefly leaves bin/ present with src/ missing; the router then died with MODULE_NOT_FOUND inside the freshly-wrapped tmux session — a visible open/close flash with terminal-probe garbage — and the launchd daemon crash-looped thousands of times into daemon.log): agent-launch paths (_run, bare claude args) now degrade to the plain agent CLI when the package can't load; background paths (hook, monitor, resumer, daemon, update-check) exit 0 quietly. Only module load failures are caught — runtime errors still surface, and an agent that already ran is never re-run.
  • Daemon crash-loop guards: launchd plist gains ThrottleInterval 30; the systemd unit moves to Restart=always + RestartSec=30 (a clean exit-0 must also respawn — on-failure would leave the daemon dead after an intentional exit) with the start rate-limit disabled so a long broken install can never trip the unit into a permanent failed state. A version-skew watch makes a long-lived daemon exit cleanly (and get restarted on fresh code) when npm -g swaps the package underneath it — no more zombie daemons running deleted code.
  • Log rotation: unsnooze.log and daemon.log are capped at 5 MB with one rotated generation (.1) — a crash-loop can no longer grow a log without bound. daemon.log is rotated copy-truncate style because launchd holds its fd open for the daemon's whole lifetime.
  • Resume-retry backoff (fix: five resume attempts burned in ~2 minutes when a revival kept failing): failed attempts now back off exponentially (1m, 2m, 4m… capped at 30m). Manual resume-now records are exempt — an explicit immediate wake is never silently deferred. After unsnooze gives up, a session re-arms only on a fresh limit detection.
  • Singleton-lock hygiene: the resumer lock is acquired atomically (wx), a lock held by a recycled pid that is not actually an unsnooze process is taken over instead of honored forever, and the daemon's "another resumer holds the lock" log line is throttled to ~once per 15 minutes instead of every 30 s tick.
  • Update notice on the launch path: wrapper-only users (who never run unsnooze status) now get the one-line "new version available" notice on stderr right after their agent session ends — outer terminal only, TTY only, never in -p/--print runs, at most once per day. The launch path also refreshes the daily version-check cache.

v1.10.0

  • Session-name ownership (fix: interactive claude dying with duplicate session: unsnooze): the interactive launcher owns the base name unsnooze (and unsnooze-2… on collision); the resumer daemon may join a live session but only ever creates unsnooze-resumed. Records now discover the live mux session via sessionForPane instead of freezing the load-time MUX_SESSION_NAME constant. tmux newWindow returns paneOwner: null (pane ids are server-global). Uninstall stops the resumer. Failed session creation degrades to an unwatched agent CLI instead of bricking claude/codex. New: unsnooze sessions, unsnooze reap [--dry-run|--yes]; optional reapResumed / reapIdleAfter. Zellij revival uses --close-on-exit and closes the default shell pane left by attach -b -c. Env: UNSNOOZE_SESSION_NAME, UNSNOOZE_RESUME_SESSION.
  • Reset-time accuracy (fix: blind now + 5h fallback and +24h rollover of already-past clock times): reset times are anchored to the banner's own timestamp (bannerAt), not the scrape moment. Claude stops prefer the dated transcript entry over an undated pane scrape. An absolute clock time already past relative to wall clock means the limit already reset (due now), not tomorrow. Compact durations like 1h 30m sum all tokens. Unparseable banners probe cheaply (PROBE_INTERVAL_MS, backoff to PROBE_MAX_MS) instead of sleeping five hours; hard ceiling remains FALLBACK_RESET_MS. Monitor first tick requires corroboration; later ticks can upgrade a weak estimate. unsnooze status shows provenance (absolute, from transcript vs guessed: no reset time found — probing).
  • Upgrade-safe state migration for existing installs: tmuxSessionmuxSession (unchanged), bogus tmux paneOwner values cleared so leases match again, old blind fallback waits beyond the probe ladder are pulled into the first probe window (absolute/relative schedules untouched), TMUX_SESSION_NAME alias kept, reapResumed defaults off, new config keys only add defaults (existing config.json keeps working without edits).

v1.9.0

  • Context-size guard (contextGuard: off | inform | pause, default inform; threshold contextGuardTokens, default 100000): waking a session hours after a limit stop re-reads its entire context at full uncached price (the provider's prompt cache expires in minutes) — a 150k-token session can eat a real slice of a fresh 5-hour window the moment it wakes. unsnooze now estimates the size from the session transcript (the last message.usage entry, tail-read) before dispatch: inform resumes and notifies you of the price once the wake lands; pause holds sessions at or above the threshold (held: context ~152k tokens in status) until unsnooze resume-now, which always bypasses the guard. The estimate also shows per-session in unsnooze status (ctx ~152k tok). Claude Code only for now — the agent-adapter hook (contextTokens) is open for Codex, whose rollout token_count events carry the same data. Prompted by r/ClaudeAI feedback on a resume consuming 30% of a 5h quota.

v1.8.1

  • Discovery / SEO: package description and keywords expanded so npm and GitHub surface Qwen Code, Kimi CLI, OpenCode, Antigravity, OpenRouter, and Zellij alongside Claude/Codex/Grok. README comparison table updated to match.

v1.8.0

  • Notification channels (notifyChannel: auto | native | osc | bell, env UNSNOOZE_NOTIFY_CHANNEL): terminal-branded alerts via OSC 9 (iTerm2, kitty, WezTerm, Ghostty, Warp) or OSC 777 (rxvt), plus BEL on the pane tty. auto sends OSC+BEL when tmux can reach client/pane ttys and falls back to the OS toast only if OSC delivered nothing; denylisted terminals (Apple Terminal, VS Code, Alacritty, Zed) skip OSC in auto. OSC/BEL require tmux — Zellij and GUI-watcher stops use native. Existing notifications remains the master off-switch.
  • Unified ChatGPT desktop app support (July 2026: the Codex app became the ChatGPT app). Verified against a real install: the app's bundled codex app-server still writes rollouts to ~/.codex/sessions/ in the same format (now with additive limit_id/credits/plan_type fields and reason-string rate_limit_reached_type values — both handled), and codex resume <uuid> works for app-originated sessions. New: when codex is not on PATH but ChatGPT.app is installed, unsnooze resolves the app-bundled binary (ChatGPT.app/Contents/Resources/codex) for wrappers, wizard detection, and revival. Rollouts older than 7 days are now zstd-compressed by codex; irrelevant for detection (freshness window is minutes) but noted for unsnooze report archaeology.
  • Added a dual tmux/Zellij multiplexer backend. The new multiplexer setting accepts auto, tmux, or zellij; status output identifies the backend, qualified pane address, and revival session. Zellij revival uses structured pane ownership and a reserved-session smoke test without adding a statusline notification path.

v1.7.0

  • Four new agent adapters (all ⚠️ experimental, off by default — enable in unsnooze setup):
    • Qwen Code (qwen): Claude-shaped StopFailure hook installed into ~/.qwen/settings.json + verbatim quota-banner scraping (legacy OAuth, Coding Plan Allocated quota exceeded → 5h window, OpenRouter passthroughs). Resumes via qwen --resume <id>, ids from qwen's *.runtime.json sidecars.
    • Kimi CLI (kimi): detects the terminal red Error code: 429 … rate_limit_reached_error line; resumes via kimi -r <id> -p "<msg>" with an on-disk id check (kimi silently starts a NEW session for unknown ids). Membership expired is notify-only.
    • OpenCode (opencode): OpenCode self-retries limits forever (sleeping until reset), so unsnooze records the stop, never touches a live self-retrying pane, and revives dead panes mid-wait via opencode -s <ses_id> — reset time parsed from the [retrying in 2h5m attempt #N] countdown.
    • Antigravity CLI (agy, Google's Gemini-CLI successor): scrapes Model quota limit exceeded / Refreshes in 6 days and 18 hours (multi-day refresh = weekly cap); 503 MODEL_CAPACITY_EXHAUSTED is treated as transient overload. Resumes via agy --conversation=<id>.
  • OpenRouter awareness: 429 bodies (Rate limit exceeded: limit_…, free-models-per-day) are detected inside OpenCode/Qwen sessions; credit exhaustion (402) notifies instead of snoozing.
  • Terminal-error channel: non-resetting errors (credits exhausted, membership expired, discontinued tiers) now raise a single desktop notification instead of being retried against a reset that will never come.
  • time-parser: understands Refreshes in 6 days and 18 hours, It will reset in 2 hours 5 minutes, Retry in 45 minutes, and Go-style countdowns (2h5m, 2m 5s, ~2 days).

v1.6.0

  • Stale-workspace guard (workspaceGuard: off | inform | pause, default inform): the repo's HEAD + dirty state are fingerprinted when a session stops and re-checked at wake. inform resumes with a "workspace changed while you slept — re-read before acting" note in the wake message; pause holds the session (desktop notification, workspace changed marker in status) until unsnooze resume-now, which prints the diff stat first. Non-git directories are unaffected. Suggested by r/codex feedback.

v1.5.0

  • unsnooze update: one command to update unsnooze itself — runs npm install -g unsnooze@latest and immediately prints the new version's changelog. Update notices and the daemon toast now say run: unsnooze update instead of the raw npm command.

v1.4.0

  • Update notices: unsnooze now checks the npm registry (at most once a day, a plain GET with nothing identifying) and tells you when a new version is out — a one-line notice after CLI commands, and a single desktop toast per version from the daemon. After you update, the next command shows a short "what's new" straight from the bundled changelog. Turn it all off with unsnooze config set updateCheck off.

v1.3.0

  • Per-session wake messages: unsnooze message <id|--all> "<text>" sets a custom resume message for specific tracked sessions (--clear reverts). Precedence: per-session → per-agent (resumeMessages.<id>) → global resumeMessage. Applies on both wake paths — typed into a live pane, or carried in argv for codex resume — and sessions with a custom message show a msg: "…" marker in unsnooze status.

v1.2.0

GUI surfaces: VS Code extension, desktop apps

Sessions running outside a terminal — Claude Code's VS Code extension and desktop app, Codex's IDE extension and desktop app — are now guarded too. There is no pane to scrape and (for Codex) no hook, so detection tails the session files the CLIs already write:

  • Claude Code: rate-limit stops land in ~/.claude/projects transcripts as structured entries (error:"rate_limit", session id, cwd, reset text). The new watcher turns them into ledger records; the weekly banner form ("resets Jul 4 at 12:30am (tz)") now parses, DST-safe.
  • Codex: rollouts never persist error events, but every turn's token_count event carries a rate_limits snapshot (used_percent, resets_at epoch). An exhausted window becomes a stop with an exact epoch reset — more precise than any scraped banner — and works for every Codex surface, since they share ~/.codex/sessions.
  • Claude desktop (cowork) sessions (experimental, macOS): sandboxed sessions under ~/Library/Application Support/Claude are detected, and revival exports the session's isolated CLAUDE_CONFIG_DIR together with CLAUDE_SECURESTORAGE_CONFIG_DIR='' so auth resolves through the default keychain entry (the sandbox holds no credentials). Verified end-to-end against a real desktop session.

Revival stays terminal-based: when the limit resets, the session reopens in a tmux window via claude --resume <id> / codex resume <id> — the same session file continues, so the conversation stays visible in the GUI's own history. Resuming inside the GUI panels is not possible today (no IPC/URI sends a prompt into them).

  • unsnooze daemon: persistent watcher process; unsnooze install --daemon (or the new wizard step) installs it as a launchd agent (macOS) or systemd user unit (Linux) so GUI sessions are watched without a shell.
  • guiWatch setting (default on) gates the watching; unsnooze status shows each stop's origin (cli, vscode, desktop, …).
  • Ledger dedupe: transcript records merge with hook/scrape records of the same session, so terminal sessions are never double-resumed.

v1.1.0

Per-agent resume messages

  • resumeMessages.claude / .codex / .grok: optional per-agent override of the global resumeMessageunsnooze config set resumeMessages.codex "..." or UNSNOOZE_RESUME_MESSAGE_CODEX. Empty means "use the global message"; clear with unsnooze config set resumeMessages.codex "". Specificity beats source: a per-agent file value outranks a global env var.
  • Setup wizard asks for per-agent messages, prefills every message prompt from the existing config, and merges over the config file — re-runs no longer clobber values set via unsnooze config.
  • Blank or whitespace-only messages are never sent: resolution falls through to the global message, then the built-in default.

CLI & fixes

  • -h / --help now print the unsnooze help (previously only unsnooze help), and the usage documents every command.
  • Install: unsnooze setup / install no longer crashes with ENOENT on machines without a ~/.claude/ directory.
  • unsnooze config set <key> "" is a valid way to clear string overrides, and a config file holding non-object JSON is treated as empty instead of corrupting later writes.

v1.0.0

First public release (previously the private claude-session-guard/csg).

Multi-CLI auto-resume

  • Claude Code and OpenAI Codex CLI fully supported; xAI Grok Build experimental (generic patterns + unsnooze report to contribute real banner captures).
  • Agent adapters (src/agents/): per-CLI banner regexes, busy/idle markers, resume invocation, session-store lookup, hook wiring.
  • Codex specifics: verbatim banner strings from the Codex source; parses try again at 3:51 PM, Feb 23rd, 2026 9:01 PM, and in 4 days 20 hours 9 minutes; dead sessions revive via codex resume <id> "<message>" — the prompt travels in argv, nothing is typed into the pane.

Settings & UX

  • Settings: ~/.unsnooze/config.json, unsnooze config list/get/set, toggles for autoResume, menuAutoAnswer, notifications, resumeMessage, and per-agent enablement (env > file > default).
  • Setup wizard: unsnooze setup — detects installed CLIs, warns when grok is the community CLI rather than Grok Build, installs wrappers (zsh + bash) and hooks.
  • Desktop notifications on limit detected / session resumed / gave up (macOS osascript, Linux notify-send, tmux fallback).

Windows / WSL

  • Windows via WSL: native Windows toast notifications from inside WSL via powershell.exe (no notify-send/X server needed), where-based CLI detection, and a friendly "install tmux / use WSL" message instead of a hard failure when tmux is missing (the agent CLI still runs, just unwatched). The tmux-independent core is exercised on Windows in CI.

Safety hardening

  • Wrappers and hooks can never brick the wrapped CLI: the shell wrapper falls through to the real claude/codex/grok when the unsnooze entry point is missing, and hook commands no-op (exit 0) instead of erroring on every turn.
  • Menus are answered from the visible screen only, never from tmux scrollback — an already-answered menu in history can no longer trigger stray keystrokes (relevant for non-alt-screen TUIs like codex --no-alt-screen).
  • Session reopen uses absolute node + entry-point paths instead of a PATH lookup — a tmux server started without npm globals (or nvm's node) on PATH can no longer break revival with command-not-found.
  • Migrates cleanly off claude-auto-retry and the pre-release csg install (fenced rc blocks and settings.json hook entries are replaced, with backups).

Testing

  • 104 unit tests plus a 12-scenario end-to-end suite (scripts/e2e-simulate.sh) that exercises every agent and safety path in real tmux with real monitor/hook/resumer processes — banner detection for all three CLIs, raw-key menu driving with selection verification, hook ingestion, dead-pane and live-pane resume, both toggles, and the 529-overload retry ladder (now env-tunable via UNSNOOZE_OVERLOAD_BACKOFF_S).
  • CI: Ubuntu + macOS (Node 20/22) and Windows (Node 22).