unsnooze 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 — 2026-07-12
4
+
5
+ - **Four new agent adapters** (all ⚠️ experimental, off by default — enable in
6
+ `unsnooze setup`):
7
+ - **Qwen Code** (`qwen`): Claude-shaped `StopFailure` hook installed into
8
+ `~/.qwen/settings.json` + verbatim quota-banner scraping (legacy OAuth,
9
+ Coding Plan `Allocated quota exceeded` → 5h window, OpenRouter
10
+ passthroughs). Resumes via `qwen --resume <id>`, ids from qwen's
11
+ `*.runtime.json` sidecars.
12
+ - **Kimi CLI** (`kimi`): detects the terminal red
13
+ `Error code: 429 … rate_limit_reached_error` line; resumes via
14
+ `kimi -r <id> -p "<msg>"` with an on-disk id check (kimi silently starts a
15
+ NEW session for unknown ids). `Membership expired` is notify-only.
16
+ - **OpenCode** (`opencode`): OpenCode self-retries limits forever (sleeping
17
+ until reset), so unsnooze records the stop, never touches a live
18
+ self-retrying pane, and revives dead panes mid-wait via
19
+ `opencode -s <ses_id>` — reset time parsed from the
20
+ `[retrying in 2h5m attempt #N]` countdown.
21
+ - **Antigravity CLI** (`agy`, Google's Gemini-CLI successor): scrapes
22
+ `Model quota limit exceeded` / `Refreshes in 6 days and 18 hours`
23
+ (multi-day refresh = weekly cap); `503 MODEL_CAPACITY_EXHAUSTED` is treated
24
+ as transient overload. Resumes via `agy --conversation=<id>`.
25
+ - **OpenRouter awareness**: 429 bodies (`Rate limit exceeded: limit_…`,
26
+ free-models-per-day) are detected inside OpenCode/Qwen sessions; credit
27
+ exhaustion (402) notifies instead of snoozing.
28
+ - **Terminal-error channel**: non-resetting errors (credits exhausted,
29
+ membership expired, discontinued tiers) now raise a single desktop
30
+ notification instead of being retried against a reset that will never come.
31
+ - **time-parser**: understands `Refreshes in 6 days and 18 hours`,
32
+ `It will reset in 2 hours 5 minutes`, `Retry in 45 minutes`, and Go-style
33
+ countdowns (`2h5m`, `2m 5s`, `~2 days`).
34
+
35
+ ## 1.6.0 — 2026-07-12
36
+
37
+ - **Stale-workspace guard** (`workspaceGuard`: `off` | `inform` | `pause`,
38
+ default `inform`): the repo's HEAD + dirty state are fingerprinted when a
39
+ session stops and re-checked at wake. `inform` resumes with a "workspace
40
+ changed while you slept — re-read before acting" note in the wake message;
41
+ `pause` holds the session (desktop notification, `workspace changed` marker
42
+ in status) until `unsnooze resume-now`, which prints the diff stat first.
43
+ Non-git directories are unaffected. Suggested by r/codex feedback.
44
+
3
45
  ## 1.5.0 — 2026-07-12
4
46
 
5
47
  - **`unsnooze update`**: one command to update unsnooze itself — runs
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3fb950)](package.json)
10
10
  [![license](https://img.shields.io/badge/license-MIT-8b949e)](LICENSE)
11
11
 
12
- **Claude Code · Codex CLI · Grok** — when they hit the 5-hour or weekly usage limit
12
+ **Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
13
13
  ("You've hit your usage limit"), your session just… stops.<br/>
14
14
  unsnooze auto-resumes them: it tracks **every** limit-stopped session across all
15
15
  your projects and **wakes each one up in tmux the moment the usage limit resets.**
@@ -53,6 +53,42 @@ existing tool solves only a slice of it:
53
53
  not publicly documented, so detection uses generic patterns with a safe
54
54
  fallback. Hit a banner unsnooze missed? Run `unsnooze report` and paste the
55
55
  capture into an issue — that's how this adapter gets good.
56
+ - **Qwen Code** — ⚠️ *experimental*. Dual-channel: a Claude-shaped `StopFailure`
57
+ hook installed into `~/.qwen/settings.json` (fires with `error: rate_limit`)
58
+ plus scraping for the verbatim quota renders (`Qwen OAuth quota exceeded`,
59
+ Coding Plan `Allocated quota exceeded`, and OpenRouter `Rate limit exceeded:
60
+ limit_…` passthroughs). Qwen never shows a reset time, so waits use the
61
+ 5-hour fallback and self-correct on verify. Dead sessions revive via
62
+ `qwen --resume <id>` (session ids come from the `*.runtime.json` sidecars
63
+ qwen writes for exactly this purpose).
64
+ - **Kimi CLI (Moonshot)** — ⚠️ *experimental*. Scrape-based: kimi retries a 429
65
+ three times within seconds, then stops with a red `LLM provider error: Error
66
+ code: 429 … rate_limit_reached_error` line — that's the detection anchor.
67
+ The 429 body carries no reset time (5h fallback + verify). Dead sessions
68
+ revive via `kimi -r <id> -p "<message>"` — with a guard: kimi silently starts
69
+ a *new* session for unknown ids, so the id is verified on disk first
70
+ (`--continue` otherwise). `Membership expired` (402) is notify-only.
71
+ - **OpenCode** — ⚠️ *experimental*, and a different shape: OpenCode *retries
72
+ rate limits itself, forever*, honoring `retry-after` (it will sleep hours
73
+ until the reset, showing `Rate Limited [retrying in 2h5m attempt #4]`). So
74
+ unsnooze records the stop but never touches a live self-retrying pane; its
75
+ job is reviving sessions whose process died mid-wait (laptop slept, tmux
76
+ gone) via `opencode -s <ses_id>`, with the reset time parsed straight from
77
+ the banner countdown. Zen plan banners (`5 hour/weekly/monthly usage limit
78
+ reached…`) and OpenRouter passthroughs are detected too; `insufficient
79
+ credits` (402) is notify-only.
80
+ - **Antigravity CLI (Google, `agy`)** — ⚠️ *experimental*. The Gemini-CLI
81
+ successor. Scrapes the forum-reported quota strings (`Model quota limit
82
+ exceeded`, `Refreshes in 6 days and 18 hours` — parsed, multi-day refresh =
83
+ the weekly cap) and treats `503 MODEL_CAPACITY_EXHAUSTED` as a transient
84
+ overload, not a limit. Dead sessions revive via `agy --conversation=<id>`
85
+ (ids from `~/.gemini/antigravity-cli/history.jsonl`). Like Grok: closed
86
+ source, so `unsnooze report` captures make this adapter better.
87
+
88
+ **OpenRouter** (the API gateway) isn't a separate agent: its 429 bodies
89
+ (`Rate limit exceeded: limit_rpd/…`, free-models-per-day) are detected inside
90
+ the CLIs that use it (OpenCode, Qwen Code), and credit exhaustion (402) is
91
+ surfaced as a notification — there's no reset to wait for, only a top-up.
56
92
 
57
93
  ## GUI surfaces (VS Code extension, desktop apps)
58
94
 
@@ -153,8 +189,10 @@ unsnooze help # full command list (also -h / --help)
153
189
  | `notifications` | `true` | Desktop notification on limit detected / session resumed / gave up. |
154
190
  | `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
155
191
  | `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`. |
156
- | `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
157
- | `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
192
+ | `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 ""`. |
193
+ | `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
194
+ | `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`. |
195
+ | `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. |
158
196
  | `updateCheck` | `true` | Daily new-version check (a plain GET to the npm registry, nothing identifying is sent). Notices after commands + one desktop toast per version. |
159
197
 
160
198
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
@@ -178,6 +216,11 @@ all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
178
216
  block the CLI).
179
217
  - **Overload ≠ limit**: 5xx/529/429 transient errors take a seconds-scale
180
218
  backoff path ([30,60,120,240,300]s ± jitter) and never enter the ledger.
219
+ - **Stale-workspace guard**: the repo's HEAD + dirty state are fingerprinted
220
+ when a session stops. If another session (or you) changed the repo before
221
+ the wake, the resumed agent is told to re-read before acting — or, with
222
+ `workspaceGuard=pause`, the session is held and `unsnooze resume-now`
223
+ shows the diff first.
181
224
 
182
225
  ## Requirements
183
226
 
@@ -233,6 +276,15 @@ desktop notification per version); after updating, the next command shows a
233
276
  short "what's new" from the changelog. It's a plain registry GET with nothing
234
277
  identifying — turn it off with `unsnooze config set updateCheck off`.
235
278
 
279
+ ### What if another session changed the repo while one was stopped?
280
+
281
+ unsnooze fingerprints the workspace (HEAD + uncommitted state) at stop time
282
+ and re-checks at wake. By default the session still resumes, but the wake
283
+ message includes what changed ("HEAD abc1234 → def5678 — re-read before
284
+ continuing"). Set `workspaceGuard` to `pause` to hold such sessions for a
285
+ manual `unsnooze resume-now` (which prints the diff stat), or `off` to
286
+ disable the check.
287
+
236
288
  ### Does it work if my laptop was asleep or the terminal was closed?
237
289
 
238
290
  Yes — reset times are stored as absolute timestamps and checked every 30
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.5.0",
4
- "description": "Auto-resume Claude Code, Codex CLI & Grok sessions in tmux when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
3
+ "version": "1.7.0",
4
+ "description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity CLI sessions in tmux when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -10,6 +10,14 @@
10
10
  "openai",
11
11
  "anthropic",
12
12
  "grok",
13
+ "qwen",
14
+ "qwen-code",
15
+ "kimi",
16
+ "kimi-cli",
17
+ "opencode",
18
+ "antigravity",
19
+ "antigravity-cli",
20
+ "openrouter",
13
21
  "usage-limit",
14
22
  "usage limit",
15
23
  "rate-limit",
@@ -0,0 +1,100 @@
1
+ // Google Antigravity CLI adapter (`agy`) — EXPERIMENTAL.
2
+ //
3
+ // agy is the closed-source Go successor to Gemini CLI (which stopped serving
4
+ // individual accounts 2026-06-18). Limits: rolling ~5h "sprint" window plus a
5
+ // weekly cap, metered per model. The banner strings here come from Google
6
+ // forum reports, not source ("Model quota limit exceeded", "Refreshes in
7
+ // 6 days and 18 hours") — grok-bar experimental; improve via `unsnooze report`.
8
+ //
9
+ // 503 MODEL_CAPACITY_EXHAUSTED is provider capacity (transient), NOT a user
10
+ // quota — it takes the overload path.
11
+ //
12
+ // Deferred by design: the hooks.json channel (schema drifted between builds)
13
+ // and the loopback RetrieveUserQuotaSummary endpoint (authoritative per-meter
14
+ // resetTime) — the latter is the natural future resetProbe seam.
15
+
16
+ import { openSync, readSync, closeSync, statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const AGY_DIR = () => process.env.UNSNOOZE_AGY_DIR || join(homedir(), '.gemini', 'antigravity-cli');
21
+
22
+ const LIMIT_ANCHORS = [
23
+ /Model quota limit exceeded/i,
24
+ /RESOURCE_EXHAUSTED/,
25
+ /quota (?:limit )?exceeded/i,
26
+ ];
27
+
28
+ export const patterns = {
29
+ limitPatterns: LIMIT_ANCHORS,
30
+ // "Refreshes in 6 days and 18 hours" renders near the quota banner; anchors
31
+ // double as reset lines for the API-key single-line renders.
32
+ resetPatterns: [
33
+ /Refreshes in/i,
34
+ ...LIMIT_ANCHORS,
35
+ ],
36
+ weeklyPatterns: [/Refreshes in \d+ days?/i], // multi-day refresh = the weekly cap
37
+ fiveHourPatterns: [],
38
+ busyPatterns: [
39
+ /esc to interrupt/i,
40
+ /attempt \d+\/\d+/i,
41
+ ],
42
+ idleRegex: /[›❯>]/,
43
+ overloadPatterns: [/MODEL_CAPACITY_EXHAUSTED/i, /503.*(?:capacity|exhausted)/i],
44
+ transientPatterns: [/MODEL_CAPACITY_EXHAUSTED/i],
45
+ terminalPatterns: [/not logged into Antigravity/i], // auth, not quota — notify only
46
+ };
47
+
48
+ // ~/.gemini/antigravity-cli/history.jsonl indexes all conversations. Schema is
49
+ // undocumented (and a SQLite migration is rumored) — parse the tail
50
+ // tolerantly: an entry counts if any string value equals the cwd, its id is
51
+ // the first conversation-ish field. Null on any doubt (the resumer then opens
52
+ // a fresh agy session; the wake message explains the context loss).
53
+ function tailBytes(path, bytes = 64 * 1024) {
54
+ let fd;
55
+ try {
56
+ const size = statSync(path).size;
57
+ fd = openSync(path, 'r');
58
+ const start = Math.max(0, size - bytes);
59
+ const buf = Buffer.alloc(size - start);
60
+ const n = readSync(fd, buf, 0, buf.length, start);
61
+ return buf.toString('utf-8', 0, n);
62
+ } catch {
63
+ return '';
64
+ } finally {
65
+ if (fd !== undefined) closeSync(fd);
66
+ }
67
+ }
68
+
69
+ export function latestSessionId(cwd, aroundTs = null, agyDir = AGY_DIR()) {
70
+ const text = tailBytes(join(agyDir, 'history.jsonl'));
71
+ if (!text) return null;
72
+ const lines = text.split('\n').filter(l => l.trim());
73
+ for (let i = lines.length - 1; i >= 0; i--) {
74
+ let entry;
75
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
76
+ if (!entry || typeof entry !== 'object') continue;
77
+ if (!Object.values(entry).some(v => v === cwd)) continue;
78
+ const id = entry.conversation_id ?? entry.conversationId ?? entry.id;
79
+ if (typeof id === 'string' && id) return id;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export default {
85
+ id: 'agy',
86
+ name: 'Antigravity CLI (Google)',
87
+ bin: process.env.UNSNOOZE_AGY_BIN || 'agy',
88
+ experimental: true,
89
+ patterns,
90
+ menu: null,
91
+ // `agy --conversation=<id>` resumes (printed by agy itself on exit);
92
+ // `--continue` reopens the most recent conversation (verified in agy --help).
93
+ resumeArgs(sessionId) {
94
+ return { args: sessionId ? [`--conversation=${sessionId}`] : ['--continue'], messageViaPane: true };
95
+ },
96
+ latestSessionId,
97
+ isForegroundCommand(cmd) {
98
+ return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
99
+ },
100
+ };
@@ -4,8 +4,12 @@
4
4
  import claude from './claude.js';
5
5
  import codex from './codex.js';
6
6
  import grok from './grok.js';
7
+ import qwen from './qwen.js';
8
+ import kimi from './kimi.js';
9
+ import opencode from './opencode.js';
10
+ import agy from './agy.js';
7
11
 
8
- const REGISTRY = { claude, codex, grok };
12
+ const REGISTRY = { claude, codex, grok, qwen, kimi, opencode, agy };
9
13
 
10
14
  export function getAgent(id) {
11
15
  return REGISTRY[id] || claude;
@@ -0,0 +1,120 @@
1
+ // Kimi CLI adapter (MoonshotAI/kimi-cli, command `kimi`) — EXPERIMENTAL.
2
+ //
3
+ // Limits (researched 2026-07): rolling 5-hour window + weekly quota (7 days
4
+ // from subscription date); a concurrency throttle returns the SAME 429. The
5
+ // CLI retries 429/5xx three times within seconds, then stops with a red
6
+ // "LLM provider error: Error code: 429 - {...rate_limit_reached_error...}"
7
+ // line. The 429 body carries NO reset time → 5h fallback + verify loop.
8
+ // Known limitation: a weekly limit burns fallback attempts until the resumer
9
+ // gives up (a future resetProbe against GET api.kimi.com/coding/v1/usages
10
+ // would fix that).
11
+ //
12
+ // kimi-cli is Python today (pane_current_command may be python3); its
13
+ // kimi-code successor is TypeScript (node) with the same `kimi` command and
14
+ // auto-migrated ~/.kimi state — both are covered.
15
+
16
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { createHash } from 'node:crypto';
20
+
21
+ const KIMI_DIR = () => process.env.UNSNOOZE_KIMI_DIR || join(homedir(), '.kimi');
22
+
23
+ const LIMIT_ANCHORS = [
24
+ /rate_limit_reached_error/i,
25
+ /LLM provider error:.*429/i,
26
+ /receiving too many requests at the moment/i,
27
+ ];
28
+
29
+ export const patterns = {
30
+ limitPatterns: LIMIT_ANCHORS,
31
+ // The 429 render is one line with no reset text — anchors double as reset
32
+ // lines and time-parser falls back to the 5h default.
33
+ resetPatterns: [...LIMIT_ANCHORS],
34
+ weeklyPatterns: [],
35
+ fiveHourPatterns: [],
36
+ // Fast in-CLI retries ("Retrying after rate limit · attempt 2/3 · 1.2s")
37
+ // resolve within seconds — never inject keys during them.
38
+ busyPatterns: [
39
+ /Retrying after rate limit/i,
40
+ /attempt \d+\/\d+/i,
41
+ /esc to interrupt/i,
42
+ ],
43
+ idleRegex: /[›❯>]/,
44
+ overloadPatterns: [/LLM provider error:.*5\d\d/i, /Retrying after rate limit/i],
45
+ transientPatterns: [/Retrying after rate limit/i],
46
+ // 402: no reset exists — notify-only.
47
+ terminalPatterns: [/Membership expired/i],
48
+ };
49
+
50
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
51
+
52
+ // Sessions live in ~/.kimi/sessions/<md5(cwd)>/<uuid>/.
53
+ function sessionsDirFor(cwd, kimiDir) {
54
+ return join(kimiDir, 'sessions', createHash('md5').update(cwd).digest('hex'));
55
+ }
56
+
57
+ function sessionExists(sessionId, kimiDir) {
58
+ let hashes;
59
+ try { hashes = readdirSync(join(kimiDir, 'sessions')); } catch { return false; }
60
+ return hashes.some(h => existsSync(join(kimiDir, 'sessions', h, sessionId)));
61
+ }
62
+
63
+ // ~/.kimi/kimi.json maps each workdir to its last-used session. The exact
64
+ // entry shape is undocumented — accept a bare id or any uuid-valued field.
65
+ function sessionFromKimiJson(cwd, kimiDir) {
66
+ try {
67
+ const meta = JSON.parse(readFileSync(join(kimiDir, 'kimi.json'), 'utf-8'));
68
+ const entry = meta?.work_dirs?.[cwd];
69
+ if (typeof entry === 'string' && UUID_RE.test(entry)) return entry;
70
+ if (entry && typeof entry === 'object') {
71
+ for (const v of Object.values(entry)) {
72
+ if (typeof v === 'string' && UUID_RE.test(v)) return v;
73
+ }
74
+ }
75
+ } catch { /* missing or unreadable — fall through */ }
76
+ return null;
77
+ }
78
+
79
+ export function latestSessionId(cwd, aroundTs = null, kimiDir = KIMI_DIR()) {
80
+ const fromMeta = sessionFromKimiJson(cwd, kimiDir);
81
+ if (fromMeta) return fromMeta;
82
+
83
+ const dir = sessionsDirFor(cwd, kimiDir);
84
+ let entries;
85
+ try { entries = readdirSync(dir); } catch { return null; }
86
+ let best = null;
87
+ for (const name of entries) {
88
+ if (!UUID_RE.test(name)) continue;
89
+ let mtime;
90
+ try { mtime = statSync(join(dir, name)).mtimeMs; } catch { continue; }
91
+ if (aroundTs != null && Math.abs(mtime - aroundTs) > 30 * 60_000) continue;
92
+ if (!best || mtime > best.mtime) best = { id: name, mtime };
93
+ }
94
+ return best ? best.id : null;
95
+ }
96
+
97
+ export default {
98
+ id: 'kimi',
99
+ name: 'Kimi CLI',
100
+ bin: process.env.UNSNOOZE_KIMI_BIN || 'kimi',
101
+ experimental: true,
102
+ patterns,
103
+ menu: null,
104
+ // `kimi -r <id> -p "<msg>"` carries the prompt in argv. Guard: a missing id
105
+ // makes kimi silently create a NEW session, so verify it exists on disk and
106
+ // otherwise use --continue (kimi scopes that to the cwd itself).
107
+ resumeArgs(sessionId, message) {
108
+ const valid = sessionId && sessionExists(sessionId, KIMI_DIR());
109
+ return {
110
+ args: valid ? ['-r', sessionId, '-p', message] : ['--continue', '-p', message],
111
+ messageViaPane: false,
112
+ };
113
+ },
114
+ latestSessionId,
115
+ isForegroundCommand(cmd) {
116
+ // /i: macOS framework builds report "Python" (verified via tmux
117
+ // pane_current_command against the real pipx install).
118
+ return cmd === 'kimi' || cmd === 'node' || cmd === 'unsnooze' || /^python(\d(\.\d+)?)?$/i.test(cmd);
119
+ },
120
+ };
@@ -0,0 +1,105 @@
1
+ // OpenCode adapter (sst/opencode, command `opencode`) — EXPERIMENTAL.
2
+ //
3
+ // OpenCode is unusual: it self-retries rate limits FOREVER, honoring
4
+ // retry-after headers (it will happily sleep 3 hours until a reset), showing a
5
+ // status line like "Rate Limited [retrying in 2h5m attempt #4]". The session
6
+ // only stops on non-retryable errors (402 credits, auth, context overflow) or
7
+ // user abort. So the retry banner is BOTH a limit anchor and a busy pattern:
8
+ // - a LIVE self-retrying pane records a stop but is busy → the resumer
9
+ // defers, and the monitor flips the record to 'resumed' when the banner
10
+ // clears (OpenCode recovered on its own)
11
+ // - a DEAD pane mid-wait (laptop slept, tmux killed) is revived at the reset
12
+ // via `opencode -s <ses_id>`
13
+ // Banner strings are verbatim from packages/opencode/src/session/retry.ts and
14
+ // the console zen i18n; the bracketed countdown parses via time-parser's
15
+ // Go-duration support.
16
+
17
+ import { execFileSync } from 'node:child_process';
18
+
19
+ const LIMIT_ANCHORS = [
20
+ /(?:5.hour|weekly|monthly) usage limit reached/i, // Zen Go plans
21
+ /usage limit reached/i,
22
+ /Free usage exceeded/i, // Zen free tier
23
+ /Subscription quota exceeded/i, // Zen Black
24
+ /Rate Limited \[retrying/i, // status-line retry banner
25
+ /Too Many Requests \[retrying/i,
26
+ /Rate limit exceeded: limit_/i, // OpenRouter limit_rpd/limit_rpm
27
+ /free models per day/i, // OpenRouter free-tier daily
28
+ ];
29
+
30
+ const RETRY_BANNER = /\[retrying(?: in [^\]]+)? attempt #\d+\]/i;
31
+
32
+ export const patterns = {
33
+ limitPatterns: LIMIT_ANCHORS,
34
+ resetPatterns: [
35
+ /It will reset in/i,
36
+ /Resets? in/i,
37
+ /Retry in/i,
38
+ /\[retrying in/i,
39
+ ...LIMIT_ANCHORS,
40
+ ],
41
+ weeklyPatterns: [/weekly usage limit/i],
42
+ fiveHourPatterns: [/5.hour usage limit/i],
43
+ busyPatterns: [
44
+ RETRY_BANNER, // self-retrying — OpenCode is handling it
45
+ /esc (?:again to )?interrupt/i,
46
+ ],
47
+ idleRegex: /[›❯>]/,
48
+ overloadPatterns: [/Provider is overloaded/i],
49
+ transientPatterns: [/Provider is overloaded \[retrying/i],
50
+ // Non-retryable, non-resetting stops: notify-only.
51
+ terminalPatterns: [/insufficient credits/i, /out of credits/i],
52
+ };
53
+
54
+ function runSessionList() {
55
+ return execFileSync(process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
56
+ ['session', 'list', '--format', 'json'],
57
+ { timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
58
+ }
59
+
60
+ // `opencode session list --format json` — sessions live in a SQLite db, so ask
61
+ // the CLI rather than parsing it. Output tolerated as a JSON array or JSONL.
62
+ export function latestSessionId(cwd, aroundTs = null, runner = runSessionList) {
63
+ let raw;
64
+ try { raw = runner(); } catch { return null; }
65
+ let sessions = [];
66
+ try {
67
+ const parsed = JSON.parse(raw);
68
+ sessions = Array.isArray(parsed) ? parsed : [parsed];
69
+ } catch {
70
+ for (const line of String(raw).split('\n')) {
71
+ if (!line.trim()) continue;
72
+ try { sessions.push(JSON.parse(line)); } catch { /* skip noise lines */ }
73
+ }
74
+ }
75
+ let best = null;
76
+ for (const s of sessions) {
77
+ if (!s || typeof s !== 'object') continue;
78
+ const dir = s.directory ?? s.dir ?? s.cwd;
79
+ const id = s.id ?? s.sessionID ?? s.session_id;
80
+ if (dir !== cwd || !id) continue;
81
+ const updated = s.time_updated ?? s.time?.updated ?? 0;
82
+ if (!best || updated > best.updated) best = { id, updated };
83
+ }
84
+ return best ? best.id : null;
85
+ }
86
+
87
+ export default {
88
+ id: 'opencode',
89
+ name: 'OpenCode',
90
+ bin: process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
91
+ experimental: true,
92
+ patterns,
93
+ menu: null,
94
+ // `opencode -s <id>` reopens the TUI on that session; the resume message is
95
+ // typed once idle. (`opencode run -s <id> "msg"` exists but is headless-only.)
96
+ resumeArgs(sessionId) {
97
+ return { args: sessionId ? ['-s', sessionId] : ['--continue'], messageViaPane: true };
98
+ },
99
+ latestSessionId,
100
+ isForegroundCommand(cmd) {
101
+ // The npm-shipped bun binary reports pane_current_command "opencode.exe"
102
+ // even on macOS (verified live).
103
+ return /^opencode(\.exe)?$/.test(cmd) || cmd === 'node' || cmd === 'unsnooze';
104
+ },
105
+ };
@@ -0,0 +1,108 @@
1
+ // Qwen Code CLI adapter (QwenLM/qwen-code, command `qwen`) — EXPERIMENTAL.
2
+ //
3
+ // Limits landscape (researched 2026-07): the Qwen OAuth free tier was
4
+ // discontinued 2026-04-15 (that message is terminal, not a limit). The paid
5
+ // path is the Alibaba Coding Plan — a rolling 5-hour window + weekly quota
6
+ // whose exhaustion 429 (`Throttling.AllocationQuota`) is fail-fast: the CLI
7
+ // stops. Error strings below are verbatim from the qwen-code source
8
+ // (packages/core/src/utils/{retry,errorParsing}.ts). No reset time is ever
9
+ // shown or written to disk → detection leans on the 5h fallback + the
10
+ // verify/self-correct loop.
11
+ //
12
+ // Qwen also supports Claude-shaped JSON hooks in ~/.qwen/settings.json with a
13
+ // StopFailure event (matcher matches the error class: rate_limit | ... );
14
+ // install.js merges ours in when the agent is enabled.
15
+
16
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const QWEN_DIR = () => process.env.UNSNOOZE_QWEN_DIR || join(homedir(), '.qwen');
21
+
22
+ const LIMIT_ANCHORS = [
23
+ /Qwen (?:OAuth|API) quota exceeded/i, // legacy free-tier / API-quota renders
24
+ /Allocated quota exceeded/i, // Coding Plan + "Free allocated quota exceeded"
25
+ /\[API Error:.*(?:429|rate limit)/i, // OpenAI-compat providers incl. OpenRouter
26
+ /Possible quota limitations in place/i, // qwen's own 429 suffix line
27
+ /Rate limit exceeded: limit_/i, // OpenRouter limit_rpd/limit_rpm bodies
28
+ ];
29
+
30
+ export const patterns = {
31
+ limitPatterns: LIMIT_ANCHORS,
32
+ // No reset text exists in any qwen limit render — anchors double as reset
33
+ // lines (codex/grok trick) and time-parser falls back to the 5h default.
34
+ resetPatterns: [...LIMIT_ANCHORS],
35
+ weeklyPatterns: [],
36
+ fiveHourPatterns: [/Allocated quota exceeded/i], // Coding Plan window is rolling 5h
37
+ // Transient throttles self-retry with a countdown — never inject keys then.
38
+ busyPatterns: [
39
+ /Retrying in \d+s/i, // "↻ Retrying in 5s… (attempt 2/5)"
40
+ /attempt \d+\/\d+/i,
41
+ /esc to cancel/i,
42
+ ],
43
+ idleRegex: /[›❯>]/,
44
+ overloadPatterns: [/\[API Error:.*5\d\d/i, /Retrying in \d+s/i],
45
+ transientPatterns: [/Retrying in \d+s/i],
46
+ // Non-resetting stops: notify-only, never the ledger.
47
+ terminalPatterns: [/free tier has been discontinued/i, /insufficient credits/i],
48
+ };
49
+
50
+ // qwen-code maps a cwd to a project dir by replacing every non-alphanumeric
51
+ // char with '-' (packages/core/src/utils/paths.ts sanitizeCwd) — close to but
52
+ // not identical with claude's scheme (which keeps '_').
53
+ export function sanitizeCwd(cwd) {
54
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
55
+ }
56
+
57
+ const RUNTIME_RE = /\.runtime\.json$/;
58
+ const CHAT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
59
+
60
+ // v0.16+ writes a <sessionId>.runtime.json sidecar ({pid, session_id,
61
+ // work_dir, started_at}) next to each chat, purpose-built for observability
62
+ // daemons. Prefer it; fall back to the newest chat transcript by mtime.
63
+ export function latestSessionId(cwd, aroundTs = null, qwenDir = QWEN_DIR()) {
64
+ const chatsDir = join(qwenDir, 'projects', sanitizeCwd(cwd), 'chats');
65
+ let entries;
66
+ try { entries = readdirSync(chatsDir); } catch { return null; }
67
+
68
+ let best = null;
69
+ for (const name of entries) {
70
+ if (!RUNTIME_RE.test(name)) continue;
71
+ try {
72
+ const meta = JSON.parse(readFileSync(join(chatsDir, name), 'utf-8'));
73
+ if (meta.work_dir !== cwd || !meta.session_id) continue;
74
+ if (!best || (meta.started_at || 0) > best.startedAt) {
75
+ best = { id: meta.session_id, startedAt: meta.started_at || 0 };
76
+ }
77
+ } catch { /* unreadable sidecar — skip */ }
78
+ }
79
+ if (best) return best.id;
80
+
81
+ let newest = null;
82
+ for (const name of entries) {
83
+ if (!CHAT_RE.test(name)) continue;
84
+ let mtime;
85
+ try { mtime = statSync(join(chatsDir, name)).mtimeMs; } catch { continue; }
86
+ if (aroundTs != null && Math.abs(mtime - aroundTs) > 30 * 60_000) continue;
87
+ if (!newest || mtime > newest.mtime) newest = { id: name.slice(0, -6), mtime };
88
+ }
89
+ return newest ? newest.id : null;
90
+ }
91
+
92
+ export default {
93
+ id: 'qwen',
94
+ name: 'Qwen Code',
95
+ bin: process.env.UNSNOOZE_QWEN_BIN || 'qwen',
96
+ experimental: true,
97
+ patterns,
98
+ menu: null,
99
+ // `qwen --resume <id>` reopens the TUI; there is no verified
100
+ // resume-with-prompt argv form, so the message is typed once idle.
101
+ resumeArgs(sessionId) {
102
+ return { args: sessionId ? ['--resume', sessionId] : ['--continue'], messageViaPane: true };
103
+ },
104
+ latestSessionId,
105
+ isForegroundCommand(cmd) {
106
+ return cmd === 'qwen' || cmd === 'node' || cmd === 'unsnooze';
107
+ },
108
+ };
package/src/cli.js CHANGED
@@ -33,8 +33,11 @@ export function cmdStatus() {
33
33
  const msg = s.resumeMessage
34
34
  ? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
35
35
  : '';
36
+ const hold = s.workspaceHold
37
+ ? ` · workspace changed (${s.holdReason ?? '?'}) — resume-now to wake`
38
+ : '';
36
39
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
37
- console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}`);
40
+ console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
38
41
  }
