unsnooze 1.8.1 → 1.10.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
@@ -2,6 +2,55 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.10.0 — 2026-07-14
6
+
7
+ - **Session-name ownership** (fix: interactive `claude` dying with
8
+ `duplicate session: unsnooze`): the interactive launcher owns the base name
9
+ `unsnooze` (and `unsnooze-2`… on collision); the resumer daemon may join a
10
+ live session but only ever **creates** `unsnooze-resumed`. Records now
11
+ discover the live mux session via `sessionForPane` instead of freezing the
12
+ load-time `MUX_SESSION_NAME` constant. tmux `newWindow` returns
13
+ `paneOwner: null` (pane ids are server-global). Uninstall stops the resumer.
14
+ Failed session creation degrades to an unwatched agent CLI instead of
15
+ bricking `claude`/`codex`. New: `unsnooze sessions`, `unsnooze reap
16
+ [--dry-run|--yes]`; optional `reapResumed` / `reapIdleAfter`. Zellij revival
17
+ uses `--close-on-exit` and closes the default shell pane left by
18
+ `attach -b -c`. Env: `UNSNOOZE_SESSION_NAME`, `UNSNOOZE_RESUME_SESSION`.
19
+ - **Reset-time accuracy** (fix: blind `now + 5h` fallback and +24h rollover
20
+ of already-past clock times): reset times are anchored to the banner's own
21
+ timestamp (`bannerAt`), not the scrape moment. Claude stops prefer the dated
22
+ transcript entry over an undated pane scrape. An absolute clock time already
23
+ past relative to wall clock means the limit already reset (due now), not
24
+ tomorrow. Compact durations like `1h 30m` sum all tokens. Unparseable banners
25
+ probe cheaply (`PROBE_INTERVAL_MS`, backoff to `PROBE_MAX_MS`) instead of
26
+ sleeping five hours; hard ceiling remains `FALLBACK_RESET_MS`. Monitor first
27
+ tick requires corroboration; later ticks can upgrade a weak estimate.
28
+ `unsnooze status` shows provenance (`absolute, from transcript` vs
29
+ `guessed: no reset time found — probing`).
30
+ - **Upgrade-safe state migration** for existing installs: `tmuxSession` →
31
+ `muxSession` (unchanged), bogus tmux `paneOwner` values cleared so leases
32
+ match again, old blind `fallback` waits beyond the probe ladder are pulled
33
+ into the first probe window (absolute/relative schedules untouched),
34
+ `TMUX_SESSION_NAME` alias kept, `reapResumed` defaults off, new config keys
35
+ only add defaults (existing `config.json` keeps working without edits).
36
+
37
+ ## 1.9.0 — 2026-07-13
38
+
39
+ - **Context-size guard** (`contextGuard`: `off` | `inform` | `pause`, default
40
+ `inform`; threshold `contextGuardTokens`, default `100000`): waking a
41
+ session hours after a limit stop re-reads its entire context at full
42
+ uncached price (the provider's prompt cache expires in minutes) — a
43
+ 150k-token session can eat a real slice of a fresh 5-hour window the moment
44
+ it wakes. unsnooze now estimates the size from the session transcript (the
45
+ last `message.usage` entry, tail-read) before dispatch: `inform` resumes
46
+ and notifies you of the price once the wake lands; `pause` holds sessions
47
+ at or above the threshold (`held: context ~152k tokens` in status) until
48
+ `unsnooze resume-now`, which always bypasses the guard. The estimate also
49
+ shows per-session in `unsnooze status` (`ctx ~152k tok`). Claude Code only
50
+ for now — the agent-adapter hook (`contextTokens`) is open for Codex, whose
51
+ rollout `token_count` events carry the same data. Prompted by r/ClaudeAI
52
+ feedback on a resume consuming 30% of a 5h quota.
53
+
5
54
  ## 1.8.1 — 2026-07-13
6
55
 
7
56
  - **Discovery / SEO**: package description and keywords expanded so npm and
package/README.md CHANGED
@@ -171,6 +171,8 @@ unsnooze status # tracked sessions + reset countdowns
171
171
  unsnooze resume-now [id|--all] # don't wait for the reset time
172
172
  unsnooze cancel [id|--all] # stop tracking a session
173
173
  unsnooze message <id> "text" # per-session wake message (--clear to reset)
174
+ unsnooze sessions # list unsnooze-owned mux sessions + panes
175
+ unsnooze reap [--dry-run|--yes] # close finished panes / empty sessions (default dry-run)
174
176
  unsnooze config list # settings (see below)
175
177
  unsnooze config set <k> <v> # e.g. autoResume off
176
178
  unsnooze logs [-f] # what unsnooze has been doing
@@ -200,11 +202,30 @@ unsnooze help # full command list (also -h / --help)
200
202
  | `agents.claude` / `agents.codex` | `true` | Which CLIs are guarded. |
201
203
  | `agents.grok` / `agents.qwen` / `agents.kimi` / `agents.opencode` / `agents.agy` | `false` | Experimental adapters — off by default; enable in `unsnooze setup` or `unsnooze config set agents.qwen on`. |
202
204
  | `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
205
+ | `contextGuard` | `inform` | Big cold context at wake? Waking a session re-reads its **entire context at full uncached price** ([why](#why-did-resuming-a-big-session-eat-so-much-of-my-quota)). `inform` resumes and notifies you of the size; `pause` holds sessions above the threshold for `unsnooze resume-now`; `off` disables. Claude Code only for now. |
206
+ | `contextGuardTokens` | `100000` | Context-size threshold (tokens) at which `contextGuard` notifies or holds. |
207
+ | `reapResumed` | `false` | Opt-in: auto-close `resumed` panes idle longer than `reapIdleAfter`. Off by default — use `unsnooze reap --yes` for explicit cleanup. |
208
+ | `reapIdleAfter` | `604800000` (7d) | Idle age (ms) before an opt-in auto-reap closes a `resumed` pane. |
203
209
  | `updateCheck` | `true` | Daily new-version check (a plain GET to the npm registry, nothing identifying is sent). Notices after commands + one desktop toast per version. |
204
210
 
205
211
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
206
212
  all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
207
213
 
214
+ ### Multiplexer session names
215
+
216
+ Interactive launches own the base session name (default **`unsnooze`**); a second
217
+ concurrent terminal takes `unsnooze-2`, etc. The resumer daemon may **join** a
218
+ live session but only ever **creates** `unsnooze-resumed`, so a revived agent
219
+ never steals the interactive name.
220
+
221
+ | env | default | meaning |
222
+ |---|---|---|
223
+ | `UNSNOOZE_SESSION_NAME` | `unsnooze` | Interactive base session name (also accepts legacy `UNSNOOZE_TMUX_SESSION`). |
224
+ | `UNSNOOZE_RESUME_SESSION` | `<base>-resumed` | Session the daemon creates when the pane's original session is gone. |
225
+
226
+ Find revived agents with `unsnooze status` (prints an attach hint when the
227
+ session is live), `unsnooze sessions`, or e.g. `tmux attach -t unsnooze-resumed`.
228
+
208
229
  ### Notification channels
209
230
 
210
231
  When a limit is hit, a session resumes, or unsnooze gives up, it can alert you
@@ -258,6 +279,12 @@ watcher stops (no pane context) always use native.
258
279
  the wake, the resumed agent is told to re-read before acting — or, with
259
280
  `workspaceGuard=pause`, the session is held and `unsnooze resume-now`
260
281
  shows the diff first.
282
+ - **Cold-cache wake guard**: waking a session after hours means the provider's
283
+ prompt cache is long expired — the first message re-reads the entire
284
+ context at full price, and a fat session can eat a real slice of the fresh
285
+ quota window. unsnooze estimates the size from the session transcript
286
+ (shown as `ctx ~152k tok` in `unsnooze status`) and, per `contextGuard`,
287
+ notifies you or holds the session instead of spending it silently.
261
288
 
262
289
  ## Requirements
263
290
 
@@ -327,6 +354,25 @@ continuing"). Set `workspaceGuard` to `pause` to hold such sessions for a
327
354
  manual `unsnooze resume-now` (which prints the diff stat), or `off` to
328
355
  disable the check.
329
356
 
357
+ ### Why did resuming a big session eat so much of my quota?
358
+
359
+ Because of prompt-cache expiry, not because of unsnooze. Providers cache your
360
+ session's context so each turn only pays full price for what's new — but that
361
+ cache lives ~5 minutes. After hours stopped at a limit, the cache is long
362
+ gone, so the **first** wake message (unsnooze's, or a hand-typed "continue" —
363
+ identical cost either way; the wake message itself is ~30 tokens) makes the
364
+ API re-read the entire conversation at full uncached price. A ~150k-token
365
+ session on a top-tier model can cost a meaningful slice of a fresh 5-hour
366
+ window the moment it wakes, before any new work happens.
367
+
368
+ What actually helps: `/compact` (or starting a fresh session with a handoff
369
+ summary) *before* you hit the limit, and keeping overnight sessions lean.
370
+ What unsnooze does: the `contextGuard` setting estimates the context size
371
+ from the session transcript before waking. `inform` (default) resumes and
372
+ notifies you of the price; `pause` holds sessions above `contextGuardTokens`
373
+ (default 100k) until you decide with `unsnooze resume-now`; the estimate is
374
+ also visible per-session in `unsnooze status` (`ctx ~152k tok`).
375
+
330
376
  ### Does it work if my laptop was asleep or the terminal was closed?
331
377
 
332
378
  Yes — reset times are stored as absolute timestamps and checked every 30
package/bin/unsnooze.js CHANGED
@@ -6,7 +6,7 @@ const [, , cmd, ...rest] = process.argv;
6
6
 
7
7
  // Only human-facing commands may print update notices — never the wrapper
8
8
  // passthrough, hooks, or daemons (their output lands in agent panes/logs).
9
- const USER_FACING = new Set(['status', 'resume-now', 'cancel', 'message', 'config', 'logs', 'report', 'help', '-h', '--help', '--help-unsnooze']);
9
+ const USER_FACING = new Set(['status', 'resume-now', 'cancel', 'message', 'config', 'logs', 'report', 'sessions', 'reap', 'help', '-h', '--help', '--help-unsnooze']);
10
10
 
11
11
  async function maybeUpdateNotices() {
12
12
  try {
@@ -56,6 +56,14 @@ async function main() {
56
56
  const { cmdConfig } = await import('../src/cli.js');
57
57
  return cmdConfig(rest);
58
58
  }
59
+ case 'sessions': {
60
+ const { cmdSessions } = await import('../src/cli.js');
61
+ return cmdSessions();
62
+ }
63
+ case 'reap': {
64
+ const { cmdReap } = await import('../src/cli.js');
65
+ return cmdReap(rest);
66
+ }
59
67
  case 'install': {
60
68
  const { cmdInstall } = await import('../src/install.js');
61
69
  return cmdInstall(rest);
@@ -119,6 +127,9 @@ Usage:
119
127
  unsnooze resume-now [id|--all] resume stopped session(s) immediately
120
128
  unsnooze cancel [id|--all] stop tracking session(s)
121
129
  unsnooze message <id|--all> <t> set a per-session wake message (--clear to reset)
130
+ unsnooze sessions list unsnooze-owned mux sessions + panes
131
+ unsnooze reap [--dry-run|--yes] close terminal-record panes / empty sessions
132
+ (default: dry-run; pass --yes to apply)
122
133
  unsnooze logs [-f] show (or follow) the unsnooze log
123
134
  unsnooze update update unsnooze itself to the latest version
124
135
  unsnooze daemon persistent watcher for GUI sessions (VS Code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity when the 5-hour/weekly usage limit resets — tmux, Zellij, VS Code & desktop apps. Wakes every limit-stopped AI coding session automatically.",
5
5
  "keywords": [
6
6
  "claude",
@@ -3,7 +3,7 @@
3
3
  // session-id lookup, and (claude-only) the interactive limit-menu driver.
4
4
 
5
5
  import { contentLines } from '../patterns.js';
6
- import { latestSessionId } from '../sessions.js';
6
+ import { latestSessionId, transcriptPath, lastUsageTokens } from '../sessions.js';
7
7
 
8
8
  export const patterns = {
9
9
  // Claude Code renders limits across multiple TUI lines, e.g.:
@@ -93,6 +93,13 @@ export default {
93
93
  return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
94
94
  },
95
95
  latestSessionId,
96
+ // Estimated tokens the API re-reads on a cold wake (prompt cache long
97
+ // expired). null → unknown, contextGuard skips. Adapters without this
98
+ // method are unguarded.
99
+ contextTokens(rec) {
100
+ if (!rec?.cwd || !rec?.sessionId) return null; // watcher/scrape records may lack either
101
+ return lastUsageTokens(transcriptPath(rec.cwd, rec.sessionId));
102
+ },
96
103
  // The foreground process for a claude session is `node` (nvm shim) or `claude`.
97
104
  isForegroundCommand(cmd) {
98
105
  return cmd === 'claude' || cmd === 'node' || cmd === 'unsnooze';
package/src/cli.js CHANGED
@@ -1,11 +1,15 @@
1
- // User-facing subcommands: status, resume-now, cancel, logs, config.
1
+ // User-facing subcommands: status, resume-now, cancel, logs, config, sessions, reap.
2
2
 
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { LOG_FILE, MAX_RESUME_ATTEMPTS } from './config.js';
6
6
  import { readState, setStatus, updateState } from './state.js';
7
+ import { getAgent } from './agents/index.js';
8
+ import { approxTokens } from './sessions.js';
7
9
  import { getConfig, setConfigValue, listConfig, CONFIG_FILE } from './settings.js';
8
10
  import { spawnResumerIfNeeded } from './spawn.js';
11
+ import { getMultiplexer } from './multiplexer.js';
12
+ import { listOwnedSessions, reap, attachHint } from './reap.js';
9
13
 
10
14
  function fmtCountdown(ms) {
11
15
  if (ms <= 0) return 'due now';
@@ -15,7 +19,20 @@ function fmtCountdown(ms) {
15
19
  return `${h}h ${m % 60}m`;
16
20
  }
17
21
 
18
- export function cmdStatus() {
22
+
23
+ // Surface how the reset estimate was derived so a silent wrong guess can't hide.
24
+ export function fmtResetProvenance(s) {
25
+ if (s.resetSource === 'fallback' || !s.resetSource) {
26
+ return 'guessed: no reset time found — probing';
27
+ }
28
+ const via = s.detectedVia === 'transcript' ? 'transcript'
29
+ : s.detectedVia === 'hook' ? 'hook'
30
+ : s.detectedVia === 'scrape' ? 'scrape'
31
+ : null;
32
+ return via ? `${s.resetSource}, from ${via}` : s.resetSource;
33
+ }
34
+
35
+ export async function cmdStatus() {
19
36
  const state = readState();
20
37
  const sessions = Object.values(state.sessions);
21
38
  const paused = !getConfig('autoResume');
@@ -28,17 +45,39 @@ export function cmdStatus() {
28
45
  console.log(`unsnooze: ${sessions.length} tracked session(s) (resumer pid: ${state.resumerPid ?? 'not running'})${pausedNote}\n`);
29
46
  for (const s of sessions.sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0))) {
30
47
  const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
31
- const reset = s.resetAt ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})` : '?';
48
+ const when = s.resetAt
49
+ ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})`
50
+ : '?';
51
+ const reset = `${when} (${fmtResetProvenance(s)})`;
32
52
  const origin = s.origin ?? (s.pane ? 'cli' : '?');
33
53
  const pane = s.paneOwner ? `${s.paneOwner}:${s.pane ?? '-'}` : (s.pane ?? '-');
34
54
  const msg = s.resumeMessage
35
55
  ? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
36
56
  : '';
37
57
  const hold = s.workspaceHold
38
- ? ` · workspace changed (${s.holdReason ?? '?'}) — resume-now to wake`
58
+ ? ` · held: ${s.holdReason ?? '?'} — resume-now to wake`
39
59
  : '';
60
+ // The price of waking: estimated context tokens the API re-reads cold.
61
+ let ctx = '';
62
+ if (s.status === 'stopped') {
63
+ try {
64
+ const t = getAgent(s.agent).contextTokens?.(s);
65
+ if (t != null) ctx = ` · ctx ${approxTokens(t)} tok`;
66
+ } catch { /* estimate unavailable — omit */ }
67
+ }
68
+ // Attach hint when the mux session the record points at is still live.
69
+ let attach = '';
70
+ if (s.muxSession) {
71
+ try {
72
+ const mux = getMultiplexer(s.mux || 'tmux');
73
+ if (typeof mux.sessionExists === 'function' && await mux.sessionExists(s.muxSession)) {
74
+ const hint = attachHint(s.mux, s.muxSession);
75
+ if (hint) attach = ` · attach: ${hint}`;
76
+ }
77
+ } catch { /* mux unavailable — omit */ }
78
+ }
40
79
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
41
- console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
80
+ console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${ctx}${hold}${attach}`);
42
81
  }
