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/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
|
-
|
|
43
|
-
|
|
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
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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:
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
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 (
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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() +
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|