unsnooze 1.16.3 → 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,198 @@
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
+
67
+ ## 1.17.0 — 2026-08-23
68
+
69
+ - **A state directory unsnooze cannot write to no longer pins a CPU core
70
+ forever.** The lock around `~/.unsnooze/state.json` treated every `mkdir`
71
+ failure as contention. `EEXIST` really is contention — someone else holds the
72
+ lock — but `EACCES` on an unwritable state directory, `EROFS` on a read-only
73
+ filesystem and `ENOSPC` on a full one are not, and that branch retried with
74
+ neither a sleep nor a deadline check. Since `mkdir -p` on a directory that
75
+ already exists succeeds as a no-op, there was nothing to break the cycle:
76
+ the loop spun at around 70% of a core, ignored `UNSNOOZE_LOCK_TIMEOUT_MS`
77
+ entirely, and never returned. It took every writer with it — the daemon, any
78
+ CLI command, and the StopFailure hook that runs *inside* your agent. unsnooze
79
+ now repairs a state directory it owns and otherwise fails on the deadline with
80
+ the errno that caused it.
81
+
82
+ - **Everything under `~/.unsnooze` is now owner-only (0700 / 0600).** The
83
+ directory was 0755 and every file inside it 0644, so on a shared machine any
84
+ other local user could read them. That directory holds your ntfy bearer token
85
+ (`config.json`), the text of queued prompts and the paths they run in
86
+ (`state.json`), your fleet's ssh destinations and the commands that fetch
87
+ their passwords (`hosts.json`), a mirror of every remote host's sessions
88
+ (`fleet-cache.json`), a lease file per pane carrying its working directory
89
+ (`leases/`), and — for a headless revival — the entire stdout and stderr of
90
+ an unattended agent run (`headless/*.log`). Every writer now creates its file
91
+ owner-only and its directory 0700.
92
+
93
+ Upgrading an existing install needed more than that, because none of it is
94
+ something a writer can fix: `mkdir`'s mode is ignored for a directory that
95
+ already exists, appending to a log reuses the file's inode, and
96
+ `config.json` and `hosts.json` are written only by `config set` and
97
+ `hosts add`, so an install that never touches them again would have kept
98
+ their old mode — and the token in one of them — indefinitely. So a state
99
+ write repairs the modes directly, walking the `events/`, `leases/`,
100
+ `mux-sessions/` and `headless/` subdirectories as well as the top-level
101
+ files. It re-checks on an interval rather than once per process, because
102
+ `daemon.log` is created by launchd/systemd redirecting the daemon's stdout
103
+ and can appear after a first pass has already run — in a process that then
104
+ lives for days. It strips group and other access without touching the
105
+ owner's own bits, so an executable in there stays executable; it never
106
+ follows a symlink or a hardlink into a file it is about to change; and it
107
+ only ever touches unsnooze's own state directory. `unsnooze doctor` reports
108
+ from the same walk the repair acts on, so the two cannot disagree about what
109
+ counts as exposed. It is inert on Windows, which has no POSIX mode bits —
110
+ libuv synthesises them by mirroring the owner's, so every file there reads
111
+ as world-readable and no `chmod` can change it; access is governed by the
112
+ profile ACL instead. The same is true of a state directory on a filesystem
113
+ that does not implement permissions at all — a CIFS or vfat mount, WSL's
114
+ `drvfs`, a bind mount from a Windows host — and the platform name does not
115
+ identify those, so unsnooze finds out by attempting the change and looking:
116
+ a `chmod` that succeeds while the bits stay put means the filesystem has
117
+ nothing to set, and the directory is left alone rather than rewritten every
118
+ minute forever. `unsnooze doctor --fix` reports those three outcomes
119
+ separately, so "could not" is never printed as "did".
120
+
121
+ The statusline shim's own drop directory, `~/.claude/unsnooze`, gets the
122
+ same treatment: it holds your live rate-limit numbers and was created 0755.
123
+ It is repaired on the shim's next run rather than only for new installs.
124
+
125
+ `unsnooze doctor` now also reports anything under the state directory that
126
+ other users can read, and `doctor --fix` narrows it. That check exists
127
+ because the repair deliberately ignores a failed `chmod` — crashing every
128
+ state write on a filesystem that has no usable one would be the worse bug —
129
+ so something has to be able to tell you it did not take.
130
+
131
+ No credential was ever stored in plaintext: a password source is a
132
+ *reference* to your keychain, environment or secret tool, never the secret.
133
+ On a single-user machine nothing here was reachable by anyone new.
134
+
135
+ - **A hostile `session_id` can no longer steer where unsnooze reads or
136
+ writes.** Three places took that value — which arrives in a payload from the
137
+ agent — and used it directly as a filename. The opt-in Claude Code statusline
138
+ shim built its drop filename by concatenating it, so a `/` or a `..` walked
139
+ the write out of `~/.claude/unsnooze` and over any `.json` file you can
140
+ write. `transcriptPath`, on the StopFailure hook path that everyone has
141
+ enabled, composed a transcript path the same way, turning a read of your own
142
+ transcript into a read of any `.jsonl` you name. And the Kimi adapter probed
143
+ for a session directory by the same concatenation, which leaked whether an
144
+ arbitrary path exists. Claude Code sends a uuid, so all three needed a
145
+ malicious or compromised agent to reach, and the two reads are only ever
146
+ mined for a timestamp or a boolean — never echoed back. None of them was in
147
+ a position to know that. All three now require a filename-shaped id and fall
148
+ back to `unknown` / no-transcript / not-found. If you have the statusline
149
+ shim installed, re-run `unsnooze usage --install-statusline` to pick up the
150
+ fix; the other two need nothing.
151
+
152
+ - **A corrupt `state.json` no longer prints its own first bytes into the log.**
153
+ The quarantine message included V8's `JSON.parse` error, and V8 embeds a
154
+ snippet of the input it choked on — so a state file that had been replaced
155
+ with something else echoed that file's opening characters into
156
+ `unsnooze.log`. The quarantined file is still kept on disk, and the log still
157
+ names it and says why.
158
+
159
+ - **Three narrower lock fixes.** A writer whose stale-lock steal was itself
160
+ stolen no longer deletes the thief's lock on the way out — it only removes a
161
+ lock still stamped with its own pid. The steal itself now re-checks that the
162
+ directory it is about to remove is still the one it judged stale, narrowing
163
+ a window where two contenders could both decide to steal and the slower one
164
+ would delete the winner's fresh lock. And a lock is now stolen on age alone
165
+ once it is very old: liveness was decided by signalling the recorded pid,
166
+ which cannot tell "unsnooze is still working" from "that number now belongs
167
+ to some unrelated long-lived process", so a leaked lock whose pid was later
168
+ recycled wedged every writer permanently, with no recovery but deleting the
169
+ directory by hand.
170
+
171
+ That last one is a trade rather than a free win — stealing from a holder that
172
+ really is still working puts two writers in the critical section, and one
173
+ overwrites the other. It is chosen because a permanent wedge is the worse
174
+ failure, and the exposure is kept small by making sure the lock is only ever
175
+ held for in-memory work plus a single write: `upsertSession` used to compute
176
+ its workspace fingerprint inside the lock, which shells out to git, and
177
+ `execFileSync`'s timeout is a soft bound — it signals the child and then
178
+ waits for it to actually exit, which a git wedged on a hung network mount
179
+ never does. That call now happens before the lock is taken, so nothing
180
+ unbounded runs inside it.
181
+
182
+ *Raised in [#17](https://github.com/saaranshM/unsnooze/issues/17). The report
183
+ also flagged the askpass `command` source as command injection and reading
184
+ `state.json` as a symlink-following bug; neither is a vulnerability. A
185
+ `command` source is your own configured command, in the shape git's
186
+ `credential.helper = !cmd` has always taken, and it can only be set by
187
+ someone who can already write to your home directory. As for the symlink:
188
+ reads do follow one — point `state.json` at a file holding valid JSON and
189
+ its contents are read as state — but planting that link needs write access
190
+ to a directory that is now 0700, which is not a boundary anyone crosses
191
+ without already owning the account. The write side is genuinely safe: the
192
+ quarantine renames the link rather than the file it points at, and a state
193
+ write replaces the link with a real file, so the target is never truncated
194
+ or overwritten.*
195
+
3
196
  ## 1.16.3 — 2026-08-22
4
197
 
5
198
  - **A dashboard left open overnight no longer runs the machine out of memory.**
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.16.3",
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 }; }
@@ -54,7 +54,15 @@ function sessionsDirFor(cwd, kimiDir) {
54
54
  return join(kimiDir, 'sessions', createHash('md5').update(cwd).digest('hex'));
55
55
  }
