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.
@@ -0,0 +1,39 @@
1
+ // Lookup helpers for Claude Code's transcript store (~/.claude/projects/).
2
+ // Used to backfill sessionId when detection came from pane scraping and the
3
+ // StopFailure hook didn't supply one.
4
+
5
+ import { readdirSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { CLAUDE_DIR } from './config.js';
8
+
9
+ // Claude Code maps a cwd to a project dir by replacing every '/' and '.' with '-'.
10
+ export function dashCwd(cwd) {
11
+ return cwd.replace(/[/.]/g, '-');
12
+ }
13
+
14
+ export function projectDir(cwd) {
15
+ return join(CLAUDE_DIR, 'projects', dashCwd(cwd));
16
+ }
17
+
18
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
19
+
20
+ // Newest transcript (by mtime) for a cwd = the session most recently active
21
+ // there. aroundTs (optional) requires the transcript to have been touched
22
+ // within 30 min of the detection time, to avoid grabbing an unrelated session.
23
+ export function latestSessionId(cwd, aroundTs = null) {
24
+ let entries;
25
+ try {
26
+ entries = readdirSync(projectDir(cwd));
27
+ } catch {
28
+ return null;
29
+ }
30
+ let best = null;
31
+ for (const name of entries) {
32
+ if (!UUID_RE.test(name)) continue;
33
+ let mtime;
34
+ try { mtime = statSync(join(projectDir(cwd), name)).mtimeMs; } catch { continue; }
35
+ if (aroundTs != null && Math.abs(mtime - aroundTs) > 30 * 60_000) continue;
36
+ if (!best || mtime > best.mtime) best = { id: name.slice(0, -6), mtime };
37
+ }
38
+ return best ? best.id : null;
39
+ }
@@ -0,0 +1,105 @@
1
+ // User-facing settings: ~/.unsnooze/config.json, managed by `unsnooze config`
2
+ // and the setup wizard. Precedence: env var > config file > default — env
3
+ // stays the power-user/test escape hatch, the file is what the wizard writes.
4
+
5
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { STATE_DIR } from './config.js';
8
+
9
+ export const CONFIG_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || STATE_DIR, 'config.json');
10
+
11
+ export const DEFAULTS = {
12
+ autoResume: true, // master switch: dispatch resumes when limits reset
13
+ menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
14
+ notifications: true, // desktop notifications on detect/resume
15
+ resumeMessage: 'Continue where you left off. The session was interrupted by a usage limit which has now reset — pick up the task you were working on and finish it.',
16
+ agents: { claude: true, codex: true, grok: false }, // grok is experimental
17
+ };
18
+
19
+ // Env override per key. Booleans accept 1/0, true/false, on/off, yes/no.
20
+ const ENV_NAMES = {
21
+ autoResume: 'UNSNOOZE_AUTO_RESUME',
22
+ menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
23
+ notifications: 'UNSNOOZE_NOTIFICATIONS',
24
+ resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
25
+ 'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
26
+ 'agents.codex': 'UNSNOOZE_AGENT_CODEX',
27
+ 'agents.grok': 'UNSNOOZE_AGENT_GROK',
28
+ };
29
+
30
+ const KNOWN_KEYS = Object.keys(ENV_NAMES);
31
+
32
+ function parseBool(raw) {
33
+ if (/^(1|true|on|yes)$/i.test(raw)) return true;
34
+ if (/^(0|false|off|no)$/i.test(raw)) return false;
35
+ return null;
36
+ }
37
+
38
+ function readFileConfig() {
39
+ try {
40
+ return JSON.parse(readFileSync(CONFIG_FILE(), 'utf-8'));
41
+ } catch {
42
+ return {}; // missing or corrupt file → defaults (never crash the hook path)
43
+ }
44
+ }
45
+
46
+ function dig(obj, key) {
47
+ return key.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
48
+ }
49
+
50
+ export function getConfig(key) {
51
+ if (!KNOWN_KEYS.includes(key)) throw new Error(`unsnooze: unknown setting "${key}"`);
52
+ const def = dig(DEFAULTS, key);
53
+ const env = process.env[ENV_NAMES[key]];
54
+ if (env !== undefined && env !== '') {
55
+ if (typeof def === 'boolean') {
56
+ const b = parseBool(env);
57
+ if (b !== null) return b;
58
+ } else {
59
+ return env;
60
+ }
61
+ }
62
+ const fromFile = dig(readFileConfig(), key);
63
+ return fromFile !== undefined ? fromFile : def;
64
+ }
65
+
66
+ export function setConfigValue(key, rawValue) {
67
+ if (!KNOWN_KEYS.includes(key)) throw new Error(`unsnooze: unknown setting "${key}" (known: ${KNOWN_KEYS.join(', ')})`);
68
+ const def = dig(DEFAULTS, key);
69
+ let value = rawValue;
70
+ if (typeof def === 'boolean') {
71
+ const b = typeof rawValue === 'boolean' ? rawValue : parseBool(String(rawValue));
72
+ if (b === null) throw new Error(`unsnooze: "${key}" needs a boolean (on/off, true/false)`);
73
+ value = b;
74
+ } else {
75
+ value = String(rawValue);
76
+ }
77
+ const config = readFileConfig();
78
+ const parts = key.split('.');
79
+ let cursor = config;
80
+ for (const part of parts.slice(0, -1)) {
81
+ if (typeof cursor[part] !== 'object' || cursor[part] === null) cursor[part] = {};
82
+ cursor = cursor[part];
83
+ }
84
+ cursor[parts[parts.length - 1]] = value;
85
+ writeConfig(config);
86
+ return value;
87
+ }
88
+
89
+ export function writeConfig(config) {
90
+ const path = CONFIG_FILE();
91
+ mkdirSync(dirname(path), { recursive: true });
92
+ const tmp = join(dirname(path), `.config.tmp.${process.pid}`);
93
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
94
+ renameSync(tmp, path);
95
+ }
96
+
97
+ export function configFileExists() {
98
+ try { readFileSync(CONFIG_FILE()); return true; } catch { return false; }
99
+ }
100
+
101
+ export function listConfig() {
102
+ const out = {};
103
+ for (const key of KNOWN_KEYS) out[key] = getConfig(key);
104
+ return out;
105
+ }
package/src/spawn.js ADDED
@@ -0,0 +1,41 @@
1
+ // Detached-process helpers shared by launcher, hook, and monitor.
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { join, dirname } from 'node:path';
7
+ import { RESUMER_LOCK } from './config.js';
8
+ import { makeLogger } from './logger.js';
9
+
10
+ const log = makeLogger('spawn');
11
+
12
+ export const UNSNOOZE_BIN = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'unsnooze.js');
13
+
14
+ export function spawnDetached(args, env = {}) {
15
+ const child = spawn(process.execPath, [UNSNOOZE_BIN, ...args], {
16
+ detached: true,
17
+ stdio: 'ignore',
18
+ env: { ...process.env, ...env },
19
+ });
20
+ child.unref();
21
+ return child.pid;
22
+ }
23
+
24
+ function pidAlive(pid) {
25
+ try { process.kill(pid, 0); return true; } catch { return false; }
26
+ }
27
+
28
+ // Spawn the resumer daemon unless one is already running (pidfile check).
29
+ // The resumer itself re-checks under its own lock; this is just to avoid
30
+ // pointless spawns.
31
+ export function spawnResumerIfNeeded() {
32
+ try {
33
+ if (existsSync(RESUMER_LOCK)) {
34
+ const pid = parseInt(readFileSync(RESUMER_LOCK, 'utf-8'), 10);
35
+ if (Number.isFinite(pid) && pidAlive(pid)) return null;
36
+ }
37
+ } catch { /* unreadable lock — let the daemon sort it out */ }
38
+ const pid = spawnDetached(['_resumer']);
39
+ log(`spawned resumer pid ${pid}`);
40
+ return pid;
41
+ }
package/src/state.js ADDED
@@ -0,0 +1,147 @@
1
+ // Shared multi-writer state store: ~/.unsnooze/state.json.
2
+ // Writers: N monitors, the StopFailure hook, the resumer, CLI subcommands.
3
+ // Safety: mkdir-based lock (atomic on POSIX) around read-modify-write, tmp
4
+ // file + rename for atomic replacement, stale-lock stealing, corrupt-file
5
+ // quarantine. All synchronous — callers are short-lived or infrequent.
6
+
7
+ import {
8
+ mkdirSync, rmdirSync, readFileSync, writeFileSync, renameSync,
9
+ existsSync, statSync,
10
+ } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import {
13
+ STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS,
14
+ DEDUPE_WINDOW_MS,
15
+ } from './config.js';
16
+ import { makeLogger } from './logger.js';
17
+
18
+ const log = makeLogger('state');
19
+
20
+ const EMPTY = () => ({ version: 1, resumerPid: null, sessions: {} });
21
+
22
+ function sleepSync(ms) {
23
+ const buf = new SharedArrayBuffer(4);
24
+ Atomics.wait(new Int32Array(buf), 0, 0, ms);
25
+ }
26
+
27
+ function acquireLock() {
28
+ const deadline = Date.now() + 5_000;
29
+ for (;;) {
30
+ try {
31
+ mkdirSync(LOCK_DIR);
32
+ return;
33
+ } catch (err) {
34
+ if (err.code !== 'EEXIST') { mkdirSync(STATE_DIR, { recursive: true }); continue; }
35
+ try {
36
+ const age = Date.now() - statSync(LOCK_DIR).mtimeMs;
37
+ if (age > STALE_LOCK_MS) {
38
+ rmdirSync(LOCK_DIR); // steal stale lock from a killed process
39
+ log(`stole stale lock (age ${Math.round(age)}ms)`);
40
+ continue;
41
+ }
42
+ } catch { /* lock vanished between check and stat — retry */ }
43
+ if (Date.now() > deadline) throw new Error('unsnooze: state lock timeout after 5s');
44
+ sleepSync(50);
45
+ }
46
+ }
47
+ }
48
+
49
+ function releaseLock() {
50
+ try { rmdirSync(LOCK_DIR); } catch { /* already gone */ }
51
+ }
52
+
53
+ export function readState() {
54
+ try {
55
+ return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
56
+ } catch (err) {
57
+ if (err.code === 'ENOENT') return EMPTY();
58
+ // Corrupt file: quarantine loudly, start fresh — never crash the hook path.
59
+ try {
60
+ const quarantine = `${STATE_FILE}.corrupt.${Date.now()}`;
61
+ renameSync(STATE_FILE, quarantine);
62
+ log(`CORRUPT state.json quarantined to ${quarantine}: ${err.message}`);
63
+ } catch { /* someone else quarantined it first */ }
64
+ return EMPTY();
65
+ }
66
+ }
67
+
68
+ // Locked read-modify-write. mutator receives the state object and mutates it
69
+ // (or returns a replacement). Returns the final state.
70
+ export function updateState(mutator) {
71
+ mkdirSync(STATE_DIR, { recursive: true });
72
+ acquireLock();
73
+ try {
74
+ const state = readState();
75
+ const result = mutator(state) ?? state;
76
+ const tmp = join(STATE_DIR, `.state.tmp.${process.pid}`);
77
+ writeFileSync(tmp, JSON.stringify(result, null, 2));
78
+ renameSync(tmp, STATE_FILE);
79
+ return result;
80
+ } finally {
81
+ releaseLock();
82
+ }
83
+ }
84
+
85
+ // Insert or update a session record. Dedupes hook-vs-scrape double detection:
86
+ // if a record for the same pane was created within DEDUPE_WINDOW_MS, merge into
87
+ // it (a record WITH a sessionId wins over one without).
88
+ export function upsertSession(record) {
89
+ return updateState(state => {
90
+ prune(state);
91
+ const existingKey = findDuplicate(state, record);
92
+ if (existingKey) {
93
+ const existing = state.sessions[existingKey];
94
+ const merged = {
95
+ ...existing,
96
+ ...record,
97
+ // Never downgrade a known sessionId to null.
98
+ sessionId: record.sessionId || existing.sessionId,
99
+ key: existingKey,
100
+ };
101
+ state.sessions[existingKey] = merged;
102
+ log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
103
+ return state;
104
+ }
105
+ const key = record.sessionId || `pane:${record.pane}:${record.detectedAt}`;
106
+ state.sessions[key] = { ...record, key };
107
+ return state;
108
+ });
109
+ }
110
+
111
+ function findDuplicate(state, record) {
112
+ if (record.sessionId && state.sessions[record.sessionId]) return record.sessionId;
113
+ for (const [key, s] of Object.entries(state.sessions)) {
114
+ if (s.pane && s.pane === record.pane
115
+ && s.status === 'stopped'
116
+ && Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
117
+ return key;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+
123
+ export function setStatus(key, status, extra = {}) {
124
+ return updateState(state => {
125
+ const s = state.sessions[key];
126
+ if (!s) return state;
127
+ Object.assign(s, extra, { status });
128
+ return state;
129
+ });
130
+ }
131
+
132
+ function prune(state) {
133
+ const cutoff = Date.now() - PRUNE_AFTER_MS;
134
+ for (const [key, s] of Object.entries(state.sessions)) {
135
+ const terminal = ['resumed', 'failed', 'cancelled'].includes(s.status);
136
+ const ts = s.lastAttemptAt || s.detectedAt || 0;
137
+ if (terminal && ts < cutoff) delete state.sessions[key];
138
+ }
139
+ }
140
+
141
+ export function activeStopped(state = readState()) {
142
+ return Object.values(state.sessions).filter(s => s.status === 'stopped');
143
+ }
144
+
145
+ export function dueSessions(now = Date.now(), state = readState()) {
146
+ return activeStopped(state).filter(s => (s.resetAt || 0) <= now);
147
+ }
@@ -0,0 +1,172 @@
1
+ // Parse reset banner text (Claude Code or Codex CLI) into an absolute
2
+ // epoch-ms reset time. DST-safe: resolves "resets 3pm (UTC)" via iterative
3
+ // correction in the stated timezone rather than naive offset math.
4
+
5
+ // Optional weekday between "resets" and the time covers weekly banners
6
+ // ("resets Tuesday 9am (UTC)").
7
+ const RESET_TIME_REGEX = /resets?\s+(?:at\s+)?(?:on\s+)?(?:(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
8
+ const RELATIVE_TIME_REGEX = /(?:try again|wait|resets?\s+in)[:\s]\s*(?:for\s+)?(?:in\s+)?(\d+)\s*(hours?|minutes?|mins?|h|m)\b/i;
9
+ // "resets Tuesday 3pm" / "resets on Mon" — weekly limits carry a day name.
10
+ const DAY_REGEX = /resets?\s+(?:on\s+)?(mon|tue|wed|thu|fri|sat|sun)[a-z]*/i;
11
+ const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
12
+
13
+ // Codex CLI forms ("try again at …", local time):
14
+ // same day: "or try again at 3:51 PM."
15
+ // cross-day: "or try again at Feb 23rd, 2026 9:01 PM."
16
+ // older: "Try again in 4 days 20 hours 9 minutes."
17
+ const TRY_AT_TIME_REGEX = /try again at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i;
18
+ const TRY_AT_DATE_REGEX = /try again at\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\s+(\d{1,2}):(\d{2})\s*(am|pm)/i;
19
+ const MULTI_RELATIVE_REGEX = /try again in\s+(?:(\d+)\s*days?\s*)?(?:(\d+)\s*hours?\s*)?(?:(\d+)\s*min(?:ute)?s?)?/i;
20
+ const MONTH_INDEX = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
21
+
22
+ function to24h(hour, ampm) {
23
+ let h = hour;
24
+ if (ampm === 'pm' && h !== 12) h += 12;
25
+ if (ampm === 'am' && h === 12) h = 0;
26
+ return h;
27
+ }
28
+
29
+ export function parseResetTime(text) {
30
+ if (!text) return null;
31
+
32
+ // Full-date form first — its trailing "9:01 PM" would otherwise be eaten by
33
+ // the same-day "try again at" regex.
34
+ const dateMatch = text.match(TRY_AT_DATE_REGEX);
35
+ if (dateMatch) {
36
+ const [, mon, day, year, hour, minute, ampm] = dateMatch;
37
+ const atMs = new Date(
38
+ parseInt(year, 10), MONTH_INDEX[mon.toLowerCase()], parseInt(day, 10),
39
+ to24h(parseInt(hour, 10), ampm.toLowerCase()), parseInt(minute, 10),
40
+ ).getTime();
41
+ return { absolute: true, atMs };
42
+ }
43
+
44
+ const tryAtMatch = text.match(TRY_AT_TIME_REGEX);
45
+ if (tryAtMatch) {
46
+ return {
47
+ hour: to24h(parseInt(tryAtMatch[1], 10), tryAtMatch[3].toLowerCase()),
48
+ minute: tryAtMatch[2] ? parseInt(tryAtMatch[2], 10) : 0,
49
+ timezone: null, ambiguous: false, day: null,
50
+ };
51
+ }
52
+
53
+ const dayMatch = text.match(DAY_REGEX);
54
+ const absMatch = text.match(RESET_TIME_REGEX);
55
+ if (absMatch) {
56
+ let hour = parseInt(absMatch[1], 10);
57
+ const minute = absMatch[2] ? parseInt(absMatch[2], 10) : 0;
58
+ const ampm = absMatch[3]?.toLowerCase() || null;
59
+ const timezone = absMatch[4] || null;
60
+
61
+ if (ampm === 'pm' && hour !== 12) hour += 12;
62
+ if (ampm === 'am' && hour === 12) hour = 0;
63
+
64
+ const ambiguous = !ampm && hour >= 1 && hour <= 12;
65
+ return {
66
+ hour, minute, timezone, ambiguous,
67
+ day: dayMatch ? DAY_INDEX[dayMatch[1].toLowerCase()] : null,
68
+ };
69
+ }
70
+
71
+ const relMatch = text.match(RELATIVE_TIME_REGEX);
72
+ if (relMatch) {
73
+ const amount = parseInt(relMatch[1], 10);
74
+ const unit = relMatch[2].toLowerCase();
75
+ const isMinutes = unit.startsWith('m');
76
+ return { relative: true, waitMs: amount * (isMinutes ? 60_000 : 3_600_000) };
77
+ }
78
+
79
+ // Multi-unit relative ("in 4 days 20 hours 9 minutes") — checked after the
80
+ // single-unit form, which already covers "in 2 hours" / "in 5 minutes".
81
+ const multiMatch = text.match(MULTI_RELATIVE_REGEX);
82
+ if (multiMatch && (multiMatch[1] || multiMatch[2] || multiMatch[3])) {
83
+ const days = parseInt(multiMatch[1] || '0', 10);
84
+ const hours = parseInt(multiMatch[2] || '0', 10);
85
+ const minutes = parseInt(multiMatch[3] || '0', 10);
86
+ return { relative: true, waitMs: ((days * 24 + hours) * 60 + minutes) * 60_000 };
87
+ }
88
+
89
+ // Day-only weekly banner ("resets Tuesday") with no time — midnight-ish target,
90
+ // resolved by resetAtMs via hour 0.
91
+ if (dayMatch) {
92
+ return { hour: 0, minute: 0, timezone: null, ambiguous: false, day: DAY_INDEX[dayMatch[1].toLowerCase()] };
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ // Convert parsed reset info into an absolute epoch ms (includes margin).
99
+ // Unparseable input falls back to now + fallbackMs.
100
+ export function resetAtMs(parsed, { marginMs = 60_000, fallbackMs = 5 * 3_600_000, now = new Date() } = {}) {
101
+ const source = !parsed ? 'fallback' : parsed.relative ? 'relative' : 'absolute';
102
+ if (!parsed) return { at: now.getTime() + fallbackMs + marginMs, source };
103
+ if (parsed.relative) return { at: now.getTime() + parsed.waitMs + marginMs, source };
104
+ // Pre-resolved epoch (full-date banner, local time). A stale past date means
105
+ // we misread the banner — fall back rather than firing immediately.
106
+ if (parsed.absolute) {
107
+ if (parsed.atMs <= now.getTime()) return { at: now.getTime() + fallbackMs + marginMs, source: 'fallback' };
108
+ return { at: parsed.atMs + marginMs, source };
109
+ }
110
+
111
+ let tz;
112
+ try {
113
+ tz = parsed.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
114
+ Intl.DateTimeFormat('en-US', { timeZone: tz });
115
+ } catch {
116
+ return { at: now.getTime() + fallbackMs + marginMs, source: 'fallback' };
117
+ }
118
+
119
+ // DST-safe: build today's date in the target tz, then iteratively correct the
120
+ // UTC guess until it formats as the desired local h:m. Correction normalized
121
+ // to [-720, +720] minutes to take the minimum-magnitude step (avoids the
122
+ // off-by-a-day bug in high-offset timezones).
123
+ function targetTimestamp(h, m) {
124
+ const parts = new Intl.DateTimeFormat('en-US', {
125
+ timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour12: false,
126
+ }).formatToParts(now);
127
+ const y = parseInt(parts.find(p => p.type === 'year').value);
128
+ const mo = parseInt(parts.find(p => p.type === 'month').value);
129
+ const d = parseInt(parts.find(p => p.type === 'day').value);
130
+
131
+ const targetStr = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
132
+ let candidate = new Date(targetStr + 'Z').getTime();
133
+ for (let i = 0; i < 3; i++) {
134
+ const fp = new Intl.DateTimeFormat('en-US', {
135
+ timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
136
+ }).formatToParts(new Date(candidate));
137
+ const ch = parseInt(fp.find(p => p.type === 'hour').value) % 24;
138
+ const cm = parseInt(fp.find(p => p.type === 'minute').value);
139
+ let diffMin = (h - ch) * 60 + (m - cm);
140
+ diffMin = ((diffMin % 1440) + 1440) % 1440;
141
+ if (diffMin > 720) diffMin -= 1440;
142
+ if (diffMin === 0) break;
143
+ candidate += diffMin * 60_000;
144
+ }
145
+ return candidate;
146
+ }
147
+
148
+ function nextOccurrence(h, m) {
149
+ let t = targetTimestamp(h, m);
150
+ if (t <= now.getTime()) t += 86_400_000;
151
+ if (parsed.day != null) {
152
+ // Roll forward to the named weekday (in the target tz).
153
+ for (let i = 0; i < 7; i++) {
154
+ const wd = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short' })
155
+ .format(new Date(t)).toLowerCase().slice(0, 3);
156
+ if (DAY_INDEX[wd] === parsed.day) break;
157
+ t += 86_400_000;
158
+ }
159
+ }
160
+ return t;
161
+ }
162
+
163
+ let at;
164
+ if (parsed.ambiguous) {
165
+ const t1 = nextOccurrence(parsed.hour, parsed.minute);
166
+ const t2 = nextOccurrence((parsed.hour + 12) % 24, parsed.minute);
167
+ at = Math.min(t1, t2);
168
+ } else {
169
+ at = nextOccurrence(parsed.hour, parsed.minute);
170
+ }
171
+ return { at: at + marginMs, source };
172
+ }
package/src/tmux.js ADDED
@@ -0,0 +1,95 @@
1
+ import { execFile as execFileCb, spawnSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFileCb);
5
+
6
+ // Is tmux runnable at all? Used for a friendly error instead of a cryptic
7
+ // spawn failure (native Windows users get pointed at WSL).
8
+ export function tmuxAvailable() {
9
+ try {
10
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ // Submit delay: text and the submitting Enter must be TWO separate send-keys
17
+ // calls with a pause between them, or Ink (Claude Code's TUI) treats the Enter
18
+ // as a newline inside a bracketed-paste burst instead of a submit.
19
+ export const SUBMIT_DELAY_MS = 150;
20
+
21
+ async function tmux(...args) {
22
+ const { stdout } = await execFileAsync('tmux', args);
23
+ return stdout;
24
+ }
25
+
26
+ export function insideTmux() { return !!process.env.TMUX; }
27
+ export function currentPaneId() { return process.env.TMUX_PANE || null; }
28
+
29
+ export async function capturePane(pane, lines = 200) {
30
+ return tmux('capture-pane', '-t', pane, '-p', '-S', `-${lines}`);
31
+ }
32
+
33
+ // Visible screen ONLY (no scrollback). Interactive menus must be checked
34
+ // against this: a menu that scrolled into history was already answered —
35
+ // re-driving it sends stray keystrokes into whatever is foreground now.
36
+ export async function capturePaneVisible(pane) {
37
+ return tmux('capture-pane', '-t', pane, '-p');
38
+ }
39
+
40
+ // Type a message into a TUI and submit it (split form; -l sends literally so
41
+ // tmux key names inside the message are typed, not interpreted).
42
+ export async function sendText(pane, text) {
43
+ await tmux('send-keys', '-t', pane, '-l', text);
44
+ await new Promise(r => setTimeout(r, SUBMIT_DELAY_MS));
45
+ await tmux('send-keys', '-t', pane, 'Enter');
46
+ }
47
+
48
+ // Single named key ('Down', 'Up', 'Enter', 'Escape') — drives menus.
49
+ export async function sendKey(pane, key) {
50
+ await tmux('send-keys', '-t', pane, key);
51
+ }
52
+
53
+ export async function paneAlive(pane) {
54
+ try {
55
+ await tmux('display-message', '-t', pane, '-p', '#{pane_id}');
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export async function paneCurrentCommand(pane) {
63
+ try {
64
+ return (await tmux('display-message', '-t', pane, '-p', '#{pane_current_command}')).trim();
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ // NOTE: plain session names, not the '=name' exact-match prefix — tmux 3.7b
71
+ // rejects '=name' as a target here ("name not found" despite the session
72
+ // existing). Exact matches take priority over prefix matches anyway.
73
+ export async function sessionExists(name) {
74
+ try {
75
+ await tmux('has-session', '-t', name);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ // Open a new window in the named session (creating the session if needed),
83
+ // cd'd to cwd, running command. Returns the new pane id.
84
+ export async function newWindow(sessionName, cwd, command) {
85
+ if (!(await sessionExists(sessionName))) {
86
+ await tmux('new-session', '-d', '-s', sessionName, '-c', cwd);
87
+ // The fresh session's first window IS our window — run the command there.
88
+ const pane = (await tmux('display-message', '-t', sessionName, '-p', '#{pane_id}')).trim();
89
+ await tmux('send-keys', '-t', pane, '-l', command);
90
+ await tmux('send-keys', '-t', pane, 'Enter');
91
+ return pane;
92
+ }
93
+ const out = await tmux('new-window', '-t', `${sessionName}:`, '-c', cwd, '-P', '-F', '#{pane_id}', command);
94
+ return out.trim();
95
+ }