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/resumer.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
+ import { spawnSync } from 'node:child_process';
9
10
  import { getMultiplexer } from './multiplexer.js';
10
11
  import {
11
12
  RESUMER_LOCK, STATE_DIR, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS,
@@ -27,7 +28,7 @@ import { workspaceFingerprint, workspaceChanged, describeChange } from './worksp
27
28
  import { notify } from './notify.js';
28
29
  import { UNSNOOZE_BIN } from './spawn.js';
29
30
  import { makeLogger } from './logger.js';
30
- import { createLeaseId, leaseMatches } from './lease.js';
31
+ import { createLeaseId, leaseMatches, paneOwnedByRecord } from './lease.js';
31
32
  import { autoReapIfEnabled, attachHint } from './reap.js';
32
33
 
33
34
  const log = makeLogger('resumer');
@@ -39,14 +40,46 @@ function pidAlive(pid) {
39
40
  try { process.kill(pid, 0); return true; } catch { return false; }
40
41
  }
41
42
 
42
- export function acquireSingleton() {
43
- mkdirSync(STATE_DIR, { recursive: true });
43
+ // Is the lock-holder pid actually an unsnooze resumer? Pids get recycled: a
44
+ // lock naming a live-but-unrelated program would otherwise be honored forever
45
+ // and stops would never dispatch. Lock acquirers are only ever `_resumer` or
46
+ // `daemon` processes, so require both tokens — a bare /unsnooze/ substring
47
+ // would let any process that merely mentions the name squat the lock.
48
+ // No evidence (empty/failed ps) → true: never steal on doubt.
49
+ export function looksLikeResumerCommand(cmd) {
50
+ const c = String(cmd || '').trim();
51
+ if (c === '') return true;
52
+ return /unsnooze/i.test(c) && /(?:^|\s)(?:_resumer|daemon)(?:\s|$)/.test(c);
53
+ }
54
+
55
+ function defaultIsResumer(pid) {
44
56
  try {
45
- const pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10);
46
- if (Number.isFinite(pid) && pid !== process.pid && pidAlive(pid)) return false;
47
- } catch { /* no lock */ }
48
- writeFileSync(RESUMER_LOCK, String(process.pid));
49
- return true;
57
+ const r = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf-8' });
58
+ if (r.error || r.status !== 0) return true;
59
+ return looksLikeResumerCommand(r.stdout);
60
+ } catch {
61
+ return true;
62
+ }
63
+ }
64
+
65
+ // Atomic acquire: `wx` create wins or loses the race outright — no
66
+ // read-check-write window. A held lock is honored only when its pid is alive
67
+ // AND looks like a resumer; stale/garbage/recycled locks are replaced.
68
+ export function acquireSingleton({ isResumer = defaultIsResumer } = {}) {
69
+ mkdirSync(STATE_DIR, { recursive: true });
70
+ for (let attempt = 0; attempt < 2; attempt++) {
71
+ try {
72
+ writeFileSync(RESUMER_LOCK, String(process.pid), { flag: 'wx' });
73
+ return true;
74
+ } catch { /* lock exists — inspect the holder below */ }
75
+ let pid = NaN;
76
+ try { pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10); }
77
+ catch { /* vanished between wx and read — retry the create */ }
78
+ if (pid === process.pid) return true; // we already hold it
79
+ if (Number.isFinite(pid) && pidAlive(pid) && isResumer(pid)) return false;
80
+ try { unlinkSync(RESUMER_LOCK); } catch { /* raced with another cleaner */ }
81
+ }
82
+ return false; // lost the post-cleanup race to another acquirer
50
83
  }
51
84
 
52
85
  export function releaseSingleton() {
@@ -56,6 +89,23 @@ export function releaseSingleton() {
56
89
  } catch { /* already gone */ }
57
90
  }
58
91
 
92
+ // Exponential backoff between failed resume attempts: 1m, 2m, 4m, 8m … capped
93
+ // at 30m. Without it, a due record whose resetAt already passed retries every
94
+ // poll tick and burns all MAX_RESUME_ATTEMPTS in ~2 minutes (observed).
95
+ // A transient hook-spawned resumer keeps holding the singleton lock through
96
+ // the backoff window — intentional, and consistent with it holding the lock
97
+ // through a multi-hour reset wait (the daemon politely waits either way).
98
+ export function retryBackoffMs(attempts, { baseMs = 60_000, maxMs = 1_800_000 } = {}) {
99
+ return Math.min(baseMs * 2 ** Math.max(0, attempts - 1), maxMs);
100
+ }
101
+
102
+ // Waiting behind a legitimately-held lock is normal (a transient hook-spawned
103
+ // resumer riding out a long reset); logging it every 30s tick for hours is
104
+ // not. First tick, then every 30th (~15 min).
105
+ export function shouldLogLockWait(n) {
106
+ return n === 1 || n % 30 === 0;
107
+ }
108
+
59
109
  // Due sessions that are actually allowed to dispatch: everything when
