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 +49 -0
- package/README.md +46 -0
- package/bin/unsnooze.js +12 -1
- package/package.json +1 -1
- package/src/agents/claude.js +8 -1
- package/src/cli.js +97 -5
- 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 +216 -25
- package/src/sessions.js +51 -2
- package/src/settings.js +27 -1
- 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,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.
|
|
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/agents/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
? ` ·
|
|
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
|
-
|
|
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();
|