43
82
  return 0;
44
83
  }
@@ -164,3 +203,56 @@ export function cmdConfig(rest) {
164
203
  return 1;
165
204
  }
166
205
  }
206
+
207
+ // `unsnooze sessions` — list unsnooze-owned mux sessions with panes + records.
208
+ export async function cmdSessions() {
209
+ const owned = await listOwnedSessions();
210
+ if (owned.length === 0) {
211
+ console.log('unsnooze: no unsnooze-owned mux sessions found.');
212
+ return 0;
213
+ }
214
+ console.log(`unsnooze: ${owned.length} mux session(s)\n`);
215
+ for (const s of owned) {
216
+ const flag = s.exited ? 'EXITED' : 'live';
217
+ const panes = s.panes.length ? s.panes.join(', ') : '(none)';
218
+ console.log(` [${flag.padEnd(6)}] ${s.mux} ${s.name} panes: ${panes}`);
219
+ if (s.attach) console.log(` attach: ${s.attach}`);
220
+ for (const r of s.records) {
221
+ console.log(` · ${r.status} ${(r.agent || '?').padEnd(6)} ${r.cwd || '?'} pane ${r.pane ?? '-'}`);
222
+ }
223
+ }
224
+ return 0;
225
+ }
226
+
227
+ // `unsnooze reap [--dry-run|--yes]` — close terminal-record panes and empty/
228
+ // EXITED unsnooze sessions. Default is dry-run.
229
+ export async function cmdReap(rest = []) {
230
+ // Default dry-run unless --yes.
231
+ const yes = rest.includes('--yes');
232
+ const result = await reap({ dryRun: !yes, yes });
233
+ if (result.actions.length === 0) {
234
+ console.log(result.dryRun
235
+ ? 'unsnooze reap (dry-run): nothing to do.'
236
+ : 'unsnooze reap: nothing to do.');
237
+ return 0;
238
+ }
239
+ const prefix = result.dryRun ? 'unsnooze reap (dry-run)' : 'unsnooze reap';
240
+ console.log(`${prefix}: ${result.actions.length} action(s)\n`);
241
+ for (const a of result.actions) {
242
+ if (a.kind === 'close-pane') {
243
+ console.log(` close pane ${a.mux} ${a.paneOwner ? `${a.paneOwner}:` : ''}${a.pane} (record ${a.key})`);
244
+ } else if (a.kind === 'delete-session') {
245
+ console.log(` delete session ${a.mux} ${a.name} (${a.reason})`);
246
+ } else if (a.kind === 'drop-record') {
247
+ console.log(` drop record ${a.key} (${a.reason})`);
248
+ } else if (a.kind === 'error') {
249
+ console.log(` error: ${a.key || a.name}: ${a.message}`);
250
+ } else {
251
+ console.log(` ${a.kind}: ${JSON.stringify(a)}`);
252
+ }
253
+ }
254
+ if (result.dryRun) {
255
+ console.log('\nRe-run with --yes to apply.');
256
+ }
257
+ return 0;
258
+ }
package/src/config.js CHANGED
@@ -20,15 +20,28 @@ export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.cod
20
20
  // Transcript/rollout watcher (GUI detection channel)