56
56
 
57
+ // sessionId comes from a hook payload and is used as a path component here.
58
+ // The result only picks `-r <id>` over `--continue` and the id travels as
59
+ // argv with no shell, so the exposure is an existence oracle rather than a
60
+ // traversal — but it is the same shape as transcriptPath's, and the same
61
+ // filename-shape gate closes it.
62
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
63
+
57
64
  function sessionExists(sessionId, kimiDir) {
65
+ if (!SAFE_SESSION_ID_RE.test(String(sessionId ?? ''))) return false;
58
66
  let hashes;
59
67
  try { hashes = readdirSync(join(kimiDir, 'sessions')); } catch { return false; }
60
68
  return hashes.some(h => existsSync(join(kimiDir, 'sessions', h, sessionId)));
package/src/askpass.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // anywhere. The SERVICE/ACCOUNT/VAR *names* may appear on argv; the secret
4
4
  // itself never does — it only ever comes back on stdout/stdin.
5
5
  import { execFileSync, spawnSync } from 'node:child_process';
6
- import { writeFileSync, chmodSync, mkdirSync, renameSync } from 'node:fs';
6
+ import { writeFileSync, chmodSync, renameSync } from 'node:fs';
7
+ import { ensureStateDir } from './config.js';
7
8
  import { join } from 'node:path';
8
9
 
9
10
  export class AuthError extends Error {
@@ -163,7 +164,7 @@ export async function cmdAskpass(args = []) {
163
164
  // by Task 5) because ssh controls the helper's argv — it passes only the
164
165
  // prompt text, never the host.
165
166
  export function ensureAskpassHelper({ platform = process.platform, stateDir, nodePath = process.execPath, scriptPath }) {
166
- mkdirSync(stateDir, { recursive: true });
167
+ ensureStateDir(stateDir);
167
168
  if (platform === 'win32') {
168
169
  // Native ssh.exe needs a real exe; unix-like win ssh (Git/WSL) accepts a script.
169
170
  // The resolution ladder (design §5c) is finalized in Task 7's cross-platform pass;