unsnooze 1.9.0 → 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,38 @@
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
+
5
37
  ## 1.9.0 — 2026-07-13
6
38
 
7
39
  - **Context-size guard** (`contextGuard`: `off` | `inform` | `pause`, default
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
@@ -202,11 +204,28 @@ unsnooze help # full command list (also -h / --help)
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. |
203
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. |
204
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. |
205
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. |
206
210
 
207
211
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
208
212
  all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
209
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
+
210
229
  ### Notification channels
211
230
 
212
231
  When a limit is hit, a session resumes, or unsnooze gives up, it can alert you
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.9.0",
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",
package/src/cli.js CHANGED
@@ -1,4 +1,4 @@
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';
@@ -8,6 +8,8 @@ import { getAgent } from './agents/index.js';
8
8
  import { approxTokens } from './sessions.js';
9
9
  import { getConfig, setConfigValue, listConfig, CONFIG_FILE } from './settings.js';
10
10
  import { spawnResumerIfNeeded } from './spawn.js';
11
+ import { getMultiplexer } from './multiplexer.js';
12
+ import { listOwnedSessions, reap, attachHint } from './reap.js';
11
13
 
12
14
  function fmtCountdown(ms) {
13
15
  if (ms <= 0) return 'due now';
@@ -17,7 +19,20 @@ function fmtCountdown(ms) {
17
19
  return `${h}h ${m % 60}m`;
18
20
  }
19
21
 
20
- 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() {
21
36
  const state = readState();
22
37
  const sessions = Object.values(state.sessions);
23
38
  const paused = !getConfig('autoResume');
@@ -30,7 +45,10 @@ export function cmdStatus() {
30
45
  console.log(`unsnooze: ${sessions.length} tracked session(s) (resumer pid: ${state.resumerPid ?? 'not running'})${pausedNote}\n`);
31
46
  for (const s of sessions.sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0))) {
32
47
  const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
33
- 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)})`;
34
52
  const origin = s.origin ?? (s.pane ? 'cli' : '?');
35
53
  const pane = s.paneOwner ? `${s.paneOwner}:${s.pane ?? '-'}` : (s.pane ?? '-');
36
54
  const msg = s.resumeMessage
@@ -47,8 +65,19 @@ export function cmdStatus() {
47
65
  if (t != null) ctx = ` · ctx ${approxTokens(t)} tok`;
48
66
  } catch { /* estimate unavailable — omit */ }
49
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
+ }
50
79
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
51
- 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}`);
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}`);
52
81
  }
53
82
  return 0;
54
83
  }
@@ -174,3 +203,56 @@ export function cmdConfig(rest) {
174
203
  return 1;
175
204
  }
176
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();
package/src/monitor.js CHANGED
@@ -14,14 +14,15 @@ import { getMultiplexer } from './multiplexer.js';
14
14
  import {
15
15
  SCRAPE_INTERVAL_MS, PANE_SCAN_LINES, CAPTURE_LINES, EVENTS_DIR,
16
16
  EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
17
- FALLBACK_RESET_MS, RESET_MARGIN_MS, MUX_SESSION_NAME,
17
+ PROBE_INTERVAL_MS, RESET_MARGIN_MS,
18
18
  } from './config.js';
19
19
  import { detectLimit, isBusy, overloadMatch } from './patterns.js';
20
20
  import { getAgent } from './agents/index.js';
21
21
  import { getConfig } from './settings.js';
22
22
  import { notify } from './notify.js';
23
- import { parseResetTime, resetAtMs } from './time-parser.js';
24
- import { upsertSession, setStatus, readState } from './state.js';
23
+ import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
24
+ import { upsertSession, setStatus, readState, updateState } from './state.js';
25
+ import { latestRateLimitFromTranscript } from './watchers/claude.js';
25
26
  import { spawnResumerIfNeeded } from './spawn.js';
26
27
  import { makeLogger } from './logger.js';
27
28
  import { addressHash } from './lease.js';
@@ -40,6 +41,7 @@ export function createMonitor({
40
41
  let overloadAttempt = 0;
41
42
  let terminalNotified = false; // one notification per terminal-error appearance
42
43
  let running = true;
44
+ let firstTick = true; // §6: don't inherit a previous session's banner
43
45
 
44
46
  function markerPath() {
45
47
  return join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`);