60
110
  // autoResume is on; only explicitly `resume-now`-marked (manual) records when
61
111
  // it's off. Stops stay tracked either way.
@@ -205,6 +255,179 @@ function rescheduleProbe(rec, now = Date.now()) {
205
255
  return 'probe';
206
256
  }
207
257
 
258
+ // --- shared decision core (dispatchOne acts on it, planFor narrates it) -----
259
+ // These helpers are DECISION-ONLY: no state writes, no notifications, no
260
+ // keystrokes. `unsnooze preview` reuses them verbatim, so the dry-run cannot
261
+ // drift from what dispatch actually does.
262
+
263
+ // Workspace guard: returns the (possibly suffixed) message, or a hold.
264
+ export function evaluateWorkspaceGuard(rec, message, { fingerprint = workspaceFingerprint } = {}) {
265
+ const mode = getConfig('workspaceGuard');
266
+ if (!rec.workspace || mode === 'off' || rec.manual) return { message, hold: null, informed: null };
267
+ const change = workspaceChanged(rec, fingerprint(rec.cwd));
268
+ if (!change) return { message, hold: null, informed: null };
269
+ const desc = describeChange(change);
270
+ if (mode === 'pause') return { message, hold: { reason: `workspace changed (${desc})`, desc }, informed: null };
271
+ return {
272
+ message: message + `\n\nHeads up: this workspace changed while the session was stopped (${desc}). Re-read the current state of the repo before continuing.`,
273
+ hold: null,
274
+ informed: desc,
275
+ };
276
+ }
277
+
278
+ // Context-size guard: returns a hold, an inform note, or neither.
279
+ export function evaluateContextGuard(rec, agent, { contextTokens = null } = {}) {
280
+ const mode = getConfig('contextGuard');
281
+ if (mode === 'off' || rec.manual) return { hold: null, note: null, tokens: null };
282
+ const estimate = contextTokens
283
+ ?? (typeof agent.contextTokens === 'function' ? r => agent.contextTokens(r) : null);
284
+ let tokens = null;
285
+ try { tokens = estimate ? estimate(rec) : null; } catch { tokens = null; }
286
+ if (tokens == null || tokens < getConfig('contextGuardTokens')) return { hold: null, note: null, tokens };
287
+ const size = approxTokens(tokens);
288
+ if (mode === 'pause') return { hold: { reason: `context ${size} tokens`, size }, note: null, tokens };
289
+ return {
290
+ hold: null,
291
+ note: `${rec.cwd}: waking a ${size}-token session — its full context is re-read at full (uncached) price`,
292
+ tokens,
293
+ };
294
+ }
295
+
296
+ // Read-only live-pane classification: liveness, busy/menu/idle content, and
297
+ // the identity/liveness ownership triad. Never sends anything.
298
+ export async function assessPane(rec, agent, { mux, matchesLease = leaseMatches } = {}) {
299
+ if (!rec.pane) return { alive: false };
300
+ let alive = false;
301
+ try { alive = await mux.paneAlive(rec.pane); } catch { alive = false; }
302
+ if (!alive) return { alive: false };
303
+ let text;
304
+ try { text = await mux.capturePane(rec.pane, CAPTURE_LINES); }
305
+ catch (err) { return { alive: true, captureError: err.message }; }
306
+ const busy = isBusy(text, agent.patterns.busyPatterns);
307
+ const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
308
+ const menu = !!(agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES));
309
+ let cmd = null;
310
+ try { cmd = await mux.paneCurrentCommand?.(rec.pane); }
311
+ catch (err) { log(`${rec.key}: pane command lookup failed: ${err.message}`); }
312
+ const contentOwned = menu || d.hit || agent.patterns.idleRegex.test(text);
313
+ // Two independent questions, both required before touching the pane:
314
+ // identity — is this pane still OURS? (stamp/lease; false VETOES)
315
+ // liveness — is our agent still RUNNING in it? (lease pid+birth, or the
316
+ // foreground command; the stamp alone never counts — it outlives the
317
+ // agent and the pane may now be the user's shell.)
318
+ const identity = await paneOwnedByRecord(rec, { mux, matchesLease });
319
+ const leased = await matchesLease(rec, { mux });
320
+ const owned = identity !== false && (leased || agent.isForegroundCommand(cmd));
321
+ const authorized = owned && contentOwned;
322
+ return { alive: true, text, busy, d, menu, cmd, contentOwned, identity, leased, owned, authorized };
323
+ }
324
+
325
+ // Dry-run: what WOULD dispatch do with this record right now, and why.
326
+ // Read-only pane captures only — never mutates state, types, or opens panes.
327
+ export async function planFor(rec, {
328
+ mux = resolveRecordMux(rec), matchesLease = leaseMatches,
329
+ fingerprint = workspaceFingerprint, contextTokens = null, now = Date.now(),
330
+ } = {}) {
331
+ const agent = getAgent(rec.agent);
332
+ const gates = [];
333
+ const base = { key: rec.key, sessionId: rec.sessionId, agent: agent.id, cwd: rec.cwd, status: rec.status, gates };
334
+
335
+ if (rec.status !== 'stopped') {
336
+ if (rec.status === 'resuming') {
337
+ gates.push('resume in flight — the next tick verifies the outcome');
338
+ return { ...base, action: 'verifying' };
339
+ }
340
+ gates.push(`status ${rec.status} — nothing to dispatch`);
341
+ return { ...base, action: 'none' };
342
+ }
343
+ // Gate order mirrors runResumer exactly: give-up only ever happens to
344
+ // records that pass dueForDispatch (due AND auto-or-manual AND not held),
345
+ // so paused/held/backing-off records are narrated as such even when their
346
+ // attempt counter is maxed — dispatch is genuinely still waiting on them.
347
+ if (!getConfig('autoResume') && !rec.manual) {
348
+ gates.push('paused — autoResume is off (`unsnooze resume-now` overrides)');
349
+ return { ...base, action: 'paused' };
350
+ }
351
+ if (rec.workspaceHold && !rec.manual) {
352
+ gates.push(`held: ${rec.holdReason ?? 'guard hold'} — \`unsnooze resume-now\` wakes it`);
353
+ return { ...base, action: 'held' };
354
+ }
355
+ const isProbe = rec.resetSource === 'fallback' && !rec.manual;
356
+ if ((rec.resetAt || 0) > now) {
357
+ gates.push(`not due — ${isProbe ? 'next probe' : 'resets'} ${new Date(rec.resetAt).toLocaleString()}`);
358
+ if (isProbe) gates.push('reset time unknown — cheap pane probes stand in for a schedule');
359
+ return { ...base, action: isProbe ? 'probe' : 'waiting', at: rec.resetAt };
360
+ }
361
+ if ((rec.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
362
+ gates.push(`attempts ${rec.attempts}/${MAX_RESUME_ATTEMPTS} exhausted — would be marked failed (re-arms on a fresh detection)`);
363
+ return { ...base, action: 'give-up' };
364
+ }
365
+ if (isProbe) {
366
+ // Past the hard ceiling, rescheduleProbe deterministically stops probing
367
+ // and dispatch falls through to a REAL wake — narrate that, not a probe.
368
+ const ceiling = (rec.detectedAt || now) + FALLBACK_RESET_MS + RESET_MARGIN_MS;
369
+ if (now < ceiling) {
370
+ gates.push('reset time unknown — would probe the pane/transcript before any resume (a cleared banner resumes immediately)');
371
+ return { ...base, action: 'probe', at: rec.resetAt };
372
+ }
373
+ gates.push('probe ceiling reached — probing is over, a real resume follows');
374
+ }
375
+
376
+ // Same message precedence and guard evaluation dispatch uses.
377
+ let message = rec.resumeMessage ?? resolveResumeMessage(agent.id);
378
+ const ws = evaluateWorkspaceGuard(rec, message, { fingerprint });
379
+ if (ws.hold) {
380
+ gates.push(`would hold: ${ws.hold.reason} (workspaceGuard=pause)`);
381
+ return { ...base, action: 'held' };
382
+ }
383
+ message = ws.message;
384
+ if (ws.informed) gates.push(`workspace changed (${ws.informed}) — the wake message warns the agent`);
385
+ const ctx = evaluateContextGuard(rec, agent, { contextTokens });
386
+ if (ctx.hold) {
387
+ gates.push(`would hold: ${ctx.hold.reason} (contextGuard=pause)`);
388
+ return { ...base, action: 'held' };
389
+ }
390
+ if (ctx.note) gates.push(`big-context wake: ~${approxTokens(ctx.tokens)} tokens re-read at full (uncached) price`);
391
+
392
+ // Same live-pane classification dispatch uses.
393
+ const a = await assessPane(rec, agent, { mux, matchesLease });
394
+ if (a.alive) {
395
+ if (a.captureError) {
396
+ gates.push(`pane capture failed (${a.captureError}) — dispatch would record the error and retry with backoff`);
397
+ return { ...base, action: 'retry', target: { pane: rec.pane } };
398
+ }
399
+ if (a.busy) {
400
+ gates.push('agent is mid-work (busy footer) — dispatch defers, never interrupts');
401
+ return { ...base, action: 'busy', target: { pane: rec.pane } };
402
+ }
403
+ if (a.menu && a.authorized) {
404
+ if (!getConfig('menuAutoAnswer')) {
405
+ gates.push('limit menu on screen but menuAutoAnswer is off — would wait for you');
406
+ return { ...base, action: 'menu-held', target: { pane: rec.pane } };
407
+ }
408
+ return { ...base, action: 'drive-menu', target: { pane: rec.pane } };
409
+ }
410
+ if (a.authorized) return { ...base, action: 'inject', target: { pane: rec.pane }, message };
411
+ if (a.owned && !a.menu) {
412
+ gates.push('pane is ours but not at an idle prompt — dispatch defers');
413
+ return { ...base, action: 'busy', target: { pane: rec.pane } };
414
+ }
415
+ if (a.identity === false) gates.push('pane id was recycled — demonstrably not ours anymore');
416
+ else gates.push('pane content/ownership unproven — no keystrokes allowed');
417
+ } else if (rec.pane) {
418
+ gates.push('original pane is gone');
419
+ } else {
420
+ gates.push('no pane recorded (GUI/transcript detection)');
421
+ }
422
+
423
+ const target = await reviveTarget(mux, rec);
424
+ const resume = agent.resumeArgs(rec.sessionId, message);
425
+ return {
426
+ ...base, action: 'reopen', target: { session: target }, message,
427
+ argv: [agent.id, ...resume.args], messageViaPane: !!resume.messageViaPane,
428
+ };
429
+ }
430
+
208
431
  // Ordered safety decision. Returns: busy | retry | progress | held | injected | reopen | probe.
209
432
  export async function dispatchOne(rec, {
210
433
  mux = resolveRecordMux(rec), resolveMux = null,
@@ -230,92 +453,66 @@ export async function dispatchOne(rec, {
230
453
 
231
454
  // Stale-workspace guard: another session (or a human) may have moved the
232
455
  // repo while this one slept. Manual resumes (resume-now) always proceed.
233
- const guardMode = getConfig('workspaceGuard');
234
- if (rec.workspace && guardMode !== 'off' && !rec.manual) {
235
- const change = workspaceChanged(rec, fingerprint(rec.cwd));
236
- if (change) {
237
- const desc = describeChange(change);
238
- if (guardMode === 'pause') {
239
- setStatus(key, 'stopped', { workspaceHold: true, holdReason: `workspace changed (${desc})` });
240
- notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`, { context: ctxOf(rec) });
241
- log(`${key}: workspace changed (${desc}) — held (workspaceGuard=pause)`);
242
- return 'held';
243
- }
244
- resumeMessage += `\n\nHeads up: this workspace changed while the session was stopped (${desc}). Re-read the current state of the repo before continuing.`;
245
- log(`${key}: workspace changed (${desc}) — informing agent in the wake message`);
246
- }
456
+ // Decision comes from the shared evaluator (planFor uses the same one);
457
+ // dispatch owns the side effects.
458
+ const ws = evaluateWorkspaceGuard(rec, resumeMessage, { fingerprint });
459
+ if (ws.hold) {
460
+ setStatus(key, 'stopped', { workspaceHold: true, holdReason: ws.hold.reason });
461
+ notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${ws.hold.desc}) — run: unsnooze resume-now`, { context: ctxOf(rec) });
462
+ log(`${key}: workspace changed (${ws.hold.desc}) — held (workspaceGuard=pause)`);
463
+ return 'held';
247
464
  }
465
+ if (ws.informed) log(`${key}: workspace changed (${ws.informed}) — informing agent in the wake message`);
466
+ resumeMessage = ws.message;
248
467
 
249
468
  // Context-size guard: the prompt cache expired hours ago, so the first wake
250
469
  // message re-reads the session's entire context at full (uncached) price.
251
- // Estimate the size from the agent's transcript; `pause` holds big sessions,
252
- // `inform` notifies the user — but only once the wake actually lands, since
253
- // dispatchOne re-runs on every busy/retry tick. Manual resumes proceed.
254
- let contextNote = null;
255
- const ctxGuardMode = getConfig('contextGuard');
256
- if (ctxGuardMode !== 'off' && !rec.manual) {
257
- const estimate = contextTokens
258
- ?? (typeof agent.contextTokens === 'function' ? r => agent.contextTokens(r) : null);
259
- let tokens = null;
260
- try { tokens = estimate ? estimate(rec) : null; } catch { /* unknown size — skip */ }
261
- if (tokens != null && tokens >= getConfig('contextGuardTokens')) {
262
- const size = approxTokens(tokens);
263
- if (ctxGuardMode === 'pause') {
264
- setStatus(key, 'stopped', { workspaceHold: true, holdReason: `context ${size} tokens` });
265
- notifier('unsnooze: session held', `${rec.cwd}: waking would re-read ${size} tokens of context at full (uncached) price — run: unsnooze resume-now`, { context: ctxOf(rec) });
266
- log(`${key}: context ${size} tokens ≥ threshold — held (contextGuard=pause)`);
267
- return 'held';
268
- }
269
- contextNote = `${rec.cwd}: waking a ${size}-token session — its full context is re-read at full (uncached) price`;
270
- log(`${key}: context ${size} tokens — informing (contextGuard=inform)`);
271
- }
470
+ // `pause` holds big sessions, `inform` notifies the user — but only once
471
+ // the wake actually lands, since dispatchOne re-runs on every busy/retry
472
+ // tick. Manual resumes proceed.
473
+ const ctx = evaluateContextGuard(rec, agent, { contextTokens });
474
+ if (ctx.hold) {
475
+ setStatus(key, 'stopped', { workspaceHold: true, holdReason: ctx.hold.reason });
476
+ notifier('unsnooze: session held', `${rec.cwd}: waking would re-read ${ctx.hold.size} tokens of context at full (uncached) price — run: unsnooze resume-now`, { context: ctxOf(rec) });
477
+ log(`${key}: context ${ctx.hold.size} tokens ≥ threshold — held (contextGuard=pause)`);
478
+ return 'held';
272
479
  }
480
+ const contextNote = ctx.note;
481
+ if (contextNote) log(`${key}: context ${approxTokens(ctx.tokens)} tokens — informing (contextGuard=inform)`);
273
482
  const notifyContext = () => {
274
483
  if (contextNote) notifier('unsnooze: big-context wake', contextNote, { context: ctxOf(rec) });
275
484
  };
276
485
  const reopenGuarded = () =>
277
486
  reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onDelivered: notifyContext });
278
487
 
279
- // Live-pane path: only if the pane still exists AND the agent CLI is its
280
- // foreground command (pane ids get recycled — never inject into a random
281
- // program).
282
- if (rec.pane && await mux.paneAlive(rec.pane)) {
283
- let text;
284
- try { text = await mux.capturePane(rec.pane, CAPTURE_LINES); }
285
- catch (err) {
286
- setStatus(key, 'stopped', { lastError: `capture: ${err.message}` });
488
+ // Live-pane path: classification comes from the shared assessPane (planFor
489
+ // narrates the identical assessment); dispatch owns every side effect.
490
+ const a = await assessPane(rec, agent, { mux, matchesLease });
491
+ if (a.alive) {
492
+ if (a.captureError) {
493
+ setStatus(key, 'stopped', { lastError: `capture: ${a.captureError}` });
287
494
  return 'retry';
288
495
  }
289
- if (isBusy(text, agent.patterns.busyPatterns)) return 'busy';
290
- const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
291
- const menu = !!(agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES));
292
- let cmd = null;
293
- try { cmd = await mux.paneCurrentCommand(rec.pane); }
294
- catch (err) { log(`${key}: pane command lookup failed: ${err.message}`); }
295
- const contentOwned = menu || d.hit || agent.patterns.idleRegex.test(text);
296
- const leased = await matchesLease(rec, { mux });
297
- const owned = leased || agent.isForegroundCommand(cmd);
298
- const authorized = owned && contentOwned;
299
-
300
- if (menu) {
301
- if (!authorized) return reopenGuarded();
496
+ if (a.busy) return 'busy';
497
+ if (a.menu) {
498
+ if (!a.authorized) return reopenGuarded();
302
499
  if (!getConfig('menuAutoAnswer')) return 'held';
303
500
  try {
304
- if (await driveMenu(mux, rec.pane, agent, text)) return 'progress';
501
+ if (await driveMenu(mux, rec.pane, agent, a.text)) return 'progress';
305
502
  setStatus(key, 'stopped', { lastError: 'menu drive: wait option unavailable' });
306
503
  } catch (err) {
307
504
  setStatus(key, 'stopped', { lastError: `menu drive: ${err.message}` });
308
505
  }
309
506
  return 'retry';
310
507
  }
311
- if (authorized) {
508
+ if (a.authorized) {
312
509
  setStatus(key, 'resuming', { lastAttemptAt: Date.now(), lastError: null, verifyRetries: 0 });
313
510
  await mux.sendText(rec.pane, resumeMessage);
314
511
  log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
315
512
  notifyContext();
316
513
  return 'injected';
317
514
  }
318
- if (owned) return 'busy';
515
+ if (a.owned) return 'busy';
319
516
  }
320
517
 
321
518
  return reopenGuarded();
@@ -337,6 +534,10 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
337
534
  try {
338
535
  address = await mux.newWindow(target, rec.cwd || homedir(), launchSpec);
339
536
  } catch (err) {
537
+ // Log it — a revival that cannot even spawn the multiplexer (e.g. tmux
538
+ // ENOENT under launchd's bare PATH) otherwise fails 5 times in total
539
+ // silence, with the only evidence buried in a soon-swept record.
540
+ log(`${key}: new-window failed in session "${target}": ${err.message}`);
340
541
  setStatus(key, 'stopped', {
341
542
  attempts: (rec.attempts || 0) + 1,
342
543
  lastError: `new-window: ${err.message}`,
@@ -344,6 +545,11 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
344
545
  });
345
546
  return 'retry';
346
547
  }
548
+ // Stamp the fresh pane as ours (best-effort; tmux only) so later close /
549
+ // inject decisions can prove identity even after the agent process exits.
550
+ if (address?.pane && typeof mux.stampPaneOwner === 'function') {
551
+ try { await mux.stampPaneOwner(address.pane, leaseId); } catch { /* legacy tmux */ }
552
+ }
347
553
  // Persist the revival target as muxSession so future joins and attach hints
348
554
  // name the session the pane actually lives in.
349
555
  updateState(state => {
@@ -388,8 +594,11 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
388
594
  function recordVerifyRetry(rec, lastError) {
389
595
  const verifyRetries = (rec.verifyRetries || 0) + 1;
390
596
  if (verifyRetries >= MAX_VERIFY_RETRIES) {
597
+ const attempts = (rec.attempts || 0) + 1;
391
598
  setStatus(rec.key, 'stopped', {
392
- attempts: (rec.attempts || 0) + 1,
599
+ attempts,
600
+ // Same backoff (and same manual exemption) as routed retries.
601
+ resetAt: rec.manual ? Date.now() : Date.now() + retryBackoffMs(attempts),
393
602
  lastError,
394
603
  verifyRetries: 0,
395
604
  });
@@ -449,7 +658,7 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
449
658
  return 'resumed';
450
659
  }
451
660
 
452
- export function routeDispatchOutcome(result, rec, deferCounts, { maxBusyDefers = MAX_BUSY_DEFERS } = {}) {
661
+ export function routeDispatchOutcome(result, rec, deferCounts, { maxBusyDefers = MAX_BUSY_DEFERS, now = Date.now() } = {}) {
453
662
  if (result === 'busy') {
454
663
  const n = (deferCounts.get(rec.key) || 0) + 1;
455
664
  deferCounts.set(rec.key, n);
@@ -460,8 +669,14 @@ export function routeDispatchOutcome(result, rec, deferCounts, { maxBusyDefers =
460
669
  return { verify: false, waitBusy: true };
461
670
  }
462
671
  if (result === 'retry') {
672
+ const attempts = (rec.attempts || 0) + 1;
463
673
  setStatus(rec.key, 'stopped', {
464
- attempts: (rec.attempts || 0) + 1,
674
+ attempts,
675
+ // Back off before the next attempt — a due record retried on every poll
676
+ // tick exhausts MAX_RESUME_ATTEMPTS in minutes. Manual records are
677
+ // exempt: `resume-now` promised an immediate wake, so a transient error
678
+ // must not silently defer it.
679
+ resetAt: rec.manual ? now : now + retryBackoffMs(attempts),
465
680
  lastError: readState().sessions[rec.key]?.lastError,
466
681
  verifyRetries: 0,
467
682
  });
@@ -490,11 +705,13 @@ export async function runResumer({
490
705
  if (!watcher || !getConfig('guiWatch')) return;
491
706
  try { await watcher.tick(); } catch (err) { log(`watcher tick failed: ${err.message}`); }
492
707
  };
708
+ let lockWaits = 0;
493
709
  while (!acquireSingleton()) {
494
710
  if (!persistent) { log('another resumer is running — exiting'); return 0; }
495
711
  if (signal?.aborted) return 0;
496
712
  await tickWatcher();
497
- log('another resumer holds the lock — daemon waiting');
713
+ lockWaits += 1;
714
+ if (shouldLogLockWait(lockWaits)) log('another resumer holds the lock — daemon waiting');
498
715
  await sleep(pollInterval);
499
716
  }
500
717
  updateState(state => { state.resumerPid = process.pid; });
@@ -529,7 +746,7 @@ export async function runResumer({
529
746
  if ((s.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
530
747
  setStatus(s.key, 'failed', { lastError: 'max resume attempts exceeded', verifyRetries: 0 });
531
748
  log(`${s.key}: giving up after ${s.attempts} attempts`);
532
- notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status\``, { context: ctxOf(s) });
749
+ notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status\``, { context: ctxOf(s), priority: 4 });
533
750
  }
534
751
  }
535
752
 
package/src/settings.js CHANGED
@@ -18,6 +18,14 @@ export const DEFAULTS = {
18
18
  menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
19
19
  notifications: true, // desktop notifications on detect/resume
20
20
  notifyChannel: 'auto', // auto | native | osc | bell
21
+ // ntfy push (https://ntfy.sh) — ADDITIVE to notifyChannel; off until a
22
+ // topic is set. ntfy.sh topics are public: the name is the password —
23
+ // use `unsnooze config set ntfyTopic $(unsnooze doctor)`-style random
24
+ // names (generateNtfyTopic) or a self-hosted/authed server.
25
+ ntfyTopic: '', // '' = ntfy off
26
+ ntfyServer: 'https://ntfy.sh',
27
+ ntfyToken: '', // optional Bearer token (tk_…) for authed servers
28
+ ntfyPrivacy: 'full', // full | terse (terse: never push cwd paths)
21
29
  guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
22
30
  updateCheck: true, // daily registry version check + update notices/toast
23
31
  workspaceGuard: 'inform', // repo changed while stopped: off | inform | pause
@@ -40,6 +48,10 @@ const ENV_NAMES = {
40
48
  menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
41
49
  notifications: 'UNSNOOZE_NOTIFICATIONS',
42
50
  notifyChannel: 'UNSNOOZE_NOTIFY_CHANNEL',
51
+ ntfyTopic: 'UNSNOOZE_NTFY_TOPIC',
52
+ ntfyServer: 'UNSNOOZE_NTFY_SERVER',
53
+ ntfyToken: 'UNSNOOZE_NTFY_TOKEN',
54
+ ntfyPrivacy: 'UNSNOOZE_NTFY_PRIVACY',
43
55
  guiWatch: 'UNSNOOZE_GUI_WATCH',
44
56
  updateCheck: 'UNSNOOZE_UPDATE_CHECK',
45
57
  workspaceGuard: 'UNSNOOZE_WORKSPACE_GUARD',
@@ -72,6 +84,7 @@ const ENUMS = {
72
84
  workspaceGuard: ['off', 'inform', 'pause'],
73
85
  contextGuard: ['off', 'inform', 'pause'],
74
86
  notifyChannel: ['auto', 'native', 'osc', 'bell'],
87
+ ntfyPrivacy: ['full', 'terse'],
75
88
  };
76
89
 
77
90
  function parseBool(raw) {
package/src/state.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // quarantine. All synchronous — callers are short-lived or infrequent.
6
6
 
7
7
  import {
8
- mkdirSync, rmdirSync, readFileSync, writeFileSync, renameSync,
8
+ mkdirSync, rmSync, readFileSync, writeFileSync, renameSync,
9
9
  existsSync, statSync,
10
10
  } from 'node:fs';
11
11
  import { join } from 'node:path';
@@ -27,30 +27,51 @@ function sleepSync(ms) {
27
27
  Atomics.wait(new Int32Array(buf), 0, 0, ms);
28
28
  }
29
29
 
30
+ const LOCK_TIMEOUT_MS = (() => {
31
+ const v = parseInt(process.env.UNSNOOZE_LOCK_TIMEOUT_MS ?? '', 10);
32
+ return Number.isFinite(v) ? v : 5_000;
33
+ })();
34
+
35
+ // Is the pid recorded inside the lock dir still alive? Old-version locks have
36
+ // no pid file — unknown (null) so age-based stealing still applies to them.
37
+ function lockHolderAlive() {
38
+ try {
39
+ const pid = parseInt(readFileSync(join(LOCK_DIR, 'pid'), 'utf-8'), 10);
40
+ if (!Number.isFinite(pid)) return null;
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch (err) {
44
+ return err?.code === 'ESRCH' ? false : null; // dead vs unreadable/no-permission
45
+ }
46
+ }
47
+
30
48
  function acquireLock() {
31
- const deadline = Date.now() + 5_000;
49
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
32
50
  for (;;) {
33
51
  try {
34
52
  mkdirSync(LOCK_DIR);
53
+ // Record the holder so a slow-but-alive writer is never robbed — age
54
+ // alone can't tell a hung process from a busy one.
55
+ try { writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid)); } catch { /* best-effort */ }
35
56
  return;
36
57
  } catch (err) {
37
58
  if (err.code !== 'EEXIST') { mkdirSync(STATE_DIR, { recursive: true }); continue; }
38
59
  try {
39
60
  const age = Date.now() - statSync(LOCK_DIR).mtimeMs;
40
- if (age > STALE_LOCK_MS) {
41
- rmdirSync(LOCK_DIR); // steal stale lock from a killed process
61
+ if (age > STALE_LOCK_MS && lockHolderAlive() !== true) {
62
+ rmSync(LOCK_DIR, { recursive: true, force: true }); // steal from a dead/unknown holder
42
63
  log(`stole stale lock (age ${Math.round(age)}ms)`);
43
64
  continue;
44
65
  }
45
66
  } catch { /* lock vanished between check and stat — retry */ }
46
- if (Date.now() > deadline) throw new Error('unsnooze: state lock timeout after 5s');
67
+ if (Date.now() > deadline) throw new Error(`unsnooze: state lock timeout after ${LOCK_TIMEOUT_MS}ms`);
47
68
  sleepSync(50);
48
69
  }
49
70
  }
50
71
  }
51
72
 
52
73
  function releaseLock() {
53
- try { rmdirSync(LOCK_DIR); } catch { /* already gone */ }
74
+ try { rmSync(LOCK_DIR, { recursive: true, force: true }); } catch { /* already gone */ }
54
75
  }
55
76
 
56
77
  export function readState() {
@@ -191,10 +212,14 @@ function findDuplicate(state, record) {
191
212
  return null;
192
213
  }
193
214
 
194
- export function setStatus(key, status, extra = {}) {
215
+ // `expect`: compare-and-set — apply only while the record is still in one of
216
+ // the listed statuses. Sweepers that decide from a snapshot (markStaleAbandoned)
217
+ // must not clobber a record that moved on (e.g. to 'resumed') mid-decision.
218
+ export function setStatus(key, status, extra = {}, { expect = null } = {}) {
195
219
  return updateState(state => {
196
220
  const s = state.sessions[key];
197
221
  if (!s) return state;
222
+ if (expect && !expect.includes(s.status)) return state;
198
223
  Object.assign(s, extra, { status });
199
224
  return state;
200
225
  });
@@ -225,7 +250,10 @@ export async function sweepRecords({ resolveMux } = {}) {
225
250
  const state = readState();
226
251
  const drop = [];
227
252
  for (const rec of Object.values(state.sessions)) {
228
- if (!['resumed', 'failed', 'cancelled'].includes(rec.status)) continue;
253
+ // 'failed' is deliberately NOT swept: its lastError is the post-mortem
254
+ // ("why didn't my session wake?") and a give-up was once erased within
255
+ // 30 seconds of happening. Age-based prune() owns failed-record expiry.
256
+ if (!['resumed', 'cancelled'].includes(rec.status)) continue;
229
257
  if (!rec.pane) {
230
258
  drop.push(rec.key);
231
259
  continue;
@@ -267,10 +295,12 @@ export async function markStaleAbandoned({
267
295
  }
268
296
  }
269
297
  if (!dead) continue;
298
+ // CAS: the async liveness probe races real resumes — only mark failed if
299
+ // the record is still where the snapshot saw it.
270
300
  setStatus(rec.key, 'failed', {
271
301
  lastError: rec.pane ? 'stale: pane dead' : 'stale: pane absent',
272
302
  verifyRetries: 0,
273
- });
303
+ }, { expect: ['stopped', 'resuming'] });
274
304
  marked += 1;
275
305
  log(`${rec.key}: marked failed (stale abandoned, detectedAt ${new Date(rec.detectedAt || 0).toISOString()})`);
276
306
  }