39
42
  return 0;
40
43
  }
@@ -46,15 +49,30 @@ function selectKeys(state, idOrAll, statuses = ['stopped']) {
46
49
  return match.map(s => s.key);
47
50
  }
48
51
 
49
- export function cmdResumeNow(idOrAll) {
52
+ export async function cmdResumeNow(idOrAll) {
50
53
  const state = readState();
51
54
  const keys = selectKeys(state, idOrAll);
52
55
  if (keys.length === 0) { console.log('unsnooze: no matching stopped sessions.'); return 1; }
56
+ // The commenter's "show me the diff": held records print what moved since
57
+ // the stop-time baseline before we wake them. Best-effort, never blocking.
58
+ for (const key of keys) {
59
+ const rec = state.sessions[key];
60
+ if (rec?.workspaceHold && rec.workspace?.head && rec.cwd) {
61
+ try {
62
+ const { execFileSync } = await import('node:child_process');
63
+ const stat = execFileSync('git', ['-C', rec.cwd, 'diff', '--stat', `${rec.workspace.head}..HEAD`],
64
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).toString().trim();
65
+ if (stat) console.log(`unsnooze: workspace changes since ${key.slice(0, 12)} stopped:\n${stat}`);
66
+ } catch { /* repo gone or git unhappy — proceed anyway */ }
67
+ }
68
+ }
53
69
  updateState(s => {
54
70
  for (const key of keys) {
55
71
  if (s.sessions[key]) {
56
72
  s.sessions[key].resetAt = Date.now();
57
- s.sessions[key].manual = true; // explicit user action beats autoResume=off
73
+ s.sessions[key].manual = true; // explicit user action beats autoResume=off + workspaceGuard
74
+ delete s.sessions[key].workspaceHold;
75
+ delete s.sessions[key].holdReason;
58
76
  }
59
77
  }
60
78
  });
package/src/install.js CHANGED
@@ -56,15 +56,18 @@ function isLegacy(entry) {
56
56
  return (entry.hooks || []).some(h => /claude-auto-retry|csg\.js _hook-stopfailure/.test(h.command || ''));
57
57
  }
58
58
 
59
- export function mergeHookIntoSettings(settingsJson) {
59
+ // agent/matcher options cover CLIs with Claude-shaped hook config in their own
60
+ // settings.json (qwen); the default stays byte-identical for claude.
61
+ export function mergeHookIntoSettings(settingsJson, { agent = null, matcher = 'overloaded|server_error|rate_limit' } = {}) {
60
62
  const settings = JSON.parse(settingsJson);
61
63
  settings.hooks = settings.hooks || {};
62
64
  const list = (settings.hooks.StopFailure || []).filter(e => !isLegacy(e) && !isOurs(e));
65
+ const agentFlag = agent ? ` --agent ${agent}` : '';
63
66
  list.push({
64
- matcher: 'overloaded|server_error|rate_limit',
67
+ matcher,
65
68
  // Guarded like the shell wrapper: a vanished entry point must exit 0, not
66
69
  // spray MODULE_NOT_FOUND errors into every Claude Code turn.
67
- hooks: [{ type: 'command', command: `test -f "${UNSNOOZE_BIN}" && node "${UNSNOOZE_BIN}" _hook-stopfailure || exit 0`, timeout: 5 }],
70
+ hooks: [{ type: 'command', command: `test -f "${UNSNOOZE_BIN}" && node "${UNSNOOZE_BIN}" _hook-stopfailure${agentFlag} || exit 0`, timeout: 5 }],
68
71
  });
69
72
  settings.hooks.StopFailure = list;
70
73
  return JSON.stringify(settings, null, 2) + '\n';
@@ -229,7 +232,36 @@ export function uninstallDaemonAutostart({ platform = process.platform, dir = nu
229
232
  // --- commands ---
230
233
 
231
234
  export function enabledAgents() {
232
- return ['claude', 'codex', 'grok'].filter(id => getConfig(`agents.${id}`));
235
+ return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy'].filter(id => getConfig(`agents.${id}`));
236
+ }
237
+
238
+ // Qwen keeps Claude-shaped hooks in its own settings.json — reuse the same
239
+ // merge/remove machinery against ~/.qwen/settings.json. Matcher matches qwen's
240
+ // StopFailure `error` class; `unknown` is included because the discontinued
241
+ // free-tier message classifies as unknown, and hook.js's banner gate keeps
242
+ // unknowns without visible limit banners out of the ledger anyway.
243
+ const QWEN_HOOK_OPTS = { agent: 'qwen', matcher: 'rate_limit|unknown' };
244
+
245
+ function qwenSettingsPath() {
246
+ return join(process.env.UNSNOOZE_QWEN_DIR || join(homedir(), '.qwen'), 'settings.json');
247
+ }
248
+
249
+ export function installQwenHooks() {
250
+ const path = qwenSettingsPath();
251
+ if (existsSync(path)) {
252
+ copyFileSync(path, `${path}.unsnooze-bak`);
253
+ atomicWrite(path, mergeHookIntoSettings(readFileSync(path, 'utf-8'), QWEN_HOOK_OPTS));
254
+ } else {
255
+ atomicWrite(path, mergeHookIntoSettings('{}', QWEN_HOOK_OPTS));
256
+ }
257
+ return path;
258
+ }
259
+
260
+ export function uninstallQwenHooks() {
261
+ const path = qwenSettingsPath();
262
+ if (!existsSync(path)) return null;
263
+ atomicWrite(path, removeHookFromSettings(readFileSync(path, 'utf-8')));
264
+ return path;
233
265
  }
234
266
 
235
267
  // rc files to touch: the explicit --zshrc target, or every rc file that exists
@@ -271,6 +303,12 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
271
303
  console.log(`unsnooze: Grok StopFailure hook installed at ${file}`);
272
304
  }
273
305
 
306
+ // 2b. Qwen Code hook (Claude-shaped hooks in ~/.qwen/settings.json).
307
+ if (agents.includes('qwen')) {
308
+ const file = installQwenHooks();
309
+ console.log(`unsnooze: Qwen StopFailure hook installed in ${file}`);
310
+ }
311
+
274
312
  // 3. Shell wrappers (zsh + bash).
275
313
  for (const rc of rcTargets(opts, explicitRc)) {
276
314
  const rcContent = existsSync(rc) ? readFileSync(rc, 'utf-8') : '';
@@ -310,6 +348,8 @@ export function cmdUninstall(rest) {
310
348
  }
311
349
 
312
350
  uninstallGrokHooks();
351
+ const qwenFile = uninstallQwenHooks();
352
+ if (qwenFile) console.log(`unsnooze: StopFailure hook removed from ${qwenFile}`);
313
353
 
314
354
  for (const rc of rcTargets(opts, explicitRc)) {
315
355
  if (!existsSync(rc)) continue;
package/src/monitor.js CHANGED
@@ -29,9 +29,10 @@ const log = makeLogger('monitor');
29
29
 
30
30
  const sleep = ms => new Promise(r => setTimeout(r, ms));
31
31
 
32
- export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = realTmux, scrapeInterval = SCRAPE_INTERVAL_MS }) {
32
+ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = realTmux, scrapeInterval = SCRAPE_INTERVAL_MS, notifier = notify }) {
33
33
  let trackedKey = null; // state key of the record we created
34
34
  let overloadAttempt = 0;
35
+ let terminalNotified = false; // one notification per terminal-error appearance
35
36
  let running = true;
36
37
 
37
38
  function markerPath() {
@@ -156,6 +157,20 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
156
157
  return;
157
158
  }
158
159
 
160
+ // Non-resetting terminal errors (credits exhausted, membership expired,
161
+ // discontinued tiers): notify once per appearance, never touch the ledger —
162
+ // there is no reset to wait for. Re-arms when the error clears.
163
+ const term = overloadMatch(text, agent.patterns.terminalPatterns || []);
164
+ if (term) {
165
+ if (!terminalNotified) {
166
+ terminalNotified = true;
167
+ notifier(`${agent.name} needs attention ⚠️`, `${cwd} — ${term.line}`);
168
+ log(`pane ${pane}: terminal error (no auto-resume): ${term.line}`);
169
+ }
170
+ } else {
171
+ terminalNotified = false;
172
+ }
173
+
159
174
  // No banner. If we were tracking a stopped record and claude is active
160
175
  // again, someone resumed it (user or resumer) — mark it.
161
176
  if (trackedKey) {
package/src/resumer.js CHANGED
@@ -18,6 +18,7 @@ import { getAgent } from './agents/index.js';
18
18
  import { parseResetTime, resetAtMs } from './time-parser.js';
19
19
  import { readState, updateState, setStatus, dueSessions, activeStopped } from './state.js';
20
20
  import { getConfig, resolveResumeMessage } from './settings.js';
21
+ import { workspaceFingerprint, workspaceChanged, describeChange } from './workspace.js';
21
22
  import { notify } from './notify.js';
22
23
  import { UNSNOOZE_BIN } from './spawn.js';
23
24
  import { makeLogger } from './logger.js';
@@ -51,7 +52,8 @@ export function releaseSingleton() {
51
52
  // it's off. Stops stay tracked either way.
52
53
  export function dueForDispatch(now = Date.now()) {
53
54
  const auto = getConfig('autoResume');
54
- return dueSessions(now).filter(s => auto || s.manual);
55
+ // workspaceHold: guarded sessions wait for an explicit resume-now (manual).
56
+ return dueSessions(now).filter(s => (auto || s.manual) && (!s.workspaceHold || s.manual));
55
57
  }
56
58
 
57
59
  function shellQuote(arg) {
@@ -69,7 +71,7 @@ function selfCommand() {
69
71
 
70
72
  // Decide how to act on one due record. Pure-ish; tmux injectable.
71
73
  // Returns: 'sent' | 'reopened' | 'deferred' | 'skip' | 'failed'
72
- export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand() } = {}) {
74
+ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint, notifier = notify } = {}) {
73
75
  const key = rec.key;
74
76
  const agent = getAgent(rec.agent);
75
77
  // Wake-message precedence: per-session (`unsnooze message <id> "..."`) →
@@ -77,6 +79,24 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
77
79
  // both the live-pane sendText and the argv reopen path.
78
80
  resumeMessage = rec.resumeMessage ?? resumeMessage ?? resolveResumeMessage(agent.id);
79
81
 
82
+ // Stale-workspace guard: another session (or a human) may have moved the
83
+ // repo while this one slept. Manual resumes (resume-now) always proceed.
84
+ const guardMode = getConfig('workspaceGuard');
85
+ if (rec.workspace && guardMode !== 'off' && !rec.manual) {
86
+ const change = workspaceChanged(rec, fingerprint(rec.cwd));
87
+ if (change) {
88
+ const desc = describeChange(change);
89
+ if (guardMode === 'pause') {
90
+ setStatus(key, 'stopped', { workspaceHold: true, holdReason: desc });
91
+ notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`);
92
+ log(`${key}: workspace changed (${desc}) — held (workspaceGuard=pause)`);
93
+ return 'held';
94
+ }
95
+ resumeMessage += `\n\nHeads up: this workspace changed while the session was stopped (${desc}). Re-read the current state of the repo before continuing.`;
96
+ log(`${key}: workspace changed (${desc}) — informing agent in the wake message`);
97
+ }
98
+ }
99
+
80
100
  // Live-pane path: only if the pane still exists AND the agent CLI is its
81
101
  // foreground command (pane ids get recycled — never inject into a random
82
102
  // program).
package/src/settings.js CHANGED
@@ -18,9 +18,10 @@ export const DEFAULTS = {
18
18
  notifications: true, // desktop notifications on detect/resume
19
19
  guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
20
20
  updateCheck: true, // daily registry version check + update notices/toast
21
+ workspaceGuard: 'inform', // repo changed while stopped: off | inform | pause
21
22
  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.',
22
- resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
23
- agents: { claude: true, codex: true, grok: false }, // grok is experimental
23
+ resumeMessages: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' }, // per-agent override; '' = use resumeMessage
24
+ agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false }, // experimental agents default off
24
25
  };
25
26
 
26
27
  // Env override per key. Booleans accept 1/0, true/false, on/off, yes/no.
@@ -30,17 +31,31 @@ const ENV_NAMES = {
30
31
  notifications: 'UNSNOOZE_NOTIFICATIONS',
31
32
  guiWatch: 'UNSNOOZE_GUI_WATCH',
32
33
  updateCheck: 'UNSNOOZE_UPDATE_CHECK',
34
+ workspaceGuard: 'UNSNOOZE_WORKSPACE_GUARD',
33
35
  resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
34
36
  'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
35
37
  'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
36
38
  'resumeMessages.grok': 'UNSNOOZE_RESUME_MESSAGE_GROK',
39
+ 'resumeMessages.qwen': 'UNSNOOZE_RESUME_MESSAGE_QWEN',
40
+ 'resumeMessages.kimi': 'UNSNOOZE_RESUME_MESSAGE_KIMI',
41
+ 'resumeMessages.opencode': 'UNSNOOZE_RESUME_MESSAGE_OPENCODE',
42
+ 'resumeMessages.agy': 'UNSNOOZE_RESUME_MESSAGE_AGY',
37
43
  'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
38
44
  'agents.codex': 'UNSNOOZE_AGENT_CODEX',
39
45
  'agents.grok': 'UNSNOOZE_AGENT_GROK',
46
+ 'agents.qwen': 'UNSNOOZE_AGENT_QWEN',
47
+ 'agents.kimi': 'UNSNOOZE_AGENT_KIMI',
48
+ 'agents.opencode': 'UNSNOOZE_AGENT_OPENCODE',
49
+ 'agents.agy': 'UNSNOOZE_AGENT_AGY',
40
50
  };
41
51
 
42
52
  const KNOWN_KEYS = Object.keys(ENV_NAMES);
43
53
 
54
+ // String settings restricted to a fixed set of values.
55
+ const ENUMS = {
56
+ workspaceGuard: ['off', 'inform', 'pause'],
57
+ };
58
+
44
59
  function parseBool(raw) {
45
60
  if (/^(1|true|on|yes)$/i.test(raw)) return true;
46
61
  if (/^(0|false|off|no)$/i.test(raw)) return false;
@@ -98,6 +113,9 @@ export function setConfigValue(key, rawValue) {
98
113
  value = b;
99
114
  } else {
100
115
  value = String(rawValue);
116
+ if (ENUMS[key] && !ENUMS[key].includes(value)) {
117
+ throw new Error(`unsnooze: "${key}" must be one of: ${ENUMS[key].join(', ')}`);
118
+ }
101
119
  }
102
120
  const config = readFileConfig();
103
121
  const parts = key.split('.');
package/src/state.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS,
14
14
  DEDUPE_WINDOW_MS,
15
15
  } from './config.js';
16
+ import { workspaceFingerprint } from './workspace.js';
16
17
  import { makeLogger } from './logger.js';
17
18
 
18
19
  const log = makeLogger('state');
@@ -105,6 +106,11 @@ export function upsertSession(record) {
105
106
  log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
106
107
  return state;
107
108
  }
109
+ // Baseline for the stale-workspace guard, captured once at stop time.
110
+ // (Merged duplicates above keep the ORIGINAL baseline — spread semantics.)
111
+ if (record.status === 'stopped' && record.workspace === undefined) {
112
+ record.workspace = workspaceFingerprint(record.cwd);
113
+ }
108
114
  const key = record.sessionId || `pane:${record.pane}:${record.detectedAt}`;
109
115
  state.sessions[key] = { ...record, key };
110
116
  return state;
@@ -19,7 +19,32 @@ const RESET_DATE_REGEX = /resets?\s+(?:on\s+)?(jan|feb|mar|apr|may|jun|jul|aug|s
19
19
  // older: "Try again in 4 days 20 hours 9 minutes."
20
20
  const TRY_AT_TIME_REGEX = /try again at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i;
21
21
  const TRY_AT_DATE_REGEX = /try again at\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\s+(\d{1,2}):(\d{2})\s*(am|pm)/i;
22
- const MULTI_RELATIVE_REGEX = /try again in\s+(?:(\d+)\s*days?\s*)?(?:(\d+)\s*hours?\s*)?(?:(\d+)\s*min(?:ute)?s?)?/i;
22
+ // Multi-unit relative, verb-generalized across CLIs: codex "Try again in 4 days
23
+ // 20 hours 9 minutes", agy "Refreshes in 6 days and 18 hours", opencode "It
24
+ // will reset in 2 hours 5 minutes" / "Retry in 45 minutes".
25
+ const MULTI_RELATIVE_REGEX = /(?:try again|resets?|refreshes|retry|wait)\s+in\s+(?:(\d+)\s*days?\b(?:\s+and)?\s*)?(?:(\d+)\s*hours?\b(?:\s+and)?\s*)?(?:(\d+)\s*min(?:ute)?s?\b)?/i;
26
+ // opencode status line: "Rate Limited [retrying in 2h5m attempt #4]" — the
27
+ // duration is Go-style ("2h5m", "2m 5s", "~2 days").
28
+ const RETRY_BANNER_REGEX = /retrying in\s+([^\]\n]*?)\s*attempt\s*#?\d+/i;
29
+ // (?![a-z]) instead of \b: compact Go durations pack units against the next
30
+ // digit ("2h5m"), where h→5 is not a word boundary.
31
+ const DURATION_TOKEN_REGEX = /~?\s*(\d+(?:\.\d+)?)\s*(ms|milliseconds?|seconds?|secs?|s|min(?:ute)?s?|m|hours?|hrs?|h|days?|d|weeks?|w)(?![a-z])/gi;
32
+ const DURATION_UNIT_MS = {
33
+ ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000,
34
+ millisecond: 1, second: 1000, sec: 1000, min: 60_000, minute: 60_000,
35
+ hour: 3_600_000, hr: 3_600_000, day: 86_400_000, week: 604_800_000,
36
+ };
37
+
38
+ function parseGoDuration(text) {
39
+ let total = 0;
40
+ for (const m of text.matchAll(DURATION_TOKEN_REGEX)) {
41
+ const raw = m[2].toLowerCase();
42
+ // Exact form first ("ms" must not singular-strip to "m"), then singular.
43
+ const perUnit = DURATION_UNIT_MS[raw] ?? DURATION_UNIT_MS[raw.replace(/s$/, '')];
44
+ if (perUnit) total += parseFloat(m[1]) * perUnit;
45
+ }
46
+ return total;
47
+ }
23
48
  const MONTH_INDEX = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
24
49
 
25
50
  function to24h(hour, ampm) {
@@ -32,6 +57,14 @@ function to24h(hour, ampm) {
32
57
  export function parseResetTime(text) {
33
58
  if (!text) return null;
34
59
 
60
+ // Self-retry countdown banner first (opencode) — the bracketed duration is
61
+ // the live countdown and beats any older prose in the same line.
62
+ const retryMatch = text.match(RETRY_BANNER_REGEX);
63
+ if (retryMatch) {
64
+ const waitMs = parseGoDuration(retryMatch[1]);
65
+ if (waitMs > 0) return { relative: true, waitMs };
66
+ }
67
+
35
68
  // Full-date form first — its trailing "9:01 PM" would otherwise be eaten by
36
69
  // the same-day "try again at" regex.
37
70
  const dateMatch = text.match(TRY_AT_DATE_REGEX);
@@ -89,16 +122,9 @@ export function parseResetTime(text) {
89
122
  };
90
123
  }
91
124
 
92
- const relMatch = text.match(RELATIVE_TIME_REGEX);
93
- if (relMatch) {
94
- const amount = parseInt(relMatch[1], 10);
95
- const unit = relMatch[2].toLowerCase();
96
- const isMinutes = unit.startsWith('m');
97
- return { relative: true, waitMs: amount * (isMinutes ? 60_000 : 3_600_000) };
98
- }
99
-
100
- // Multi-unit relative ("in 4 days 20 hours 9 minutes") — checked after the
101
- // single-unit form, which already covers "in 2 hours" / "in 5 minutes".
125
+ // Multi-unit relative ("in 4 days 20 hours 9 minutes", "in 6 days and 18
126
+ // hours", "reset in 2 hours 5 minutes") — checked BEFORE the single-unit
127
+ // form, which would truncate "2 hours 5 minutes" to just "2 hours".
102
128
  const multiMatch = text.match(MULTI_RELATIVE_REGEX);
103
129
  if (multiMatch && (multiMatch[1] || multiMatch[2] || multiMatch[3])) {
104
130
  const days = parseInt(multiMatch[1] || '0', 10);
@@ -107,6 +133,16 @@ export function parseResetTime(text) {
107
133
  return { relative: true, waitMs: ((days * 24 + hours) * 60 + minutes) * 60_000 };
108
134
  }
109
135
 
136
+ // Single-unit fallback covers colon/verb forms the multi regex doesn't
137
+ // ("resets in: 3 hours", "wait 5 minutes").
138
+ const relMatch = text.match(RELATIVE_TIME_REGEX);
139
+ if (relMatch) {
140
+ const amount = parseInt(relMatch[1], 10);
141
+ const unit = relMatch[2].toLowerCase();
142
+ const isMinutes = unit.startsWith('m');
143
+ return { relative: true, waitMs: amount * (isMinutes ? 60_000 : 3_600_000) };
144
+ }
145
+
110
146
  // Day-only weekly banner ("resets Tuesday") with no time — midnight-ish target,
111
147
  // resolved by resetAtMs via hour 0.
112
148
  if (dayMatch) {
package/src/wizard.js CHANGED
@@ -142,7 +142,7 @@ export async function runWizard() {
142
142
  s.stop(code === 0 ? 'Wrappers and hooks installed' : 'Install hit a problem — see output above');
143
143
 
144
144
  p.outro(code === 0
145
- ? 'Done. Reload your shell (`exec $SHELL`) and use claude/codex/grok as usual.\nCheck `unsnooze status` anytime; change settings with `unsnooze config`.'
145
+ ? 'Done. Reload your shell (`exec $SHELL`) and use your AI CLIs as usual.\nCheck `unsnooze status` anytime; change settings with `unsnooze config`.'
146
146
  : 'Setup incomplete — fix the issue above and re-run `unsnooze setup`.');
147
147
  return code;
148
148
  }
@@ -0,0 +1,44 @@
1
+ // Stale-workspace guard: fingerprint a repo when a session stops, compare at
2
+ // wake time. If another session (or a human) moved the repo meanwhile, the
3
+ // resumer either warns the agent in the wake message or holds the session,
4
+ // per the `workspaceGuard` setting. Non-git directories fingerprint to null
5
+ // and are never guarded.
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+
10
+ function git(cwd, args) {
11
+ return execFileSync('git', ['-C', cwd, ...args], {
12
+ stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
13
+ }).toString().trim();
14
+ }
15
+
16
+ export function workspaceFingerprint(cwd) {
17
+ if (!cwd) return null;
18
+ try {
19
+ const head = git(cwd, ['rev-parse', 'HEAD']);
20
+ const dirty = git(cwd, ['status', '--porcelain']);
21
+ return { head, dirtyHash: createHash('sha1').update(dirty).digest('hex') };
22
+ } catch {
23
+ return null; // not a git repo / git missing / unreadable — skip the guard
24
+ }
25
+ }
26
+
27
+ // null → nothing to report (no baseline, no current, or identical).
28
+ export function workspaceChanged(rec, current) {
29
+ const before = rec?.workspace;
30
+ if (!before || !current) return null;
31
+ if (before.head === current.head && before.dirtyHash === current.dirtyHash) return null;
32
+ return {
33
+ oldHead: before.head,
34
+ newHead: current.head,
35
+ dirtyChanged: before.dirtyHash !== current.dirtyHash,
36
+ };
37
+ }
38
+
39
+ export function describeChange(d) {
40
+ const parts = [];
41
+ if (d.oldHead !== d.newHead) parts.push(`HEAD ${d.oldHead.slice(0, 7)} → ${d.newHead.slice(0, 7)}`);
42
+ if (d.dirtyChanged) parts.push('uncommitted changes differ');
43
+ return parts.join('; ');
44
+ }