@@ -56,27 +58,102 @@ export function createMonitor({
56
58
  }
57
59
  }
58
60
 
59
- async function recordLimit(resetLine, limitType, via) {
61
+ // Prefer a dated transcript entry when the agent keeps one (claude).
62
+ function resolveBanner(paneResetLine, paneLimitType) {
60
63
  const detectedAt = Date.now();
61
- const { at, source } = resetAtMs(parseResetTime(resetLine), {
62
- marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
64
+ const sessionId = agent.latestSessionId?.(cwd, detectedAt) || null;
65
+ const fromTx = latestRateLimitFromTranscript(cwd, sessionId, { now: detectedAt });
66
+ if (fromTx) {
67
+ return {
68
+ resetLine: fromTx.resetLine ?? paneResetLine,
69
+ limitType: fromTx.limitType && fromTx.limitType !== 'unknown' ? fromTx.limitType : paneLimitType,
70
+ bannerAt: fromTx.timestampMs,
71
+ sessionId: fromTx.sessionId || sessionId,
72
+ via: 'transcript',
73
+ detectedAt,
74
+ };
75
+ }
76
+ return {
77
+ resetLine: paneResetLine,
78
+ limitType: paneLimitType,
79
+ bannerAt: null,
80
+ sessionId,
81
+ via: null, // filled by caller (scrape/hook)
82
+ detectedAt,
83
+ };
84
+ }
85
+
86
+ function computeReset(resetLine, bannerAt, detectedAt) {
87
+ return resetAtMs(parseResetTime(resetLine), {
88
+ marginMs: RESET_MARGIN_MS,
89
+ fallbackMs: PROBE_INTERVAL_MS,
90
+ now: new Date(detectedAt),
91
+ bannerAt,
92
+ });
93
+ }
94
+
95
+ // §6 first-tick corroboration: only record if a fresh transcript backs it,
96
+ // or an absolute clock time resolves to a still-future instant (not the
97
+ // "already past → due now" path, which is margin above wall clock only).
98
+ function isCorroborated(resolved, at, source) {
99
+ if (resolved.via === 'transcript' && resolved.bannerAt != null) return true;
100
+ if (source === 'absolute' && at - RESET_MARGIN_MS > Date.now()) return true;
101
+ return false;
102
+ }
103
+
104
+ // §7: upgrade a weak estimate (fallback/relative) when a better value
105
+ // becomes available. A strictly better source (e.g. fallback→absolute)
106
+ // always wins — absolute is truth even if later than a probe guess.
107
+ // Same-or-worse source may only pull the wake earlier, never push it later.
108
+ function maybeUpgrade(existing, at, source, bannerAt, limitType, via) {
109
+ if (!existing || existing.status !== 'stopped') return false;
110
+ const oldRank = sourceRank(existing.resetSource);
111
+ const newRank = sourceRank(source);
112
+ const betterSource = newRank > oldRank;
113
+ const earlier = at < existing.resetAt;
114
+ if (!betterSource && !earlier) return false;
115
+ if (!betterSource && at > existing.resetAt) return false;
116
+ updateState(state => {
117
+ const s = state.sessions[existing.key];
118
+ if (!s || s.status !== 'stopped') return;
119
+ s.resetAt = at;
120
+ s.resetSource = source;
121
+ if (bannerAt != null) s.bannerAt = bannerAt;
122
+ if (limitType && limitType !== 'unknown') s.limitType = limitType;
123
+ if (via) s.detectedVia = via;
124
+ if (source !== 'fallback') delete s.probeCount;
63
125
  });
64
- const sessionId = agent.latestSessionId(cwd, detectedAt);
126
+ log(`pane ${pane}: upgraded reset ${existing.resetSource}→${source} at ${new Date(at).toISOString()}`);
127
+ return true;
128
+ }
129
+
130
+ async function recordLimit(resetLine, limitType, via) {
131
+ const resolved = resolveBanner(resetLine, limitType);
132
+ const detectedVia = resolved.via || via;
133
+ const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
134
+ let muxSession = null;
135
+ if (typeof mux.sessionForPane === 'function') {
136
+ try { muxSession = await mux.sessionForPane(pane); } catch { muxSession = null; }
137
+ }
65
138
  const state = upsertSession({
66
- sessionId, cwd, pane, mux: muxName, paneOwner, leaseId,
67
- agent: agent.id, muxSession: MUX_SESSION_NAME,
68
- status: 'stopped', limitType, detectedVia: via, detectedAt,
139
+ sessionId: resolved.sessionId, cwd, pane, mux: muxName, paneOwner, leaseId,
140
+ agent: agent.id, muxSession,
141
+ status: 'stopped', limitType: resolved.limitType, detectedVia,
142
+ detectedAt: resolved.detectedAt,
143
+ bannerAt: resolved.bannerAt,
69
144
  resetAt: at, resetSource: source,
70
145
  attempts: 0, lastAttemptAt: null, lastError: null,
146
+ ...(source === 'fallback' ? { probeCount: 0 } : {}),
71
147
  });
72
- trackedKey = sessionId
148
+ trackedKey = resolved.sessionId
73
149
  || Object.values(state.sessions).find(s => s.mux === muxName
74
150
  && s.paneOwner === paneOwner && s.pane === pane
75
151
  && ['stopped', 'resuming', 'resumed'].includes(s.status))?.key
76
152
  || null;
77
- log(`pane ${pane}: limit recorded (${limitType}, via ${via}), resets ${new Date(at).toISOString()}`);
153
+ log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
78
154
  notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
79
155
  spawnResumerIfNeeded();
156
+ return { at, source, resolved };
80
157
  }
81
158
 
82
159
  async function driveMenu(text) {
@@ -150,9 +227,11 @@ export function createMonitor({
150
227
  log(`pane ${pane}: limit menu detected but menuAutoAnswer is off — recording only`);
151
228
  await recordLimit(d.hit ? d.resetLine : null, d.hit ? d.limitType : 'unknown', 'scrape');
152
229
  }
230
+ firstTick = false;
153
231
  return;
154
232
  }
155
233
  await driveMenu(visible);
234
+ firstTick = false;
156
235
  return;
157
236
  }
158
237
 
@@ -160,9 +239,37 @@ export function createMonitor({
160
239
  if (d.hit) {
161
240
  const state = readState();
162
241
  const existing = trackedKey && state.sessions[trackedKey];
242
+
243
+ // §7: still-stopped records can upgrade a weak estimate.
244
+ if (existing && existing.status === 'stopped') {
245
+ const resolved = resolveBanner(d.resetLine, d.limitType);
246
+ const via = resolved.via || (marker ? 'hook' : 'scrape');
247
+ const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
248
+ maybeUpgrade(existing, at, source, resolved.bannerAt, resolved.limitType, via);
249
+ firstTick = false;
250
+ return;
251
+ }
252
+
163
253
  if (!existing || existing.status !== 'stopped') {
164
- await recordLimit(d.resetLine, d.limitType, marker ? 'hook' : 'scrape');
254
+ const via = marker ? 'hook' : 'scrape';
255
+ // §6: first tick needs corroboration so we don't inherit a leftover banner.
256
+ if (firstTick) {
257
+ const resolved = resolveBanner(d.resetLine, d.limitType);
258
+ const detectedVia = resolved.via || via;
259
+ const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
260
+ if (!isCorroborated({ ...resolved, via: detectedVia }, at, source)) {
261
+ log(`pane ${pane}: first tick banner uncorroborated (${source}) — skipping`);
262
+ firstTick = false;
263
+ return;
264
+ }
265
+ // Reuse recordLimit path; resolveBanner is idempotent enough.
266
+ await recordLimit(d.resetLine, d.limitType, via);
267
+ firstTick = false;
268
+ return;
269
+ }
270
+ await recordLimit(d.resetLine, d.limitType, via);
165
271
  }
272
+ firstTick = false;
166
273
  return;
167
274
  }
168
275
 
@@ -202,6 +309,8 @@ export function createMonitor({
202
309
  } else if (!marker && overloadMatch(text, agent.patterns.overloadPatterns) && !isBusy(text, agent.patterns.busyPatterns)) {
203
310
  await handleOverload();
204
311
  }
312
+
313
+ firstTick = false;
205
314
  }
206
315
 
207
316
  return {
@@ -216,6 +325,7 @@ export function createMonitor({
216
325
  stop() { running = false; },
217
326
  _tick: tick, // exposed for tests
218
327
  get trackedKey() { return trackedKey; },
328
+ get _firstTick() { return firstTick; },
219
329
  };
220
330
  }
221
331