unsnooze 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +35 -0
- package/bin/unsnooze.js +20 -2
- package/package.json +1 -1
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +2 -4
- package/src/cli.js +6 -3
- package/src/config.js +5 -0
- package/src/hook.js +1 -0
- package/src/install.js +115 -2
- package/src/notify.js +1 -1
- package/src/resumer.js +44 -9
- package/src/settings.js +27 -4
- package/src/state.js +16 -0
- package/src/time-parser.js +73 -10
- package/src/watcher.js +333 -0
- package/src/watchers/claude.js +39 -0
- package/src/watchers/codex.js +106 -0
- package/src/wizard.js +49 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0 — 2026-07-10
|
|
4
|
+
|
|
5
|
+
### GUI surfaces: VS Code extension, desktop apps
|
|
6
|
+
|
|
7
|
+
Sessions running outside a terminal — Claude Code's VS Code extension and
|
|
8
|
+
desktop app, Codex's IDE extension and desktop app — are now guarded too.
|
|
9
|
+
There is no pane to scrape and (for Codex) no hook, so detection tails the
|
|
10
|
+
session files the CLIs already write:
|
|
11
|
+
|
|
12
|
+
- **Claude Code**: rate-limit stops land in `~/.claude/projects` transcripts
|
|
13
|
+
as structured entries (`error:"rate_limit"`, session id, cwd, reset text).
|
|
14
|
+
The new watcher turns them into ledger records; the weekly banner form
|
|
15
|
+
("resets Jul 4 at 12:30am (tz)") now parses, DST-safe.
|
|
16
|
+
- **Codex**: rollouts never persist error events, but every turn's
|
|
17
|
+
`token_count` event carries a `rate_limits` snapshot (`used_percent`,
|
|
18
|
+
`resets_at` epoch). An exhausted window becomes a stop with an exact epoch
|
|
19
|
+
reset — more precise than any scraped banner — and works for every Codex
|
|
20
|
+
surface, since they share `~/.codex/sessions`.
|
|
21
|
+
- **Claude desktop (cowork) sessions** *(experimental, macOS)*: sandboxed
|
|
22
|
+
sessions under `~/Library/Application Support/Claude` are detected, and
|
|
23
|
+
revival exports the session's isolated `CLAUDE_CONFIG_DIR` together with
|
|
24
|
+
`CLAUDE_SECURESTORAGE_CONFIG_DIR=''` so auth resolves through the default
|
|
25
|
+
keychain entry (the sandbox holds no credentials). Verified end-to-end
|
|
26
|
+
against a real desktop session.
|
|
27
|
+
|
|
28
|
+
Revival stays terminal-based: when the limit resets, the session reopens in a
|
|
29
|
+
tmux window via `claude --resume <id>` / `codex resume <id>` — the same
|
|
30
|
+
session file continues, so the conversation stays visible in the GUI's own
|
|
31
|
+
history. Resuming *inside* the GUI panels is not possible today (no IPC/URI
|
|
32
|
+
sends a prompt into them).
|
|
33
|
+
|
|
34
|
+
- **`unsnooze daemon`**: persistent watcher process; `unsnooze install
|
|
35
|
+
--daemon` (or the new wizard step) installs it as a launchd agent (macOS)
|
|
36
|
+
or systemd user unit (Linux) so GUI sessions are watched without a shell.
|
|
37
|
+
- **`guiWatch` setting** (default on) gates the watching; `unsnooze status`
|
|
38
|
+
shows each stop's origin (`cli`, `vscode`, `desktop`, …).
|
|
39
|
+
- Ledger dedupe: transcript records merge with hook/scrape records of the
|
|
40
|
+
same session, so terminal sessions are never double-resumed.
|
|
41
|
+
|
|
42
|
+
## 1.1.0 — 2026-07-10
|
|
43
|
+
|
|
44
|
+
### Per-agent resume messages
|
|
45
|
+
|
|
46
|
+
- **`resumeMessages.claude` / `.codex` / `.grok`**: optional per-agent override
|
|
47
|
+
of the global `resumeMessage` — `unsnooze config set resumeMessages.codex
|
|
48
|
+
"..."` or `UNSNOOZE_RESUME_MESSAGE_CODEX`. Empty means "use the global
|
|
49
|
+
message"; clear with `unsnooze config set resumeMessages.codex ""`.
|
|
50
|
+
Specificity beats source: a per-agent file value outranks a global env var.
|
|
51
|
+
- **Setup wizard** asks for per-agent messages, prefills every message prompt
|
|
52
|
+
from the existing config, and merges over the config file — re-runs no
|
|
53
|
+
longer clobber values set via `unsnooze config`.
|
|
54
|
+
- Blank or whitespace-only messages are never sent: resolution falls through
|
|
55
|
+
to the global message, then the built-in default.
|
|
56
|
+
|
|
57
|
+
### CLI & fixes
|
|
58
|
+
|
|
59
|
+
- **`-h` / `--help`** now print the unsnooze help (previously only
|
|
60
|
+
`unsnooze help`), and the usage documents every command.
|
|
61
|
+
- **Install**: `unsnooze setup` / `install` no longer crashes with ENOENT on
|
|
62
|
+
machines without a `~/.claude/` directory.
|
|
63
|
+
- `unsnooze config set <key> ""` is a valid way to clear string overrides, and
|
|
64
|
+
a config file holding non-object JSON is treated as empty instead of
|
|
65
|
+
corrupting later writes.
|
|
66
|
+
|
|
3
67
|
## 1.0.0 — 2026-07-10
|
|
4
68
|
|
|
5
69
|
First public release (previously the private `claude-session-guard`/`csg`).
|
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ existing tool solves only a slice of it:
|
|
|
29
29
|
| | **unsnooze** | claude-auto-retry | autoclaude | hydra |
|
|
30
30
|
|---|:---:|:---:|:---:|:---:|
|
|
31
31
|
| Multi-CLI (Claude Code + Codex + Grok) | ✅ | ❌ Claude only | ❌ Claude only | ✅ |
|
|
32
|
+
| GUI sessions (VS Code ext, desktop apps) | ✅ watcher daemon | ❌ | ❌ | ❌ |
|
|
32
33
|
| Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
|
|
33
34
|
| All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
|
|
34
35
|
| Revives sessions whose pane/process is **gone** | ✅ `--resume <id>` | ❌ | ❌ | ❌ |
|
|
@@ -52,6 +53,35 @@ existing tool solves only a slice of it:
|
|
|
52
53
|
fallback. Hit a banner unsnooze missed? Run `unsnooze report` and paste the
|
|
53
54
|
capture into an issue — that's how this adapter gets good.
|
|
54
55
|
|
|
56
|
+
## GUI surfaces (VS Code extension, desktop apps)
|
|
57
|
+
|
|
58
|
+
Terminal sessions are watched through the shell wrapper + tmux. Sessions in
|
|
59
|
+
**Claude Code's VS Code extension / desktop app** and **Codex's IDE
|
|
60
|
+
extension / desktop app** have no pane to scrape — so `unsnooze daemon` tails
|
|
61
|
+
the session files those surfaces already write:
|
|
62
|
+
|
|
63
|
+
- **Claude Code** records every rate-limit stop as a structured entry in its
|
|
64
|
+
`~/.claude/projects/**.jsonl` transcript (session id, cwd, reset time) —
|
|
65
|
+
shared by the CLI and the VS Code extension.
|
|
66
|
+
- **Codex** writes a `rate_limits` snapshot (usage %, **exact epoch reset
|
|
67
|
+
time**) into every rollout under `~/.codex/sessions/` — shared by the CLI,
|
|
68
|
+
IDE extension, and desktop app.
|
|
69
|
+
- **Claude desktop (cowork) sessions** *(experimental, macOS)* run in
|
|
70
|
+
sandboxes under `~/Library/Application Support/Claude`; unsnooze watches
|
|
71
|
+
those too and revives with the session's isolated `CLAUDE_CONFIG_DIR`
|
|
72
|
+
(plus the keychain-scope override that keeps auth working — verified
|
|
73
|
+
against a real desktop session).
|
|
74
|
+
|
|
75
|
+
When the limit resets, the session is revived **in a tmux window** with
|
|
76
|
+
`claude --resume <id>` / `codex resume <id>` — it's the same session file, so
|
|
77
|
+
the continued conversation stays visible in the GUI's own history. (Resuming
|
|
78
|
+
*inside* the GUI panel isn't possible today: no extension/app exposes an
|
|
79
|
+
IPC/URI that can send a prompt.)
|
|
80
|
+
|
|
81
|
+
Enable it in `unsnooze setup` (installs a launchd agent / systemd user unit),
|
|
82
|
+
or run `unsnooze install --daemon` / `unsnooze daemon` yourself. Turn it off
|
|
83
|
+
anytime with `unsnooze config set guiWatch off`.
|
|
84
|
+
|
|
55
85
|
## How it works
|
|
56
86
|
|
|
57
87
|
<div align="center">
|
|
@@ -101,8 +131,11 @@ unsnooze cancel [id|--all] # stop tracking a session
|
|
|
101
131
|
unsnooze config list # settings (see below)
|
|
102
132
|
unsnooze config set <k> <v> # e.g. autoResume off
|
|
103
133
|
unsnooze logs [-f] # what unsnooze has been doing
|
|
134
|
+
unsnooze daemon # persistent GUI-session watcher (usually run
|
|
135
|
+
# by launchd/systemd via `install --daemon`)
|
|
104
136
|
unsnooze report [agent] # capture a pane to report an undetected banner
|
|
105
137
|
unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
|
|
138
|
+
unsnooze help # full command list (also -h / --help)
|
|
106
139
|
```
|
|
107
140
|
|
|
108
141
|
## Settings
|
|
@@ -115,7 +148,9 @@ unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
|
|
|
115
148
|
| `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
|
|
116
149
|
| `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
|
|
117
150
|
| `notifications` | `true` | Desktop notification on limit detected / session resumed / gave up. |
|
|
151
|
+
| `guiWatch` | `true` | May the daemon watch session files for GUI-surface stops (VS Code extension, desktop apps)? Needs the daemon running (`unsnooze install --daemon`). |
|
|
118
152
|
| `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. |
|
|
153
|
+
| `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
|
|
119
154
|
| `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
|
|
120
155
|
|
|
121
156
|
Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
|
package/bin/unsnooze.js
CHANGED
|
@@ -60,7 +60,20 @@ async function main() {
|
|
|
60
60
|
const { runResumer } = await import('../src/resumer.js');
|
|
61
61
|
return runResumer();
|
|
62
62
|
}
|
|
63
|
+
case 'daemon': {
|
|
64
|
+
// Persistent resumer + transcript watcher: detects and revives limit
|
|
65
|
+
// stops from GUI surfaces (VS Code extension, desktop apps) where no
|
|
66
|
+
// shell wrapper or tmux pane exists. Run via launchd/systemd or a shell.
|
|
67
|
+
const { runResumer } = await import('../src/resumer.js');
|
|
68
|
+
const { createWatcher } = await import('../src/watcher.js');
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
process.on('SIGTERM', () => controller.abort());
|
|
71
|
+
process.on('SIGINT', () => controller.abort());
|
|
72
|
+
return runResumer({ persistent: true, watcher: createWatcher(), signal: controller.signal });
|
|
73
|
+
}
|
|
63
74
|
case 'help':
|
|
75
|
+
case '-h':
|
|
76
|
+
case '--help':
|
|
64
77
|
case '--help-unsnooze': {
|
|
65
78
|
console.log(`unsnooze — wakes every limit-stopped AI coding session when the limit resets
|
|
66
79
|
|
|
@@ -71,11 +84,16 @@ Usage:
|
|
|
71
84
|
unsnooze resume-now [id|--all] resume stopped session(s) immediately
|
|
72
85
|
unsnooze cancel [id|--all] stop tracking session(s)
|
|
73
86
|
unsnooze logs [-f] show (or follow) the unsnooze log
|
|
74
|
-
unsnooze
|
|
87
|
+
unsnooze daemon persistent watcher for GUI sessions (VS Code
|
|
88
|
+
extension, desktop apps) — no tmux needed to
|
|
89
|
+
detect; revival still opens in tmux
|
|
90
|
+
unsnooze config [list|get|set] view or change settings (toggles, global +
|
|
91
|
+
per-agent resume messages)
|
|
75
92
|
unsnooze setup interactive setup wizard (agents + toggles)
|
|
76
93
|
unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
|
|
77
94
|
unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
|
|
78
|
-
unsnooze report [agent] [pane] capture a pane to report an undetected banner
|
|
95
|
+
unsnooze report [agent] [pane] capture a pane to report an undetected banner
|
|
96
|
+
unsnooze help show this help (also -h / --help)`);
|
|
79
97
|
return 0;
|
|
80
98
|
}
|
|
81
99
|
default: {
|
package/package.json
CHANGED
package/src/agents/claude.js
CHANGED
|
@@ -23,6 +23,7 @@ export const patterns = {
|
|
|
23
23
|
/resets?\s+in[:\s]\s*\d/i,
|
|
24
24
|
/try again in \d+\s*(?:hours?|minutes?|h|m)/i,
|
|
25
25
|
/resets?\s+(?:on\s+)?(?:mon|tue|wed|thu|fri|sat|sun)/i, // weekly: "resets Tuesday 9am"
|
|
26
|
+
/resets?\s+(?:on\s+)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2}\b/i, // weekly: "resets Jul 4 at 12:30am"
|
|
26
27
|
],
|
|
27
28
|
weeklyPatterns: [
|
|
28
29
|
/week(?:ly)?\s+limit/i,
|
package/src/agents/codex.js
CHANGED
|
@@ -11,10 +11,8 @@
|
|
|
11
11
|
// the overload path, never the ledger.
|
|
12
12
|
|
|
13
13
|
import { openSync, readSync, closeSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
15
14
|
import { join } from 'node:path';
|
|
16
|
-
|
|
17
|
-
const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
|
|
15
|
+
import { CODEX_DIR } from '../config.js';
|
|
18
16
|
|
|
19
17
|
const LIMIT_ANCHORS = [
|
|
20
18
|
/You've hit your usage limit/i,
|
|
@@ -48,7 +46,7 @@ export const patterns = {
|
|
|
48
46
|
// the first JSONL line carries the session cwd. Conservative: no cwd match →
|
|
49
47
|
// null (the resumer then uses `codex resume --last`, which codex itself scopes
|
|
50
48
|
// to the launch cwd).
|
|
51
|
-
const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
49
|
+
export const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
52
50
|
|
|
53
51
|
function fileHead(path, bytes = 4096) {
|
|
54
52
|
let fd;
|
package/src/cli.js
CHANGED
|
@@ -29,8 +29,9 @@ export function cmdStatus() {
|
|
|
29
29
|
for (const s of sessions.sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0))) {
|
|
30
30
|
const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
|
|
31
31
|
const reset = s.resetAt ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})` : '?';
|
|
32
|
+
const origin = s.origin ?? (s.pane ? 'cli' : '?');
|
|
32
33
|
console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
|
|
33
|
-
console.log(` pane ${s.pane ?? '-'} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}`);
|
|
34
|
+
console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}`);
|
|
34
35
|
}
|
|
35
36
|
return 0;
|
|
36
37
|
}
|
|
@@ -89,7 +90,7 @@ export function cmdConfig(rest) {
|
|
|
89
90
|
const listed = listConfig();
|
|
90
91
|
console.log(`unsnooze settings (${CONFIG_FILE()}):\n`);
|
|
91
92
|
for (const [k, v] of Object.entries(listed)) {
|
|
92
|
-
console.log(` ${k.padEnd(
|
|
93
|
+
console.log(` ${k.padEnd(24)} ${JSON.stringify(v)}`);
|
|
93
94
|
}
|
|
94
95
|
return 0;
|
|
95
96
|
}
|
|
@@ -100,7 +101,9 @@ export function cmdConfig(rest) {
|
|
|
100
101
|
}
|
|
101
102
|
if (action === 'set') {
|
|
102
103
|
const value = valueParts.join(' ');
|
|
103
|
-
|
|
104
|
+
// An explicit "" is a valid value (clears string overrides); only a
|
|
105
|
+
// genuinely missing value is a usage error.
|
|
106
|
+
if (!key || valueParts.length === 0) { console.error('unsnooze config set <key> <value>'); return 2; }
|
|
104
107
|
const applied = setConfigValue(key, value);
|
|
105
108
|
console.log(`unsnooze: ${key} = ${JSON.stringify(applied)}`);
|
|
106
109
|
return 0;
|
package/src/config.js
CHANGED
|
@@ -15,6 +15,10 @@ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
|
|
|
15
15
|
|
|
16
16
|
export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR || join(homedir(), '.claude');
|
|
17
17
|
export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
|
18
|
+
export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
|
|
19
|
+
|
|
20
|
+
// Transcript/rollout watcher (GUI detection channel)
|
|
21
|
+
export const WATCH_OFFSETS_FILE = join(STATE_DIR, 'watch-offsets.json');
|
|
18
22
|
|
|
19
23
|
export const TMUX_SESSION_NAME = process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
|
|
20
24
|
|
|
@@ -28,6 +32,7 @@ export const VERIFY_DELAY_MS = envInt('UNSNOOZE_VERIFY_DELAY_MS', 20_000);
|
|
|
28
32
|
export const BUSY_DEFER_MS = envInt('UNSNOOZE_BUSY_DEFER_MS', 60_000);
|
|
29
33
|
export const READY_TIMEOUT_MS = envInt('UNSNOOZE_READY_TIMEOUT_MS', 60_000);
|
|
30
34
|
export const EVENT_MARKER_TTL_MS = envInt('UNSNOOZE_EVENT_MARKER_TTL_MS', 120_000);
|
|
35
|
+
export const WATCH_FRESHNESS_MS = envInt('UNSNOOZE_WATCH_FRESHNESS_MS', 15 * 60_000);
|
|
31
36
|
|
|
32
37
|
// Pane scanning
|
|
33
38
|
export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
|
package/src/hook.js
CHANGED
package/src/install.js
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
// - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
|
|
9
9
|
// --settings <path> / --zshrc <path> override targets (used by tests).
|
|
10
10
|
|
|
11
|
-
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync } from 'node:fs';
|
|
11
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
12
13
|
import { homedir } from 'node:os';
|
|
13
14
|
import { join, dirname } from 'node:path';
|
|
14
15
|
import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
|
|
15
16
|
import { getConfig, configFileExists } from './settings.js';
|
|
17
|
+
import { xmlEscape } from './notify.js';
|
|
16
18
|
import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
|
|
17
19
|
import { UNSNOOZE_BIN } from './spawn.js';
|
|
18
20
|
|
|
@@ -27,10 +29,11 @@ const LEGACY_FENCES = [
|
|
|
27
29
|
const OLD_FENCE_OPEN = LEGACY_FENCES[0].open;
|
|
28
30
|
|
|
29
31
|
function parseArgs(rest) {
|
|
30
|
-
const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false };
|
|
32
|
+
const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false, daemon: false };
|
|
31
33
|
for (let i = 0; i < rest.length; i++) {
|
|
32
34
|
if (rest[i] === '--yes' || rest[i] === '-y') opts.yes = true;
|
|
33
35
|
else if (rest[i] === '--purge') opts.purge = true;
|
|
36
|
+
else if (rest[i] === '--daemon') opts.daemon = true;
|
|
34
37
|
else if (rest[i] === '--settings') opts.settings = rest[++i];
|
|
35
38
|
else if (rest[i] === '--zshrc') opts.zshrc = rest[++i];
|
|
36
39
|
}
|
|
@@ -38,6 +41,7 @@ function parseArgs(rest) {
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function atomicWrite(path, content) {
|
|
44
|
+
mkdirSync(dirname(path), { recursive: true }); // e.g. ~/.claude may not exist yet
|
|
41
45
|
const tmp = join(dirname(path), `.${Date.now()}.tmp`);
|
|
42
46
|
writeFileSync(tmp, content);
|
|
43
47
|
renameSync(tmp, path);
|
|
@@ -123,6 +127,105 @@ export function installZshrcBlock(content, agents = ['claude']) {
|
|
|
123
127
|
return { content: result, oldRemoved };
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
// --- daemon autostart ---
|
|
131
|
+
// GUI sessions (VS Code extension, desktop apps) never pass through the shell
|
|
132
|
+
// wrappers, so their limit stops are only caught while `unsnooze daemon` is
|
|
133
|
+
// alive. Autostart keeps it alive: a launchd user agent on macOS, a systemd
|
|
134
|
+
// user unit on Linux/WSL.
|
|
135
|
+
|
|
136
|
+
export const DAEMON_LABEL = 'com.unsnooze.daemon';
|
|
137
|
+
|
|
138
|
+
// Env overrides keep tests/e2e away from the real LaunchAgents / systemd dirs.
|
|
139
|
+
function autostartDir(platform) {
|
|
140
|
+
if (platform === 'darwin') {
|
|
141
|
+
return process.env.UNSNOOZE_LAUNCH_AGENTS_DIR || join(homedir(), 'Library', 'LaunchAgents');
|
|
142
|
+
}
|
|
143
|
+
return process.env.UNSNOOZE_SYSTEMD_USER_DIR || join(homedir(), '.config', 'systemd', 'user');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function launchdPlist({
|
|
147
|
+
nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN,
|
|
148
|
+
logFile = join(STATE_DIR, 'daemon.log'),
|
|
149
|
+
} = {}) {
|
|
150
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
151
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
152
|
+
<plist version="1.0">
|
|
153
|
+
<dict>
|
|
154
|
+
<key>Label</key><string>${DAEMON_LABEL}</string>
|
|
155
|
+
<key>ProgramArguments</key>
|
|
156
|
+
<array>
|
|
157
|
+
<string>${xmlEscape(nodeBin)}</string>
|
|
158
|
+
<string>${xmlEscape(unsnoozeBin)}</string>
|
|
159
|
+
<string>daemon</string>
|
|
160
|
+
</array>
|
|
161
|
+
<key>RunAtLoad</key><true/>
|
|
162
|
+
<key>KeepAlive</key><true/>
|
|
163
|
+
<key>StandardOutPath</key><string>${xmlEscape(logFile)}</string>
|
|
164
|
+
<key>StandardErrorPath</key><string>${xmlEscape(logFile)}</string>
|
|
165
|
+
</dict>
|
|
166
|
+
</plist>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function systemdUnit({ nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN } = {}) {
|
|
171
|
+
return `[Unit]
|
|
172
|
+
Description=unsnooze daemon — watches GUI AI-coding sessions for limit stops
|
|
173
|
+
|
|
174
|
+
[Service]
|
|
175
|
+
ExecStart="${nodeBin}" "${unsnoozeBin}" daemon
|
|
176
|
+
Restart=on-failure
|
|
177
|
+
RestartSec=10
|
|
178
|
+
|
|
179
|
+
[Install]
|
|
180
|
+
WantedBy=default.target
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function defaultActivate(cmd, args) {
|
|
185
|
+
try {
|
|
186
|
+
execFileSync(cmd, args, { stdio: 'ignore' });
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false; // best-effort: the file is in place; a reboot/login loads it
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function installDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
|
194
|
+
if (platform === 'darwin') {
|
|
195
|
+
const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
|
|
196
|
+
atomicWrite(target, launchdPlist());
|
|
197
|
+
activate('launchctl', ['unload', target]); // reload cleanly if already loaded
|
|
198
|
+
activate('launchctl', ['load', '-w', target]);
|
|
199
|
+
return target;
|
|
200
|
+
}
|
|
201
|
+
if (platform === 'linux') {
|
|
202
|
+
const target = join(dir || autostartDir(platform), 'unsnooze.service');
|
|
203
|
+
atomicWrite(target, systemdUnit());
|
|
204
|
+
activate('systemctl', ['--user', 'daemon-reload']);
|
|
205
|
+
activate('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
|
|
206
|
+
return target;
|
|
207
|
+
}
|
|
208
|
+
return null; // native Windows: no tmux to revive into — daemon unsupported
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
|
212
|
+
if (platform === 'darwin') {
|
|
213
|
+
const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
|
|
214
|
+
if (!existsSync(target)) return null;
|
|
215
|
+
activate('launchctl', ['unload', target]);
|
|
216
|
+
try { unlinkSync(target); } catch { /* already gone */ }
|
|
217
|
+
return target;
|
|
218
|
+
}
|
|
219
|
+
if (platform === 'linux') {
|
|
220
|
+
const target = join(dir || autostartDir(platform), 'unsnooze.service');
|
|
221
|
+
if (!existsSync(target)) return null;
|
|
222
|
+
activate('systemctl', ['--user', 'disable', '--now', 'unsnooze.service']);
|
|
223
|
+
try { unlinkSync(target); } catch { /* already gone */ }
|
|
224
|
+
return target;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
126
229
|
// --- commands ---
|
|
127
230
|
|
|
128
231
|
export function enabledAgents() {
|
|
@@ -184,6 +287,13 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
184
287
|
console.log(`unsnooze: wrappers (${agents.join(', ')}) installed in ${rc}${oldRemoved ? ' (legacy wrapper block removed)' : ''}`);
|
|
185
288
|
}
|
|
186
289
|
|
|
290
|
+
// 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
|
|
291
|
+
if (opts.daemon) {
|
|
292
|
+
const target = installDaemonAutostart();
|
|
293
|
+
if (target) console.log(`unsnooze: daemon autostart installed (${target}) — GUI sessions are watched`);
|
|
294
|
+
else console.log('unsnooze: daemon autostart is not supported on this platform');
|
|
295
|
+
}
|
|
296
|
+
|
|
187
297
|
console.log('\nunsnooze: done. Reload your shell:');
|
|
188
298
|
console.log(' exec $SHELL');
|
|
189
299
|
return 0;
|
|
@@ -210,6 +320,9 @@ export function cmdUninstall(rest) {
|
|
|
210
320
|
}
|
|
211
321
|
}
|
|
212
322
|
|
|
323
|
+
const autostart = uninstallDaemonAutostart();
|
|
324
|
+
if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
|
|
325
|
+
|
|
213
326
|
if (opts.purge) {
|
|
214
327
|
rmSync(STATE_DIR, { recursive: true, force: true });
|
|
215
328
|
console.log(`unsnooze: state dir ${STATE_DIR} removed`);
|
package/src/notify.js
CHANGED
|
@@ -18,7 +18,7 @@ function appleScriptString(s) {
|
|
|
18
18
|
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function xmlEscape(s) {
|
|
21
|
+
export function xmlEscape(s) {
|
|
22
22
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
23
23
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
24
24
|
}
|
package/src/resumer.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Exits when no non-terminal records remain; the next limit event respawns it.
|
|
6
6
|
|
|
7
7
|
import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
8
9
|
import * as realTmux from './tmux.js';
|
|
9
10
|
import {
|
|
10
11
|
RESUMER_LOCK, STATE_DIR, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS,
|
|
@@ -16,7 +17,7 @@ import { detectLimit, isBusy } from './patterns.js';
|
|
|
16
17
|
import { getAgent } from './agents/index.js';
|
|
17
18
|
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
18
19
|
import { readState, updateState, setStatus, dueSessions, activeStopped } from './state.js';
|
|
19
|
-
import { getConfig } from './settings.js';
|
|
20
|
+
import { getConfig, resolveResumeMessage } from './settings.js';
|
|
20
21
|
import { notify } from './notify.js';
|
|
21
22
|
import { UNSNOOZE_BIN } from './spawn.js';
|
|
22
23
|
import { makeLogger } from './logger.js';
|
|
@@ -68,9 +69,11 @@ function selfCommand() {
|
|
|
68
69
|
|
|
69
70
|
// Decide how to act on one due record. Pure-ish; tmux injectable.
|
|
70
71
|
// Returns: 'sent' | 'reopened' | 'deferred' | 'skip' | 'failed'
|
|
71
|
-
export async function dispatchOne(rec, { tmux = realTmux, resumeMessage
|
|
72
|
+
export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand() } = {}) {
|
|
72
73
|
const key = rec.key;
|
|
73
74
|
const agent = getAgent(rec.agent);
|
|
75
|
+
// Explicit option wins; otherwise the agent's own message (or the global).
|
|
76
|
+
resumeMessage = resumeMessage ?? resolveResumeMessage(agent.id);
|
|
74
77
|
|
|
75
78
|
// Live-pane path: only if the pane still exists AND the agent CLI is its
|
|
76
79
|
// foreground command (pane ids get recycled — never inject into a random
|
|
@@ -90,12 +93,21 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage = getCon
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
// Re-open path: new tmux window in the well-known session, resume by id.
|
|
96
|
+
// Records from sandboxed GUI sessions carry env (e.g. CLAUDE_CONFIG_DIR) the
|
|
97
|
+
// revived CLI needs to find its session store.
|
|
93
98
|
const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
|
|
94
|
-
|
|
99
|
+
// `env K=V cmd`, not a bare `K=V cmd` prefix: the command runs in the
|
|
100
|
+
// user's default shell, and fish/nushell reject POSIX prefix assignments.
|
|
101
|
+
const envPrefix = rec.env
|
|
102
|
+
? 'env ' + Object.entries(rec.env).map(([k, v]) => `${k}=${shellQuote(String(v))}`).join(' ') + ' '
|
|
103
|
+
: '';
|
|
104
|
+
const command = envPrefix + [...selfCmd, '_run', agent.id, ...resume.args].map(shellQuote).join(' ');
|
|
95
105
|
setStatus(key, 'resuming', { lastAttemptAt: Date.now() });
|
|
96
106
|
let newPane;
|
|
97
107
|
try {
|
|
98
|
-
|
|
108
|
+
// cwd can be null on watcher records (unreadable rollout head) — execFile
|
|
109
|
+
// rejects null args, so fall back to the home dir rather than crash-loop.
|
|
110
|
+
newPane = await tmux.newWindow(rec.tmuxSession || TMUX_SESSION_NAME, rec.cwd || homedir(), command);
|
|
99
111
|
} catch (err) {
|
|
100
112
|
setStatus(key, 'stopped', { attempts: (rec.attempts || 0) + 1, lastError: `new-window: ${err.message}` });
|
|
101
113
|
return 'failed';
|
|
@@ -151,20 +163,43 @@ export async function verifyOne(key, { tmux = realTmux } = {}) {
|
|
|
151
163
|
}
|
|
152
164
|
setStatus(key, 'resumed');
|
|
153
165
|
log(`${key}: verified resumed`);
|
|
154
|
-
|
|
166
|
+
const fromGui = rec.origin && rec.origin !== 'cli';
|
|
167
|
+
notify('unsnoozed ✅', `${rec.cwd} is running again${fromGui ? ` (was in ${rec.origin} — revived in tmux)` : ''}`);
|
|
155
168
|
}
|
|
156
169
|
|
|
157
|
-
|
|
158
|
-
|
|
170
|
+
// persistent: never exit on an empty ledger (daemon mode — `unsnooze daemon`,
|
|
171
|
+
// launchd/systemd). watcher: transcript watcher ticked every loop, so GUI
|
|
172
|
+
// sessions are detected without a hook or pane. signal: clean shutdown.
|
|
173
|
+
export async function runResumer({
|
|
174
|
+
tmux = realTmux, pollInterval = POLL_INTERVAL_MS,
|
|
175
|
+
persistent = false, watcher = null, signal = null,
|
|
176
|
+
} = {}) {
|
|
177
|
+
// A transient hook-spawned resumer may hold the lock right now; a daemon
|
|
178
|
+
// outlives it, so wait for the lock instead of dying. The watcher MUST keep
|
|
179
|
+
// ticking during the wait: it only records stops (its own state lock covers
|
|
180
|
+
// that), and a stop left unread past the freshness window is lost for good.
|
|
181
|
+
const tickWatcher = async () => {
|
|
182
|
+
if (!watcher || !getConfig('guiWatch')) return;
|
|
183
|
+
try { await watcher.tick(); } catch (err) { log(`watcher tick failed: ${err.message}`); }
|
|
184
|
+
};
|
|
185
|
+
while (!acquireSingleton()) {
|
|
186
|
+
if (!persistent) { log('another resumer is running — exiting'); return 0; }
|
|
187
|
+
if (signal?.aborted) return 0;
|
|
188
|
+
await tickWatcher();
|
|
189
|
+
log('another resumer holds the lock — daemon waiting');
|
|
190
|
+
await sleep(pollInterval);
|
|
191
|
+
}
|
|
159
192
|
updateState(state => { state.resumerPid = process.pid; });
|
|
160
|
-
log(`resumer started (pid ${process.pid})`);
|
|
193
|
+
log(`resumer started (pid ${process.pid}${persistent ? ', persistent' : ''})`);
|
|
161
194
|
const deferCounts = new Map();
|
|
162
195
|
|
|
163
196
|
try {
|
|
164
197
|
for (;;) {
|
|
198
|
+
if (signal?.aborted) { log('shutdown requested — resumer exiting'); return 0; }
|
|
199
|
+
await tickWatcher();
|
|
165
200
|
const stopped = activeStopped();
|
|
166
201
|
const resuming = Object.values(readState().sessions).filter(s => s.status === 'resuming');
|
|
167
|
-
if (stopped.length === 0 && resuming.length === 0) {
|
|
202
|
+
if (stopped.length === 0 && resuming.length === 0 && !persistent) {
|
|
168
203
|
log('no pending sessions — resumer exiting');
|
|
169
204
|
return 0;
|
|
170
205
|
}
|
package/src/settings.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// User-facing settings: ~/.unsnooze/config.json, managed by `unsnooze config`
|
|
2
|
-
// and the setup wizard. Precedence: env var > config file > default —
|
|
3
|
-
// stays the power-user/test escape hatch, the file is what the wizard
|
|
2
|
+
// and the setup wizard. Precedence per key: env var > config file > default —
|
|
3
|
+
// env stays the power-user/test escape hatch, the file is what the wizard
|
|
4
|
+
// writes. One cross-key exception: the resume message resolves by specificity
|
|
5
|
+
// first, so a per-agent resumeMessages.<id> value (from either source) beats
|
|
6
|
+
// the global resumeMessage even when the global came from an env var (see
|
|
7
|
+
// resolveResumeMessage).
|
|
4
8
|
|
|
5
9
|
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
6
10
|
import { join, dirname } from 'node:path';
|
|
@@ -12,7 +16,9 @@ export const DEFAULTS = {
|
|
|
12
16
|
autoResume: true, // master switch: dispatch resumes when limits reset
|
|
13
17
|
menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
|
|
14
18
|
notifications: true, // desktop notifications on detect/resume
|
|
19
|
+
guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
|
|
15
20
|
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.',
|
|
21
|
+
resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
|
|
16
22
|
agents: { claude: true, codex: true, grok: false }, // grok is experimental
|
|
17
23
|
};
|
|
18
24
|
|
|
@@ -21,7 +27,11 @@ const ENV_NAMES = {
|
|
|
21
27
|
autoResume: 'UNSNOOZE_AUTO_RESUME',
|
|
22
28
|
menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
|
|
23
29
|
notifications: 'UNSNOOZE_NOTIFICATIONS',
|
|
30
|
+
guiWatch: 'UNSNOOZE_GUI_WATCH',
|
|
24
31
|
resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
|
|
32
|
+
'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
|
|
33
|
+
'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
|
|
34
|
+
'resumeMessages.grok': 'UNSNOOZE_RESUME_MESSAGE_GROK',
|
|
25
35
|
'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
|
|
26
36
|
'agents.codex': 'UNSNOOZE_AGENT_CODEX',
|
|
27
37
|
'agents.grok': 'UNSNOOZE_AGENT_GROK',
|
|
@@ -35,9 +45,11 @@ function parseBool(raw) {
|
|
|
35
45
|
return null;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
function readFileConfig() {
|
|
48
|
+
export function readFileConfig() {
|
|
39
49
|
try {
|
|
40
|
-
|
|
50
|
+
const parsed = JSON.parse(readFileSync(CONFIG_FILE(), 'utf-8'));
|
|
51
|
+
// Wrong-typed but valid JSON (a bare string/array) counts as corrupt too.
|
|
52
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
41
53
|
} catch {
|
|
42
54
|
return {}; // missing or corrupt file → defaults (never crash the hook path)
|
|
43
55
|
}
|
|
@@ -63,6 +75,17 @@ export function getConfig(key) {
|
|
|
63
75
|
return fromFile !== undefined ? fromFile : def;
|
|
64
76
|
}
|
|
65
77
|
|
|
78
|
+
// The message to send a given agent: its resumeMessages.<id> override when
|
|
79
|
+
// set, else the global resumeMessage. Unknown agent ids fall back to global;
|
|
80
|
+
// blank values (empty or whitespace-only) fall through to the next level so a
|
|
81
|
+
// blank message is never sent.
|
|
82
|
+
export function resolveResumeMessage(agentId) {
|
|
83
|
+
const key = `resumeMessages.${agentId}`;
|
|
84
|
+
const set = v => (typeof v === 'string' && v.trim() ? v : '');
|
|
85
|
+
const perAgent = agentId && KNOWN_KEYS.includes(key) ? getConfig(key) : '';
|
|
86
|
+
return set(perAgent) || set(getConfig('resumeMessage')) || DEFAULTS.resumeMessage;
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
export function setConfigValue(key, rawValue) {
|
|
67
90
|
if (!KNOWN_KEYS.includes(key)) throw new Error(`unsnooze: unknown setting "${key}" (known: ${KNOWN_KEYS.join(', ')})`);
|
|
68
91
|
const def = dig(DEFAULTS, key);
|
package/src/state.js
CHANGED
|
@@ -111,11 +111,27 @@ export function upsertSession(record) {
|
|
|
111
111
|
function findDuplicate(state, record) {
|
|
112
112
|
if (record.sessionId && state.sessions[record.sessionId]) return record.sessionId;
|
|
113
113
|
for (const [key, s] of Object.entries(state.sessions)) {
|
|
114
|
+
// Same sessionId living under a pane-based key (a scrape record that later
|
|
115
|
+
// learned its id through a merge).
|
|
116
|
+
if (record.sessionId && s.sessionId === record.sessionId) return key;
|
|
114
117
|
if (s.pane && s.pane === record.pane
|
|
115
118
|
&& s.status === 'stopped'
|
|
116
119
|
&& Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
|
|
117
120
|
return key;
|
|
118
121
|
}
|
|
122
|
+
// A transcript/hook record with a sessionId matches a scrape record that
|
|
123
|
+
// never learned its id — same agent, same cwd, same detection window.
|
|
124
|
+
// Known trade-off: TWO different sessions of the same agent in the same
|
|
125
|
+
// cwd stopping within the window would wrongly merge — but a pane session
|
|
126
|
+
// writes the very transcript the watcher reads, so same-cwd evidence is
|
|
127
|
+
// almost always the same session, and the alternative (two records) would
|
|
128
|
+
// double-resume it.
|
|
129
|
+
if (record.sessionId && !s.sessionId
|
|
130
|
+
&& s.agent === record.agent && s.cwd && s.cwd === record.cwd
|
|
131
|
+
&& s.status === 'stopped'
|
|
132
|
+
&& Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
|
|
133
|
+
return key;
|
|
134
|
+
}
|
|
119
135
|
}
|
|
120
136
|
return null;
|
|
121
137
|
}
|