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 +32 -0
- package/README.md +19 -0
- package/bin/unsnooze.js +12 -1
- package/package.json +1 -1
- package/src/cli.js +86 -4
- package/src/config.js +17 -2
- package/src/hook.js +33 -6
- package/src/install.js +11 -1
- package/src/launcher.js +24 -7
- package/src/monitor.js +123 -13
- package/src/multiplexers/session-name.js +28 -0
- package/src/multiplexers/tmux.js +63 -2
- package/src/multiplexers/zellij.js +111 -5
- package/src/reap.js +187 -0
- package/src/resumer.js +175 -19
- package/src/sessions.js +2 -1
- package/src/settings.js +7 -0
- package/src/spawn.js +23 -1
- package/src/state.js +91 -3
- package/src/time-parser.js +108 -43
- package/src/watcher.js +10 -3
- package/src/watchers/claude.js +44 -1
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
104
|
+
marginMs: RESET_MARGIN_MS,
|
|
105
|
+
fallbackMs: PROBE_INTERVAL_MS,
|
|
106
|
+
now: new Date(detectedAt),
|
|
107
|
+
bannerAt,
|
|
89
108
|
});
|
|
90
|
-
|
|
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
|
|
126
|
+
muxSession,
|
|
102
127
|
status: 'stopped',
|
|
103
128
|
limitType,
|
|
104
|
-
detectedVia
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
status: 'stopped', limitType
|
|
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 ${
|
|
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
|
-
|
|
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
|
|