unsnooze 1.17.0 → 1.18.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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.18.0 — 2026-09-02
4
+
5
+ **Cursor CLI support**, a wrapper change that keeps `cursor .` working, and a
6
+ model-limit notification that finally names a remedy the CLI actually has.
7
+
8
+ ### Cursor CLI (`cursor-agent`)
9
+
10
+ ⚠️ Experimental, off by default — turn it on in `unsnooze setup`, or:
11
+
12
+ ```sh
13
+ unsnooze config set agents.cursor on && unsnooze setup
14
+ ```
15
+
16
+ | | |
17
+ |---|---|
18
+ | **Detection** | pane scrape |
19
+ | **Limit type** | `model` — probed, never a scheduled wake |
20
+ | **Revive** | `cursor-agent --resume=<id>`, else `--continue` |
21
+ | **Chat ids** | `~/.cursor/chats/<md5 of cwd>/<id>/meta.json`, verified against the cwd |
22
+ | **Wraps** | `cursor-agent` only |
23
+
24
+ **Cursor's limit is the first one not worth waiting for.** Its usage resets on
25
+ your monthly *billing cycle*, not a rolling window. From a real limit:
26
+
27
+ ```
28
+ Error: You've hit your usage limit
29
+ Get Cursor Pro for more Agent usage, unlimited Tab, and more.
30
+ fallbackModel:
31
+ spendLimitHit: false
32
+ chatMessage: *…Your usage limits will reset when your monthly cycle ends on 10/2/2026.*
33
+ spendLimits: [50,100,200]
34
+ ```
35
+
36
+ A month out. Sleeping on that would mean a monitor waking in October to type
37
+ into a wall that never moved. So unsnooze treats it as a **model limit**:
38
+
39
+ - records the stop and tells you it needs a decision
40
+ - probes at 15/30/60 min, resuming the moment the banner clears — you change
41
+ plan, or the cycle rolls
42
+ - marks the record `failed` at the probe ceiling instead of sending a futile wake
43
+
44
+ `resetPatterns` is empty on purpose (a test pins it): any reset pattern there
45
+ would silently re-route Cursor onto the 5-hour fallback ladder.
46
+
47
+ Transport errors take the transient-overload ladder and `Authentication
48
+ required` is notify-only — neither reaches the ledger. Banner text is
49
+ server-provided and plan-dependent, so `unsnooze report cursor` is how it
50
+ improves.
51
+
52
+ ### `cursor .` still opens the IDE
53
+
54
+ Cursor ships its CLI as both `cursor-agent` and `agent`, while the bare
55
+ `cursor` is the *editor* launcher. Adapters can now declare `wrapperNames`, so
56
+ unsnooze wraps `cursor-agent` and leaves the other two alone — `agent` is far
57
+ too generic to shadow safely. Wrapper output for the existing seven adapters is
58
+ unchanged, byte for byte.
59
+
60
+ ### Model limits now suggest something you can actually do
61
+
62
+ Both the "needs you" and probe-ceiling notifications hardcoded Claude's
63
+ wording, so a Cursor user was told to run `/usage-credits` — a command Cursor
64
+ does not have. The hint moves onto the adapter, and since a model limit has no
65
+ reset time to wait for, that one sentence is the whole remedy.
66
+
3
67
  ## 1.17.0 — 2026-08-23
4
68
 
5
69
  - **A state directory unsnooze cannot write to no longer pins a CPU core
