unsnooze 1.9.0 → 1.12.1
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 +164 -0
- package/README.md +68 -0
- package/bin/unsnooze.js +147 -16
- package/package.json +1 -1
- package/src/cli.js +157 -4
- package/src/config.js +17 -2
- package/src/doctor.js +310 -0
- package/src/hook.js +33 -6
- package/src/install.js +63 -6
- package/src/launcher.js +29 -7
- package/src/lease.js +22 -0
- package/src/logger.js +40 -1
- package/src/monitor.js +142 -14
- package/src/multiplexers/session-name.js +28 -0
- package/src/multiplexers/tmux.js +124 -4
- package/src/multiplexers/zellij.js +131 -8
- package/src/notify-ntfy.js +76 -0
- package/src/notify.js +9 -0
- package/src/reap.js +228 -0
- package/src/resumer.js +464 -91
- package/src/sessions.js +2 -1
- package/src/settings.js +20 -0
- package/src/spawn.js +23 -1
- package/src/state.js +128 -10
- package/src/time-parser.js +136 -55
- package/src/update-check.js +34 -0
- package/src/watcher.js +10 -3
- package/src/watchers/claude.js +44 -1
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,9 @@ 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';
|
|
13
|
+
import { planFor } from './resumer.js';
|
|
11
14
|
|
|
12
15
|
function fmtCountdown(ms) {
|
|
13
16
|
if (ms <= 0) return 'due now';
|
|
@@ -17,7 +20,20 @@ function fmtCountdown(ms) {
|
|
|
17
20
|
return `${h}h ${m % 60}m`;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
|
|
24
|
+
// Surface how the reset estimate was derived so a silent wrong guess can't hide.
|
|
25
|
+
export function fmtResetProvenance(s) {
|
|
26
|
+
if (s.resetSource === 'fallback' || !s.resetSource) {
|
|
27
|
+
return 'guessed: no reset time found — probing';
|
|
28
|
+
}
|
|
29
|
+
const via = s.detectedVia === 'transcript' ? 'transcript'
|
|
30
|
+
: s.detectedVia === 'hook' ? 'hook'
|
|
31
|
+
: s.detectedVia === 'scrape' ? 'scrape'
|
|
32
|
+
: null;
|
|
33
|
+
return via ? `${s.resetSource}, from ${via}` : s.resetSource;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function cmdStatus() {
|
|
21
37
|
const state = readState();
|
|
22
38
|
const sessions = Object.values(state.sessions);
|
|
23
39
|
const paused = !getConfig('autoResume');
|
|
@@ -30,7 +46,10 @@ export function cmdStatus() {
|
|
|
30
46
|
console.log(`unsnooze: ${sessions.length} tracked session(s) (resumer pid: ${state.resumerPid ?? 'not running'})${pausedNote}\n`);
|
|
31
47
|
for (const s of sessions.sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0))) {
|
|
32
48
|
const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
|
|
33
|
-
const
|
|
49
|
+
const when = s.resetAt
|
|
50
|
+
? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})`
|
|
51
|
+
: '?';
|
|
52
|
+
const reset = `${when} (${fmtResetProvenance(s)})`;
|
|
34
53
|
const origin = s.origin ?? (s.pane ? 'cli' : '?');
|
|
35
54
|
const pane = s.paneOwner ? `${s.paneOwner}:${s.pane ?? '-'}` : (s.pane ?? '-');
|
|
36
55
|
const msg = s.resumeMessage
|
|
@@ -47,8 +66,19 @@ export function cmdStatus() {
|
|
|
47
66
|
if (t != null) ctx = ` · ctx ${approxTokens(t)} tok`;
|
|
48
67
|
} catch { /* estimate unavailable — omit */ }
|
|
49
68
|
}
|
|
69
|
+
// Attach hint when the mux session the record points at is still live.
|
|
70
|
+
let attach = '';
|
|
71
|
+
if (s.muxSession) {
|
|
72
|
+
try {
|
|
73
|
+
const mux = getMultiplexer(s.mux || 'tmux');
|
|
74
|
+
if (typeof mux.sessionExists === 'function' && await mux.sessionExists(s.muxSession)) {
|
|
75
|
+
const hint = attachHint(s.mux, s.muxSession);
|
|
76
|
+
if (hint) attach = ` · attach: ${hint}`;
|
|
77
|
+
}
|
|
78
|
+
} catch { /* mux unavailable — omit */ }
|
|
79
|
+
}
|
|
50
80
|
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}`);
|
|
81
|
+
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
82
|
}
|
|
53
83
|
return 0;
|
|
54
84
|
}
|
|
@@ -174,3 +204,126 @@ export function cmdConfig(rest) {
|
|
|
174
204
|
return 1;
|
|
175
205
|
}
|
|
176
206
|
}
|
|
207
|
+
|
|
208
|
+
// `unsnooze preview [id]` — dry-run: what WOULD the resumer do right now,
|
|
209
|
+
// and why? Shares planFor/assessPane/evaluate*Guard with the real dispatch,
|
|
210
|
+
// so this can never drift from what dispatch actually does. Read-only pane
|
|
211
|
+
// captures only — nothing is typed, opened, or modified.
|
|
212
|
+
// Exit codes (terraform -detailed-exitcode style): 0 = nothing would wake
|
|
213
|
+
// right now, 2 = at least one actionable wake, 1 = internal error.
|
|
214
|
+
export async function cmdPreview(rest = [], {
|
|
215
|
+
resolveMux = rec => getMultiplexer(rec.mux, { owner: rec.paneOwner }),
|
|
216
|
+
matchesLease = undefined,
|
|
217
|
+
print = console.log,
|
|
218
|
+
now = Date.now(),
|
|
219
|
+
} = {}) {
|
|
220
|
+
try {
|
|
221
|
+
const idOrAll = rest.find(a => !a.startsWith('-'));
|
|
222
|
+
const state = readState();
|
|
223
|
+
let records = Object.values(state.sessions);
|
|
224
|
+
if (idOrAll) {
|
|
225
|
+
records = records.filter(s => s.key.startsWith(idOrAll) || (s.sessionId || '').startsWith(idOrAll));
|
|
226
|
+
}
|
|
227
|
+
if (records.length === 0) {
|
|
228
|
+
print(idOrAll ? `unsnooze preview: no session matching "${idOrAll}".` : 'unsnooze preview: no tracked sessions.');
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
print('unsnooze preview — what WOULD happen right now (nothing is sent)\n');
|
|
232
|
+
const VERBS = {
|
|
233
|
+
inject: p => `would TYPE the wake message into pane ${p.target?.pane}`,
|
|
234
|
+
'drive-menu': p => `would SELECT "Stop and wait for limit to reset" in pane ${p.target?.pane}`,
|
|
235
|
+
reopen: p => `would REOPEN in session "${p.target?.session}"${p.messageViaPane ? ' and type the message once the TUI shows an idle prompt' : ' (message travels in argv)'}`,
|
|
236
|
+
busy: () => 'would DEFER — agent is busy',
|
|
237
|
+
'menu-held': () => 'limit menu on screen — would WAIT (menuAutoAnswer off)',
|
|
238
|
+
probe: () => 'would PROBE (reset time unknown) — not a resume',
|
|
239
|
+
waiting: () => 'waiting for the reset',
|
|
240
|
+
paused: () => 'paused — would do nothing',
|
|
241
|
+
held: () => 'held — would do nothing until resume-now',
|
|
242
|
+
'give-up': () => 'would GIVE UP (attempt cap reached)',
|
|
243
|
+
verifying: () => 'verifying an in-flight resume',
|
|
244
|
+
retry: () => 'would RETRY later (pane capture failed)',
|
|
245
|
+
none: () => 'nothing to do',
|
|
246
|
+
};
|
|
247
|
+
let actionable = false;
|
|
248
|
+
for (const rec of records.sort((x, y) => (x.resetAt || 0) - (y.resetAt || 0))) {
|
|
249
|
+
let plan;
|
|
250
|
+
try {
|
|
251
|
+
plan = await planFor(rec, {
|
|
252
|
+
mux: resolveMux(rec),
|
|
253
|
+
...(matchesLease ? { matchesLease } : {}),
|
|
254
|
+
now,
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
print(` ${rec.key.slice(0, 12)}: preview failed: ${err.message}`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const id = rec.sessionId ? rec.sessionId.slice(0, 8) : '(no id)';
|
|
261
|
+
print(` [${(rec.status || '?').toUpperCase().padEnd(9)}] ${id} ${(rec.agent || 'claude').padEnd(6)} ${rec.cwd || ''}`);
|
|
262
|
+
print(` ${(VERBS[plan.action] || (() => plan.action))(plan)}`);
|
|
263
|
+
for (const g of plan.gates) print(` · ${g}`);
|
|
264
|
+
if (plan.message && ['inject', 'reopen'].includes(plan.action)) {
|
|
265
|
+
const m = plan.message.length > 140 ? `${plan.message.slice(0, 140)}…` : plan.message;
|
|
266
|
+
print(` message: "${m.replace(/\n+/g, ' ⏎ ')}"`);
|
|
267
|
+
}
|
|
268
|
+
if (['inject', 'drive-menu', 'reopen'].includes(plan.action)) actionable = true;
|
|
269
|
+
}
|
|
270
|
+
print('\nNothing was typed, opened, or modified. `unsnooze resume-now <id>` wakes a session immediately.');
|
|
271
|
+
return actionable ? 2 : 0;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error(`unsnooze preview: ${err.message}`);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// `unsnooze sessions` — list unsnooze-owned mux sessions with panes + records.
|
|
279
|
+
export async function cmdSessions() {
|
|
280
|
+
const owned = await listOwnedSessions();
|
|
281
|
+
if (owned.length === 0) {
|
|
282
|
+
console.log('unsnooze: no unsnooze-owned mux sessions found.');
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
console.log(`unsnooze: ${owned.length} mux session(s)\n`);
|
|
286
|
+
for (const s of owned) {
|
|
287
|
+
const flag = s.exited ? 'EXITED' : 'live';
|
|
288
|
+
const panes = s.panes.length ? s.panes.join(', ') : '(none)';
|
|
289
|
+
console.log(` [${flag.padEnd(6)}] ${s.mux} ${s.name} panes: ${panes}`);
|
|
290
|
+
if (s.attach) console.log(` attach: ${s.attach}`);
|
|
291
|
+
for (const r of s.records) {
|
|
292
|
+
console.log(` · ${r.status} ${(r.agent || '?').padEnd(6)} ${r.cwd || '?'} pane ${r.pane ?? '-'}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// `unsnooze reap [--dry-run|--yes]` — close terminal-record panes and empty/
|
|
299
|
+
// EXITED unsnooze sessions. Default is dry-run.
|
|
300
|
+
export async function cmdReap(rest = []) {
|
|
301
|
+
// Default dry-run unless --yes.
|
|
302
|
+
const yes = rest.includes('--yes');
|
|
303
|
+
const result = await reap({ dryRun: !yes, yes });
|
|
304
|
+
if (result.actions.length === 0) {
|
|
305
|
+
console.log(result.dryRun
|
|
306
|
+
? 'unsnooze reap (dry-run): nothing to do.'
|
|
307
|
+
: 'unsnooze reap: nothing to do.');
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
const prefix = result.dryRun ? 'unsnooze reap (dry-run)' : 'unsnooze reap';
|
|
311
|
+
console.log(`${prefix}: ${result.actions.length} action(s)\n`);
|
|
312
|
+
for (const a of result.actions) {
|
|
313
|
+
if (a.kind === 'close-pane') {
|
|
314
|
+
console.log(` close pane ${a.mux} ${a.paneOwner ? `${a.paneOwner}:` : ''}${a.pane} (record ${a.key})`);
|
|
315
|
+
} else if (a.kind === 'delete-session') {
|
|
316
|
+
console.log(` delete session ${a.mux} ${a.name} (${a.reason})`);
|
|
317
|
+
} else if (a.kind === 'drop-record') {
|
|
318
|
+
console.log(` drop record ${a.key} (${a.reason})`);
|
|
319
|
+
} else if (a.kind === 'error') {
|
|
320
|
+
console.log(` error: ${a.key || a.name}: ${a.message}`);
|
|
321
|
+
} else {
|
|
322
|
+
console.log(` ${a.kind}: ${JSON.stringify(a)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (result.dryRun) {
|
|
326
|
+
console.log('\nRe-run with --yes to apply.');
|
|
327
|
+
}
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
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/doctor.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// `unsnooze doctor [--fix]` — health check + csg (claude-session-guard)
|
|
2
|
+
// migration sweep. npm has no successor mechanism and `-g` uninstall hooks are
|
|
3
|
+
// unreliable, so the ONLY robust cleanup of the pre-release csg install is
|
|
4
|
+
// unsnooze itself finding and retiring its leftovers: zombie csg monitors/
|
|
5
|
+
// resumers, an old launchd/systemd unit, the orphaned state dir, the stale
|
|
6
|
+
// global package. Everything is injectable for tests; --fix never deletes
|
|
7
|
+
// data (the state dir is archived) and never runs npm (hint only).
|
|
8
|
+
|
|
9
|
+
import { readFileSync, readdirSync, renameSync, existsSync, unlinkSync, lstatSync } from 'node:fs';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { CLAUDE_SETTINGS, RESUMER_LOCK } from './config.js';
|
|
14
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
15
|
+
import { makeLogger } from './logger.js';
|
|
16
|
+
|
|
17
|
+
const log = makeLogger('doctor');
|
|
18
|
+
|
|
19
|
+
const CSG_PROCESS_RE = /\bcsg\.js\s+_?(?:monitor|resumer|run|daemon|hook)/;
|
|
20
|
+
// A unit is csg-legacy when it execs csg.js, or mentions claude-session-guard
|
|
21
|
+
// WITHOUT execing unsnooze — the dev repo kept the old folder name after the
|
|
22
|
+
// rename, so a live unsnooze daemon plist can legitimately have
|
|
23
|
+
// .../claude-session-guard/bin/unsnooze.js in its path. Flagging that would
|
|
24
|
+
// let --fix destroy the running daemon.
|
|
25
|
+
function isCsgUnit(content) {
|
|
26
|
+
if (/csg\.js/.test(content)) return true;
|
|
27
|
+
return /claude-session-guard/.test(content) && !/unsnooze\.js|com\.unsnooze/.test(content);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function defaultRunner(cmd, args) {
|
|
31
|
+
try {
|
|
32
|
+
return spawnSync(cmd, args, { encoding: 'utf-8' });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return { status: 1, stdout: '', error: err };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- detections ---------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Live csg-era processes (monitors/resumers/daemons). */
|
|
41
|
+
export function findCsgProcesses({ runner = defaultRunner } = {}) {
|
|
42
|
+
const r = runner('ps', ['-axo', 'pid=,command=']);
|
|
43
|
+
if (r.error || r.status !== 0) return [];
|
|
44
|
+
return String(r.stdout || '').split('\n')
|
|
45
|
+
.map(line => line.trim())
|
|
46
|
+
.filter(line => CSG_PROCESS_RE.test(line))
|
|
47
|
+
.map(line => {
|
|
48
|
+
const m = line.match(/^(\d+)\s+(.*)$/);
|
|
49
|
+
return m ? { pid: Number(m[1]), command: m[2] } : null;
|
|
50
|
+
})
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** launchd plists / systemd units that exec csg (never the unsnooze one). */
|
|
55
|
+
export function findCsgAutostarts({ dir = defaultLaunchAgentsDir() } = {}) {
|
|
56
|
+
try {
|
|
57
|
+
return readdirSync(dir)
|
|
58
|
+
.filter(name => name.endsWith('.plist') || name.endsWith('.service'))
|
|
59
|
+
.map(name => join(dir, name))
|
|
60
|
+
.filter(path => {
|
|
61
|
+
try { return isCsgUnit(readFileSync(path, 'utf-8')); }
|
|
62
|
+
catch { return false; }
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return []; // dir missing — nothing installed
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultLaunchAgentsDir(platform = process.platform) {
|
|
70
|
+
if (platform === 'darwin') {
|
|
71
|
+
return process.env.UNSNOOZE_LAUNCH_AGENTS_DIR || join(homedir(), 'Library', 'LaunchAgents');
|
|
72
|
+
}
|
|
73
|
+
return process.env.UNSNOOZE_SYSTEMD_USER_DIR || join(homedir(), '.config', 'systemd', 'user');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The stale global package. `command -v csg` is NOT enough: after the rename
|
|
77
|
+
// the repo has no bin/csg.js, so the global `csg` symlink dangles and shells
|
|
78
|
+
// skip it — but the package entry in node_modules is still there (often as an
|
|
79
|
+
// npm-link). lstat sees the entry even when the symlink target is gone.
|
|
80
|
+
export function defaultCsgPackagePath({
|
|
81
|
+
runner = defaultRunner,
|
|
82
|
+
execPathRoots = [join(dirname(dirname(process.execPath)), 'lib', 'node_modules')],
|
|
83
|
+
} = {}) {
|
|
84
|
+
const roots = [];
|
|
85
|
+
const r = runner('npm', ['root', '-g']);
|
|
86
|
+
if (!r.error && r.status === 0 && r.stdout) roots.push(String(r.stdout).trim());
|
|
87
|
+
roots.push(...execPathRoots); // nvm/unix prefix layout fallback when npm is slow/absent
|
|
88
|
+
for (const root of roots) {
|
|
89
|
+
if (!root) continue;
|
|
90
|
+
const candidate = join(root, 'claude-session-guard');
|
|
91
|
+
try { lstatSync(candidate); return candidate; } catch { /* not here */ }
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function defaultHookInstalled(settingsPath = CLAUDE_SETTINGS) {
|
|
97
|
+
try {
|
|
98
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
99
|
+
return (settings.hooks?.StopFailure || []).some(entry =>
|
|
100
|
+
(entry.hooks || []).some(h => /unsnooze\.js"? _hook-stopfailure/.test(h.command || '')));
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function defaultWrappersInstalled() {
|
|
107
|
+
return [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')].some(rc => {
|
|
108
|
+
try { return readFileSync(rc, 'utf-8').includes('# >>> unsnooze >>>'); }
|
|
109
|
+
catch { return false; }
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function daemonRunning() {
|
|
114
|
+
try {
|
|
115
|
+
const pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10);
|
|
116
|
+
if (!Number.isFinite(pid)) return false;
|
|
117
|
+
process.kill(pid, 0);
|
|
118
|
+
return true;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- report ---------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run every check. Findings: { id, kind: 'legacy'|'health'|'info', title,
|
|
128
|
+
* detail, fix? } — `fix` is a machine-actionable descriptor consumed by
|
|
129
|
+
* applyFixes. healthy === no legacy and no health findings.
|
|
130
|
+
*/
|
|
131
|
+
export async function runDoctor({
|
|
132
|
+
runner = defaultRunner,
|
|
133
|
+
platform = process.platform,
|
|
134
|
+
launchAgentsDir = defaultLaunchAgentsDir(platform),
|
|
135
|
+
csgStateDir = join(homedir(), '.claude-session-guard'),
|
|
136
|
+
csgBinPath = defaultCsgPackagePath({ runner }),
|
|
137
|
+
mux = getMultiplexer(),
|
|
138
|
+
hookInstalled = defaultHookInstalled,
|
|
139
|
+
wrappersInstalled = defaultWrappersInstalled,
|
|
140
|
+
} = {}) {
|
|
141
|
+
const findings = [];
|
|
142
|
+
|
|
143
|
+
const procs = findCsgProcesses({ runner });
|
|
144
|
+
if (procs.length) {
|
|
145
|
+
findings.push({
|
|
146
|
+
id: 'csg-processes', kind: 'legacy',
|
|
147
|
+
title: `${procs.length} old csg process(es) still running`,
|
|
148
|
+
detail: procs.map(p => ` pid ${p.pid}: ${p.command}`).join('\n'),
|
|
149
|
+
fix: { action: 'kill-processes', pids: procs.map(p => p.pid) },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const units = findCsgAutostarts({ dir: launchAgentsDir });
|
|
154
|
+
if (units.length) {
|
|
155
|
+
findings.push({
|
|
156
|
+
id: 'csg-autostart', kind: 'legacy',
|
|
157
|
+
title: 'old csg daemon autostart unit(s) present',
|
|
158
|
+
detail: units.map(u => ` ${u}`).join('\n'),
|
|
159
|
+
fix: { action: 'remove-units', units, platform },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (existsSync(csgStateDir)) {
|
|
164
|
+
findings.push({
|
|
165
|
+
id: 'csg-state-dir', kind: 'legacy',
|
|
166
|
+
title: `old csg state dir at ${csgStateDir}`,
|
|
167
|
+
detail: ' contains the pre-rename ledger/logs; superseded by ~/.unsnooze',
|
|
168
|
+
fix: { action: 'archive-dir', dir: csgStateDir },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (csgBinPath) {
|
|
173
|
+
findings.push({
|
|
174
|
+
id: 'csg-package', kind: 'legacy',
|
|
175
|
+
title: `old claude-session-guard package still installed (${csgBinPath})`,
|
|
176
|
+
detail: ' run: npm rm -g claude-session-guard',
|
|
177
|
+
// npm owns the package (it may even be an npm-link to a dev repo) —
|
|
178
|
+
// doctor only ever hints, never runs npm.
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!hookInstalled()) {
|
|
183
|
+
findings.push({
|
|
184
|
+
id: 'hook-missing', kind: 'health',
|
|
185
|
+
title: 'Claude StopFailure hook is not installed',
|
|
186
|
+
detail: ' run: unsnooze install --yes',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (!wrappersInstalled()) {
|
|
190
|
+
findings.push({
|
|
191
|
+
id: 'wrappers-missing', kind: 'health',
|
|
192
|
+
title: 'shell wrappers are not installed',
|
|
193
|
+
detail: ' run: unsnooze install --yes (then: exec $SHELL)',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
let muxOk = false;
|
|
197
|
+
try { muxOk = !!mux.available(); } catch { muxOk = false; }
|
|
198
|
+
if (!muxOk) {
|
|
199
|
+
findings.push({
|
|
200
|
+
id: 'mux-missing', kind: 'health',
|
|
201
|
+
title: `${mux.name || 'tmux'} is not installed — limit-watch and revival are off`,
|
|
202
|
+
detail: ` install ${mux.name || 'tmux'} to enable auto-resume`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
findings.push({
|
|
207
|
+
id: 'daemon', kind: 'info',
|
|
208
|
+
title: daemonRunning() ? 'resumer/daemon: running' : 'resumer/daemon: not running (starts on the next limit stop)',
|
|
209
|
+
detail: '',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const healthy = findings.every(f => f.kind === 'info');
|
|
213
|
+
return { findings, healthy };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- fixes ------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function defaultKill(pid) {
|
|
219
|
+
try { process.kill(pid, 'SIGTERM'); return true; } catch { return false; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Apply the machine-actionable fixes from a runDoctor report. */
|
|
223
|
+
export async function applyFixes(report, {
|
|
224
|
+
kill = defaultKill,
|
|
225
|
+
runner = defaultRunner,
|
|
226
|
+
now = Date.now(),
|
|
227
|
+
} = {}) {
|
|
228
|
+
const actions = [];
|
|
229
|
+
for (const finding of report.findings) {
|
|
230
|
+
const fix = finding.fix;
|
|
231
|
+
if (!fix) continue;
|
|
232
|
+
if (fix.action === 'kill-processes') {
|
|
233
|
+
for (const pid of fix.pids) {
|
|
234
|
+
kill(pid);
|
|
235
|
+
actions.push({ action: 'killed', pid });
|
|
236
|
+
log(`doctor: SIGTERM csg process ${pid}`);
|
|
237
|
+
}
|
|
238
|
+
} else if (fix.action === 'remove-units') {
|
|
239
|
+
for (const unit of fix.units) {
|
|
240
|
+
if (fix.platform === 'darwin') runner('launchctl', ['unload', unit]);
|
|
241
|
+
else runner('systemctl', ['--user', 'disable', '--now', unit]);
|
|
242
|
+
try { unlinkSync(unit); } catch { /* already gone */ }
|
|
243
|
+
actions.push({ action: 'removed-unit', unit });
|
|
244
|
+
log(`doctor: removed csg autostart ${unit}`);
|
|
245
|
+
}
|
|
246
|
+
} else if (fix.action === 'archive-dir') {
|
|
247
|
+
let target = `${fix.dir}.bak`;
|
|
248
|
+
if (existsSync(target)) target = `${fix.dir}.bak.${now}`;
|
|
249
|
+
try {
|
|
250
|
+
renameSync(fix.dir, target);
|
|
251
|
+
actions.push({ action: 'archived', from: fix.dir, to: target });
|
|
252
|
+
log(`doctor: archived ${fix.dir} → ${target}`);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
actions.push({ action: 'error', detail: `archive ${fix.dir}: ${err.message}` });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return actions;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- CLI --------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
export async function cmdDoctor(rest = [], deps = {}) {
|
|
264
|
+
const { print = console.log, ...checkDeps } = deps;
|
|
265
|
+
const wantFix = rest.includes('--fix');
|
|
266
|
+
const report = await runDoctor(checkDeps);
|
|
267
|
+
|
|
268
|
+
const legacy = report.findings.filter(f => f.kind === 'legacy');
|
|
269
|
+
const health = report.findings.filter(f => f.kind === 'health');
|
|
270
|
+
const info = report.findings.filter(f => f.kind === 'info');
|
|
271
|
+
|
|
272
|
+
if (legacy.length) {
|
|
273
|
+
print('unsnooze doctor — leftovers from the old claude-session-guard (csg) install:');
|
|
274
|
+
for (const f of legacy) {
|
|
275
|
+
print(` ✗ ${f.title}`);
|
|
276
|
+
if (f.detail) print(f.detail);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (health.length) {
|
|
280
|
+
print('unsnooze doctor — install health:');
|
|
281
|
+
for (const f of health) {
|
|
282
|
+
print(` ✗ ${f.title}`);
|
|
283
|
+
if (f.detail) print(f.detail);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const f of info) print(` · ${f.title}`);
|
|
287
|
+
|
|
288
|
+
if (report.healthy) {
|
|
289
|
+
print('unsnooze doctor: all clear — install is healthy.');
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (wantFix) {
|
|
294
|
+
const actions = await applyFixes(report, deps);
|
|
295
|
+
for (const a of actions) {
|
|
296
|
+
if (a.action === 'killed') print(` ✓ stopped csg process ${a.pid}`);
|
|
297
|
+
else if (a.action === 'removed-unit') print(` ✓ removed ${a.unit}`);
|
|
298
|
+
else if (a.action === 'archived') print(` ✓ archived ${a.from} → ${a.to}`);
|
|
299
|
+
else if (a.action === 'error') print(` ! ${a.detail}`);
|
|
300
|
+
}
|
|
301
|
+
const pkg = legacy.find(f => f.id === 'csg-package');
|
|
302
|
+
if (pkg) print(' → finish up manually: npm rm -g claude-session-guard');
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
print(legacy.length
|
|
307
|
+
? '\nRun `unsnooze doctor --fix` to stop csg processes, remove its autostart, and archive its state.'
|
|
308
|
+
: '\nSee the hints above to finish the install.');
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
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();
|