unsnooze 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +24 -6
- package/bin/unsnooze.js +1 -0
- 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/install.js +22 -10
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.18.0 — 2026-09-02
|
|
4
|
+
|
|
5
|
+
**Cursor CLI support**, a wrapper change that keeps `cursor .` working, and a
|
|
6
|
+
model-limit notification that finally names a remedy the CLI actually has.
|
|
7
|
+
|
|
8
|
+
### Cursor CLI (`cursor-agent`)
|
|
9
|
+
|
|
10
|
+
⚠️ Experimental, off by default — turn it on in `unsnooze setup`, or:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
unsnooze config set agents.cursor on && unsnooze setup
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| | |
|
|
17
|
+
|---|---|
|
|
18
|
+
| **Detection** | pane scrape |
|
|
19
|
+
| **Limit type** | `model` — probed, never a scheduled wake |
|
|
20
|
+
| **Revive** | `cursor-agent --resume=<id>`, else `--continue` |
|
|
21
|
+
| **Chat ids** | `~/.cursor/chats/<md5 of cwd>/<id>/meta.json`, verified against the cwd |
|
|
22
|
+
| **Wraps** | `cursor-agent` only |
|
|
23
|
+
|
|
24
|
+
**Cursor's limit is the first one not worth waiting for.** Its usage resets on
|
|
25
|
+
your monthly *billing cycle*, not a rolling window. From a real limit:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
Error: You've hit your usage limit
|
|
29
|
+
Get Cursor Pro for more Agent usage, unlimited Tab, and more.
|
|
30
|
+
fallbackModel:
|
|
31
|
+
spendLimitHit: false
|
|
32
|
+
chatMessage: *…Your usage limits will reset when your monthly cycle ends on 10/2/2026.*
|
|
33
|
+
spendLimits: [50,100,200]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A month out. Sleeping on that would mean a monitor waking in October to type
|
|
37
|
+
into a wall that never moved. So unsnooze treats it as a **model limit**:
|
|
38
|
+
|
|
39
|
+
- records the stop and tells you it needs a decision
|
|
40
|
+
- probes at 15/30/60 min, resuming the moment the banner clears — you change
|
|
41
|
+
plan, or the cycle rolls
|
|
42
|
+
- marks the record `failed` at the probe ceiling instead of sending a futile wake
|
|
43
|
+
|
|
44
|
+
`resetPatterns` is empty on purpose (a test pins it): any reset pattern there
|
|
45
|
+
would silently re-route Cursor onto the 5-hour fallback ladder.
|
|
46
|
+
|
|
47
|
+
Transport errors take the transient-overload ladder and `Authentication
|
|
48
|
+
required` is notify-only — neither reaches the ledger. Banner text is
|
|
49
|
+
server-provided and plan-dependent, so `unsnooze report cursor` is how it
|
|
50
|
+
improves.
|
|
51
|
+
|
|
52
|
+
### `cursor .` still opens the IDE
|
|
53
|
+
|
|
54
|
+
Cursor ships its CLI as both `cursor-agent` and `agent`, while the bare
|
|
55
|
+
`cursor` is the *editor* launcher. Adapters can now declare `wrapperNames`, so
|
|
56
|
+
unsnooze wraps `cursor-agent` and leaves the other two alone — `agent` is far
|
|
57
|
+
too generic to shadow safely. Wrapper output for the existing seven adapters is
|
|
58
|
+
unchanged, byte for byte.
|
|
59
|
+
|
|
60
|
+
### Model limits now suggest something you can actually do
|
|
61
|
+
|
|
62
|
+
Both the "needs you" and probe-ceiling notifications hardcoded Claude's
|
|
63
|
+
wording, so a Cursor user was told to run `/usage-credits` — a command Cursor
|
|
64
|
+
does not have. The hint moves onto the adapter, and since a model limit has no
|
|
65
|
+
reset time to wait for, that one sentence is the whole remedy.
|
|
66
|
+
|
|
3
67
|
## 1.17.0 — 2026-08-23
|
|
4
68
|
|
|
5
69
|
- **A state directory unsnooze cannot write to no longer pins a CPU core
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
[Website](https://unsnooze.dev) · [Documentation](https://unsnooze.dev/docs/) · [Changelog](https://unsnooze.dev/changelog/) · [Feedback](https://unsnooze.dev/feedback/)
|
|
15
15
|
|
|
16
|
-
**Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
|
|
16
|
+
**Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity · Cursor** — when they hit the 5-hour or weekly usage limit
|
|
17
17
|
("You've hit your usage limit"), your session just… stops.<br/>
|
|
18
18
|
unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
|
|
19
19
|
your projects and **wakes each one up the moment the usage limit resets** — in
|
|
@@ -35,7 +35,7 @@ existing tool solves only a slice of it:
|
|
|
35
35
|
|
|
36
36
|
| | **unsnooze** | claude-auto-retry | autoclaude | hydra |
|
|
37
37
|
|---|:---:|:---:|:---:|:---:|
|
|
38
|
-
| Multi-CLI (Claude · Codex · Grok · Qwen · Kimi · OpenCode · Antigravity) | ✅ | ❌ Claude only | ❌ Claude only | partial |
|
|
38
|
+
| Multi-CLI (Claude · Codex · Grok · Qwen · Kimi · OpenCode · Antigravity · Cursor) | ✅ | ❌ Claude only | ❌ Claude only | partial |
|
|
39
39
|
| GUI sessions (VS Code ext, desktop apps) | ✅ watcher daemon | ❌ | ❌ | ❌ |
|
|
40
40
|
| Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
|
|
41
41
|
| All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
|
|
@@ -143,6 +143,24 @@ risks, and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
|
|
|
143
143
|
overload, not a limit. Dead sessions revive via `agy --conversation=<id>`
|
|
144
144
|
(ids from `~/.gemini/antigravity-cli/history.jsonl`). Like Grok: closed
|
|
145
145
|
source, so `unsnooze report` captures make this adapter better.
|
|
146
|
+
- **Cursor CLI (`cursor-agent`)** — ⚠️ *experimental*, and the only adapter
|
|
147
|
+
whose limit is **not waitable**. Cursor's included usage resets on your
|
|
148
|
+
monthly *billing cycle*, not a rolling window, so unsnooze deliberately never
|
|
149
|
+
schedules a wake for it: the banner is classified as a model limit, which
|
|
150
|
+
probes every 15/30/60 min and resumes the moment it clears (you switch to
|
|
151
|
+
Auto, enable on-demand, or the cycle rolls) instead of sleeping for weeks.
|
|
152
|
+
The banner text is server-provided — the CLI renders `Error: <title>` plus a
|
|
153
|
+
detail line and `key: value` extras — and the patterns are pinned against a
|
|
154
|
+
real captured limit (whose own reset date was a month out, which is the point).
|
|
155
|
+
`unsnooze report cursor` is how the rest gets better. Transport errors take the transient-overload ladder and
|
|
156
|
+
`Authentication required` is notify-only. Dead sessions revive via
|
|
157
|
+
`cursor-agent --resume=<id>` — ids come from `~/.cursor/chats/<md5 of the
|
|
158
|
+
cwd>/<chatId>/meta.json`, which records the `cwd` so every candidate is
|
|
159
|
+
verified before use — and `--continue` otherwise, which is workspace-scoped.
|
|
160
|
+
|
|
161
|
+
The wrapper shadows **`cursor-agent`** only. The bare `cursor` command is the
|
|
162
|
+
IDE launcher (`cursor .`) and is never touched; the newer `agent` alias is
|
|
163
|
+
left alone too, being too generic a name to shadow safely.
|
|
146
164
|
|
|
147
165
|
**OpenRouter** (the API gateway) isn't a separate agent: its 429 bodies
|
|
148
166
|
(`Rate limit exceeded: limit_rpd/…`, free-models-per-day) are detected inside
|
|
@@ -557,11 +575,11 @@ password hosts on Windows; a plain `ssh <host>` prompt works with native
|
|
|
557
575
|
| `notifyChannel` | `auto` | How to deliver: `auto`, `native`, `osc`, or `bell` (see [Notification channels](#notification-channels)). Env: `UNSNOOZE_NOTIFY_CHANNEL`. |
|
|
558
576
|
| `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
|
|
559
577
|
| `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. Override it for a single session with `unsnooze message <id> "…"` — visible in `unsnooze status`. |
|
|
560
|
-
| `resumeMessages.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
561
|
-
| `resumeExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Extra flags for launches unsnooze performs itself. A revival spawns the agent binary directly, so flags you normally get from a shell alias or wrapper (`--dangerously-skip-permissions`, `--model …`) do not apply unless you put them here. Quoting is respected: `--append-system-prompt "stay in this repo"` is two arguments, not five. `config.json` may also hold an array (`["--append-system-prompt", "stay in this repo"]`), which skips parsing entirely. |
|
|
562
|
-
| `launchExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` | `""` | Extra flags for the sessions **you** start through the shell wrapper — the launch-side twin of `resumeExtraArgs`. Use it for flags that have to hold for the whole session: `unsnooze config set launchExtraArgs.claude "--autocompact 400000"` keeps a long, context-heavy run compacting instead of stalling. Flags are placed before your own arguments, since `claude "do the thing"` puts a positional prompt there. Revivals inherit them too, so a flag survives the wake. |
|
|
578
|
+
| `resumeMessages.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
579
|
+
| `resumeExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Extra flags for launches unsnooze performs itself. A revival spawns the agent binary directly, so flags you normally get from a shell alias or wrapper (`--dangerously-skip-permissions`, `--model …`) do not apply unless you put them here. Quoting is respected: `--append-system-prompt "stay in this repo"` is two arguments, not five. `config.json` may also hold an array (`["--append-system-prompt", "stay in this repo"]`), which skips parsing entirely. |
|
|
580
|
+
| `launchExtraArgs.claude` / `.codex` / `.grok` / `.qwen` / `.kimi` / `.opencode` / `.agy` / `.cursor` | `""` | Extra flags for the sessions **you** start through the shell wrapper — the launch-side twin of `resumeExtraArgs`. Use it for flags that have to hold for the whole session: `unsnooze config set launchExtraArgs.claude "--autocompact 400000"` keeps a long, context-heavy run compacting instead of stalling. Flags are placed before your own arguments, since `claude "do the thing"` puts a positional prompt there. Revivals inherit them too, so a flag survives the wake. |
|
|
563
581
|
| `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
|
|
564
|
-
| `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
|
|
582
|
+
| `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` / `agents.cursor` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
|
|
565
583
|
| `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
|
|
566
584
|
| `contextGuard` | `inform` | Big cold context at wake? Waking a session re-reads its **entire context at full uncached price** ([why](#why-did-resuming-a-big-session-eat-so-much-of-my-quota)). `inform` resumes and notifies you of the size; `pause` holds sessions above the threshold for `unsnooze resume-now`; `off` disables. Claude Code only for now. |
|
|
567
585
|
| `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
|
package/bin/unsnooze.js
CHANGED
|
@@ -26,6 +26,7 @@ const AGENT_FALLBACK_BINS = {
|
|
|
26
26
|
kimi: () => process.env.UNSNOOZE_KIMI_BIN || 'kimi',
|
|
27
27
|
opencode: () => process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
|
|
28
28
|
agy: () => process.env.UNSNOOZE_AGY_BIN || 'agy',
|
|
29
|
+
cursor: () => process.env.UNSNOOZE_CURSOR_BIN || 'cursor-agent',
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
async function safeImport(specifier) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and
|
|
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",
|
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/install.js
CHANGED
|
@@ -16,6 +16,7 @@ import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
|
|
|
16
16
|
import { getConfig, configFileExists } from './settings.js';
|
|
17
17
|
import { xmlEscape } from './notify.js';
|
|
18
18
|
import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
|
|
19
|
+
import { getAgent } from './agents/index.js';
|
|
19
20
|
import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
|
|
20
21
|
import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
|
|
21
22
|
import { uninstallStatuslineShim } from './usage.js';
|
|
@@ -115,18 +116,29 @@ export function removeHookFromSettings(settingsJson) {
|
|
|
115
116
|
|
|
116
117
|
// --- zshrc block management ---
|
|
117
118
|
|
|
119
|
+
// Which shell commands an agent's wrappers shadow. Normally exactly the id,
|
|
120
|
+
// because the id IS the command users type. Cursor breaks that: its id is
|
|
121
|
+
// `cursor` but its command is `cursor-agent`, and the bare `cursor` belongs to
|
|
122
|
+
// the IDE launcher (`cursor .`) which must never be shadowed. An adapter names
|
|
123
|
+
// the commands to wrap via `wrapperNames`; the `_run` argument stays the id.
|
|
124
|
+
export function wrapperNamesFor(id) {
|
|
125
|
+
const agent = getAgent(id);
|
|
126
|
+
// getAgent falls back to claude for unknown ids — never borrow its names.
|
|
127
|
+
return agent.id === id && agent.wrapperNames?.length ? agent.wrapperNames : [id];
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
export function wrapperBlock(agents = ['claude']) {
|
|
119
131
|
// The missing-file guard is load-bearing: if unsnooze is ever uninstalled,
|
|
120
132
|
// moved, or renamed without cleaning the rc file, the wrapper must degrade
|
|
121
133
|
// to the plain CLI — never brick the user's `claude`/`codex` command.
|
|
122
|
-
const fns = agents.
|
|
123
|
-
${
|
|
134
|
+
const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `unalias ${name} 2>/dev/null || true
|
|
135
|
+
${name}() {
|
|
124
136
|
if [ "\${UNSNOOZE_ACTIVE}" = "1" ] || [ ! -f "${UNSNOOZE_BIN}" ]; then
|
|
125
|
-
command ${
|
|
137
|
+
command ${name} "$@"
|
|
126
138
|
return $?
|
|
127
139
|
fi
|
|
128
140
|
node "${UNSNOOZE_BIN}" _run ${id} "$@"
|
|
129
|
-
}`).join('\n');
|
|
141
|
+
}`)).join('\n');
|
|
130
142
|
return `${FENCE_OPEN}
|
|
131
143
|
# unsnooze wrappers: route every interactive launch of the CLIs below through
|
|
132
144
|
# unsnooze so limit stops are recorded and auto-resumed.
|
|
@@ -142,16 +154,16 @@ ${FENCE_CLOSE}`;
|
|
|
142
154
|
// PowerShell comments are `#`, so the same fence markers work and
|
|
143
155
|
// stripFencedBlock() removes it unchanged.
|
|
144
156
|
export function powershellWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
|
|
145
|
-
const fns = agents.
|
|
146
|
-
function ${
|
|
157
|
+
const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `Remove-Item -Path Alias:${name} -Force -ErrorAction SilentlyContinue
|
|
158
|
+
function ${name} {
|
|
147
159
|
if ($env:UNSNOOZE_ACTIVE -eq '1' -or -not (Test-Path -LiteralPath '${bin}')) {
|
|
148
|
-
$real = Get-Command ${
|
|
160
|
+
$real = Get-Command ${name} -CommandType Application -ErrorAction SilentlyContinue |
|
|
149
161
|
Select-Object -First 1
|
|
150
|
-
if ($real) { & $real.Source @args } else { Write-Error '${
|
|
162
|
+
if ($real) { & $real.Source @args } else { Write-Error '${name}: not found' }
|
|
151
163
|
return
|
|
152
164
|
}
|
|
153
165
|
& node '${bin}' _run ${id} @args
|
|
154
|
-
}`).join('\n');
|
|
166
|
+
}`)).join('\n');
|
|
155
167
|
return `${FENCE_OPEN}
|
|
156
168
|
# unsnooze wrappers: route every interactive launch of the CLIs below through
|
|
157
169
|
# unsnooze so limit stops are recorded and auto-resumed.
|
|
@@ -502,7 +514,7 @@ export function uninstallDaemonAutostart({ platform = process.platform, dir = nu
|
|
|
502
514
|
// --- commands ---
|
|
503
515
|
|
|
504
516
|
export function enabledAgents() {
|
|
505
|
-
return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy'].filter(id => getConfig(`agents.${id}`));
|
|
517
|
+
return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy', 'cursor'].filter(id => getConfig(`agents.${id}`));
|
|
506
518
|
}
|
|
507
519
|
|
|
508
520
|
// Qwen keeps Claude-shaped hooks in its own settings.json — reuse the same
|
package/src/monitor.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
|
|
17
17
|
PROBE_INTERVAL_MS, RESET_MARGIN_MS, LEASE_GRACE_MS,
|
|
18
18
|
} from './config.js';
|
|
19
|
-
import { detectLimit, isBusy, overloadMatch } from './patterns.js';
|
|
19
|
+
import { detectLimit, isBusy, overloadMatch, modelRemedy } from './patterns.js';
|
|
20
20
|
import { getAgent } from './agents/index.js';
|
|
21
21
|
import { getConfig } from './settings.js';
|
|
22
22
|
import { notify } from './notify.js';
|
|
@@ -208,7 +208,7 @@ export function createMonitor({
|
|
|
208
208
|
log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
|
|
209
209
|
if (resolved.limitType === 'model') {
|
|
210
210
|
notifier(`${agent.name} hit a model limit — needs you`,
|
|
211
|
-
`${cwd} —
|
|
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);
|