unsnooze 1.0.0

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/logger.js ADDED
@@ -0,0 +1,23 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { LOG_FILE } from './config.js';
4
+
5
+ let dirReady = false;
6
+
7
+ export function log(component, message) {
8
+ const line = `${new Date().toISOString()} [${component}] ${message}\n`;
9
+ try {
10
+ if (!dirReady) {
11
+ mkdirSync(dirname(LOG_FILE), { recursive: true });
12
+ dirReady = true;
13
+ }
14
+ appendFileSync(LOG_FILE, line);
15
+ } catch {
16
+ // Logging must never crash the hook or monitor paths.
17
+ }
18
+ if (process.env.UNSNOOZE_DEBUG) process.stderr.write(line);
19
+ }
20
+
21
+ export function makeLogger(component) {
22
+ return (message) => log(component, message);
23
+ }
package/src/monitor.js ADDED
@@ -0,0 +1,199 @@
1
+ // Per-pane watcher (`unsnooze _monitor <pane>`), spawned detached by the launcher.
2
+ // Responsibilities:
3
+ // - scrape the pane every SCRAPE_INTERVAL_MS for a live limit banner or the
4
+ // /rate-limit-options menu; on limit → record in state + spawn resumer
5
+ // - consume overload event markers from the StopFailure hook and run the
6
+ // seconds-scale backoff retry ladder (never touches state.json)
7
+ // - flip a tracked record to 'resumed' if the user/resumer got things moving
8
+ // - exit when the pane dies
9
+ // tmux + timers are injectable for tests.
10
+
11
+ import { readFileSync, unlinkSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import * as realTmux from './tmux.js';
14
+ import {
15
+ SCRAPE_INTERVAL_MS, PANE_SCAN_LINES, CAPTURE_LINES, EVENTS_DIR,
16
+ EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
17
+ FALLBACK_RESET_MS, RESET_MARGIN_MS, TMUX_SESSION_NAME,
18
+ } from './config.js';
19
+ import { detectLimit, isBusy, overloadMatch } from './patterns.js';
20
+ import { getAgent } from './agents/index.js';
21
+ import { getConfig } from './settings.js';
22
+ import { notify } from './notify.js';
23
+ import { parseResetTime, resetAtMs } from './time-parser.js';
24
+ import { upsertSession, setStatus, readState } from './state.js';
25
+ import { spawnResumerIfNeeded } from './spawn.js';
26
+ import { makeLogger } from './logger.js';
27
+
28
+ const log = makeLogger('monitor');
29
+
30
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
31
+
32
+ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = realTmux, scrapeInterval = SCRAPE_INTERVAL_MS }) {
33
+ let trackedKey = null; // state key of the record we created
34
+ let overloadAttempt = 0;
35
+ let running = true;
36
+
37
+ function markerPath() {
38
+ return join(EVENTS_DIR, `${pane.replace(/[^%\w]/g, '_')}.json`);
39
+ }
40
+
41
+ function consumeMarker() {
42
+ try {
43
+ const marker = JSON.parse(readFileSync(markerPath(), 'utf-8'));
44
+ unlinkSync(markerPath());
45
+ if (Date.now() - marker.at > EVENT_MARKER_TTL_MS) return null; // stale
46
+ return marker;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ async function recordLimit(resetLine, limitType, via) {
53
+ const detectedAt = Date.now();
54
+ const { at, source } = resetAtMs(parseResetTime(resetLine), {
55
+ marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
56
+ });
57
+ const sessionId = agent.latestSessionId(cwd, detectedAt);
58
+ const state = upsertSession({
59
+ sessionId, cwd, pane, agent: agent.id, tmuxSession: TMUX_SESSION_NAME,
60
+ status: 'stopped', limitType, detectedVia: via, detectedAt,
61
+ resetAt: at, resetSource: source,
62
+ attempts: 0, lastAttemptAt: null, lastError: null,
63
+ });
64
+ trackedKey = sessionId
65
+ || Object.values(state.sessions).find(s => s.pane === pane && s.status === 'stopped')?.key
66
+ || null;
67
+ log(`pane ${pane}: limit recorded (${limitType}, via ${via}), resets ${new Date(at).toISOString()}`);
68
+ notify(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`);
69
+ spawnResumerIfNeeded();
70
+ }
71
+
72
+ async function driveMenu(text) {
73
+ const steps = agent.menu.stepsToWait(text, PANE_SCAN_LINES);
74
+ if (steps === null) {
75
+ log(`pane ${pane}: rate-limit menu unreadable — NOT pressing Enter`);
76
+ return false;
77
+ }
78
+ const key = steps > 0 ? 'Down' : 'Up';
79
+ for (let i = 0; i < Math.abs(steps); i++) {
80
+ await tmux.sendKey(pane, key);
81
+ await sleep(120);
82
+ }
83
+ await tmux.sendKey(pane, 'Enter');
84
+ log(`pane ${pane}: selected "Stop and wait for limit to reset" (${steps} steps)`);
85
+ await sleep(1000);
86
+ // After selection the TUI prints the limit banner — next scrape records it.
87
+ return true;
88
+ }
89
+
90
+ async function handleOverload() {
91
+ if (overloadAttempt >= OVERLOAD_BACKOFF_S.length) {
92
+ log(`pane ${pane}: overload retries exhausted`);
93
+ overloadAttempt = 0; // reset ladder; next marker starts fresh
94
+ return;
95
+ }
96
+ const base = OVERLOAD_BACKOFF_S[overloadAttempt] * 1000;
97
+ const jitter = base * OVERLOAD_JITTER * (Math.random() * 2 - 1);
98
+ const wait = Math.round(base + jitter);
99
+ overloadAttempt++;
100
+ log(`pane ${pane}: overload — retry ${overloadAttempt}/${OVERLOAD_BACKOFF_S.length} in ${Math.round(wait / 1000)}s`);
101
+ await sleep(wait);
102
+ if (!running) return;
103
+ const text = await tmux.capturePane(pane, CAPTURE_LINES).catch(() => null);
104
+ if (text === null) return;
105
+ if (isBusy(text, agent.patterns.busyPatterns)) { log(`pane ${pane}: busy after overload wait — skip inject`); return; }
106
+ if (!overloadMatch(text, agent.patterns.overloadPatterns)) { overloadAttempt = 0; return; } // recovered on its own
107
+ await tmux.sendText(pane, 'Continue where you left off. The previous attempt failed with a transient API error.');
108
+ log(`pane ${pane}: overload retry message sent`);
109
+ }
110
+
111
+ async function tick() {
112
+ if (!(await tmux.paneAlive(pane))) {
113
+ log(`pane ${pane}: gone — monitor exiting (record persists for resumer)`);
114
+ running = false;
115
+ return;
116
+ }
117
+
118
+ const marker = consumeMarker();
119
+ let text;
120
+ try {
121
+ text = await tmux.capturePane(pane, CAPTURE_LINES);
122
+ } catch {
123
+ return; // transient capture failure
124
+ }
125
+
126
+ // Interactive menu takes priority — it blocks the session until answered.
127
+ // Checked against the VISIBLE screen only: a menu in scrollback history
128
+ // was already answered, and re-driving it would inject stray keys.
129
+ const visible = tmux.capturePaneVisible
130
+ ? await tmux.capturePaneVisible(pane).catch(() => '')
131
+ : text;
132
+ if (agent.menu && agent.menu.isPrompt(visible, PANE_SCAN_LINES)) {
133
+ if (!getConfig('menuAutoAnswer')) {
134
+ // Watch-only mode: record the stop (reset time may not be visible
135
+ // until the menu is answered — fallback covers that), touch nothing.
136
+ const state = readState();
137
+ const existing = trackedKey && state.sessions[trackedKey];
138
+ if (!existing || existing.status !== 'stopped') {
139
+ const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
140
+ log(`pane ${pane}: limit menu detected but menuAutoAnswer is off — recording only`);
141
+ await recordLimit(d.hit ? d.resetLine : null, d.hit ? d.limitType : 'unknown', 'scrape');
142
+ }
143
+ return;
144
+ }
145
+ await driveMenu(visible);
146
+ return;
147
+ }
148
+
149
+ const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
150
+ if (d.hit) {
151
+ const state = readState();
152
+ const existing = trackedKey && state.sessions[trackedKey];
153
+ if (!existing || existing.status !== 'stopped') {
154
+ await recordLimit(d.resetLine, d.limitType, marker ? 'hook' : 'scrape');
155
+ }
156
+ return;
157
+ }
158
+
159
+ // No banner. If we were tracking a stopped record and claude is active
160
+ // again, someone resumed it (user or resumer) — mark it.
161
+ if (trackedKey) {
162
+ const state = readState();
163
+ const rec = state.sessions[trackedKey];
164
+ if (rec && rec.status === 'stopped') {
165
+ setStatus(trackedKey, 'resumed', { lastAttemptAt: Date.now() });
166
+ log(`pane ${pane}: banner cleared, ${trackedKey} marked resumed`);
167
+ }
168
+ if (rec && rec.status !== 'stopped') trackedKey = null;
169
+ }
170
+
171
+ // Overload marker (banner-less path).
172
+ if (marker && marker.kind === 'overload') {
173
+ await handleOverload();
174
+ } else if (!marker && overloadMatch(text, agent.patterns.overloadPatterns) && !isBusy(text, agent.patterns.busyPatterns)) {
175
+ await handleOverload();
176
+ }
177
+ }
178
+
179
+ return {
180
+ async run() {
181
+ log(`monitor started for pane ${pane} (cwd ${cwd})`);
182
+ while (running) {
183
+ await tick();
184
+ if (!running) break;
185
+ await sleep(scrapeInterval);
186
+ }
187
+ },
188
+ stop() { running = false; },
189
+ _tick: tick, // exposed for tests
190
+ get trackedKey() { return trackedKey; },
191
+ };
192
+ }
193
+
194
+ export async function runMonitor(pane, agentId) {
195
+ const cwd = process.env.UNSNOOZE_CWD || process.cwd();
196
+ const monitor = createMonitor({ pane, cwd, agent: getAgent(agentId) });
197
+ await monitor.run();
198
+ return 0;
199
+ }
package/src/notify.js ADDED
@@ -0,0 +1,63 @@
1
+ // Desktop notifications — fire-and-forget, zero deps, never blocks or throws
2
+ // (detection and resume paths must not care whether a notifier exists).
3
+ // macOS: osascript. Linux: notify-send. WSL & native Windows: powershell.exe
4
+ // toast (notify-send rarely exists inside WSL; powershell.exe always does).
5
+ // Fallback: tmux status-line message.
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { release as osRelease } from 'node:os';
9
+ import { getConfig } from './settings.js';
10
+
11
+ function defaultSpawner(cmd, args) {
12
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
13
+ child.on('error', () => { /* notifier missing — silently drop */ });
14
+ child.unref();
15
+ }
16
+
17
+ function appleScriptString(s) {
18
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
19
+ }
20
+
21
+ function xmlEscape(s) {
22
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
24
+ }
25
+
26
+ export function isWsl(platform = process.platform, release = osRelease()) {
27
+ return platform === 'linux' && /microsoft/i.test(release);
28
+ }
29
+
30
+ // Windows toast without any module: raise it through PowerShell's own AppId
31
+ // (unregistered AppIds get their toasts dropped on recent Windows builds).
32
+ const PS_APP_ID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe';
33
+
34
+ function powershellToast(spawner, title, message) {
35
+ const xml = `<toast><visual><binding template="ToastGeneric"><text>${xmlEscape(title)}</text><text>${xmlEscape(message)}</text></binding></visual></toast>`;
36
+ const script = [
37
+ `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;`,
38
+ `$xml = New-Object Windows.Data.Xml.Dom.XmlDocument;`,
39
+ `$xml.LoadXml('${xml.replace(/'/g, "''")}');`,
40
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${PS_APP_ID}').Show([Windows.UI.Notifications.ToastNotification]::new($xml));`,
41
+ ].join(' ');
42
+ spawner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
43
+ }
44
+
45
+ export function notify(title, message, {
46
+ platform = process.platform,
47
+ wsl = isWsl(platform),
48
+ spawner = defaultSpawner,
49
+ } = {}) {
50
+ try {
51
+ if (!getConfig('notifications')) return;
52
+ if (platform === 'darwin') {
53
+ spawner('osascript', ['-e',
54
+ `display notification ${appleScriptString(message)} with title ${appleScriptString(title)}`]);
55
+ } else if (platform === 'win32' || wsl) {
56
+ powershellToast(spawner, title, message);
57
+ } else if (platform === 'linux') {
58
+ spawner('notify-send', ['-a', 'unsnooze', title, message]);
59
+ } else if (process.env.TMUX) {
60
+ spawner('tmux', ['display-message', `${title}: ${message}`]);
61
+ }
62
+ } catch { /* never let a notification break the caller */ }
63
+ }
@@ -0,0 +1,109 @@
1
+ // Detection ENGINE for usage-limit banners in tmux pane text. Agent-agnostic:
2
+ // every regex set comes from an agent adapter (src/agents/*), with claude as
3
+ // the default so pre-adapter call sites keep working.
4
+ //
5
+ // Detection requires a "limit" line AND a "resets" line within PROXIMITY lines
6
+ // of each other, scanning only the tail of the pane (a live banner sits at the
7
+ // prompt; the same words in scrollback are history, not current state).
8
+
9
+ import { patterns as claudePatterns } from './agents/claude.js';
10
+
11
+ // ANSI stripping — full CSI/OSC/DCS/APC coverage per ECMA-48.
12
+ const CSI_REGEX = /\x1b\[[\x20-\x3f]*[\x40-\x7e]/g;
13
+ const OSC_REGEX = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
14
+ const DCS_REGEX = /\x1bP[\s\S]*?(?:\x07|\x1b\\)/g;
15
+ const OTHER_ESC_REGEX = /\x1b[_X^][\s\S]*?(?:\x07|\x1b\\)/g;
16
+
17
+ export function stripAnsi(text) {
18
+ return text
19
+ .replace(OSC_REGEX, '')
20
+ .replace(DCS_REGEX, '')
21
+ .replace(OTHER_ESC_REGEX, '')
22
+ .replace(CSI_REGEX, '');
23
+ }
24
+
25
+ const PROXIMITY = 6;
26
+
27
+ // tmux capture-pane pads the visible pane with blank rows below the last
28
+ // content (a fresh pane shows a banner at the top and 20 empty rows under it).
29
+ // The "last N lines" tail must be taken from the CONTENT, not the screen rows,
30
+ // or a banner above the padding escapes the window.
31
+ export function contentLines(text, tailLines) {
32
+ const lines = stripAnsi(text).split('\n');
33
+ let end = lines.length;
34
+ while (end > 0 && lines[end - 1].trim() === '') end--;
35
+ const trimmed = lines.slice(0, end);
36
+ return tailLines > 0 ? trimmed.slice(-tailLines) : trimmed;
37
+ }
38
+
39
+ function hasNearbyMatch(lines, idx, patterns) {
40
+ const start = Math.max(0, idx - PROXIMITY);
41
+ const end = Math.min(lines.length, idx + PROXIMITY + 1);
42
+ for (let j = start; j < end; j++) {
43
+ if (patterns.some(p => p.test(lines[j]))) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ // Main entry: detect a live usage-limit banner in the pane tail.
49
+ // Returns { hit, limitType: '5h'|'weekly'|'unknown', resetLine } — resetLine is
50
+ // the text to feed to time-parser (most recent reset mention, bottom-up).
51
+ export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
52
+ const lines = contentLines(text, tailLines);
53
+
54
+ let hit = false;
55
+ for (let i = 0; i < lines.length; i++) {
56
+ if (sets.limitPatterns.some(p => p.test(lines[i])) && hasNearbyMatch(lines, i, sets.resetPatterns)) {
57
+ hit = true;
58
+ break;
59
+ }
60
+ }
61
+ if (!hit) return { hit: false, limitType: null, resetLine: null };
62
+
63
+ const joined = lines.join('\n');
64
+ let limitType = 'unknown';
65
+ if (sets.weeklyPatterns.some(p => p.test(joined))) limitType = 'weekly';
66
+ else if ((sets.fiveHourPatterns || []).some(p => p.test(joined))) limitType = '5h';
67
+
68
+ // Most recent reset line wins (the TUI never clears old banners from scrollback).
69
+ let resetLine = null;
70
+ for (let i = lines.length - 1; i >= 0; i--) {
71
+ if (sets.resetPatterns.some(p => p.test(lines[i]))) { resetLine = lines[i].trim(); break; }
72
+ }
73
+ if (!resetLine) {
74
+ for (let i = lines.length - 1; i >= 0; i--) {
75
+ if (sets.limitPatterns.some(p => p.test(lines[i]))) { resetLine = lines[i].trim(); break; }
76
+ }
77
+ }
78
+ return { hit: true, limitType, resetLine };
79
+ }
80
+
81
+ function tail(text, n = 12) {
82
+ return contentLines(text, n);
83
+ }
84
+
85
+ // While the agent is streaming or running its own internal retries, the pane
86
+ // is NOT in a terminal state — never inject keys then.
87
+ export function isBusy(text, busyPatterns = claudePatterns.busyPatterns) {
88
+ return tail(text).some(line => busyPatterns.some(p => p.test(line)));
89
+ }
90
+
91
+ // Overload patterns are adapter-supplied, anchored to the CLI's actual error
92
+ // render ("API Error: 529", "overloaded_error") — never bare digits.
93
+ export function overloadMatch(text, patterns = []) {
94
+ if (!patterns || patterns.length === 0) return null;
95
+ const lines = tail(text);
96
+ if (!lines.join('').trim()) return null;
97
+ const regexes = [];
98
+ for (const p of patterns) {
99
+ if (p instanceof RegExp) { regexes.push(p); continue; }
100
+ if (typeof p !== 'string' || !p) continue;
101
+ try { regexes.push(new RegExp(p, 'i')); } catch { /* skip invalid */ }
102
+ }
103
+ for (const line of lines) {
104
+ for (const r of regexes) {
105
+ if (r.test(line)) return { pattern: r.source, line: line.trim().slice(0, 200) };
106
+ }
107
+ }
108
+ return null;
109
+ }
package/src/report.js ADDED
@@ -0,0 +1,46 @@
1
+ // `unsnooze report [pane]` — capture a pane (ANSI-stripped), show it to the
2
+ // user, and print a pre-filled GitHub issue URL. Exists mainly so early Grok
3
+ // Build users can contribute real limit-banner text (the patterns for grok
4
+ // are generic until someone shows us the actual banner).
5
+
6
+ import { capturePane, currentPaneId } from './tmux.js';
7
+ import { stripAnsi } from './patterns.js';
8
+
9
+ export const REPO_URL = 'https://github.com/saaranshM/unsnooze';
10
+ const MAX_CAPTURE_CHARS = 3000;
11
+
12
+ export function buildIssueUrl(agentId, captureText) {
13
+ const title = `[banner-capture] ${agentId}: limit banner sample`;
14
+ const body = [
15
+ `**Agent CLI:** ${agentId}`,
16
+ '',
17
+ 'Pane capture (redact anything sensitive before submitting!):',
18
+ '',
19
+ '```text',
20
+ captureText.slice(-MAX_CAPTURE_CHARS),
21
+ '```',
22
+ ].join('\n');
23
+ return `${REPO_URL}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
24
+ }
25
+
26
+ export async function cmdReport(rest) {
27
+ const [agentId = 'grok', paneArg] = rest;
28
+ const pane = paneArg || currentPaneId();
29
+ if (!pane) {
30
+ console.error('unsnooze report: no pane (run inside tmux or pass a pane id, e.g. %3)');
31
+ return 2;
32
+ }
33
+ let text;
34
+ try {
35
+ text = stripAnsi(await capturePane(pane, 200));
36
+ } catch (err) {
37
+ console.error(`unsnooze report: cannot capture pane ${pane}: ${err.message}`);
38
+ return 1;
39
+ }
40
+ console.log('--- pane capture (review & redact before sharing) ---');
41
+ console.log(text.trimEnd());
42
+ console.log('--- end capture ---\n');
43
+ console.log('If this shows a usage-limit banner, please open an issue so detection improves:');
44
+ console.log(buildIssueUrl(agentId, text));
45
+ return 0;
46
+ }
package/src/resumer.js ADDED
@@ -0,0 +1,211 @@
1
+ // Resumer daemon (`unsnooze _resumer`) — SINGLETON. Watches state.json for stopped
2
+ // sessions, polls wall-clock against the earliest resetAt (interval polling,
3
+ // never one long setTimeout — survives laptop sleep; a wake past the target
4
+ // fires on the next tick), then re-opens/continues every due session.
5
+ // Exits when no non-terminal records remain; the next limit event respawns it.
6
+
7
+ import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
8
+ import * as realTmux from './tmux.js';
9
+ import {
10
+ RESUMER_LOCK, STATE_DIR, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS,
11
+ BUSY_DEFER_MS, MAX_BUSY_DEFERS, MAX_RESUME_ATTEMPTS, READY_TIMEOUT_MS,
12
+ CAPTURE_LINES, PANE_SCAN_LINES, TMUX_SESSION_NAME,
13
+ RESET_MARGIN_MS, FALLBACK_RESET_MS,
14
+ } from './config.js';
15
+ import { detectLimit, isBusy } from './patterns.js';
16
+ import { getAgent } from './agents/index.js';
17
+ import { parseResetTime, resetAtMs } from './time-parser.js';
18
+ import { readState, updateState, setStatus, dueSessions, activeStopped } from './state.js';
19
+ import { getConfig } from './settings.js';
20
+ import { notify } from './notify.js';
21
+ import { UNSNOOZE_BIN } from './spawn.js';
22
+ import { makeLogger } from './logger.js';
23
+
24
+ const log = makeLogger('resumer');
25
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
26
+
27
+ function pidAlive(pid) {
28
+ try { process.kill(pid, 0); return true; } catch { return false; }
29
+ }
30
+
31
+ export function acquireSingleton() {
32
+ mkdirSync(STATE_DIR, { recursive: true });
33
+ try {
34
+ const pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10);
35
+ if (Number.isFinite(pid) && pid !== process.pid && pidAlive(pid)) return false;
36
+ } catch { /* no lock */ }
37
+ writeFileSync(RESUMER_LOCK, String(process.pid));
38
+ return true;
39
+ }
40
+
41
+ export function releaseSingleton() {
42
+ try {
43
+ const pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10);
44
+ if (pid === process.pid) unlinkSync(RESUMER_LOCK);
45
+ } catch { /* already gone */ }
46
+ }
47
+
48
+ // Due sessions that are actually allowed to dispatch: everything when
49
+ // autoResume is on; only explicitly `resume-now`-marked (manual) records when
50
+ // it's off. Stops stay tracked either way.
51
+ export function dueForDispatch(now = Date.now()) {
52
+ const auto = getConfig('autoResume');
53
+ return dueSessions(now).filter(s => auto || s.manual);
54
+ }
55
+
56
+ function shellQuote(arg) {
57
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
58
+ }
59
+
60
+ // The reopen command runs inside a fresh tmux window, i.e. with the tmux
61
+ // SERVER's environment — which may lack npm globals or nvm's node on PATH.
62
+ // Always embed absolute paths. UNSNOOZE_SELF is the test-harness override.
63
+ function selfCommand() {
64
+ return process.env.UNSNOOZE_SELF
65
+ ? [process.env.UNSNOOZE_SELF]
66
+ : [process.execPath, UNSNOOZE_BIN];
67
+ }
68
+
69
+ // Decide how to act on one due record. Pure-ish; tmux injectable.
70
+ // Returns: 'sent' | 'reopened' | 'deferred' | 'skip' | 'failed'
71
+ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage = getConfig('resumeMessage'), selfCmd = selfCommand() } = {}) {
72
+ const key = rec.key;
73
+ const agent = getAgent(rec.agent);
74
+
75
+ // Live-pane path: only if the pane still exists AND the agent CLI is its
76
+ // foreground command (pane ids get recycled — never inject into a random
77
+ // program).
78
+ if (rec.pane && await tmux.paneAlive(rec.pane)) {
79
+ const cmd = await tmux.paneCurrentCommand(rec.pane);
80
+ if (agent.isForegroundCommand(cmd)) {
81
+ const text = await tmux.capturePane(rec.pane, CAPTURE_LINES).catch(() => '');
82
+ if (isBusy(text, agent.patterns.busyPatterns)) return 'deferred';
83
+ setStatus(key, 'resuming', { lastAttemptAt: Date.now() });
84
+ await tmux.sendText(rec.pane, resumeMessage);
85
+ log(`${key}: sent continue to live pane ${rec.pane}`);
86
+ return 'sent';
87
+ }
88
+ // Pane alive but the agent gone (user's shell now) — fall through to
89
+ // re-open; never hijack a shell prompt with a chat message.
90
+ }
91
+
92
+ // Re-open path: new tmux window in the well-known session, resume by id.
93
+ const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
94
+ const command = [...selfCmd, '_run', agent.id, ...resume.args].map(shellQuote).join(' ');
95
+ setStatus(key, 'resuming', { lastAttemptAt: Date.now() });
96
+ let newPane;
97
+ try {
98
+ newPane = await tmux.newWindow(rec.tmuxSession || TMUX_SESSION_NAME, rec.cwd, command);
99
+ } catch (err) {
100
+ setStatus(key, 'stopped', { attempts: (rec.attempts || 0) + 1, lastError: `new-window: ${err.message}` });
101
+ return 'failed';
102
+ }
103
+ updateState(state => {
104
+ const s = state.sessions[key];
105
+ if (s) s.pane = newPane;
106
+ });
107
+ log(`${key}: re-opened in pane ${newPane} (${command})`);
108
+
109
+ // The resume prompt traveled in argv (e.g. `codex resume <id> "msg"`) —
110
+ // nothing to type into the TUI; verifyOne checks the outcome later.
111
+ if (!resume.messageViaPane) return 'reopened';
112
+
113
+ // Wait for the TUI to be ready (input prompt visible), then send the message.
114
+ const deadline = Date.now() + READY_TIMEOUT_MS;
115
+ while (Date.now() < deadline) {
116
+ await sleep(2000);
117
+ const text = await tmux.capturePane(newPane, CAPTURE_LINES).catch(() => '');
118
+ if ((agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES)) || detectLimit(text, PANE_SCAN_LINES, agent.patterns).hit) {
119
+ // Limit hadn't actually reset — the fresh session hit it immediately.
120
+ return 'failed';
121
+ }
122
+ // The idle input box: a prompt glyph with no busy footer.
123
+ if (!isBusy(text, agent.patterns.busyPatterns) && agent.patterns.idleRegex.test(text)) {
124
+ await tmux.sendText(newPane, resumeMessage);
125
+ log(`${key}: resume message sent to new pane ${newPane}`);
126
+ return 'reopened';
127
+ }
128
+ }
129
+ setStatus(key, 'stopped', { attempts: (rec.attempts || 0) + 1, lastError: 'ready timeout' });
130
+ return 'failed';
131
+ }
132
+
133
+ // Post-dispatch verification: did the limit banner come back?
134
+ export async function verifyOne(key, { tmux = realTmux } = {}) {
135
+ const rec = readState().sessions[key];
136
+ if (!rec || rec.status !== 'resuming') return;
137
+ const agent = getAgent(rec.agent);
138
+ const text = rec.pane ? await tmux.capturePane(rec.pane, CAPTURE_LINES).catch(() => '') : '';
139
+ const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
140
+ if (d.hit || (agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES))) {
141
+ // Limit not actually reset — reschedule from the fresh banner.
142
+ const { at, source } = resetAtMs(parseResetTime(d.resetLine), {
143
+ marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
144
+ });
145
+ setStatus(key, 'stopped', {
146
+ attempts: (rec.attempts || 0) + 1, resetAt: at, resetSource: source,
147
+ lastError: 'limit still active at resume time',
148
+ });
149
+ log(`${key}: limit still active, rescheduled to ${new Date(at).toISOString()}`);
150
+ return;
151
+ }
152
+ setStatus(key, 'resumed');
153
+ log(`${key}: verified resumed`);
154
+ notify('unsnoozed ✅', `${rec.cwd} is running again`);
155
+ }
156
+
157
+ export async function runResumer({ tmux = realTmux, pollInterval = POLL_INTERVAL_MS } = {}) {
158
+ if (!acquireSingleton()) { log('another resumer is running — exiting'); return 0; }
159
+ updateState(state => { state.resumerPid = process.pid; });
160
+ log(`resumer started (pid ${process.pid})`);
161
+ const deferCounts = new Map();
162
+
163
+ try {
164
+ for (;;) {
165
+ const stopped = activeStopped();
166
+ const resuming = Object.values(readState().sessions).filter(s => s.status === 'resuming');
167
+ if (stopped.length === 0 && resuming.length === 0) {
168
+ log('no pending sessions — resumer exiting');
169
+ return 0;
170
+ }
171
+
172
+ const due = dueForDispatch().filter(s => (s.attempts || 0) < MAX_RESUME_ATTEMPTS);
173
+ // Anything over the attempts cap is dead — mark failed so we can exit.
174
+ for (const s of dueForDispatch()) {
175
+ if ((s.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
176
+ setStatus(s.key, 'failed', { lastError: 'max resume attempts exceeded' });
177
+ log(`${s.key}: giving up after ${s.attempts} attempts`);
178
+ notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status\``);
179
+ }
180
+ }
181
+
182
+ const dispatched = [];
183
+ for (const rec of due) {
184
+ const result = await dispatchOne(rec, { tmux });
185
+ if (result === 'deferred') {
186
+ const n = (deferCounts.get(rec.key) || 0) + 1;
187
+ deferCounts.set(rec.key, n);
188
+ if (n > MAX_BUSY_DEFERS) {
189
+ setStatus(rec.key, 'resumed', { lastError: null });
190
+ log(`${rec.key}: busy through ${n} defers — it is clearly working, marking resumed`);
191
+ } else {
192
+ await sleep(BUSY_DEFER_MS);
193
+ }
194
+ continue;
195
+ }
196
+ if (result === 'sent' || result === 'reopened') dispatched.push(rec.key);
197
+ if (due.indexOf(rec) < due.length - 1) await sleep(STAGGER_MS);
198
+ }
199
+
200
+ if (dispatched.length > 0) {
201
+ await sleep(VERIFY_DELAY_MS);
202
+ for (const key of dispatched) await verifyOne(key, { tmux });
203
+ }
204
+
205
+ await sleep(pollInterval);
206
+ }
207
+ } finally {
208
+ releaseSingleton();
209
+ updateState(state => { if (state.resumerPid === process.pid) state.resumerPid = null; });
210
+ }
211
+ }