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/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({ nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN } = {}) {
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
- Restart=on-failure
180
- RestartSec=10
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
- copyFileSync(opts.settings, `${opts.settings}.unsnooze-bak`);
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)) copyFileSync(rc, `${rc}.unsnooze-bak`);
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