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 +42 -0
- package/README.md +55 -3
- package/package.json +10 -2
- package/src/agents/agy.js +100 -0
- package/src/agents/index.js +5 -1
- package/src/agents/kimi.js +120 -0
- package/src/agents/opencode.js +105 -0
- package/src/agents/qwen.js +108 -0
- package/src/cli.js +21 -3
- package/src/install.js +44 -4
- package/src/monitor.js +16 -1
- package/src/resumer.js +22 -2
- package/src/settings.js +20 -2
- package/src/state.js +6 -0
- package/src/time-parser.js +47 -11
- package/src/wizard.js +1 -1
- package/src/workspace.js +44 -0
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
|
[](package.json)
|
|
10
10
|
[](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`
|
|
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.
|
|
4
|
-
"description": "Auto-resume Claude Code, Codex CLI &
|
|
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
|
+
};
|
package/src/agents/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 }, //
|
|
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;
|
package/src/time-parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
}
|
package/src/workspace.js
ADDED
|
@@ -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
|
+
}
|