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 +118 -0
- package/README.md +48 -14
- package/bin/unsnooze.js +9 -2
- package/package.json +5 -2
- package/src/agents/claude.js +3 -0
- package/src/agents/cursor.js +225 -0
- package/src/agents/index.js +2 -1
- package/src/doctor.js +4 -2
- package/src/fish.js +22 -0
- package/src/install.js +106 -12
- package/src/launcher.js +13 -4
- package/src/monitor.js +2 -2
- package/src/patterns.js +7 -0
- package/src/resumer.js +2 -2
- package/src/settings.js +8 -4
- package/src/watcher.js +11 -7
- package/src/watchers/codex.js +79 -6
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
|
[](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml)
|
|
8
8
|
[](https://www.npmjs.com/package/unsnooze)
|
|
9
|
-
[](package.json)
|
|
9
|
+
[](package.json)
|
|
10
10
|
[](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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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`),
|
|
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.
|
|
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
|
|
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.
|
|
4
|
-
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and
|
|
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",
|
package/src/agents/claude.js
CHANGED
|
@@ -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
|
+
};
|
package/src/agents/index.js
CHANGED
|
@@ -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
|
|
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 = {
|
|
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.
|
|
123
|
-
${
|
|
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 ${
|
|
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.
|
|
146
|
-
function ${
|
|
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 ${
|
|
168
|
+
$real = Get-Command ${name} -CommandType Application -ErrorAction SilentlyContinue |
|
|
149
169
|
Select-Object -First 1
|
|
150
|
-
if ($real) { & $real.Source @args } else { Write-Error '${
|
|
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
|
|
20
|
-
return args.
|
|
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
|
-
|
|
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} —
|
|
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 —
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
357
|
-
if (!
|
|
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
|
}
|
package/src/watchers/codex.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|