21
21
  export const WATCH_OFFSETS_FILE = join(STATE_DIR, 'watch-offsets.json');
22
22
 
23
+ // Interactive launcher base session name. The daemon never CREATES this name
24
+ // (see RESUME_SESSION_NAME); it may only join it when already live.
23
25
  export const MUX_SESSION_NAME = process.env.UNSNOOZE_SESSION_NAME
24
26
  || process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
25
- export const TMUX_SESSION_NAME = MUX_SESSION_NAME; // read compatibility
27
+ // Legacy alias — older docs/scripts and any external importers used this name.
28
+ export const TMUX_SESSION_NAME = MUX_SESSION_NAME;
29
+
30
+ // Session the daemon creates for revivals when the pane's original session is
31
+ // gone. Must never collide with the interactive base name.
32
+ export const RESUME_SESSION_NAME = process.env.UNSNOOZE_RESUME_SESSION
33
+ || `${MUX_SESSION_NAME}-resumed`;
26
34
 
27
35
  // Timing (ms unless noted)
28
36
  export const RESET_MARGIN_MS = envInt('UNSNOOZE_RESET_MARGIN_MS', 60_000);
29
37
  export const POLL_INTERVAL_MS = envInt('UNSNOOZE_POLL_INTERVAL_MS', 30_000); // resumer epoch polling
