unsnooze 1.1.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,44 @@
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
+
3
42
  ## 1.1.0 — 2026-07-10
4
43
 
5
44
  ### Per-agent resume messages
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,6 +131,8 @@ 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)
106
138
  unsnooze help # full command list (also -h / --help)
@@ -116,6 +148,7 @@ unsnooze help # full command list (also -h / --help)
116
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. |
117
149
  | `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
118
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`). |
119
152
  | `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. |
120
153
  | `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
121
154
  | `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
package/bin/unsnooze.js CHANGED
@@ -60,6 +60,17 @@ 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':
64
75
  case '-h':
65
76
  case '--help':
@@ -73,6 +84,9 @@ Usage:
73
84
  unsnooze resume-now [id|--all] resume stopped session(s) immediately
74
85
  unsnooze cancel [id|--all] stop tracking session(s)
75
86
  unsnooze logs [-f] show (or follow) the unsnooze log
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
76
90
  unsnooze config [list|get|set] view or change settings (toggles, global +
77
91
  per-agent resume messages)
78
92
  unsnooze setup interactive setup wizard (agents + toggles)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.1.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
  }
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, mkdirSync } 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
  }
@@ -124,6 +127,105 @@ export function installZshrcBlock(content, agents = ['claude']) {
124
127
  return { content: result, oldRemoved };
125
128
  }
126
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
+
127
229
  // --- commands ---
128
230
 
129
231
  export function enabledAgents() {
@@ -185,6 +287,13 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
185
287
  console.log(`unsnooze: wrappers (${agents.join(', ')}) installed in ${rc}${oldRemoved ? ' (legacy wrapper block removed)' : ''}`);
186
288
  }
187
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
+
188
297
  console.log('\nunsnooze: done. Reload your shell:');
189
298
  console.log(' exec $SHELL');
190
299
  return 0;
@@ -211,6 +320,9 @@ export function cmdUninstall(rest) {
211
320
  }
212
321
  }
213
322
 
323
+ const autostart = uninstallDaemonAutostart();
324
+ if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
325
+
214
326
  if (opts.purge) {
215
327
  rmSync(STATE_DIR, { recursive: true, force: true });
216
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,
@@ -92,12 +93,21 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
92
93
  }
93
94
 
94
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.
95
98
  const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
96
- 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(' ');
97
105
  setStatus(key, 'resuming', { lastAttemptAt: Date.now() });
98
106
  let newPane;
99
107
  try {
100
- 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);
101
111
  } catch (err) {
102
112
  setStatus(key, 'stopped', { attempts: (rec.attempts || 0) + 1, lastError: `new-window: ${err.message}` });
103
113
  return 'failed';
@@ -153,20 +163,43 @@ export async function verifyOne(key, { tmux = realTmux } = {}) {
153
163
  }
154
164
  setStatus(key, 'resumed');
155
165
  log(`${key}: verified resumed`);
156
- 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)` : ''}`);
157
168
  }
158
169
 
159
- export async function runResumer({ tmux = realTmux, pollInterval = POLL_INTERVAL_MS } = {}) {
160
- 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
+ }
161
192
  updateState(state => { state.resumerPid = process.pid; });
162
- log(`resumer started (pid ${process.pid})`);
193
+ log(`resumer started (pid ${process.pid}${persistent ? ', persistent' : ''})`);
163
194
  const deferCounts = new Map();
164
195
 
165
196
  try {
166
197
  for (;;) {
198
+ if (signal?.aborted) { log('shutdown requested — resumer exiting'); return 0; }
199
+ await tickWatcher();
167
200
  const stopped = activeStopped();
168
201
  const resuming = Object.values(readState().sessions).filter(s => s.status === 'resuming');
169
- if (stopped.length === 0 && resuming.length === 0) {
202
+ if (stopped.length === 0 && resuming.length === 0 && !persistent) {
170
203
  log('no pending sessions — resumer exiting');
171
204
  return 0;
172
205
  }
package/src/settings.js CHANGED
@@ -16,6 +16,7 @@ export const DEFAULTS = {
16
16
  autoResume: true, // master switch: dispatch resumes when limits reset
17
17
  menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
18
18
  notifications: true, // desktop notifications on detect/resume
19
+ guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
19
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.',
20
21
  resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
21
22
  agents: { claude: true, codex: true, grok: false }, // grok is experimental
@@ -26,6 +27,7 @@ const ENV_NAMES = {
26
27
  autoResume: 'UNSNOOZE_AUTO_RESUME',
27
28
  menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
28
29
  notifications: 'UNSNOOZE_NOTIFICATIONS',
30
+ guiWatch: 'UNSNOOZE_GUI_WATCH',
29
31
  resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
30
32
  'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
31
33
  'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
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
  }
@@ -9,6 +9,9 @@ const RELATIVE_TIME_REGEX = /(?:try again|wait|resets?\s+in)[:\s]\s*(?:for\s+)?(
9
9
  // "resets Tuesday 3pm" / "resets on Mon" — weekly limits carry a day name.
10
10
  const DAY_REGEX = /resets?\s+(?:on\s+)?(mon|tue|wed|thu|fri|sat|sun)[a-z]*/i;
11
11
  const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
12
+ // Month-date weekly form (transcript/API error text):
13
+ // "resets Jul 4 at 12:30am (Asia/Calcutta)"
14
+ const RESET_DATE_REGEX = /resets?\s+(?:on\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+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
12
15
 
13
16
  // Codex CLI forms ("try again at …", local time):
14
17
  // same day: "or try again at 3:51 PM."
@@ -50,6 +53,24 @@ export function parseResetTime(text) {
50
53
  };
51
54
  }
52
55
 
56
+ const resetDateMatch = text.match(RESET_DATE_REGEX);
57
+ if (resetDateMatch) {
58
+ const [, mon, dayOfMonth, hourRaw, minuteRaw, ampmRaw, timezone] = resetDateMatch;
59
+ const ampm = ampmRaw?.toLowerCase() || null;
60
+ let hour = parseInt(hourRaw, 10);
61
+ if (ampm === 'pm' && hour !== 12) hour += 12;
62
+ if (ampm === 'am' && hour === 12) hour = 0;
63
+ return {
64
+ month: MONTH_INDEX[mon.toLowerCase()],
65
+ dayOfMonth: parseInt(dayOfMonth, 10),
66
+ hour,
67
+ minute: minuteRaw ? parseInt(minuteRaw, 10) : 0,
68
+ timezone: timezone || null,
69
+ ambiguous: !ampm && hour >= 1 && hour <= 12,
70
+ day: null,
71
+ };
72
+ }
73
+
53
74
  const dayMatch = text.match(DAY_REGEX);
54
75
  const absMatch = text.match(RESET_TIME_REGEX);
55
76
  if (absMatch) {
@@ -120,16 +141,7 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
120
141
  // UTC guess until it formats as the desired local h:m. Correction normalized
121
142
  // to [-720, +720] minutes to take the minimum-magnitude step (avoids the
122
143
  // off-by-a-day bug in high-offset timezones).
123
- function targetTimestamp(h, m) {
124
- const parts = new Intl.DateTimeFormat('en-US', {
125
- timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour12: false,
126
- }).formatToParts(now);
127
- const y = parseInt(parts.find(p => p.type === 'year').value);
128
- const mo = parseInt(parts.find(p => p.type === 'month').value);
129
- const d = parseInt(parts.find(p => p.type === 'day').value);
130
-
131
- const targetStr = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
132
- let candidate = new Date(targetStr + 'Z').getTime();
144
+ function correctWallClock(candidate, h, m) {
133
145
  for (let i = 0; i < 3; i++) {
134
146
  const fp = new Intl.DateTimeFormat('en-US', {
135
147
  timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
@@ -145,6 +157,18 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
145
157
  return candidate;
146
158
  }
147
159
 
160
+ function targetTimestamp(h, m) {
161
+ const parts = new Intl.DateTimeFormat('en-US', {
162
+ timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour12: false,
163
+ }).formatToParts(now);
164
+ const y = parseInt(parts.find(p => p.type === 'year').value);
165
+ const mo = parseInt(parts.find(p => p.type === 'month').value);
166
+ const d = parseInt(parts.find(p => p.type === 'day').value);
167
+
168
+ const targetStr = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
169
+ return correctWallClock(new Date(targetStr + 'Z').getTime(), h, m);
170
+ }
171
+
148
172
  function nextOccurrence(h, m) {
149
173
  let t = targetTimestamp(h, m);
150
174
  if (t <= now.getTime()) t += 86_400_000;
@@ -160,6 +184,45 @@ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_00
160
184
  return t;
161
185
  }
162
186
 
187
+ // Month-date form ("resets Jul 4 at 12:30am (tz)"): roll forward from today
188
+ // in the target tz to the named month/day, re-correct the wall-clock (the
189
+ // walk may cross DST). An already-past date means we misread stale text —
190
+ // fall back rather than waiting toward next year.
191
+ if (parsed.month != null) {
192
+ const monthDayAt = t => {
193
+ const fp = new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'numeric', day: 'numeric' })
194
+ .formatToParts(new Date(t));
195
+ return [
196
+ parseInt(fp.find(p => p.type === 'month').value, 10) - 1,
197
+ parseInt(fp.find(p => p.type === 'day').value, 10),
198
+ ];
199
+ };
200
+ const resolve = (h, m) => {
201
+ // Re-correct the wall clock on EVERY day step: a raw 24h jump across a
202
+ // DST transition drifts the local time, and a drifted probe can match
203
+ // the named date at the wrong instant (landing a day late after the
204
+ // final correction). The walk is capped just past the 10-day acceptance
205
+ // window below — longer walks are discarded anyway.
206
+ let t = targetTimestamp(h, m);
207
+ for (let i = 0; i <= 12; i++) {
208
+ const [mo, d] = monthDayAt(t);
209
+ if (mo === parsed.month && d === parsed.dayOfMonth) return t;
210
+ t = correctWallClock(t + 86_400_000, h, m);
211
+ }
212
+ return null; // beyond the weekly window (or an impossible date)
213
+ };
214
+ const candidates = parsed.ambiguous
215
+ ? [resolve(parsed.hour, parsed.minute), resolve((parsed.hour + 12) % 24, parsed.minute)]
216
+ : [resolve(parsed.hour, parsed.minute)];
217
+ // The yearless form only ever names a date within the weekly window; a
218
+ // resolution further out means the date already passed and the walk landed
219
+ // on NEXT year's occurrence — that's a misread, not an 11-month wait.
220
+ const maxAhead = now.getTime() + 10 * 86_400_000;
221
+ const future = candidates.filter(t => t != null && t > now.getTime() && t <= maxAhead);
222
+ if (future.length === 0) return { at: now.getTime() + fallbackMs + marginMs, source: 'fallback' };
223
+ return { at: Math.min(...future) + marginMs, source };
224
+ }
225
+
163
226
  let at;
164
227
  if (parsed.ambiguous) {
165
228
  const t1 = nextOccurrence(parsed.hour, parsed.minute);
package/src/watcher.js ADDED
@@ -0,0 +1,333 @@
1
+ // Session-file watcher — the tmux-free detection channel. Tails the files the
2
+ // agent CLIs already write (Claude Code transcripts, Codex rollouts) for
3
+ // limit-stop evidence, so sessions running in GUI surfaces (VS Code extension,
4
+ // desktop apps) are tracked even though there is no pane to scrape and no
5
+ // shell wrapper in the launch path. Records land in the same ledger with
6
+ // pane-less records the resumer already reopens by session id.
7
+ //
8
+ // Offset semantics: on a cold start (no offsets file) every existing file
9
+ // starts at EOF — history is never replayed as fresh stops. Files that appear
10
+ // later are read from the start (a stop that happened while the daemon was
11
+ // down is still a real stop); the freshness window drops anything old.
12
+
13
+ import {
14
+ readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync,
15
+ openSync, readSync, closeSync, existsSync,
16
+ } from 'node:fs';
17
+ import { join, sep, basename, dirname } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import {
20
+ CLAUDE_DIR, CODEX_DIR, WATCH_OFFSETS_FILE, WATCH_FRESHNESS_MS,
21
+ RESET_MARGIN_MS, FALLBACK_RESET_MS, TMUX_SESSION_NAME,
22
+ } from './config.js';
23
+ import { parseTranscriptLine } from './watchers/claude.js';
24
+ import { parseRolloutLine, rolloutMeta } from './watchers/codex.js';
25
+ import { ROLLOUT_RE } from './agents/codex.js';
26
+ import { parseResetTime, resetAtMs } from './time-parser.js';
27
+ import { upsertSession, readState, updateState } from './state.js';
28
+ import { getConfig } from './settings.js';
29
+ import { notify } from './notify.js';
30
+ import { makeLogger } from './logger.js';
31
+
32
+ const log = makeLogger('watcher');
33
+
34
+ const MAX_WALK_DEPTH = 8;
35
+ // A file first seen mid-run is read from the start, but never more than this —
36
+ // a giant transcript is history, and the freshness window drops it anyway.
37
+ const MAX_READ_BYTES = 10 * 1024 * 1024;
38
+
39
+ export function claudeSource({ roots }) {
40
+ return {
41
+ agent: 'claude',
42
+ roots,
43
+ enabled: () => getConfig('agents.claude'),
44
+ // Subagent transcripts live under <session>/subagents/ — their limit
45
+ // entries duplicate the parent session's own entry.
46
+ match: p => p.endsWith('.jsonl') && !p.split(sep).includes('subagents'),
47
+ parse(lines) {
48
+ return lines
49
+ .map(parseTranscriptLine)
50
+ .filter(Boolean)
51
+ .map(rec => ({
52
+ agent: 'claude',
53
+ sessionId: rec.sessionId,
54
+ cwd: rec.cwd,
55
+ limitType: rec.limitType,
56
+ resetLine: rec.resetLine,
57
+ resetAt: null,
58
+ origin: rec.entrypoint,
59
+ timestampMs: rec.timestampMs,
60
+ }));
61
+ },
62
+ };
63
+ }
64
+
65
+ export function codexSource({ roots }) {
66
+ return {
67
+ agent: 'codex',
68
+ roots,
69
+ enabled: () => getConfig('agents.codex'),
70
+ match: p => ROLLOUT_RE.test(basename(p)),
71
+ parse(lines, path) {
72
+ const hits = lines.map(parseRolloutLine).filter(Boolean);
73
+ if (hits.length === 0) return [];
74
+ const last = hits[hits.length - 1]; // the latest snapshot governs
75
+ const meta = rolloutMeta(path);
76
+ return [{
77
+ agent: 'codex',
78
+ sessionId: meta.sessionId,
79
+ cwd: meta.cwd,
80
+ limitType: last.limitType,
81
+ resetLine: null,
82
+ resetAt: last.resetAt,
83
+ origin: meta.originator,
84
+ timestampMs: last.timestampMs,
85
+ }];
86
+ },
87
+ };
88
+ }
89
+
90
+ // Claude desktop (cowork) sessions are sandboxed: each runs against an
91
+ // isolated CLAUDE_CONFIG_DIR at <session>/local_<id>/.claude, so the global
92
+ // hook never fires and the transcripts live outside ~/.claude. Reviving one
93
+ // needs that same CLAUDE_CONFIG_DIR exported — carried on the record as env —
94
+ // plus CLAUDE_SECURESTORAGE_CONFIG_DIR='' so auth stays on the DEFAULT
95
+ // keychain entry: the service name is otherwise derived from the config dir,
96
+ // and the sandbox holds no credentials (verified against a real cowork
97
+ // session: with the pair set, `claude -p --resume <id>` answers; without the
98
+ // override it dies with "Not logged in").
99
+ // Experimental: desktop worktree/VM sessions may still differ.
100
+ export function claudeDesktopSource({ roots }) {
101
+ const base = claudeSource({ roots });
102
+ return {
103
+ ...base,
104
+ parse(lines, path) {
105
+ const configDir = configDirFromPath(path);
106
+ return base.parse(lines, path).map(rec => ({
107
+ ...rec,
108
+ origin: 'desktop',
109
+ env: configDir
110
+ ? { CLAUDE_CONFIG_DIR: configDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: '' }
111
+ : undefined,
112
+ }));
113
+ },
114
+ };
115
+ }
116
+
117
+ function configDirFromPath(path) {
118
+ const marker = `${sep}.claude${sep}`;
119
+ const idx = path.indexOf(marker);
120
+ return idx === -1 ? null : path.slice(0, idx + marker.length - 1);
121
+ }
122
+
123
+ export function defaultSources() {
124
+ const sources = [
125
+ claudeSource({ roots: [join(CLAUDE_DIR, 'projects')] }),
126
+ codexSource({ roots: [join(CODEX_DIR, 'sessions')] }),
127
+ ];
128
+ if (process.platform === 'darwin') {
129
+ sources.push(claudeDesktopSource({
130
+ roots: [process.env.UNSNOOZE_CLAUDE_DESKTOP_DIR
131
+ || join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')],
132
+ }));
133
+ }
134
+ return sources;
135
+ }
136
+
137
+ // Turn a watcher candidate into a ledger record. No pane key at all — a merge
138
+ // into an existing scrape/hook record must never clobber a live pane id.
139
+ //
140
+ // The same stop is re-emitted whenever the CLI appends another limit line (a
141
+ // GUI auto-retry, or the revived session hitting the still-active limit), so
142
+ // an existing record's lifecycle must be respected: an active record only
143
+ // gets its reset time refreshed — never its status/attempts reset, or the
144
+ // MAX_RESUME_ATTEMPTS cap could never bind — and a cancelled record stays
145
+ // cancelled.
146
+ export function dispatchCandidate(c) {
147
+ const detectedAt = c.timestampMs || Date.now();
148
+ let at, source;
149
+ if (c.resetAt) {
150
+ at = c.resetAt + RESET_MARGIN_MS;
151
+ source = 'absolute';
152
+ } else {
153
+ ({ at, source } = resetAtMs(parseResetTime(c.resetLine), {
154
+ marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
155
+ }));
156
+ }
157
+
158
+ const existing = c.sessionId
159
+ ? Object.values(readState().sessions).find(s => s.sessionId === c.sessionId)
160
+ : null;
161
+ if (existing && existing.status === 'cancelled') return;
162
+ if (existing && (existing.status === 'stopped' || existing.status === 'resuming')) {
163
+ updateState(state => {
164
+ const s = state.sessions[existing.key];
165
+ if (s && (s.status === 'stopped' || s.status === 'resuming')) {
166
+ s.resetAt = at;
167
+ s.resetSource = source;
168
+ if (c.limitType && c.limitType !== 'unknown') s.limitType = c.limitType;
169
+ }
170
+ });
171
+ log(`refreshed reset for tracked stop: session=${c.sessionId} resetAt=${new Date(at).toISOString()}`);
172
+ return;
173
+ }
174
+
175
+ const record = {
176
+ sessionId: c.sessionId || null,
177
+ cwd: c.cwd || null,
178
+ agent: c.agent,
179
+ origin: c.origin || null,
180
+ tmuxSession: TMUX_SESSION_NAME,
181
+ status: 'stopped',
182
+ limitType: c.limitType || 'unknown',
183
+ detectedVia: 'transcript',
184
+ detectedAt,
185
+ resetAt: at,
186
+ resetSource: source,
187
+ attempts: 0,
188
+ lastAttemptAt: null,
189
+ lastError: null,
190
+ };
191
+ if (c.env) record.env = c.env; // e.g. CLAUDE_CONFIG_DIR for sandboxed desktop sessions
192
+ upsertSession(record);
193
+ log(`limit stop via transcript: agent=${c.agent} session=${c.sessionId || '?'} origin=${c.origin || '?'} resetAt=${new Date(at).toISOString()} (${source})`);
194
+ notify('limit hit 😴', `${c.cwd || c.agent}: tracked — resumes when the limit resets`);
195
+ }
196
+
197
+ function loadOffsets(path) {
198
+ try {
199
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
200
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ function readRange(path, from, to) {
207
+ let fd;
208
+ try {
209
+ fd = openSync(path, 'r');
210
+ const len = Math.min(to - from, MAX_READ_BYTES);
211
+ const buf = Buffer.alloc(len);
212
+ let read = 0;
213
+ while (read < len) {
214
+ const n = readSync(fd, buf, read, len - read, from + read);
215
+ if (n <= 0) break;
216
+ read += n;
217
+ }
218
+ return buf.subarray(0, read);
219
+ } catch {
220
+ return Buffer.alloc(0);
221
+ } finally {
222
+ if (fd !== undefined) closeSync(fd);
223
+ }
224
+ }
225
+
226
+ export function createWatcher({
227
+ sources = defaultSources(),
228
+ offsetsPath = WATCH_OFFSETS_FILE,
229
+ freshnessMs = WATCH_FRESHNESS_MS,
230
+ onStop = dispatchCandidate,
231
+ now = Date.now,
232
+ } = {}) {
233
+ let offsets = loadOffsets(offsetsPath);
234
+ let coldStart = offsets === null;
235
+ if (coldStart) offsets = {};
236
+ let dirty = coldStart;
237
+
238
+ function setOffset(path, value) {
239
+ if (offsets[path] !== value) { offsets[path] = value; dirty = true; }
240
+ }
241
+
242
+ function saveOffsets(seen) {
243
+ // Unseen ≠ gone: a source may be disabled this tick or a root transiently
244
+ // unreadable — wiping those offsets would replay history on re-enable.
245
+ // Only forget files that no longer exist.
246
+ for (const key of Object.keys(offsets)) {
247
+ if (!seen.has(key) && !existsSync(key)) { delete offsets[key]; dirty = true; }
248
+ }
249
+ if (!dirty) return;
250
+ try {
251
+ mkdirSync(dirname(offsetsPath), { recursive: true });
252
+ const tmp = join(dirname(offsetsPath), `.offsets.tmp.${process.pid}`);
253
+ writeFileSync(tmp, JSON.stringify(offsets));
254
+ renameSync(tmp, offsetsPath);
255
+ dirty = false;
256
+ } catch (err) {
257
+ log(`offsets save failed: ${err.message}`);
258
+ }
259
+ }
260
+
261
+ // Appended complete lines since the last tick, advancing the stored offset.
262
+ // All offset math in bytes (a \n byte can't occur inside a multibyte char).
263
+ function readAppended(path) {
264
+ let size;
265
+ try { size = statSync(path).size; } catch { return null; }
266
+ const known = Object.prototype.hasOwnProperty.call(offsets, path);
267
+ let offset;
268
+ if (!known) {
269
+ if (coldStart) { setOffset(path, size); return null; }
270
+ offset = Math.max(0, size - MAX_READ_BYTES);
271
+ } else {
272
+ offset = offsets[path];
273
+ }
274
+ // Nothing new, or rewritten shorter (rotate/truncate) — pin to EOF.
275
+ if (size <= offset) { setOffset(path, size); return null; }
276
+ const buf = readRange(path, offset, size);
277
+ const lastNl = buf.lastIndexOf(0x0a);
278
+ if (lastNl === -1) { setOffset(path, offset); return null; } // partial line — wait
279
+ setOffset(path, offset + lastNl + 1);
280
+ return buf.subarray(0, lastNl + 1).toString('utf-8').split('\n').filter(l => l.trim());
281
+ }
282
+
283
+ function walk(dir, depth, cb) {
284
+ let entries;
285
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
286
+ for (const e of entries) {
287
+ const p = join(dir, e.name);
288
+ if (e.isDirectory()) {
289
+ if (depth < MAX_WALK_DEPTH) walk(p, depth + 1, cb);
290
+ continue;
291
+ }
292
+ if (e.isFile()) cb(p);
293
+ }
294
+ }
295
+
296
+ return {
297
+ async tick() {
298
+ const seen = new Set();
299
+ const candidates = [];
300
+ for (const source of sources) {
301
+ if (source.enabled && !source.enabled()) continue;
302
+ for (const root of source.roots) {
303
+ walk(root, 0, path => {
304
+ if (!source.match(path)) return;
305
+ seen.add(path);
306
+ const lines = readAppended(path);
307
+ if (!lines || lines.length === 0) return;
308
+ try {
309
+ candidates.push(...source.parse(lines, path));
310
+ } catch (err) {
311
+ log(`parse error in ${path}: ${err.message}`);
312
+ }
313
+ });
314
+ }
315
+ }
316
+ coldStart = false;
317
+ saveOffsets(seen);
318
+ // Strict freshness: a candidate without a parseable timestamp cannot be
319
+ // dated, and dispatching it risks reviving a long-finished session (the
320
+ // offsets can legitimately replay old lines after a file reappears).
321
+ // Every real transcript/rollout line carries an ISO timestamp.
322
+ const fresh = [];
323
+ for (const c of candidates) {
324
+ if (Number.isFinite(c.timestampMs) && now() - c.timestampMs <= freshnessMs) fresh.push(c);
325
+ else log(`dropped stale/undatable candidate: agent=${c.agent} session=${c.sessionId || '?'} ts=${c.timestampMs}`);
326
+ }
327
+ for (const c of fresh) {
328
+ try { onStop(c); } catch (err) { log(`onStop failed: ${err.message}`); }
329
+ }
330
+ return fresh.length;
331
+ },
332
+ };
333
+ }
@@ -0,0 +1,39 @@
1
+ // Claude Code transcript watcher: parses lines appended to session transcript
2
+ // files (~/.claude/projects/<dashed-cwd>/<session-id>.jsonl) into limit-stop
3
+ // candidates. This is the detection channel for GUI surfaces (VS Code
4
+ // extension, desktop app) where no tmux pane exists and hooks may not fire —
5
+ // a rate-limit stop lands in the transcript as a structured API-error entry:
6
+ // { "error":"rate_limit", "isApiErrorMessage":true, "apiErrorStatus":429,
7
+ // "entrypoint":"cli", "cwd":..., "sessionId":...,
8
+ // "message":{"content":[{"type":"text","text":"You've hit your session
9
+ // limit · resets 6:40pm (Asia/Calcutta)"}]} }
10
+
11
+ import { detectLimit } from '../patterns.js';
12
+ import { patterns } from '../agents/claude.js';
13
+ import { PANE_SCAN_LINES } from '../config.js';
14
+
15
+ // One transcript JSONL line → limit-stop candidate or null. Sidechain
16
+ // (subagent) entries are skipped — the resume target is the parent session,
17
+ // whose own entry carries the same error.
18
+ export function parseTranscriptLine(line) {
19
+ if (!line || !line.trim()) return null;
20
+ let entry;
21
+ try { entry = JSON.parse(line); } catch { return null; }
22
+ if (!entry || entry.isApiErrorMessage !== true) return null;
23
+ if (entry.error !== 'rate_limit') return null;
24
+ if (entry.isSidechain) return null;
25
+
26
+ const content = entry.message?.content;
27
+ const text = (Array.isArray(content) && content.find(c => c?.type === 'text')?.text) || '';
28
+ const d = detectLimit(text, PANE_SCAN_LINES, patterns);
29
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
30
+
31
+ return {
32
+ sessionId: entry.sessionId || null,
33
+ cwd: entry.cwd || null,
34
+ entrypoint: entry.entrypoint || null,
35
+ limitType: d.hit ? d.limitType : 'unknown',
36
+ resetLine: d.hit ? d.resetLine : null,
37
+ timestampMs: Number.isFinite(ts) ? ts : null,
38
+ };
39
+ }
@@ -0,0 +1,106 @@
1
+ // Codex rollout watcher: parses lines appended to session rollout files
2
+ // (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) into limit-stop candidates.
3
+ //
4
+ // Codex never persists Error/StreamError events to rollouts (confirmed in
5
+ // codex-rs/rollout policy), so the limit banner text is NOT in the file. What
6
+ // IS persisted is a token_count event per turn carrying a rate_limits
7
+ // snapshot — used_percent per window plus an exact resets_at epoch:
8
+ // {"type":"event_msg","payload":{"type":"token_count","rate_limits":{
9
+ // "primary":{"used_percent":100,"window_minutes":300,"resets_at":1778672230},
10
+ // "secondary":{"used_percent":1,"window_minutes":10080,"resets_at":...},
11
+ // "rate_limit_reached_type":null}}}
12
+ // That epoch is more precise than any scraped banner, and rollouts are shared
13
+ // by every Codex surface (CLI, IDE extension, desktop app).
14
+
15
+ import { openSync, readSync, closeSync } from 'node:fs';
16
+ import { basename } from 'node:path';
17
+ import { ROLLOUT_RE } from '../agents/codex.js';
18
+
19
+ const WEEK_MINUTES = 7 * 1440;
20
+
21
+ function windowLimitType(windowMinutes) {
22
+ if (!Number.isFinite(windowMinutes)) return 'unknown';
23
+ return windowMinutes >= WEEK_MINUTES ? 'weekly' : '5h';
24
+ }
25
+
26
+ // One rollout JSONL line → limit-stop candidate or null. A window binds when
27
+ // its used_percent hits 100 (or rate_limit_reached_type says the model was
28
+ // actually blocked); with several exhausted windows the LATEST reset governs —
29
+ // resuming at the earlier one would immediately re-hit the other limit.
30
+ export function parseRolloutLine(line) {
31
+ if (!line || !line.trim()) return null;
32
+ let entry;
33
+ try { entry = JSON.parse(line); } catch { return null; }
34
+ if (entry?.type !== 'event_msg' || entry.payload?.type !== 'token_count') return null;
35
+ const rl = entry.payload.rate_limits;
36
+ if (!rl || typeof rl !== 'object') return null;
37
+
38
+ const windows = ['primary', 'secondary']
39
+ .map(k => rl[k])
40
+ .filter(w => w && typeof w === 'object');
41
+ let binding = null;
42
+ const exhausted = windows.filter(w => (w.used_percent ?? 0) >= 100);
43
+ if (exhausted.length > 0) {
44
+ binding = exhausted.reduce((a, b) => ((b.resets_at || 0) > (a.resets_at || 0) ? b : a));
45
+ } else if (rl.rate_limit_reached_type) {
46
+ const named = rl[rl.rate_limit_reached_type];
47
+ binding = (named && typeof named === 'object')
48
+ ? named
49
+ : windows.reduce((a, b) => ((b.resets_at || 0) > (a?.resets_at || 0) ? b : a), null);
50
+ }
51
+ if (!binding) return null;
52
+
53
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
54
+ return {
55
+ limitType: windowLimitType(binding.window_minutes),
56
+ resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
57
+ reachedType: rl.rate_limit_reached_type || null,
58
+ timestampMs: Number.isFinite(ts) ? ts : null,
59
+ };
60
+ }
61
+
62
+ // The session_meta head line can be very long (it embeds the full base
63
+ // instructions) — read in chunks until the first newline.
64
+ function readFirstLine(path, maxBytes = 256 * 1024) {
65
+ let fd;
66
+ try {
67
+ fd = openSync(path, 'r');
68
+ const chunk = Buffer.alloc(16 * 1024);
69
+ let head = '';
70
+ let pos = 0;
71
+ while (pos < maxBytes) {
72
+ const n = readSync(fd, chunk, 0, chunk.length, pos);
73
+ if (n <= 0) break;
74
+ head += chunk.toString('utf-8', 0, n);
75
+ pos += n;
76
+ const nl = head.indexOf('\n');
77
+ if (nl !== -1) return head.slice(0, nl);
78
+ }
79
+ return head;
80
+ } catch {
81
+ return '';
82
+ } finally {
83
+ if (fd !== undefined) closeSync(fd);
84
+ }
85
+ }
86
+
87
+ // Session identity for a rollout file: the session_meta head line when
88
+ // parseable, else the uuid embedded in the filename.
89
+ export function rolloutMeta(path) {
90
+ let sessionId = null;
91
+ let cwd = null;
92
+ let originator = null;
93
+ try {
94
+ const meta = JSON.parse(readFirstLine(path));
95
+ if (meta?.type === 'session_meta') {
96
+ sessionId = meta.payload?.id || null;
97
+ cwd = meta.payload?.cwd || null;
98
+ originator = meta.payload?.originator || null;
99
+ }
100
+ } catch { /* unreadable head — fall back to the filename */ }
101
+ if (!sessionId) {
102
+ const m = basename(path).match(ROLLOUT_RE);
103
+ if (m) sessionId = m[1];
104
+ }
105
+ return { sessionId, cwd, originator };
106
+ }
package/src/wizard.js CHANGED
@@ -69,6 +69,20 @@ export async function runWizard() {
69
69
  });
70
70
  if (p.isCancel(notifications)) return cancelled();
71
71
 
72
+ // GUI watching: only meaningful where an autostart daemon can run.
73
+ let guiWatch = false;
74
+ if (process.platform === 'darwin' || process.platform === 'linux') {
75
+ const answer = await p.confirm({
76
+ message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
77
+ + ' Installs a small background daemon (launchd/systemd) that watches session\n'
78
+ + ' files for limit stops; revived sessions open in tmux and stay visible in\n'
79
+ + ' the GUI\'s own history.',
80
+ initialValue: DEFAULTS.guiWatch,
81
+ });
82
+ if (p.isCancel(answer)) return cancelled();
83
+ guiWatch = answer;
84
+ }
85
+
72
86
  // Seed message prompts from the existing config so a setup re-run shows —
73
87
  // and keeps — values previously set here or via `unsnooze config`.
74
88
  const existing = readFileConfig();
@@ -116,7 +130,7 @@ export async function runWizard() {
116
130
  // doesn't ask about (e.g. per-agent messages set via `unsnooze config`).
117
131
  writeConfig({
118
132
  ...existing,
119
- autoResume, menuAutoAnswer, notifications, resumeMessage,
133
+ autoResume, menuAutoAnswer, notifications, guiWatch, resumeMessage,
120
134
  resumeMessages: { ...existingMsgs, ...resumeMessages },
121
135
  agents: Object.fromEntries(listAgents().map(a => [a.id, agents.includes(a.id)])),
122
136
  });
@@ -124,7 +138,7 @@ export async function runWizard() {
124
138
  const s = p.spinner();
125
139
  s.start('Installing shell wrappers and hooks');
126
140
  const { cmdInstall } = await import('./install.js');
127
- const code = cmdInstall(['--yes']);
141
+ const code = cmdInstall(guiWatch ? ['--yes', '--daemon'] : ['--yes']);
128
142
  s.stop(code === 0 ? 'Wrappers and hooks installed' : 'Install hit a problem — see output above');
129
143
 
130
144
  p.outro(code === 0