package/README.md CHANGED
@@ -13,7 +13,7 @@
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
@@ -557,11 +575,11 @@ password hosts on Windows; a plain `ssh <host>` prompt works with native
557
575
  | `notifyChannel` | `auto` | How to deliver: `auto`, `native`, `osc`, or `bell` (see [Notification channels](#notification-channels)). Env: `UNSNOOZE_NOTIFY_CHANNEL`. |
558
576
  | `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
577
  | `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. |
578
+ | `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 ""`. |
579
+ | `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. |
580
+ | `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
581
  | `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`. |
582
+ | `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
583
  | `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
584
  | `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
585
  | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
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) {
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.18.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/install.js CHANGED
@@ -16,6 +16,7 @@ import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
16
16
  import { getConfig, configFileExists } from './settings.js';
17
17
  import { xmlEscape } from './notify.js';
18
18
  import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
19
+ import { getAgent } from './agents/index.js';
19
20
  import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
20
21
  import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
21
22
  import { uninstallStatuslineShim } from './usage.js';
@@ -115,18 +116,29 @@ export function removeHookFromSettings(settingsJson) {
115
116
 
116
117
  // --- zshrc block management ---
117
118
 
119
+ // Which shell commands an agent's wrappers shadow. Normally exactly the id,
120
+ // because the id IS the command users type. Cursor breaks that: its id is
121
+ // `cursor` but its command is `cursor-agent`, and the bare `cursor` belongs to
122
+ // the IDE launcher (`cursor .`) which must never be shadowed. An adapter names
123
+ // the commands to wrap via `wrapperNames`; the `_run` argument stays the id.
124
+ export function wrapperNamesFor(id) {
125
+ const agent = getAgent(id);
126
+ // getAgent falls back to claude for unknown ids — never borrow its names.
127
+ return agent.id === id && agent.wrapperNames?.length ? agent.wrapperNames : [id];
128
+ }
129
+
118
130
  export function wrapperBlock(agents = ['claude']) {
119
131
  // The missing-file guard is load-bearing: if unsnooze is ever uninstalled,
120
132
  // moved, or renamed without cleaning the rc file, the wrapper must degrade
121
133
  // 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}() {
134
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `unalias ${name} 2>/dev/null || true
135
+ ${name}() {
124
136
  if [ "\${UNSNOOZE_ACTIVE}" = "1" ] || [ ! -f "${UNSNOOZE_BIN}" ]; then
125
- command ${id} "$@"
137
+ command ${name} "$@"
126
138
  return $?
127
139
  fi
128
140
  node "${UNSNOOZE_BIN}" _run ${id} "$@"
129
- }`).join('\n');
141
+ }`)).join('\n');
130
142
  return `${FENCE_OPEN}
131
143
  # unsnooze wrappers: route every interactive launch of the CLIs below through
132
144
  # unsnooze so limit stops are recorded and auto-resumed.
@@ -142,16 +154,16 @@ ${FENCE_CLOSE}`;
142
154
  // PowerShell comments are `#`, so the same fence markers work and
143
155
  // stripFencedBlock() removes it unchanged.
144
156
  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} {
157
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `Remove-Item -Path Alias:${name} -Force -ErrorAction SilentlyContinue
158
+ function ${name} {
147
159
  if ($env:UNSNOOZE_ACTIVE -eq '1' -or -not (Test-Path -LiteralPath '${bin}')) {
148
- $real = Get-Command ${id} -CommandType Application -ErrorAction SilentlyContinue |
160
+ $real = Get-Command ${name} -CommandType Application -ErrorAction SilentlyContinue |
149
161
  Select-Object -First 1
150
- if ($real) { & $real.Source @args } else { Write-Error '${id}: not found' }
162
+ if ($real) { & $real.Source @args } else { Write-Error '${name}: not found' }
151
163
  return
152
164
  }
153
165
  & node '${bin}' _run ${id} @args
154
- }`).join('\n');
166
+ }`)).join('\n');
155
167
  return `${FENCE_OPEN}
156
168
  # unsnooze wrappers: route every interactive launch of the CLIs below through
157
169
  # unsnooze so limit stops are recorded and auto-resumed.
@@ -502,7 +514,7 @@ export function uninstallDaemonAutostart({ platform = process.platform, dir = nu
502
514
  // --- commands ---
503
515
 
504
516
  export function enabledAgents() {
505
- return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy'].filter(id => getConfig(`agents.${id}`));
517
+ return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy', 'cursor'].filter(id => getConfig(`agents.${id}`));
506
518
  }
507
519
 
508
520
  // Qwen keeps Claude-shaped hooks in its own settings.json — reuse the same
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);