unsnooze 1.10.0 → 1.12.2
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 +143 -0
- package/README.md +49 -0
- package/bin/unsnooze.js +151 -16
- package/package.json +1 -1
- package/src/cli.js +71 -0
- package/src/doctor.js +310 -0
- package/src/install.js +74 -5
- package/src/launcher.js +5 -0
- package/src/lease.js +22 -0
- package/src/logger.js +40 -1
- package/src/monitor.js +19 -1
- package/src/multiplexers/tmux.js +61 -2
- package/src/multiplexers/zellij.js +20 -3
- package/src/notify-ntfy.js +76 -0
- package/src/notify.js +9 -0
- package/src/reap.js +43 -2
- package/src/resumer.js +289 -72
- package/src/settings.js +13 -0
- package/src/state.js +39 -9
- package/src/time-parser.js +31 -15
- package/src/update-check.js +34 -0
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/install.js
CHANGED
|
@@ -16,6 +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 { findCsgProcesses, findCsgAutostarts } from './doctor.js';
|
|
19
20
|
import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
|
|
20
21
|
|
|
21
22
|
const FENCE_OPEN = '# >>> unsnooze >>>';
|
|
@@ -47,6 +48,15 @@ function atomicWrite(path, content) {
|
|
|
47
48
|
renameSync(tmp, path);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// Two backup tiers: `.unsnooze-orig` is the pristine pre-unsnooze snapshot,
|
|
52
|
+
// written exactly once (a re-run must never overwrite it with an already-
|
|
53
|
+
// modified file); `.unsnooze-bak` rolls forward on every run.
|
|
54
|
+
function backupOnce(path) {
|
|
55
|
+
const orig = `${path}.unsnooze-orig`;
|
|
56
|
+
if (!existsSync(orig)) copyFileSync(path, orig);
|
|
57
|
+
copyFileSync(path, `${path}.unsnooze-bak`);
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
// --- settings.json hook management ---
|
|
51
61
|
|
|
52
62
|
function isOurs(entry) {
|
|
@@ -149,6 +159,7 @@ function autostartDir(platform) {
|
|
|
149
159
|
export function launchdPlist({
|
|
150
160
|
nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN,
|
|
151
161
|
logFile = join(STATE_DIR, 'daemon.log'),
|
|
162
|
+
path = process.env.PATH || '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin',
|
|
152
163
|
} = {}) {
|
|
153
164
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
154
165
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -161,8 +172,18 @@ export function launchdPlist({
|
|
|
161
172
|
<string>${xmlEscape(unsnoozeBin)}</string>
|
|
162
173
|
<string>daemon</string>
|
|
163
174
|
</array>
|
|
175
|
+
<!-- launchd default PATH is /usr/bin:/bin:/usr/sbin:/sbin — tmux (usually
|
|
176
|
+
/opt/homebrew/bin or /usr/local/bin) is invisible to the daemon and
|
|
177
|
+
every revival dies with spawn ENOENT. Bake the install-time PATH. -->
|
|
178
|
+
<key>EnvironmentVariables</key>
|
|
179
|
+
<dict>
|
|
180
|
+
<key>PATH</key><string>${xmlEscape(path)}</string>
|
|
181
|
+
</dict>
|
|
164
182
|
<key>RunAtLoad</key><true/>
|
|
165
183
|
<key>KeepAlive</key><true/>
|
|
184
|
+
<!-- Without a throttle, KeepAlive + a half-installed package (npm -g
|
|
185
|
+
upgrade window) = instant-respawn crash-loop into the log. -->
|
|
186
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
166
187
|
<key>StandardOutPath</key><string>${xmlEscape(logFile)}</string>
|
|
167
188
|
<key>StandardErrorPath</key><string>${xmlEscape(logFile)}</string>
|
|
168
189
|
</dict>
|
|
@@ -170,14 +191,26 @@ export function launchdPlist({
|
|
|
170
191
|
`;
|
|
171
192
|
}
|
|
172
193
|
|
|
173
|
-
export function systemdUnit({
|
|
194
|
+
export function systemdUnit({
|
|
195
|
+
nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN,
|
|
196
|
+
path = process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
197
|
+
} = {}) {
|
|
198
|
+
// Restart=always, not on-failure: the version-skew guard and the
|
|
199
|
+
// upgrade-window fail-safe exit 0 EXPECTING a respawn on fresh code.
|
|
200
|
+
// RestartSec throttles (like launchd's ThrottleInterval); the start
|
|
201
|
+
// rate-limit is disabled so a long broken-install window can never trip
|
|
202
|
+
// the unit into a permanent 'failed' state. Environment PATH mirrors the
|
|
203
|
+
// launchd fix — user units get a minimal PATH that may not find tmux.
|
|
204
|
+
// systemd treats % as a specifier — escape as %%.
|
|
174
205
|
return `[Unit]
|
|
175
206
|
Description=unsnooze daemon — watches GUI AI-coding sessions for limit stops
|
|
207
|
+
StartLimitIntervalSec=0
|
|
176
208
|
|
|
177
209
|
[Service]
|
|
178
210
|
ExecStart="${nodeBin}" "${unsnoozeBin}" daemon
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
Environment="PATH=${path.replace(/%/g, '%%')}"
|
|
212
|
+
Restart=always
|
|
213
|
+
RestartSec=30
|
|
181
214
|
|
|
182
215
|
[Install]
|
|
183
216
|
WantedBy=default.target
|
|
@@ -211,6 +244,28 @@ export function installDaemonAutostart({ platform = process.platform, dir = null
|
|
|
211
244
|
return null; // native Windows: no supported multiplexer to revive into
|
|
212
245
|
}
|
|
213
246
|
|
|
247
|
+
// One-time self-heal for units written before 1.12.0: they carry no PATH, so
|
|
248
|
+
// the daemon cannot find tmux (launchd gives daemons /usr/bin:/bin:… only) and
|
|
249
|
+
// every revival dies with spawn ENOENT. npm updates never touch the unit file,
|
|
250
|
+
// so the daemon repairs it on startup: regenerate + reload. Reloading kills
|
|
251
|
+
// the calling daemon — by design; the supervisor restarts it under the fixed
|
|
252
|
+
// unit. Returns the healed target, or null when nothing needed healing.
|
|
253
|
+
export function healDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
|
254
|
+
const marker = platform === 'darwin' ? 'EnvironmentVariables' : 'Environment="PATH=';
|
|
255
|
+
const target = platform === 'darwin'
|
|
256
|
+
? join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`)
|
|
257
|
+
: platform === 'linux'
|
|
258
|
+
? join(dir || autostartDir(platform), 'unsnooze.service')
|
|
259
|
+
: null;
|
|
260
|
+
if (!target || !existsSync(target)) return null; // not a daemon-autostart user
|
|
261
|
+
try {
|
|
262
|
+
if (readFileSync(target, 'utf-8').includes(marker)) return null; // already current
|
|
263
|
+
} catch {
|
|
264
|
+
return null; // unreadable — leave it alone
|
|
265
|
+
}
|
|
266
|
+
return installDaemonAutostart({ platform, dir, activate });
|
|
267
|
+
}
|
|
268
|
+
|
|
214
269
|
export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
|
215
270
|
if (platform === 'darwin') {
|
|
216
271
|
const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
|
|
@@ -287,7 +342,7 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
287
342
|
// when installed below).
|
|
288
343
|
if (agents.includes('claude')) {
|
|
289
344
|
if (existsSync(opts.settings)) {
|
|
290
|
-
|
|
345
|
+
backupOnce(opts.settings);
|
|
291
346
|
const before = readFileSync(opts.settings, 'utf-8');
|
|
292
347
|
atomicWrite(opts.settings, mergeHookIntoSettings(before));
|
|
293
348
|
console.log(`unsnooze: StopFailure hook installed in ${opts.settings} (backup: ${opts.settings}.unsnooze-bak)`);
|
|
@@ -319,7 +374,7 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
319
374
|
console.log(`unsnooze: "${OLD_FENCE_OPEN}" block manually first.`);
|
|
320
375
|
return 1;
|
|
321
376
|
}
|
|
322
|
-
if (existsSync(rc))
|
|
377
|
+
if (existsSync(rc)) backupOnce(rc);
|
|
323
378
|
const { content, oldRemoved } = installZshrcBlock(rcContent, agents);
|
|
324
379
|
atomicWrite(rc, content);
|
|
325
380
|
console.log(`unsnooze: wrappers (${agents.join(', ')}) installed in ${rc}${oldRemoved ? ' (legacy wrapper block removed)' : ''}`);
|
|
@@ -332,6 +387,20 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
332
387
|
else console.log('unsnooze: daemon autostart is not supported on this platform');
|
|
333
388
|
}
|
|
334
389
|
|
|
390
|
+
// 5. Migration sweep: an upgrade from the pre-release csg leaves its
|
|
391
|
+
// monitors/daemon/state behind (npm cannot clean up a renamed package) —
|
|
392
|
+
// surface it right where upgraders will see it. Detection only; the
|
|
393
|
+
// actual retirement is `unsnooze doctor --fix`, one explicit step away.
|
|
394
|
+
try {
|
|
395
|
+
const legacy = findCsgProcesses().length > 0
|
|
396
|
+
|| findCsgAutostarts().length > 0
|
|
397
|
+
|| existsSync(join(homedir(), '.claude-session-guard'));
|
|
398
|
+
if (legacy) {
|
|
399
|
+
console.log('\nunsnooze: leftovers from the old claude-session-guard (csg) install detected.');
|
|
400
|
+
console.log('unsnooze: run `unsnooze doctor --fix` to stop its processes and retire its files.');
|
|
401
|
+
}
|
|
402
|
+
} catch { /* detection is best-effort — never fail an install over it */ }
|
|
403
|
+
|
|
335
404
|
console.log('\nunsnooze: done. Reload your shell:');
|
|
336
405
|
console.log(' exec $SHELL');
|
|
337
406
|
return 0;
|
package/src/launcher.js
CHANGED
|
@@ -74,6 +74,11 @@ export function runLauncher(args, agentId = 'claude') {
|
|
|
74
74
|
: null;
|
|
75
75
|
const leaseId = process.env.UNSNOOZE_LEASE_ID || createLeaseId();
|
|
76
76
|
if (pane) {
|
|
77
|
+
// Stamp our own pane (best-effort, tmux only): the identity every later
|
|
78
|
+
// close/inject decision verifies against — pane ids get recycled.
|
|
79
|
+
if (typeof mux.stampPaneOwner === 'function') {
|
|
80
|
+
mux.stampPaneOwner(pane, leaseId).catch(() => { /* legacy tmux */ });
|
|
81
|
+
}
|
|
77
82
|
spawnDetached(['_monitor', mux.name, paneOwner || '', pane, agent.id, leaseId],
|
|
78
83
|
{ UNSNOOZE_CWD: process.cwd() });
|
|
79
84
|
log(`launching ${agent.id} in ${mux.name} ${paneOwner ?? '-'}:${pane}, monitor spawned`);
|
package/src/lease.js
CHANGED
|
@@ -85,3 +85,25 @@ export async function leaseMatches(rec, {
|
|
|
85
85
|
if (!birth || birth !== stored.pidBirth) return false;
|
|
86
86
|
try { return await mux.paneAlive(rec.pane); } catch { return false; }
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
// Ownership decision for anything that would TYPE INTO or CLOSE rec.pane.
|
|
90
|
+
// Pane ids are recycled, so proof is required, in two layers:
|
|
91
|
+
// 1. the @unsnooze_owner pane stamp (tmux; survives agent exit, dies with
|
|
92
|
+
// the pane): match → true, MISMATCH → false regardless of the lease —
|
|
93
|
+
// the pane id demonstrably belongs to a different launch now;
|
|
94
|
+
// 2. the lease (pid + birth): proves the leased agent process is alive.
|
|
95
|
+
// Returns true (ours), false (not ours), or null (record has no lease at all
|
|
96
|
+
// and no stamp evidence — legacy/GUI record; the caller applies its own
|
|
97
|
+
// last-resort heuristic).
|
|
98
|
+
export async function paneOwnedByRecord(rec, { mux, matchesLease = leaseMatches } = {}) {
|
|
99
|
+
if (!rec?.pane || !mux) return rec?.leaseId ? false : null;
|
|
100
|
+
let stamp = null;
|
|
101
|
+
if (typeof mux.paneOwnerStamp === 'function') {
|
|
102
|
+
try { stamp = await mux.paneOwnerStamp(rec.pane); } catch { stamp = null; }
|
|
103
|
+
}
|
|
104
|
+
if (stamp != null && stamp !== '') {
|
|
105
|
+
return rec.leaseId != null && stamp === rec.leaseId;
|
|
106
|
+
}
|
|
107
|
+
if (await matchesLease(rec, { mux })) return true;
|
|
108
|
+
return rec.leaseId ? false : null;
|
|
109
|
+
}
|
package/src/logger.js
CHANGED
|
@@ -1,9 +1,47 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, mkdirSync, statSync, renameSync, copyFileSync, truncateSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { LOG_FILE } from './config.js';
|
|
4
4
|
|
|
5
|
+
// Logs must never grow unbounded (a single upgrade-window crash-loop once
|
|
6
|
+
// produced a 10.3 MB daemon.log). One rotated generation is kept: file → .1.
|
|
7
|
+
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
|
|
5
9
|
let dirReady = false;
|
|
6
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Rename `path` to `path.1` when it exceeds maxBytes (replacing any previous
|
|
13
|
+
* .1). Returns true when a rotation happened. Missing files and fs errors are
|
|
14
|
+
* a quiet no-op — rotation must never crash the hook or monitor paths.
|
|
15
|
+
*/
|
|
16
|
+
export function rotateIfLarge(path, maxBytes = LOG_MAX_BYTES) {
|
|
17
|
+
try {
|
|
18
|
+
if (statSync(path).size <= maxBytes) return false;
|
|
19
|
+
renameSync(path, `${path}.1`);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Copy-truncate variant for files another process holds OPEN (launchd's
|
|
28
|
+
* StandardOut/ErrorPath fd on daemon.log): renaming such a file would leave
|
|
29
|
+
* the holder writing to the renamed inode forever, with no fresh file until
|
|
30
|
+
* its next restart. Copying to .1 and truncating in place keeps the inode —
|
|
31
|
+
* the holder's O_APPEND writes continue into the now-empty live file.
|
|
32
|
+
* Same quiet-no-op error contract as rotateIfLarge.
|
|
33
|
+
*/
|
|
34
|
+
export function copyTruncateIfLarge(path, maxBytes = LOG_MAX_BYTES) {
|
|
35
|
+
try {
|
|
36
|
+
if (statSync(path).size <= maxBytes) return false;
|
|
37
|
+
copyFileSync(path, `${path}.1`);
|
|
38
|
+
truncateSync(path, 0);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
7
45
|
export function log(component, message) {
|
|
8
46
|
const line = `${new Date().toISOString()} [${component}] ${message}\n`;
|
|
9
47
|
try {
|
|
@@ -11,6 +49,7 @@ export function log(component, message) {
|
|
|
11
49
|
mkdirSync(dirname(LOG_FILE), { recursive: true });
|
|
12
50
|
dirReady = true;
|
|
13
51
|
}
|
|
52
|
+
rotateIfLarge(LOG_FILE);
|
|
14
53
|
appendFileSync(LOG_FILE, line);
|
|
15
54
|
} catch {
|
|
16
55
|
// Logging must never crash the hook or monitor paths.
|
package/src/monitor.js
CHANGED
|
@@ -25,7 +25,7 @@ import { upsertSession, setStatus, readState, updateState } from './state.js';
|
|
|
25
25
|
import { latestRateLimitFromTranscript } from './watchers/claude.js';
|
|
26
26
|
import { spawnResumerIfNeeded } from './spawn.js';
|
|
27
27
|
import { makeLogger } from './logger.js';
|
|
28
|
-
import { addressHash } from './lease.js';
|
|
28
|
+
import { addressHash, readLease } from './lease.js';
|
|
29
29
|
|
|
30
30
|
const log = makeLogger('monitor');
|
|
31
31
|
|
|
@@ -42,6 +42,7 @@ export function createMonitor({
|
|
|
42
42
|
let terminalNotified = false; // one notification per terminal-error appearance
|
|
43
43
|
let running = true;
|
|
44
44
|
let firstTick = true; // §6: don't inherit a previous session's banner
|
|
45
|
+
let leaseSeen = false; // F4: enforce lease-gone exit only after first sighting
|
|
45
46
|
|
|
46
47
|
function markerPath() {
|
|
47
48
|
return join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`);
|
|
@@ -202,6 +203,22 @@ export function createMonitor({
|
|
|
202
203
|
return;
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// F4: the pane can outlive the agent (in the user's own tmux the pane is
|
|
207
|
+
// their shell). The launcher removes the lease when the agent exits — a
|
|
208
|
+
// vanished lease means there is nothing left to watch, and continuing
|
|
209
|
+
// would scrape (and eventually type into) whatever now runs in the pane.
|
|
210
|
+
// The launcher writes the lease AFTER spawning us, so enforcement starts
|
|
211
|
+
// only once the lease has been seen.
|
|
212
|
+
if (leaseId) {
|
|
213
|
+
const lease = readLease({ mux: muxName, paneOwner, pane }, leaseId);
|
|
214
|
+
if (lease) leaseSeen = true;
|
|
215
|
+
else if (leaseSeen) {
|
|
216
|
+
log(`pane ${pane}: lease ${leaseId} gone — agent exited, monitor exiting`);
|
|
217
|
+
running = false;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
205
222
|
const marker = consumeMarker();
|
|
206
223
|
let text;
|
|
207
224
|
try {
|
|
@@ -326,6 +343,7 @@ export function createMonitor({
|
|
|
326
343
|
_tick: tick, // exposed for tests
|
|
327
344
|
get trackedKey() { return trackedKey; },
|
|
328
345
|
get _firstTick() { return firstTick; },
|
|
346
|
+
get _running() { return running; },
|
|
329
347
|
};
|
|
330
348
|
}
|
|
331
349
|
|