30
38
  export const SCRAPE_INTERVAL_MS = envInt('UNSNOOZE_SCRAPE_INTERVAL_MS', 5_000); // monitor pane scraping
31
39
  export const FALLBACK_RESET_MS = envInt('UNSNOOZE_FALLBACK_RESET_MS', 5 * 3_600_000);
40
+ // When no reset time parses: cheap pane probes instead of sleeping for 5h.
41
+ // Backoff 15 → 30 → 60 min (capped at PROBE_MAX_MS); hard ceiling remains
42
+ // FALLBACK_RESET_MS from detectedAt.
43
+ export const PROBE_INTERVAL_MS = envInt('UNSNOOZE_PROBE_INTERVAL_MS', 15 * 60_000);
44
+ export const PROBE_MAX_MS = envInt('UNSNOOZE_PROBE_MAX_MS', 60 * 60_000);
32
45
  export const STAGGER_MS = envInt('UNSNOOZE_STAGGER_MS', 8_000);
33
46
  export const VERIFY_DELAY_MS = envInt('UNSNOOZE_VERIFY_DELAY_MS', 20_000);
34
47
  export const BUSY_DEFER_MS = envInt('UNSNOOZE_BUSY_DEFER_MS', 60_000);
@@ -48,5 +61,7 @@ export const OVERLOAD_BACKOFF_S = (process.env.UNSNOOZE_OVERLOAD_BACKOFF_S || '3
48
61
  export const OVERLOAD_JITTER = 0.15;
49
62
  export const DEDUPE_WINDOW_MS = envInt('UNSNOOZE_DEDUPE_WINDOW_MS', 120_000);
50
63
  export const PRUNE_AFTER_MS = envInt('UNSNOOZE_PRUNE_AFTER_MS', 7 * 86_400_000);
64
+ // Non-terminal records with a dead/absent pane older than this are marked
65
+ // failed instead of being revived forever (ghost-pane multiplier).
66
+ export const STALE_AFTER_MS = envInt('UNSNOOZE_STALE_AFTER_MS', 7 * 86_400_000);
51
67
  export const STALE_LOCK_MS = envInt('UNSNOOZE_STALE_LOCK_MS', 10_000);
52
-
package/src/hook.js CHANGED
@@ -6,12 +6,15 @@
6
6
 
7
7
  import { mkdirSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import { EVENTS_DIR, FALLBACK_RESET_MS, RESET_MARGIN_MS, CAPTURE_LINES, PANE_SCAN_LINES, MUX_SESSION_NAME } from './config.js';
9
+ import {
10
+ EVENTS_DIR, PROBE_INTERVAL_MS, RESET_MARGIN_MS, CAPTURE_LINES, PANE_SCAN_LINES,
11
+ } from './config.js';
10
12
  import { detectLimit } from './patterns.js';
11
13
  import { getAgent } from './agents/index.js';
12
14
  import { getConfig } from './settings.js';
13
15
  import { parseResetTime, resetAtMs } from './time-parser.js';
14
16
  import { upsertSession } from './state.js';
17
+ import { latestRateLimitFromTranscript } from './watchers/claude.js';
15
18
  import { getMultiplexer } from './multiplexer.js';
16
19
  import { spawnResumerIfNeeded } from './spawn.js';
17
20
  import { makeLogger } from './logger.js';
@@ -72,9 +75,22 @@ export async function runHook(rest = []) {
72
75
  // rate_limit (or unknown — treat conservatively as a limit if we can see a banner)
73
76
  const cwd = payload.cwd || process.cwd();
74
77
  const detectedAt = Date.now();
78
+ const sessionId = payload.session_id || agent.latestSessionId?.(cwd, detectedAt) || null;
79
+
80
+ // Prefer the dated transcript entry over an undated pane scrape when the
81
+ // agent keeps transcripts (claude). Yields both banner text and bannerAt.
75
82
  let resetLine = null;
76
83
  let limitType = 'unknown';
77
- if (pane) {
84
+ let bannerAt = null;
85
+ let detectedVia = 'hook';
86
+ const fromTx = latestRateLimitFromTranscript(cwd, sessionId, { now: detectedAt });
87
+ if (fromTx) {
88
+ resetLine = fromTx.resetLine;
89
+ limitType = fromTx.limitType || 'unknown';
90
+ bannerAt = fromTx.timestampMs;
91
+ detectedVia = 'transcript';
92
+ log(`StopFailure: rate-limit banner from transcript (ts=${bannerAt ? new Date(bannerAt).toISOString() : '?'})`);
93
+ } else if (pane) {
78
94
  try {
79
95
  const text = await mux.capturePane(pane, CAPTURE_LINES);
80
96
  const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
@@ -85,9 +101,18 @@ export async function runHook(rest = []) {
85
101
  if (kind === 'unknown' && !resetLine) return 0; // nothing actionable
86
102
 
87
103
  const { at, source } = resetAtMs(parseResetTime(resetLine), {
88
- marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
104
+ marginMs: RESET_MARGIN_MS,
105
+ fallbackMs: PROBE_INTERVAL_MS,
106
+ now: new Date(detectedAt),
107
+ bannerAt,
89
108
  });
90
- const sessionId = payload.session_id || agent.latestSessionId(cwd, detectedAt);
109
+
110
+ // Discover the live session name from the pane — never freeze the module
111
+ // load-time MUX_SESSION_NAME constant onto the record.
112
+ let muxSession = null;
113
+ if (pane && typeof mux.sessionForPane === 'function') {
114
+ try { muxSession = await mux.sessionForPane(pane); } catch { muxSession = null; }
115
+ }
91
116
 
92
117
  upsertSession({
93
118
  sessionId: sessionId || null,
@@ -98,16 +123,18 @@ export async function runHook(rest = []) {
98
123
  leaseId,
99
124
  agent: agent.id,
100
125
  origin: 'cli', // the hook only fires for CLI launches we can see
101
- muxSession: MUX_SESSION_NAME,
126
+ muxSession,
102
127
  status: 'stopped',
103
128
  limitType,
104
- detectedVia: 'hook',
129
+ detectedVia,
105
130
  detectedAt,
131
+ bannerAt,
106
132
  resetAt: at,
107
133
  resetSource: source,
108
134
  attempts: 0,
109
135
  lastAttemptAt: null,
110
136
  lastError: null,
137
+ ...(source === 'fallback' ? { probeCount: 0 } : {}),
111
138
  });
112
139
  log(`recorded limit stop: session=${sessionId} resetAt=${new Date(at).toISOString()} (${source})`);
113
140
  spawnResumerIfNeeded();
package/src/install.js CHANGED
@@ -16,7 +16,7 @@ import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
16
16
  import { getConfig, configFileExists } from './settings.js';
17
17
  import { xmlEscape } from './notify.js';
18
18
  import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
19
- import { UNSNOOZE_BIN } from './spawn.js';
19
+ import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
20
20
 
21
21
  const FENCE_OPEN = '# >>> unsnooze >>>';
22
22
  const FENCE_CLOSE = '# <<< unsnooze <<<';
@@ -341,6 +341,16 @@ export function cmdUninstall(rest) {
341
341
  const opts = parseArgs(rest);
342
342
  const explicitRc = rest.includes('--zshrc');
343
343
 
344
+ // Stop the resumer/daemon first so it cannot keep writing state after hooks
345
+ // are gone (zombie-daemon-running-deleted-code failure mode).
346
+ try {
347
+ const result = stopResumer();
348
+ if (result.stopped) console.log(`unsnooze: resumer stopped (pid ${result.pid})`);
349
+ else if (result.reason === 'stale') console.log('unsnooze: stale resumer lock cleared');
350
+ } catch (err) {
351
+ console.log(`unsnooze: could not stop resumer (${err.message})`);
352
+ }
353
+
344
354
  if (existsSync(opts.settings)) {
345
355
  const before = readFileSync(opts.settings, 'utf-8');
346
356
  atomicWrite(opts.settings, removeHookFromSettings(before));
package/src/launcher.js CHANGED
@@ -11,6 +11,7 @@ import { getConfig } from './settings.js';
11
11
  import { spawnDetached } from './spawn.js';
12
12
  import { makeLogger } from './logger.js';
13
13
  import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
14
+ import { SessionCreateError } from './multiplexers/session-name.js';
14
15
 
15
16
  const log = makeLogger('launcher');
16
17
 
@@ -18,6 +19,12 @@ function isPrintMode(args) {
18
19
  return args.includes('-p') || args.includes('--print');
19
20
  }
20
21
 
22
+ function runUnwatched(agent, args, reason) {
23
+ if (reason) process.stderr.write(`unsnooze: ${reason}\n`);
24
+ const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
25
+ return r.status ?? 1;
26
+ }
27
+
21
28
  export function runLauncher(args, agentId = 'claude') {
22
29
  const agent = getAgent(agentId);
23
30
 
@@ -39,15 +46,25 @@ export function runLauncher(args, agentId = 'claude') {
39
46
  } else {
40
47
  process.stderr.write(`unsnooze: install ${mux.name} to enable auto-resume.\n`);
41
48
  }
42
- const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
43
- return r.status ?? 1;
49
+ return runUnwatched(agent, args);
44
50
  }
45
51
  log(`not in ${mux.name} — wrapping into a managed session`);
46
- return mux.launchWrapped({
47
- file: process.execPath,
48
- args: [process.argv[1], '_run', agent.id, ...args],
49
- env: process.env,
50
- });
52
+ try {
53
+ // A returned status is the agent's (or session's) exit — never re-run.
54
+ return mux.launchWrapped({
55
+ file: process.execPath,
56
+ args: [process.argv[1], '_run', agent.id, ...args],
57
+ env: process.env,
58
+ });
59
+ } catch (err) {
60
+ // Session creation failed (tmux/zellij binary spawn, unexpected throw).
61
+ // Never brick the user's `claude`/`codex` — fall back unwatched.
62
+ const msg = err instanceof SessionCreateError
63
+ ? `${err.message} — running without limit-watch.`
64
+ : `failed to wrap into ${mux.name} (${err.message}) — running without limit-watch.`;
65
+ log(`launchWrapped failed: ${err.stack || err}`);
66
+ return runUnwatched(agent, args, msg);
67
+ }
51
68
  }
52
69
 
53
70
  const pane = mux.currentPaneId();