unsnooze 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,123 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.0 — 2026-09-10
4
+
5
+ Fish shell support, direct help/version commands, and a fix for Codex sessions
6
+ that stop at a reported 99% usage.
7
+
8
+ ### Fish shell support
9
+
10
+ `unsnooze setup` and `unsnooze install` now install fish wrappers when fish is
11
+ your login shell or its config file already exists. The default location is
12
+ `~/.config/fish/config.fish`; an absolute `XDG_CONFIG_HOME` is respected.
13
+ `unsnooze doctor` checks this file, and `unsnooze uninstall` removes the wrappers.
14
+ Re-running setup replaces the existing block and preserves your other settings.
15
+ Use `--fishrc <path>` with install or uninstall to choose another config file.
16
+
17
+ After upgrading, run `unsnooze setup` once to add the fish wrappers, then open
18
+ a new shell. Thanks to [@duncanmcqueen](https://github.com/duncanmcqueen) for
19
+ [PR #21](https://github.com/saaranshM/unsnooze/pull/21).
20
+
21
+ ### Help and version commands run directly
22
+
23
+ Commands such as `claude --help` and `codex --version` now pass straight to
24
+ the agent. They do not open a multiplexer session, start a pane monitor,
25
+ prepend `launchExtraArgs`, or append an unsnooze update notice. The existing
26
+ print-mode passthrough is unchanged. Thanks to [@chid](https://github.com/chid)
27
+ for [PR #23](https://github.com/saaranshM/unsnooze/pull/23).
28
+
29
+ ### Codex can be detected as stopped at a reported 99%
30
+
31
+ The GUI watcher now recognizes the sequence reported in
32
+ [issue #20](https://github.com/saaranshM/unsnooze/issues/20): a five-hour Codex
33
+ window reaches 99%, then the next rate-limit snapshot switches to an empty
34
+ `premium` bucket with no credits. It records the stop and schedules the session
35
+ using the previous reset time, plus the normal safety margin.
36
+
37
+ This fallback requires snapshots from the same rollout no more than a minute
38
+ apart, no available credits, and a future reset time. It works across watcher
39
+ polls and daemon restarts. An already-exhausted weekly window still takes
40
+ precedence if it resets later. A 99% reading alone does not trigger a resume.
41
+
42
+ The separate report of stale 64% usage was not reproduced; this release fixes
43
+ the missed stop detection. Thanks to [@mio-tsuki](https://github.com/mio-tsuki)
44
+ for the sample. Fixed in [PR #24](https://github.com/saaranshM/unsnooze/pull/24).
45
+
46
+ ### Test fixes
47
+
48
+ - Permission tests now set their required file mode explicitly, so they pass
49
+ with different system umasks. Thanks to
50
+ [@Chang-Jin-Lee](https://github.com/Chang-Jin-Lee) for
51
+ [PR #22](https://github.com/saaranshM/unsnooze/pull/22).
52
+ - Fish install/uninstall tests use temporary profiles and leave existing user
53
+ settings, wrappers, and daemon state untouched.
54
+ - Help/version tests use a portable test executable instead of relying on
55
+ platform-specific `echo` behavior.
56
+
57
+ ## 1.18.0 — 2026-09-02
58
+
59
+ **Cursor CLI support**, a wrapper change that keeps `cursor .` working, and a
60
+ model-limit notification that finally names a remedy the CLI actually has.
61
+
62
+ ### Cursor CLI (`cursor-agent`)
63
+
64
+ ⚠️ Experimental, off by default — turn it on in `unsnooze setup`, or:
65
+
66
+ ```sh
67
+ unsnooze config set agents.cursor on && unsnooze setup
68
+ ```
69
+
70
+ | | |
71
+ |---|---|
72
+ | **Detection** | pane scrape |
73
+ | **Limit type** | `model` — probed, never a scheduled wake |
74
+ | **Revive** | `cursor-agent --resume=<id>`, else `--continue` |
75
+ | **Chat ids** | `~/.cursor/chats/<md5 of cwd>/<id>/meta.json`, verified against the cwd |
76
+ | **Wraps** | `cursor-agent` only |
77
+
78
+ **Cursor's limit is the first one not worth waiting for.** Its usage resets on
79
+ your monthly *billing cycle*, not a rolling window. From a real limit:
80
+
81
+ ```
82
+ Error: You've hit your usage limit
83
+ Get Cursor Pro for more Agent usage, unlimited Tab, and more.
84
+ fallbackModel:
85
+ spendLimitHit: false
86
+ chatMessage: *…Your usage limits will reset when your monthly cycle ends on 10/2/2026.*
87
+ spendLimits: [50,100,200]
88
+ ```
89
+
90
+ A month out. Sleeping on that would mean a monitor waking in October to type
91
+ into a wall that never moved. So unsnooze treats it as a **model limit**:
92
+
93
+ - records the stop and tells you it needs a decision
94
+ - probes at 15/30/60 min, resuming the moment the banner clears — you change
95
+ plan, or the cycle rolls
96
+ - marks the record `failed` at the probe ceiling instead of sending a futile wake
97
+
98
+ `resetPatterns` is empty on purpose (a test pins it): any reset pattern there
99
+ would silently re-route Cursor onto the 5-hour fallback ladder.
100
+
101
+ Transport errors take the transient-overload ladder and `Authentication
102
+ required` is notify-only — neither reaches the ledger. Banner text is
103
+ server-provided and plan-dependent, so `unsnooze report cursor` is how it
104
+ improves.
105
+
106
+ ### `cursor .` still opens the IDE
107
+
108
+ Cursor ships its CLI as both `cursor-agent` and `agent`, while the bare
109
+ `cursor` is the *editor* launcher. Adapters can now declare `wrapperNames`, so
110
+ unsnooze wraps `cursor-agent` and leaves the other two alone — `agent` is far
111
+ too generic to shadow safely. Wrapper output for the existing seven adapters is
112
+ unchanged, byte for byte.
113
+
114
+ ### Model limits now suggest something you can actually do
115
+
116
+ Both the "needs you" and probe-ceiling notifications hardcoded Claude's
117
+ wording, so a Cursor user was told to run `/usage-credits` — a command Cursor
118
+ does not have. The hint moves onto the adapter, and since a model limit has no
119
+ reset time to wait for, that one sentence is the whole remedy.
120
+
3
121
  ## 1.17.0 — 2026-08-23
4
122
 
5
123
  - **A state directory unsnooze cannot write to no longer pins a CPU core
package/README.md CHANGED
@@ -6,14 +6,14 @@
6
6
 
7
7
  [![CI](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml/badge.svg)](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml)
8
8
  [![npm](https://img.shields.io/npm/v/unsnooze?color=f59e0b)](https://www.npmjs.com/package/unsnooze)
9
- [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3fb950)](package.json)
9
+ [![node](https://img.shields.io/badge/node-%E2%89%A5%2020.12-3fb950)](package.json)
10
10
  [![license](https://img.shields.io/badge/license-MIT-8b949e)](LICENSE)
11
11
 
12
12
  **Automatically resume every limit-stopped AI coding session when its usage limit resets.**
13
13
 
14
14
  [Website](https://unsnooze.dev) · [Documentation](https://unsnooze.dev/docs/) · [Changelog](https://unsnooze.dev/changelog/) · [Feedback](https://unsnooze.dev/feedback/)
15
15
 
16
- **Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
16
+ **Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity · Cursor** — when they hit the 5-hour or weekly usage limit
17
17
  ("You've hit your usage limit"), your session just… stops.<br/>
18
18
  unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
19
19
  your projects and **wakes each one up the moment the usage limit resets** — in
@@ -35,7 +35,7 @@ existing tool solves only a slice of it:
35
35
 
36
36
  | | **unsnooze** | claude-auto-retry | autoclaude | hydra |
37
37
  |---|:---:|:---:|:---:|:---:|
38
- | Multi-CLI (Claude · Codex · Grok · Qwen · Kimi · OpenCode · Antigravity) | ✅ | ❌ Claude only | ❌ Claude only | partial |
38
+ | Multi-CLI (Claude · Codex · Grok · Qwen · Kimi · OpenCode · Antigravity · Cursor) | ✅ | ❌ Claude only | ❌ Claude only | partial |
39
39
  | GUI sessions (VS Code ext, desktop apps) | ✅ watcher daemon | ❌ | ❌ | ❌ |
40
40
  | Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
41
41
  | All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
@@ -143,6 +143,24 @@ risks, and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
143
143
  overload, not a limit. Dead sessions revive via `agy --conversation=<id>`
144
144
  (ids from `~/.gemini/antigravity-cli/history.jsonl`). Like Grok: closed
145
145
  source, so `unsnooze report` captures make this adapter better.
146
+ - **Cursor CLI (`cursor-agent`)** — ⚠️ *experimental*, and the only adapter
147
+ whose limit is **not waitable**. Cursor's included usage resets on your
148
+ monthly *billing cycle*, not a rolling window, so unsnooze deliberately never
149
+ schedules a wake for it: the banner is classified as a model limit, which
150
+ probes every 15/30/60 min and resumes the moment it clears (you switch to
151
+ Auto, enable on-demand, or the cycle rolls) instead of sleeping for weeks.
152
+ The banner text is server-provided — the CLI renders `Error: <title>` plus a
153
+ detail line and `key: value` extras — and the patterns are pinned against a
154
+ real captured limit (whose own reset date was a month out, which is the point).
155
+ `unsnooze report cursor` is how the rest gets better. Transport errors take the transient-overload ladder and
156
+ `Authentication required` is notify-only. Dead sessions revive via
157
+ `cursor-agent --resume=<id>` — ids come from `~/.cursor/chats/<md5 of the
158
+ cwd>/<chatId>/meta.json`, which records the `cwd` so every candidate is
159
+ verified before use — and `--continue` otherwise, which is workspace-scoped.
160
+
161
+ The wrapper shadows **`cursor-agent`** only. The bare `cursor` command is the
162
+ IDE launcher (`cursor .`) and is never touched; the newer `agent` alias is
163
+ left alone too, being too generic a name to shadow safely.
146
164
 
147
165
  **OpenRouter** (the API gateway) isn't a separate agent: its 429 bodies
148
166
  (`Rate limit exceeded: limit_rpd/…`, free-models-per-day) are detected inside
@@ -205,7 +223,8 @@ Two things worth knowing:
205
223
 
206
224
  ## GUI surfaces (VS Code extension, desktop apps)
207
225
 
208
- Terminal sessions are watched through the shell wrapper + tmux, Zellij, or herdr. Sessions in
226
+ Terminal sessions are watched through the shell wrapper and tmux, Zellij, herdr,
227
+ or cmux, with headless watching when no multiplexer is available. Sessions in
209
228
  **Claude Code's VS Code extension / desktop app** and **Codex's IDE
210
229
  extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
211
230
  the session files those surfaces already write:
@@ -221,6 +240,10 @@ the session files those surfaces already write:
221
240
  where Codex lives only inside ChatGPT.app (no `codex` on PATH), unsnooze
222
241
  automatically resumes through the app-bundled binary
223
242
  (`/Applications/ChatGPT.app/Contents/Resources/codex`).
243
+ The watcher also recognizes a reported 99% five-hour window followed within
244
+ one minute by an empty `premium` bucket with no credits, using the previous
245
+ future reset time. The snapshots must come from the same rollout; 99% alone
246
+ is not a stop signal. This works across watcher polls and daemon restarts.
224
247
  - **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
225
248
  sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
226
249
  those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
@@ -271,11 +294,11 @@ StopFailure hook (claude, grok) ──────────────┤
271
294
 
272
295
  </details>
273
296
 
274
- Limit events are never persisted by the CLIs themselves; the reset time is
275
- parsed from the banner text, DST-safe. Unparseable banners are never guessed
276
- at — unsnooze probes the pane on a backoff schedule until a real reset time
277
- appears. Every resume is verified afterwards (banner came back → reschedule
278
- from the fresh banner, capped at 5 attempts).
297
+ Detection uses agent hooks, saved session files, and terminal banners. Reset
298
+ times come from the agent's saved timestamp or the banner text, parsed DST-safe.
299
+ When no reset time is available, unsnooze probes on a backoff schedule. Every
300
+ resume is verified afterwards (banner came back → reschedule from the fresh
301
+ banner, capped at 5 attempts).
279
302
 
280
303
  ## Know the wall before you hit it
281
304
 
@@ -557,11 +580,11 @@ password hosts on Windows; a plain `ssh <host>` prompt works with native
557
580
  | `notifyChannel` | `auto` | How to deliver: `auto`, `native`, `osc`, or `bell` (see [Notification channels](#notification-channels)). Env: `UNSNOOZE_NOTIFY_CHANNEL`. |
558
581
  | `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
559
582
  | `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`. |
560
- | `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 ""`. |
561
- | `resumeExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Extra flags for launches unsnooze performs itself. A revival spawns the agent binary directly, so flags you normally get from a shell alias or wrapper (`--dangerously-skip-permissions`, `--model …`) do not apply unless you put them here. Quoting is respected: `--append-system-prompt "stay in this repo"` is two arguments, not five. `config.json` may also hold an array (`["--append-system-prompt", "stay in this repo"]`), which skips parsing entirely. |
562
- | `launchExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Extra flags for the sessions **you** start through the shell wrapper — the launch-side twin of `resumeExtraArgs`. Use it for flags that have to hold for the whole session: `unsnooze config set launchExtraArgs.claude "--autocompact 400000"` keeps a long, context-heavy run compacting instead of stalling. Flags are placed before your own arguments, since `claude "do the thing"` puts a positional prompt there. Revivals inherit them too, so a flag survives the wake. |
583
+ | `resumeMessages.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
584
+ | `resumeExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Extra flags for launches unsnooze performs itself. A revival spawns the agent binary directly, so flags you normally get from a shell alias or wrapper (`--dangerously-skip-permissions`, `--model …`) do not apply unless you put them here. Quoting is respected: `--append-system-prompt "stay in this repo"` is two arguments, not five. `config.json` may also hold an array (`["--append-system-prompt", "stay in this repo"]`), which skips parsing entirely. |
585
+ | `launchExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Extra flags for the sessions **you** start through the shell wrapper — the launch-side twin of `resumeExtraArgs`. Use it for flags that have to hold for the whole session: `unsnooze config set launchExtraArgs.claude "--autocompact 400000"` keeps a long, context-heavy run compacting instead of stalling. Flags are placed before your own arguments, since `claude "do the thing"` puts a positional prompt there. Revivals inherit them too, so a flag survives the wake. |
563
586
  | `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
564
- | `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`. |
587
+ | `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` / `agents.cursor` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
565
588
  | `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. |
566
589
  | `contextGuard` | `inform` | Big cold context at wake? Waking a session re-reads its **entire context at full uncached price** ([why](#why-did-resuming-a-big-session-eat-so-much-of-my-quota)). `inform` resumes and notifies you of the size; `pause` holds sessions above the threshold for `unsnooze resume-now`; `off` disables. Claude Code only for now. |
567
590
  | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
@@ -662,9 +685,20 @@ watcher stops (no pane context) always use native.
662
685
  [Watching without a multiplexer](#watching-without-a-multiplexer))
663
686
  - tmux ≥ 3.2, Zellij, **or** herdr ≥ 0.8.0 for pane-level watching. Optional:
664
687
  without one, unsnooze runs `headless` and still catches and resumes stops.
665
- - zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), or PowerShell
688
+ - zsh or bash (wrappers go into `~/.zshrc` / `~/.bashrc`), fish (wrappers go
689
+ into `~/.config/fish/config.fish`), or PowerShell
666
690
  (wrappers go into `$PROFILE.CurrentUserAllHosts`)
667
691
 
692
+ Fish wrappers are installed when fish is the login shell or its config already
693
+ exists. An absolute `XDG_CONFIG_HOME` changes the config location to
694
+ `$XDG_CONFIG_HOME/fish/config.fish`; `unsnooze install --fishrc <path>` and
695
+ `unsnooze uninstall --fishrc <path>` override it explicitly. After upgrading an
696
+ existing install, run `unsnooze setup` and open a new shell to load the wrappers.
697
+
698
+ Agent help/version commands, such as `claude --help` and `codex --version`, run
699
+ directly without a multiplexer session, pane monitor, `launchExtraArgs`, or an
700
+ unsnooze update notice. `unsnooze help` still shows unsnooze's own commands.
701
+
668
702
  ### Supported terminals
669
703
 
670
704
  unsnooze drives four terminal multiplexers, and works without one.
package/bin/unsnooze.js CHANGED
@@ -26,6 +26,7 @@ const AGENT_FALLBACK_BINS = {
26
26
  kimi: () => process.env.UNSNOOZE_KIMI_BIN || 'kimi',
27
27
  opencode: () => process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
28
28
  agy: () => process.env.UNSNOOZE_AGY_BIN || 'agy',
29
+ cursor: () => process.env.UNSNOOZE_CURSOR_BIN || 'cursor-agent',
29
30
  };
30
31
 
31
32
  async function safeImport(specifier) {
@@ -68,7 +69,7 @@ async function maybeLaunchExitNotice(args) {
68
69
  try {
69
70
  if (process.env.TMUX || process.env.ZELLIJ || process.env.UNSNOOZE_ACTIVE === '1') return;
70
71
  if (!process.stderr.isTTY) return; // pipes, CI, scripts
71
- if (args.includes('-p') || args.includes('--print')) return; // non-interactive runs
72
+ if (args.some(a => a === '-p' || a === '--print' || a === '-h' || a === '--help' || a === '-v' || a === '-V' || a === '--version')) return; // non-interactive runs
72
73
  const mod = await safeImport('../src/update-check.js');
73
74
  if (!mod) return;
74
75
  const notice = mod.launchExitNotice();
@@ -339,17 +340,23 @@ Usage:
339
340
  unsnooze usage [--json] account burn rate & time-to-limit forecast
340
341
  (--install-statusline for exact Claude %,
341
342
  --uninstall-statusline to remove it)
343
+ unsnooze design [setup] Claude Design from the terminal: check whether
344
+ the claude-design MCP server is registered and
345
+ signed in, or register it with setup
342
346
  unsnooze logs [-f] show (or follow) the unsnooze log
343
347
  unsnooze update update unsnooze itself to the latest version
344
348
  unsnooze daemon persistent watcher for GUI sessions (VS Code
345
349
  extension, desktop apps) — no live pane needed
346
- to detect; revival opens in tmux or Zellij
350
+ to detect; revival opens in your multiplexer,
351
+ or headless where there is none
347
352
  unsnooze config [list|get|set] view or change settings (toggles, global +
348
353
  per-agent resume messages, notifyChannel
349
354
  auto|native|osc|bell, updateCheck)
350
355
  unsnooze setup interactive setup wizard (agents + toggles)
351
356
  unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
357
+ (--fishrc <path> overrides the fish config)
352
358
  unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
359
+ (--fishrc <path> overrides the fish config)
353
360
  unsnooze report [agent] [pane] capture a pane to report an undetected banner
354
361
  unsnooze help show this help (also -h / --help)`);
355
362
  return 0;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.17.0",
4
- "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
3
+ "version": "1.19.0",
4
+ "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -18,6 +18,9 @@
18
18
  "moonshot",
19
19
  "opencode",
20
20
  "antigravity",
21
+ "cursor",
22
+ "cursor-cli",
23
+ "cursor-agent",
21
24
  "antigravity-cli",
22
25
  "gemini",
23
26
  "google",
@@ -114,6 +114,9 @@ export default {
114
114
  bin: process.env.UNSNOOZE_CLAUDE_BIN || 'claude',
115
115
  experimental: false,
116
116
  patterns,
117
+ // Remedy hint for a model limit — no reset time to wait for, so the
118
+ // notification has to say what the user can actually do about it.
119
+ modelRemedy: 'switch models (/model) or add credits (/usage-credits)',
117
120
  menu: { isPrompt: isRateLimitOptionsPrompt, stepsToWait: menuStepsToWaitOption },
118
121
  // How to reopen a dead session.
119
122
  //
@@ -0,0 +1,225 @@
1
+ // Cursor CLI adapter (`cursor-agent`) — EXPERIMENTAL.
2
+ //
3
+ // Cursor ships its terminal agent as ~/.local/bin/{agent,cursor-agent}, both
4
+ // symlinks to ~/.local/share/cursor-agent/versions/<v>/cursor-agent. That file
5
+ // is a bash shim that runs `exec -a "$0" <bundled node> index.js`, so the
6
+ // binary is Node and tmux reports `node` as pane_current_command (verified on
7
+ // darwin/arm64, 2026.08.31-4057e58).
8
+ //
9
+ // The id is `cursor` but the wrapped command is `cursor-agent` (see
10
+ // `wrapperNames` below): the bare `cursor` is the IDE launcher (`cursor .`)
11
+ // and must never be shadowed. The other name, `agent`, is deliberately NOT
12
+ // wrapped — too generic to shadow safely.
13
+ //
14
+ // WHY resetPatterns IS EMPTY — do not "fix" this.
15
+ // Cursor's included usage resets on the monthly BILLING CYCLE ("Your usage
16
+ // limits will reset when your monthly cycle ends on 8/14/2025"), not on a
17
+ // rolling window. detectLimit() runs the waitable-limit loop before the
18
+ // model-limit branch, so a reset pattern here would pair with the banner and
19
+ // produce a `limitType: 'unknown'` record whose unparseable date falls back to
20
+ // 5h — i.e. a blind resume into a wall that is still standing. An empty
21
+ // resetPatterns routes the banner down the model-limit path instead, which
22
+ // probes until the banner clears and never schedules a blind wake. There is a
23
+ // test pinning this.
24
+ //
25
+ // Banner provenance: the limit text is SERVER-provided. The CLI bundle
26
+ // contains no limit strings — it renders `Error: ${details.title}`, then
27
+ // `details.detail`, then one `key: value` line per `details.additionalInfo`
28
+ // entry, with persistUntilInput so the banner stays on screen. CAPTURED FROM A
29
+ // REAL LIMIT (free plan, 2026-09-02):
30
+ //
31
+ // Error: You've hit your usage limit
32
+ // Get Cursor Pro for more Agent usage, unlimited Tab, and more.
33
+ // fallbackModel:
34
+ // spendLimitHit: false
35
+ // chatMessage: *You've hit your free requests limit. [Upgrade to
36
+ // Pro](https://www.cursor.com/...) for more usage, frontier models, Cloud
37
+ // Agents, and more. Your usage limits will reset when your monthly cycle
38
+ // ends on 10/2/2026.*
39
+ // spendLimits: [50,100,200]
40
+ //
41
+ // Note the reset is a MONTH out — exactly why this is not a waitable stop. A
42
+ // `-p` run prints the same thing to stderr with the error class as the prefix
43
+ // (`ActionRequiredError: ...`) and exits 1. Pro-plan wording differs ("Switch
44
+ // to Auto…", "set a Spend Limit") and is covered from user reports; improve
45
+ // the rest from real hits with `unsnooze report cursor`.
46
+ //
47
+ // Verified live against 2026.08.31-4057e58 (logged in, real tmux):
48
+ // idle ` → Plan, search, build anything` / ` → Add a follow-up`
49
+ // busy `⠘⠤ Working` (braille spinner + label) and the input box's
50
+ // right placeholder `ctrl+c to stop`
51
+ // resume `--continue` is WORKSPACE-scoped — run from project A it resumed
52
+ // A's chat, not the globally-newest one in B. That is what the
53
+ // resumer's one-anonymous-record-per-agent+cwd rule assumes, so no
54
+ // --workspace flag is needed.
55
+ // submit the wake message lands with tmux's `send-keys -l` + a separate
56
+ // `send-keys Enter`, which is exactly what mux.sendText already does.
57
+ //
58
+ // A first launch in an untrusted directory shows a modal "⚠ Workspace Trust
59
+ // Required" screen instead of the prompt. It carries no `→`, so idleRegex
60
+ // already refuses to type into it; busyPatterns names it as well so the refusal
61
+ // does not depend on that. unsnooze deliberately does NOT pass `--trust` on
62
+ // revival — granting execution trust is the user's call, not a side effect of
63
+ // waking a session.
64
+ //
65
+ // Deferred: Cursor writes a status into the TERMINAL TITLE (idle/generating/
66
+ // planning/shell-running/waiting-for-user...), which is a far cleaner
67
+ // busy/idle signal than scraping — but the adapter contract only sees
68
+ // capture-pane text. See the adapter notes in the changelog.
69
+
70
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
71
+ import { createHash } from 'node:crypto';
72
+ import { homedir } from 'node:os';
73
+ import { join } from 'node:path';
74
+
75
+ const CURSOR_DIR = () => process.env.UNSNOOZE_CURSOR_DIR || join(homedir(), '.cursor');
76
+ const SCAN_LIMIT = 50; // project dirs walked by the fallback scan
77
+
78
+ const LIMIT_ANCHORS = [
79
+ /You[’']ve hit your (?:usage|free requests?|monthly) limit/i,
80
+ /You[’']ve hit your limit/i,
81
+ /(?:usage|spend) limit reached/i,
82
+ ];
83
+
84
+ export const patterns = {
85
+ limitPatterns: LIMIT_ANCHORS,
86
+ resetPatterns: [], // deliberate — see the header
87
+ weeklyPatterns: [],
88
+ fiveHourPatterns: [],
89
+ // The monthly cap is lifted by a human choice (switch to Auto, enable
90
+ // on-demand, wait for the billing date), never by a short wait — the same
91
+ // shape as claude's per-model limit, so it takes the same path.
92
+ modelLimitPatterns: LIMIT_ANCHORS,
93
+ // The detail line is plan-dependent — a free account is told to upgrade, a Pro
94
+ // account to switch models or raise a spend limit — so cover both rather than
95
+ // leaning on the additionalInfo keys alone, which Cursor could drop.
96
+ modelRemedyPatterns: [
97
+ /Get Cursor Pro for more Agent usage/i, // free tier (captured live)
98
+ /\[?Upgrade to\s+Pro\]?/i,
99
+ /Switch to Auto for more usage/i, // Pro tier (reported)
100
+ /set a Spend Limit/i,
101
+ /usage limits will reset when your monthly cycle ends/i,
102
+ /enable (?:on-demand|usage-based)/i,
103
+ // additionalInfo entries render as bare `key: value` lines under the detail.
104
+ /^\s*(?:spendLimitHit|fallbackModel|spendLimits|chatMessage):/,
105
+ ],
106
+ busyPatterns: [
107
+ /ctrl\+c to stop/i, // input box's right placeholder while generating
108
+ /[\u2800-\u28ff]\s*(?:Working|Composing)\b/, // braille spinner + label, e.g. `⠘⠤ Working`
109
+ // Not "busy" in the streaming sense, but equally un-typeable: the modal
110
+ // trust gate on a first launch in a directory.
111
+ /Workspace Trust Required/i,
112
+ /Do you trust the contents of this directory/i,
113
+ ],
114
+ idleRegex: /→/, // the input box prompt glyph
115
+ overloadPatterns: [
116
+ /Can[’']t (?:resolve the Cursor API host|reach the Cursor API)/i,
117
+ /\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|ENOTFOUND|EAI_AGAIN|NGHTTP2|ERR_HTTP2_SESSION_ERROR)\b/,
118
+ /socket hang up|Premature close|connection aborted/i,
119
+ /Agent stopped retrying/i,
120
+ ],
121
+ transientPatterns: [
122
+ /\b(?:ECONNRESET|ETIMEDOUT|EAI_AGAIN)\b/,
123
+ ],
124
+ // Auth is not a quota — notify once and record nothing. `-p` runs print the
125
+ // error class instead of the `Error:` prefix the TUI uses, so match the text
126
+ // rather than either prefix.
127
+ terminalPatterns: [
128
+ /Authentication required\b/i,
129
+ /Please run '(?:cursor-)?agent login'/i,
130
+ ],
131
+ };
132
+
133
+ // Cursor stores each CLI chat as ~/.cursor/chats/<md5(cwd)>/<chatId>/, where
134
+ // <chatId> is the directory name and is exactly what `--resume=<id>` takes
135
+ // (verified live). Its meta.json carries `{ cwd, updatedAtMs, title }`, so the
136
+ // mapping needs no SQLite and no dependency — and every candidate is checked
137
+ // against the cwd before it is used. If Cursor ever changes the hash, the
138
+ // bounded fallback scan below still finds it; if it changes meta.json too,
139
+ // this returns null and the resumer falls back to `--continue`, which is
140
+ // workspace-scoped anyway. A wrong id would resume someone else's
141
+ // conversation, so null is always preferred to a guess.
142
+ //
143
+ // aroundTs is accepted and ignored (as in agy): the chat that just stopped is
144
+ // the most recently updated one for the cwd, which is what we return.
145
+ function readChatMeta(chatDir) {
146
+ try {
147
+ const meta = JSON.parse(readFileSync(join(chatDir, 'meta.json'), 'utf-8'));
148
+ if (!meta || typeof meta.cwd !== 'string') return null;
149
+ return { cwd: meta.cwd, updatedAtMs: Number(meta.updatedAtMs) || 0 };
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ // Newest chat under `dir` whose meta.json cwd equals `cwd`.
156
+ function newestChatIn(dir, cwd) {
157
+ let best = null;
158
+ let entries;
159
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return null; }
160
+ for (const entry of entries) {
161
+ if (!entry.isDirectory()) continue;
162
+ const meta = readChatMeta(join(dir, entry.name));
163
+ if (!meta || meta.cwd !== cwd) continue;
164
+ if (!best || meta.updatedAtMs > best.updatedAtMs) best = { id: entry.name, updatedAtMs: meta.updatedAtMs };
165
+ }
166
+ return best;
167
+ }
168
+
169
+ export function latestSessionId(cwd, aroundTs = null, cursorDir = CURSOR_DIR()) {
170
+ if (!cwd) return null;
171
+ const chats = join(cursorDir, 'chats');
172
+ // Fast path: the project directory is md5 of the cwd (verified live).
173
+ const direct = newestChatIn(join(chats, createHash('md5').update(cwd).digest('hex')), cwd);
174
+ if (direct) return direct.id;
175
+ // Fallback: the hash scheme changed. Scan the most recently touched project
176
+ // directories only — bounded so a long-lived install never walks hundreds.
177
+ let projects;
178
+ try {
179
+ projects = readdirSync(chats, { withFileTypes: true })
180
+ .filter(e => e.isDirectory())
181
+ .map(e => {
182
+ const full = join(chats, e.name);
183
+ let mtimeMs = 0;
184
+ try { mtimeMs = statSync(full).mtimeMs; } catch { /* skip */ }
185
+ return { full, mtimeMs };
186
+ })
187
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
188
+ .slice(0, SCAN_LIMIT);
189
+ } catch {
190
+ return null;
191
+ }
192
+ let best = null;
193
+ for (const project of projects) {
194
+ const found = newestChatIn(project.full, cwd);
195
+ if (found && (!best || found.updatedAtMs > best.updatedAtMs)) best = found;
196
+ }
197
+ return best ? best.id : null;
198
+ }
199
+
200
+ export default {
201
+ id: 'cursor',
202
+ name: 'Cursor CLI',
203
+ bin: process.env.UNSNOOZE_CURSOR_BIN || 'cursor-agent',
204
+ // `cursor` is the IDE launcher — wrap the agent command only.
205
+ wrapperNames: ['cursor-agent'],
206
+ experimental: true,
207
+ patterns,
208
+ // Cursor has /model, but no /usage-credits — on-demand usage is a dashboard
209
+ // setting. Telling a Cursor user to run a command that does not exist is
210
+ // worse than saying nothing.
211
+ modelRemedy: 'switch to Auto (/model) or enable on-demand usage in your Cursor dashboard',
212
+ menu: null,
213
+ // `--resume [chatId]` and `--continue` both verified in `cursor-agent --help`
214
+ // (2026.08.31). --resume takes an optional value, so use the equals form.
215
+ resumeArgs(sessionId) {
216
+ return { args: sessionId ? [`--resume=${sessionId}`] : ['--continue'], messageViaPane: true };
217
+ },
218
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
219
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
220
+ latestSessionId,
221
+ // The bash shim execs the bundled node, so tmux reports `node`.
222
+ isForegroundCommand(cmd) {
223
+ return cmd === 'cursor-agent' || cmd === 'agent' || cmd === 'node' || cmd === 'unsnooze';
224
+ },
225
+ };
@@ -8,8 +8,9 @@ import qwen from './qwen.js';
8
8
  import kimi from './kimi.js';
9
9
  import opencode from './opencode.js';
10
10
  import agy from './agy.js';
11
+ import cursor from './cursor.js';
11
12
 
12
- const REGISTRY = { claude, codex, grok, qwen, kimi, opencode, agy };
13
+ const REGISTRY = { claude, codex, grok, qwen, kimi, opencode, agy, cursor };
13
14
 
14
15
  // v1: every agent launches the bare TUI and gets the prompt typed once idle.
15
16
  function defaultLaunchArgs(message) { return { args: [], messageViaPane: true }; }
package/src/doctor.js CHANGED
@@ -16,6 +16,7 @@ import { makeLogger } from './logger.js';
16
16
  import { shouldUseTui, formatDoctorTui } from './tui.js';
17
17
  import { designRegisteredOffline } from './design.js';
18
18
  import { powershellProfilePath } from './powershell.js';
19
+ import { fishConfigPath } from './fish.js';
19
20
 
20
21
  const log = makeLogger('doctor');
21
22
 
@@ -116,13 +117,14 @@ function readOrEmpty(path) {
116
117
  // wrappers installed" is a health question — but it has to be asked of the
117
118
  // right file. A PowerShell user has no ~/.zshrc, so checking only the POSIX rc
118
119
  // files made every native-Windows `unsnooze doctor` report the wrappers
119
- // missing and point the user at an install they had already run.
120
+ // missing and point the user at an install they had already run. A fish user
121
+ // has no POSIX rc wrapper either — their block lives in the fish config.
120
122
  function defaultWrappersInstalled({
121
123
  platform = process.platform,
122
124
  rcContent = readOrEmpty,
123
125
  profileContent = null,
124
126
  } = {}) {
125
- const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')];
127
+ const rcs = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc'), fishConfigPath()];
126
128
  if (rcs.some(rc => rcContent(rc).includes(WRAPPER_MARK))) return true;
127
129
  if (platform !== 'win32') return false;
128
130
  const readProfile = profileContent
package/src/fish.js ADDED
@@ -0,0 +1,22 @@
1
+ // Where the fish shell looks for its config.
2
+ //
3
+ // A leaf module (node builtins only) on purpose — the same reason
4
+ // powershell.js exists: install.js already imports doctor.js, and doctor.js
5
+ // needs this to answer "are the wrappers installed?" for fish users.
6
+ // Declaring it in install.js would close that loop (see
7
+ // test/module-graph.test.js for what that breaks).
8
+ //
9
+ // fish reads its config from <config-home>/fish/config.fish, where
10
+ // <config-home> honors XDG_CONFIG_HOME (absolute paths only — the XDG spec
11
+ // says a relative value must be ignored, and fish follows that).
12
+
13
+ import { homedir } from 'node:os';
14
+ import { join, isAbsolute } from 'node:path';
15
+
16
+ export function fishConfigPath({ env = process.env, home = homedir() } = {}) {
17
+ // Test/CI override, matching UNSNOOZE_QWEN_DIR and the autostart dir vars.
18
+ if (env.UNSNOOZE_FISH_CONFIG) return env.UNSNOOZE_FISH_CONFIG;
19
+ const xdg = env.XDG_CONFIG_HOME;
20
+ if (xdg && isAbsolute(xdg)) return join(xdg, 'fish', 'config.fish');
21
+ return join(home, '.config', 'fish', 'config.fish');
22
+ }
package/src/install.js CHANGED
@@ -5,8 +5,11 @@
5
5
  // atomic write)
6
6
  // - ~/.zshrc + ~/.bashrc: one fence-marked block with a wrapper function per
7
7
  // enabled agent (claude/codex/grok), routed through `unsnooze _run`
8
+ // - ~/.config/fish/config.fish: the same block in fish syntax (fish shares
9
+ // no syntax with POSIX rc files — a bash function there is a parse error)
8
10
  // - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
9
- // --settings <path> / --zshrc <path> override targets (used by tests).
11
+ // --settings <path> / --zshrc <path> / --fishrc <path> override targets (used
12
+ // by tests).
10
13
 
11
14
  import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
12
15
  import { execFileSync } from 'node:child_process';
@@ -16,10 +19,12 @@ import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
16
19
  import { getConfig, configFileExists } from './settings.js';
17
20
  import { xmlEscape } from './notify.js';
18
21
  import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
22
+ import { getAgent } from './agents/index.js';
19
23
  import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
20
24
  import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
21
25
  import { uninstallStatuslineShim } from './usage.js';
22
26
  import { powershellProfilePath } from './powershell.js';
27
+ import { fishConfigPath } from './fish.js';
23
28
 
24
29
  const FENCE_OPEN = '# >>> unsnooze >>>';
25
30
  const FENCE_CLOSE = '# <<< unsnooze <<<';
@@ -32,13 +37,17 @@ const LEGACY_FENCES = [
32
37
  const OLD_FENCE_OPEN = LEGACY_FENCES[0].open;
33
38
 
34
39
  function parseArgs(rest) {
35
- const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false, daemon: false };
40
+ const opts = {
41
+ yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'),
42
+ fishrc: fishConfigPath(), purge: false, daemon: false,
43
+ };
36
44
  for (let i = 0; i < rest.length; i++) {
37
45
  if (rest[i] === '--yes' || rest[i] === '-y') opts.yes = true;
38
46
  else if (rest[i] === '--purge') opts.purge = true;
39
47
  else if (rest[i] === '--daemon') opts.daemon = true;
40
48
  else if (rest[i] === '--settings') opts.settings = rest[++i];
41
49
  else if (rest[i] === '--zshrc') opts.zshrc = rest[++i];
50
+ else if (rest[i] === '--fishrc') opts.fishrc = rest[++i];
42
51
  }
43
52
  return opts;
44
53
  }
@@ -115,18 +124,29 @@ export function removeHookFromSettings(settingsJson) {
115
124
 
116
125
  // --- zshrc block management ---
117
126
 
127
+ // Which shell commands an agent's wrappers shadow. Normally exactly the id,
128
+ // because the id IS the command users type. Cursor breaks that: its id is
129
+ // `cursor` but its command is `cursor-agent`, and the bare `cursor` belongs to
130
+ // the IDE launcher (`cursor .`) which must never be shadowed. An adapter names
131
+ // the commands to wrap via `wrapperNames`; the `_run` argument stays the id.
132
+ export function wrapperNamesFor(id) {
133
+ const agent = getAgent(id);
134
+ // getAgent falls back to claude for unknown ids — never borrow its names.
135
+ return agent.id === id && agent.wrapperNames?.length ? agent.wrapperNames : [id];
136
+ }
137
+
118
138
  export function wrapperBlock(agents = ['claude']) {
119
139
  // The missing-file guard is load-bearing: if unsnooze is ever uninstalled,
120
140
  // moved, or renamed without cleaning the rc file, the wrapper must degrade
121
141
  // to the plain CLI — never brick the user's `claude`/`codex` command.
122
- const fns = agents.map(id => `unalias ${id} 2>/dev/null || true
123
- ${id}() {
142
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `unalias ${name} 2>/dev/null || true
143
+ ${name}() {
124
144
  if [ "\${UNSNOOZE_ACTIVE}" = "1" ] || [ ! -f "${UNSNOOZE_BIN}" ]; then
125
- command ${id} "$@"
145
+ command ${name} "$@"
126
146
  return $?
127
147
  fi
128
148
  node "${UNSNOOZE_BIN}" _run ${id} "$@"
129
- }`).join('\n');
149
+ }`)).join('\n');
130
150
  return `${FENCE_OPEN}
131
151
  # unsnooze wrappers: route every interactive launch of the CLIs below through
132
152
  # unsnooze so limit stops are recorded and auto-resumed.
@@ -142,16 +162,16 @@ ${FENCE_CLOSE}`;
142
162
  // PowerShell comments are `#`, so the same fence markers work and
143
163
  // stripFencedBlock() removes it unchanged.
144
164
  export function powershellWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
145
- const fns = agents.map(id => `Remove-Item -Path Alias:${id} -Force -ErrorAction SilentlyContinue
146
- function ${id} {
165
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `Remove-Item -Path Alias:${name} -Force -ErrorAction SilentlyContinue
166
+ function ${name} {
147
167
  if ($env:UNSNOOZE_ACTIVE -eq '1' -or -not (Test-Path -LiteralPath '${bin}')) {
148
- $real = Get-Command ${id} -CommandType Application -ErrorAction SilentlyContinue |
168
+ $real = Get-Command ${name} -CommandType Application -ErrorAction SilentlyContinue |
149
169
  Select-Object -First 1
150
- if ($real) { & $real.Source @args } else { Write-Error '${id}: not found' }
170
+ if ($real) { & $real.Source @args } else { Write-Error '${name}: not found' }
151
171
  return
152
172
  }
153
173
  & node '${bin}' _run ${id} @args
154
- }`).join('\n');
174
+ }`)).join('\n');
155
175
  return `${FENCE_OPEN}
156
176
  # unsnooze wrappers: route every interactive launch of the CLIs below through
157
177
  # unsnooze so limit stops are recorded and auto-resumed.
@@ -192,6 +212,38 @@ export function installZshrcBlock(content, agents = ['claude']) {
192
212
  return { content: result, oldRemoved };
193
213
  }
194
214
 
215
+ // The fish twin of wrapperBlock(). Fish shares no syntax with the POSIX block:
216
+ // `name() { … }` is a parse error there, `$?` is `$status`, and `"${X}"` is
217
+ // `"${X}"` verbatim text — so the wrapper gets its own body. Same semantics as
218
+ // the zsh/bash wrapper: a recursion guard (UNSNOOZE_ACTIVE), the load-bearing
219
+ // missing-file fallback to the real CLI, and `_run <id>` routing.
220
+ //
221
+ // Redefining a function replaces any earlier one, which covers fish "aliases"
222
+ // (they are functions too) — no `unalias` analogue is needed. Fish comments
223
+ // are `#`, so the same fence markers work and stripFencedBlock() removes it
224
+ // unchanged.
225
+ export function fishWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
226
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `function ${name} --description 'unsnooze-wrapped ${name}'
227
+ if test "$UNSNOOZE_ACTIVE" = "1"; or not test -f '${bin}'
228
+ command ${name} $argv
229
+ return $status
230
+ end
231
+ node '${bin}' _run ${id} $argv
232
+ end`)).join('\n');
233
+ return `${FENCE_OPEN}
234
+ # unsnooze wrappers: route every interactive launch of the CLIs below through
235
+ # unsnooze so limit stops are recorded and auto-resumed.
236
+ ${fns}
237
+ ${FENCE_CLOSE}`;
238
+ }
239
+
240
+ // installZshrcBlock's fish twin. Same fence, same replace-don't-append
241
+ // behaviour, so re-running setup never stacks a second copy of the wrappers.
242
+ export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BIN) {
243
+ const { content: cleaned } = stripFencedBlock(content, FENCE_OPEN, FENCE_CLOSE);
244
+ return cleaned.replace(/\n+$/, '\n') + '\n' + fishWrapperBlock(agents, bin) + '\n';
245
+ }
246
+
195
247
  // --- daemon autostart ---
196
248
  // GUI sessions (VS Code extension, desktop apps) never pass through the shell
197
249
  // wrappers, so their limit stops are only caught while `unsnooze daemon` is
@@ -502,7 +554,7 @@ export function uninstallDaemonAutostart({ platform = process.platform, dir = nu
502
554
  // --- commands ---
503
555
 
504
556
  export function enabledAgents() {
505
- return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy'].filter(id => getConfig(`agents.${id}`));
557
+ return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy', 'cursor'].filter(id => getConfig(`agents.${id}`));
506
558
  }
507
559
 
508
560
  // Qwen keeps Claude-shaped hooks in its own settings.json — reuse the same
@@ -543,6 +595,22 @@ function rcTargets(opts, explicit) {
543
595
  return existing.length > 0 ? existing : [opts.zshrc];
544
596
  }
545
597
 
598
+ // The fish config is a separate target from the POSIX rc loop: fish syntax
599
+ // shares nothing with bash, so it gets its own block, and fish reads only
600
+ // <config-home>/fish/config.fish. Touch it when the file already exists (the
601
+ // same any-rc rule as zsh/bash), or when fish is the login shell — a fish
602
+ // user may not have created config.fish yet, and a wrapper that would never
603
+ // load is the same silent-miss as a profile written to a guessed path. Empty
604
+ // list = no evidence of fish on this machine; leave it alone.
605
+ function fishTargets(opts, explicit) {
606
+ if (explicit) return [opts.fishrc];
607
+ const cfg = fishConfigPath();
608
+ if (existsSync(cfg)) return [cfg];
609
+ const loginShell = process.env.SHELL || userInfo().shell;
610
+ if (typeof loginShell === 'string' && /(^|\/|\\)fish$/.test(loginShell)) return [cfg];
611
+ return [];
612
+ }
613
+
546
614
  export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
547
615
  const opts = parseArgs(rest);
548
616
  const explicitRc = rest.includes('--zshrc');
@@ -611,6 +679,21 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
611
679
  }
612
680
  }
613
681
 
682
+ // 3c. Fish config. The POSIX loop above is a parse error in fish, and the
683
+ // PowerShell profile never loads there — fish needs its own block or a
684
+ // fish user's `claude` never routes through unsnooze at all.
685
+ for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
686
+ try {
687
+ mkdirSync(dirname(cfg), { recursive: true });
688
+ const before = existsSync(cfg) ? readFileSync(cfg, 'utf-8') : '';
689
+ if (existsSync(cfg)) backupOnce(cfg);
690
+ atomicWrite(cfg, installFishBlock(before, agents, UNSNOOZE_BIN));
691
+ console.log(`unsnooze: fish wrappers (${agents.join(', ')}) installed in ${cfg}`);
692
+ } catch (err) {
693
+ console.log(`unsnooze: could not write the fish config (${err.message})`);
694
+ }
695
+ }
696
+
614
697
  // 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
615
698
  if (opts.daemon) {
616
699
  const target = installDaemonAutostart();
@@ -686,6 +769,17 @@ export function cmdUninstall(rest) {
686
769
  console.log(`unsnooze: could not clean the PowerShell profile (${err.message})`);
687
770
  }
688
771
 
772
+ // The fish config is a wrapper site too, same reason as the PowerShell
773
+ // profile above: a stale block would keep shadowing the real CLI.
774
+ for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
775
+ if (!existsSync(cfg)) continue;
776
+ const { content, found } = stripFencedBlock(readFileSync(cfg, 'utf-8'), FENCE_OPEN, FENCE_CLOSE);
777
+ if (found) {
778
+ atomicWrite(cfg, content);
779
+ console.log(`unsnooze: fish wrappers removed from ${cfg}`);
780
+ }
781
+ }
782
+
689
783
  const autostart = uninstallDaemonAutostart();
690
784
  if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
691
785
 
package/src/launcher.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // - outside a multiplexer: re-exec through its launchWrapped operation
3
3
  // - inside one: spawn a detached per-pane monitor, then run the CLI,
4
4
  // propagating its exit code
5
- // - -p/--print: pure pass-through, no monitor (nothing interactive to scrape)
5
+ // - -p/--print, -h/--help, -v/-V/--version: pure pass-through, no monitor (nothing interactive to scrape)
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
8
  import { getMultiplexer } from './multiplexer.js';
@@ -16,8 +16,12 @@ import { AgentDispatchedError, SessionCreateError, attachHint } from './multiple
16
16
 
17
17
  const log = makeLogger('launcher');
18
18
 
19
- function isPrintMode(args) {
20
- return args.includes('-p') || args.includes('--print');
19
+ export function isPassthrough(args = []) {
20
+ return args.some(a =>
21
+ a === '-p' || a === '--print'
22
+ || a === '-h' || a === '--help'
23
+ || a === '-v' || a === '-V' || a === '--version'
24
+ );
21
25
  }
22
26
 
23
27
  export function resolvePaneOwner(muxName, env = process.env) {
@@ -44,7 +48,12 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
44
48
  // Recursion / nested-launch guard: inside an unsnooze-managed session, a
45
49
  // plain `claude`/`codex`/`unsnooze` call goes straight through. Same for an
46
50
  // agent the user disabled in settings — run it, don't watch it.
47
- if (process.env.UNSNOOZE_ACTIVE === '1' || isPrintMode(args) || !getConfig(`agents.${agent.id}`)) {
51
+ //
52
+ // Non-interactive / informational invocations (-p/--print, -h/--help, -v/--version)
53
+ // also bypass multiplexer wrapping and monitoring — running a new tmux session
54
+ // or spawning a watcher for `claude --help` flashes the screen and slows down
55
+ // simple queries.
56
+ if (process.env.UNSNOOZE_ACTIVE === '1' || isPassthrough(args) || !getConfig(`agents.${agent.id}`)) {
48
57
  const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
49
58
  return r.status ?? 1;
50
59
  }
package/src/monitor.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
17
17
  PROBE_INTERVAL_MS, RESET_MARGIN_MS, LEASE_GRACE_MS,
18
18
  } from './config.js';
19
- import { detectLimit, isBusy, overloadMatch } from './patterns.js';
19
+ import { detectLimit, isBusy, overloadMatch, modelRemedy } from './patterns.js';
20
20
  import { getAgent } from './agents/index.js';
21
21
  import { getConfig } from './settings.js';
22
22
  import { notify } from './notify.js';
@@ -208,7 +208,7 @@ export function createMonitor({
208
208
  log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
209
209
  if (resolved.limitType === 'model') {
210
210
  notifier(`${agent.name} hit a model limit — needs you`,
211
- `${cwd} — switch models (/model) or add credits (/usage-credits); unsnooze wakes it once the banner clears`,
211
+ `${cwd} — ${modelRemedy(agent)}; unsnooze wakes it once the banner clears`,
212
212
  { context: notifyCtx });
213
213
  } else {
214
214
  notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
package/src/patterns.js CHANGED
@@ -148,6 +148,13 @@ export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
148
148
  return { hit: true, limitType, resetLine };
149
149
  }
150
150
 
151
+ // What a user can actually do about a model limit. There is no reset time to
152
+ // wait for, so the notification must name a real remedy — and the remedy is
153
+ // per-CLI (claude has /usage-credits, cursor does not).
154
+ export function modelRemedy(agent) {
155
+ return agent?.modelRemedy || 'switch models or add credits';
156
+ }
157
+
151
158
  function tail(text, n = 12) {
152
159
  return contentLines(text, n);
153
160
  }
package/src/resumer.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  CAPTURE_LINES, PANE_SCAN_LINES, RESUME_SESSION_NAME,
15
15
  RESET_MARGIN_MS, FALLBACK_RESET_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
16
16
  } from './config.js';
17
- import { detectLimit, isBusy } from './patterns.js';
17
+ import { detectLimit, isBusy, modelRemedy } from './patterns.js';
18
18
  import { getAgent } from './agents/index.js';
19
19
  import { parseResetTime, resetAtMs, nextProbeDelayMs } from './time-parser.js';
20
20
  import {
@@ -372,7 +372,7 @@ function rescheduleProbe(rec, now = Date.now()) {
372
372
  }, { expect: ['stopped'] });
373
373
  log(`${key}: model limit still active at probe ceiling — needs a human`);
374
374
  notify('unsnooze: model limit needs you ⚠️',
375
- `${rec.cwd}: still limited after probing — /model to switch or /usage-credits`,
375
+ `${rec.cwd}: still limited after probing — ${modelRemedy(getAgent(rec.agent))}`,
376
376
  { context: ctxOf(rec), priority: 4 });
377
377
  return 'held';
378
378
  }
package/src/settings.js CHANGED
@@ -45,19 +45,19 @@ export const DEFAULTS = {
45
45
  // command that answers 'disabled' still returns a valid framed envelope.
46
46
  remoteQueue: true,
47
47
  resumeMessage: 'Continue where you left off. The session was interrupted by a usage limit which has now reset — pick up the task you were working on and finish it.',
48
- resumeMessages: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' }, // per-agent override; '' = use resumeMessage
48
+ resumeMessages: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' }, // per-agent override; '' = use resumeMessage
49
49
  // Extra argv appended when unsnooze itself launches the agent binary (dead-pane
50
50
  // reopen, queued prompts). Space-separated. Lets a revived session match the
51
51
  // user's normal launch mode — shell aliases don't apply to direct spawns
52
52
  // (e.g. claude users who always launch with --dangerously-skip-permissions).
53
- resumeExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' },
53
+ resumeExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' },
54
54
  // Extra argv for launches the USER performs through the shell wrapper — the
55
55
  // launch-side twin of resumeExtraArgs. A flag that has to hold for the whole
56
56
  // session (claude's --autocompact, which decides when the context window is
57
57
  // compacted) is useless if it only applies to revives, because the session
58
58
  // that runs out of context is the one the user started.
59
- launchExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' },
60
- agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false }, // experimental agents default off
59
+ launchExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' },
60
+ agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false, cursor: false }, // experimental agents default off
61
61
  };
62
62
 
63
63
  // Env override per key. Booleans accept 1/0, true/false, on/off, yes/no.
@@ -90,6 +90,7 @@ const ENV_NAMES = {
90
90
  'resumeMessages.kimi': 'UNSNOOZE_RESUME_MESSAGE_KIMI',
91
91
  'resumeMessages.opencode': 'UNSNOOZE_RESUME_MESSAGE_OPENCODE',
92
92
  'resumeMessages.agy': 'UNSNOOZE_RESUME_MESSAGE_AGY',
93
+ 'resumeMessages.cursor': 'UNSNOOZE_RESUME_MESSAGE_CURSOR',
93
94
  'resumeExtraArgs.claude': 'UNSNOOZE_RESUME_EXTRA_ARGS_CLAUDE',
94
95
  'resumeExtraArgs.codex': 'UNSNOOZE_RESUME_EXTRA_ARGS_CODEX',
95
96
  'resumeExtraArgs.grok': 'UNSNOOZE_RESUME_EXTRA_ARGS_GROK',
@@ -97,6 +98,7 @@ const ENV_NAMES = {
97
98
  'resumeExtraArgs.kimi': 'UNSNOOZE_RESUME_EXTRA_ARGS_KIMI',
98
99
  'resumeExtraArgs.opencode': 'UNSNOOZE_RESUME_EXTRA_ARGS_OPENCODE',
99
100
  'resumeExtraArgs.agy': 'UNSNOOZE_RESUME_EXTRA_ARGS_AGY',
101
+ 'resumeExtraArgs.cursor': 'UNSNOOZE_RESUME_EXTRA_ARGS_CURSOR',
100
102
  'launchExtraArgs.claude': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CLAUDE',
101
103
  'launchExtraArgs.codex': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CODEX',
102
104
  'launchExtraArgs.grok': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_GROK',
@@ -104,6 +106,7 @@ const ENV_NAMES = {
104
106
  'launchExtraArgs.kimi': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_KIMI',
105
107
  'launchExtraArgs.opencode': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_OPENCODE',
106
108
  'launchExtraArgs.agy': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_AGY',
109
+ 'launchExtraArgs.cursor': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CURSOR',
107
110
  'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
108
111
  'agents.codex': 'UNSNOOZE_AGENT_CODEX',
109
112
  'agents.grok': 'UNSNOOZE_AGENT_GROK',
@@ -111,6 +114,7 @@ const ENV_NAMES = {
111
114
  'agents.kimi': 'UNSNOOZE_AGENT_KIMI',
112
115
  'agents.opencode': 'UNSNOOZE_AGENT_OPENCODE',
113
116
  'agents.agy': 'UNSNOOZE_AGENT_AGY',
117
+ 'agents.cursor': 'UNSNOOZE_AGENT_CURSOR',
114
118
  };
115
119
 
116
120
  const KNOWN_KEYS = Object.keys(ENV_NAMES);
package/src/watcher.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from './config.js';
23
23
  import { getMultiplexer } from './multiplexer.js';
24
24
  import { parseTranscriptLine } from './watchers/claude.js';
25
- import { parseRolloutLine, rolloutMeta } from './watchers/codex.js';
25
+ import { parseRolloutLines, rolloutMeta } from './watchers/codex.js';
26
26
  import { ROLLOUT_RE } from './agents/codex.js';
27
27
  import { parseResetTime, resetAtMs } from './time-parser.js';
28
28
  import { upsertSession, readState, updateState } from './state.js';
@@ -85,8 +85,8 @@ export function codexSource({ roots }) {
85
85
  roots,
86
86
  enabled: () => getConfig('agents.codex'),
87
87
  match: p => ROLLOUT_RE.test(basename(p)),
88
- parse(lines, path) {
89
- const hits = lines.map(parseRolloutLine).filter(Boolean);
88
+ parse(lines, path, { offset } = {}) {
89
+ const hits = parseRolloutLines(lines, { path, offset });
90
90
  if (hits.length === 0) return [];
91
91
  const last = hits[hits.length - 1]; // the latest snapshot governs
92
92
  const meta = rolloutMeta(path);
@@ -322,7 +322,10 @@ export function createWatcher({
322
322
  const lastNl = buf.lastIndexOf(0x0a);
323
323
  if (lastNl === -1) { setOffset(path, offset); return null; } // partial line — wait
324
324
  setOffset(path, offset + lastNl + 1);
325
- return buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim());
325
+ return {
326
+ offset,
327
+ lines: buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim()),
328
+ };
326
329
  }
327
330
 
328
331
  function walk(dir, depth, cb) {
@@ -353,11 +356,12 @@ export function createWatcher({
353
356
  const usageMatch = source.usageMatch ? source.usageMatch(path) : stopMatch;
354
357
  if (!stopMatch && !usageMatch) return;
355
358
  seen.add(path);
356
- const lines = readAppended(path);
357
- if (!lines || lines.length === 0) return;
359
+ const appended = readAppended(path);
360
+ if (!appended || appended.lines.length === 0) return;
361
+ const { lines, offset } = appended;
358
362
  if (stopMatch) {
359
363
  try {
360
- candidates.push(...source.parse(lines, path));
364
+ candidates.push(...source.parse(lines, path, { offset }));
361
365
  } catch (err) {
362
366
  log(`parse error in ${path}: ${err.message}`);
363
367
  }
@@ -23,17 +23,29 @@ export { extractCodexUsage } from '../usage.js';
23
23
  // must not conflate it with the weekly bucket.
24
24
  import { labelWindow } from '../usage.js';
25
25
 
26
- // One rollout JSONL line → limit-stop candidate or null. A window binds when
27
- // its used_percent hits 100 (or rate_limit_reached_type says the model was
28
- // actually blocked); with several exhausted windows the LATEST reset governs —
29
- // resuming at the earlier one would immediately re-hit the other limit.
30
- export function parseRolloutLine(line) {
26
+ function rolloutSnapshot(line) {
31
27
  if (!line || !line.trim()) return null;
32
28
  let entry;
33
29
  try { entry = JSON.parse(line); } catch { return null; }
34
30
  if (entry?.type !== 'event_msg' || entry.payload?.type !== 'token_count') return null;
35
31
  const rl = entry.payload.rate_limits;
36
32
  if (!rl || typeof rl !== 'object') return null;
33
+ return entry;
34
+ }
35
+
36
+ function emptyPremium(rl) {
37
+ return rl.limit_id === 'premium' && rl.primary === null && rl.secondary === null
38
+ && rl.credits?.has_credits === false && rl.credits?.unlimited === false
39
+ && rl.credits?.balance != null && rl.credits.balance !== ''
40
+ && Number(rl.credits.balance) === 0;
41
+ }
42
+
43
+ // With several exhausted windows, the latest reset governs: resuming at an
44
+ // earlier one would immediately hit the other limit again.
45
+ function parseSnapshot(entry, previous = null) {
46
+ if (!entry) return null;
47
+ const rl = entry.payload.rate_limits;
48
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
37
49
 
38
50
  const windows = ['primary', 'secondary']
39
51
  .map(k => rl[k])
@@ -48,9 +60,28 @@ export function parseRolloutLine(line) {
48
60
  ? named
49
61
  : windows.reduce((a, b) => ((b.resets_at || 0) > (a?.resets_at || 0) ? b : a), null);
50
62
  }
63
+ // #20: Codex can stop at a reported 99%, then emit an empty premium bucket
64
+ // instead of a 100% snapshot. Infer a stop only for that transition,
65
+ // in the same rollout, within a minute, with no credits and a future reset.
66
+ // A lone 99% snapshot or an unrelated premium bucket is not a stop.
67
+ if (!binding && emptyPremium(rl) && previous) {
68
+ const prior = previous.payload.rate_limits;
69
+ const elapsed = ts - Date.parse(previous.timestamp);
70
+ const primary = prior.primary;
71
+ if (prior.limit_id === 'codex' && elapsed >= 0 && elapsed <= 60_000
72
+ && primary?.window_minutes === 300
73
+ && Number.isFinite(primary.used_percent) && primary.used_percent >= 99
74
+ && Number.isFinite(primary.resets_at) && primary.resets_at * 1000 > ts) {
75
+ binding = primary;
76
+ const secondary = prior.secondary;
77
+ if (Number.isFinite(secondary?.used_percent) && secondary.used_percent >= 100
78
+ && Number.isFinite(secondary.resets_at) && secondary.resets_at > binding.resets_at) {
79
+ binding = secondary;
80
+ }
81
+ }
82
+ }
51
83
  if (!binding) return null;
52
84
 
53
- const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
54
85
  return {
55
86
  limitType: labelWindow(binding.window_minutes),
56
87
  resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
@@ -59,6 +90,48 @@ export function parseRolloutLine(line) {
59
90
  };
60
91
  }
61
92
 
93
+ export function parseRolloutLine(line) {
94
+ return parseSnapshot(rolloutSnapshot(line));
95
+ }
96
+
97
+ // Look immediately before the appended batch, not at the file's current EOF:
98
+ // it may have grown since the watcher read it. This also works after restart
99
+ // without persisting another cache alongside the watcher's byte offsets.
100
+ function previousSnapshot(path, offset) {
101
+ if (!path || !Number.isSafeInteger(offset) || offset <= 0) return null;
102
+ let fd;
103
+ try {
104
+ fd = openSync(path, 'r');
105
+ const length = Math.min(offset, 4 * 1024 * 1024);
106
+ const buf = Buffer.alloc(length);
107
+ const count = readSync(fd, buf, 0, length, offset - length);
108
+ const lines = buf.subarray(0, count).toString('utf-8').split('\n');
109
+ if (offset > length) lines.shift(); // the first line may be truncated
110
+ for (let i = lines.length - 1; i >= 0; i--) {
111
+ const snapshot = rolloutSnapshot(lines[i]);
112
+ if (snapshot) return snapshot;
113
+ }
114
+ } catch { /* missing context means no inferred stop */ }
115
+ finally { if (fd !== undefined) closeSync(fd); }
116
+ return null;
117
+ }
118
+
119
+ export function parseRolloutLines(lines, { path, offset } = {}) {
120
+ let previous;
121
+ const hits = [];
122
+ for (const line of lines) {
123
+ const snapshot = rolloutSnapshot(line);
124
+ if (!snapshot) continue;
125
+ if (previous === undefined && emptyPremium(snapshot.payload.rate_limits)) {
126
+ previous = previousSnapshot(path, offset);
127
+ }
128
+ const hit = parseSnapshot(snapshot, previous);
129
+ if (hit) hits.push(hit);
130
+ previous = snapshot;
131
+ }
132
+ return hits;
133
+ }
134
+
62
135
  // The session_meta head line can be very long (it embeds the full base
63
136
  // instructions) — read in chunks until the first newline.
64
137
  function readFirstLine(path, maxBytes = 256 * 1024) {