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 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 config [list|get|set] view or change settings (toggles, message)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Wakes every limit-stopped AI coding session (Claude Code, Codex CLI, Grok) in tmux the moment the usage limit resets.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -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,
@@ -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(16)} ${JSON.stringify(v)}`);
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
- if (!key || value === '') { console.error('unsnooze config set <key> <value>'); return 2; }
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
@@ -84,6 +84,7 @@ export async function runHook(rest = []) {
84
84
  cwd,
85
85
  pane,
86
86
  agent: agent.id,
87
+ origin: 'cli', // the hook only fires for CLI launches we can see
87
88
  tmuxSession: TMUX_SESSION_NAME,
88
89
  status: 'stopped',
89
90
  limitType,
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
23
23
  .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
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 = getConfig('resumeMessage'), selfCmd = selfCommand() } = {}) {
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
- const command = [...selfCmd, '_run', agent.id, ...resume.args].map(shellQuote).join(' ');
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
- newPane = await tmux.newWindow(rec.tmuxSession || TMUX_SESSION_NAME, rec.cwd, command);
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
- notify('unsnoozed ✅', `${rec.cwd} is running again`);
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
- export async function runResumer({ tmux = realTmux, pollInterval = POLL_INTERVAL_MS } = {}) {
158
- if (!acquireSingleton()) { log('another resumer is running exiting'); return 0; }
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 — env
3
- // stays the power-user/test escape hatch, the file is what the wizard writes.
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
- return JSON.parse(readFileSync(CONFIG_FILE(), 'utf-8'));
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
  }