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,98 @@
1
+ // xAI Grok Build adapter — EXPERIMENTAL.
2
+ //
3
+ // Grok Build is closed source and its usage-limit banner text has no public
4
+ // documentation, so limit detection uses generic patterns and leans on the
5
+ // 5-hour fallback when no reset time parses. What IS documented (docs.x.ai):
6
+ // - hooks are Claude-Code-compatible JSON, events include StopFailure,
7
+ // read from ~/.grok/hooks/*.json — so the hook channel works
8
+ // - sessions live in ~/.grok/sessions/, resume via `grok --resume [<id>]`
9
+ // or `grok -c` (most recent, keyed by cwd)
10
+ // Users can improve the patterns by sending real captures: `unsnooze report`.
11
+ //
12
+ // NOTE: the community superagent-ai/grok-cli also installs a `grok` binary and
13
+ // also uses ~/.grok — that one is API-key-only (no usage windows) and is NOT
14
+ // the target; isCommunityGrokCli() tells them apart for the setup wizard.
15
+
16
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const GROK_DIR = () => process.env.UNSNOOZE_GROK_DIR || join(homedir(), '.grok');
21
+
22
+ const LIMIT_ANCHORS = [
23
+ /usage (?:limit|quota)/i,
24
+ /rate limit exceeded/i,
25
+ /reached your .*(?:limit|quota)/i,
26
+ /out of credits/i,
27
+ ];
28
+
29
+ export const patterns = {
30
+ limitPatterns: LIMIT_ANCHORS,
31
+ // Anchors double as reset lines (single-line banners): unparseable reset
32
+ // text falls back to the 5h default and self-corrects on verify.
33
+ resetPatterns: [
34
+ /try again/i,
35
+ /resets?\s/i,
36
+ ...LIMIT_ANCHORS,
37
+ ],
38
+ weeklyPatterns: [/week(?:ly)?\s+(?:limit|quota)/i],
39
+ fiveHourPatterns: [],
40
+ // The shortcuts bar is contextual; cancel/interject hints mean a turn is running.
41
+ busyPatterns: [
42
+ /cancel the running turn/i,
43
+ /interject/i,
44
+ /esc to interrupt/i,
45
+ ],
46
+ idleRegex: /[›❯>]/,
47
+ overloadPatterns: [/API error/i, /5\d\d\b.*error/i],
48
+ transientPatterns: [/API error/i],
49
+ };
50
+
51
+ export function isCommunityGrokCli({ grokDir = GROK_DIR() } = {}) {
52
+ // Official Grok Build: ~/.grok/config.toml. Community grok-cli: user-settings.json.
53
+ return existsSync(join(grokDir, 'user-settings.json')) && !existsSync(join(grokDir, 'config.toml'));
54
+ }
55
+
56
+ // Grok reads Claude-Code-format hook JSON from ~/.grok/hooks/*.json — install
57
+ // our StopFailure handler in a file we own outright (trivial uninstall, no
58
+ // merging with user config).
59
+ export function installGrokHooks({ grokDir = GROK_DIR(), unsnoozeBin } = {}) {
60
+ const hooksDir = join(grokDir, 'hooks');
61
+ mkdirSync(hooksDir, { recursive: true });
62
+ const bin = unsnoozeBin || process.argv[1] || 'unsnooze';
63
+ const config = {
64
+ hooks: {
65
+ StopFailure: [{
66
+ matcher: 'overloaded|server_error|rate_limit',
67
+ hooks: [{ type: 'command', command: `test -f "${bin}" && node "${bin}" _hook-stopfailure --agent grok || exit 0`, timeout: 5 }],
68
+ }],
69
+ },
70
+ };
71
+ const file = join(hooksDir, 'unsnooze.json');
72
+ writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
73
+ return file;
74
+ }
75
+
76
+ export function uninstallGrokHooks({ grokDir = GROK_DIR() } = {}) {
77
+ rmSync(join(grokDir, 'hooks', 'unsnooze.json'), { force: true });
78
+ }
79
+
80
+ export default {
81
+ id: 'grok',
82
+ name: 'Grok Build (xAI)',
83
+ bin: process.env.UNSNOOZE_GROK_BIN || 'grok',
84
+ experimental: true,
85
+ patterns,
86
+ menu: null,
87
+ resumeArgs(sessionId) {
88
+ return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
89
+ },
90
+ // Session file format is undocumented; null keeps it conservative — the
91
+ // reopen path then uses `grok -c`, which Grok itself scopes to the cwd.
92
+ latestSessionId() {
93
+ return null;
94
+ },
95
+ isForegroundCommand(cmd) {
96
+ return cmd === 'grok' || cmd === 'node' || cmd === 'unsnooze';
97
+ },
98
+ };
@@ -0,0 +1,16 @@
1
+ // Agent registry. Every supported CLI gets an adapter; unknown ids fall back
2
+ // to claude (pre-adapter state records have no agent field).
3
+
4
+ import claude from './claude.js';
5
+ import codex from './codex.js';
6
+ import grok from './grok.js';
7
+
8
+ const REGISTRY = { claude, codex, grok };
9
+
10
+ export function getAgent(id) {
11
+ return REGISTRY[id] || claude;
12
+ }
13
+
14
+ export function listAgents() {
15
+ return Object.values(REGISTRY);
16
+ }
package/src/cli.js ADDED
@@ -0,0 +1,114 @@
1
+ // User-facing subcommands: status, resume-now, cancel, logs, config.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { LOG_FILE, MAX_RESUME_ATTEMPTS } from './config.js';
6
+ import { readState, setStatus, updateState } from './state.js';
7
+ import { getConfig, setConfigValue, listConfig, CONFIG_FILE } from './settings.js';
8
+ import { spawnResumerIfNeeded } from './spawn.js';
9
+
10
+ function fmtCountdown(ms) {
11
+ if (ms <= 0) return 'due now';
12
+ const m = Math.round(ms / 60_000);
13
+ if (m < 60) return `${m}m`;
14
+ const h = Math.floor(m / 60);
15
+ return `${h}h ${m % 60}m`;
16
+ }
17
+
18
+ export function cmdStatus() {
19
+ const state = readState();
20
+ const sessions = Object.values(state.sessions);
21
+ const paused = !getConfig('autoResume');
22
+ if (sessions.length === 0) {
23
+ console.log(`unsnooze: no tracked sessions.${paused ? ' (PAUSED — auto-resume off)' : ''}`);
24
+ return 0;
25
+ }
26
+ const now = Date.now();
27
+ const pausedNote = paused ? ' PAUSED — auto-resume off (`unsnooze config set autoResume on`)' : '';
28
+ console.log(`unsnooze: ${sessions.length} tracked session(s) (resumer pid: ${state.resumerPid ?? 'not running'})${pausedNote}\n`);
29
+ for (const s of sessions.sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0))) {
30
+ const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
31
+ const reset = s.resetAt ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})` : '?';
32
+ console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
33
+ console.log(` pane ${s.pane ?? '-'} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}`);
34
+ }
35
+ return 0;
36
+ }
37
+
38
+ function selectKeys(state, idOrAll) {
39
+ const stopped = Object.values(state.sessions).filter(s => s.status === 'stopped');
40
+ if (idOrAll === '--all' || idOrAll === undefined) return stopped.map(s => s.key);
41
+ const match = stopped.filter(s => s.key.startsWith(idOrAll) || (s.sessionId || '').startsWith(idOrAll));
42
+ return match.map(s => s.key);
43
+ }
44
+
45
+ export function cmdResumeNow(idOrAll) {
46
+ const state = readState();
47
+ const keys = selectKeys(state, idOrAll);
48
+ if (keys.length === 0) { console.log('unsnooze: no matching stopped sessions.'); return 1; }
49
+ updateState(s => {
50
+ for (const key of keys) {
51
+ if (s.sessions[key]) {
52
+ s.sessions[key].resetAt = Date.now();
53
+ s.sessions[key].manual = true; // explicit user action beats autoResume=off
54
+ }
55
+ }
56
+ });
57
+ spawnResumerIfNeeded();
58
+ console.log(`unsnooze: marked ${keys.length} session(s) due now; resumer dispatched.`);
59
+ return 0;
60
+ }
61
+
62
+ export function cmdCancel(idOrAll) {
63
+ const state = readState();
64
+ const keys = selectKeys(state, idOrAll);
65
+ if (keys.length === 0) { console.log('unsnooze: no matching stopped sessions.'); return 1; }
66
+ for (const key of keys) setStatus(key, 'cancelled');
67
+ console.log(`unsnooze: cancelled ${keys.length} session(s).`);
68
+ return 0;
69
+ }
70
+
71
+ export function cmdLogs(follow) {
72
+ if (follow) {
73
+ const r = spawnSync('tail', ['-f', LOG_FILE], { stdio: 'inherit' });
74
+ return r.status ?? 0;
75
+ }
76
+ try {
77
+ process.stdout.write(readFileSync(LOG_FILE, 'utf-8'));
78
+ } catch {
79
+ console.log('unsnooze: no log file yet.');
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ // `unsnooze config list | get <key> | set <key> <value>`
85
+ export function cmdConfig(rest) {
86
+ const [action, key, ...valueParts] = rest;
87
+ try {
88
+ if (!action || action === 'list') {
89
+ const listed = listConfig();
90
+ console.log(`unsnooze settings (${CONFIG_FILE()}):\n`);
91
+ for (const [k, v] of Object.entries(listed)) {
92
+ console.log(` ${k.padEnd(16)} ${JSON.stringify(v)}`);
93
+ }
94
+ return 0;
95
+ }
96
+ if (action === 'get') {
97
+ if (!key) { console.error('unsnooze config get <key>'); return 2; }
98
+ console.log(JSON.stringify(getConfig(key)));
99
+ return 0;
100
+ }
101
+ if (action === 'set') {
102
+ const value = valueParts.join(' ');
103
+ if (!key || value === '') { console.error('unsnooze config set <key> <value>'); return 2; }
104
+ const applied = setConfigValue(key, value);
105
+ console.log(`unsnooze: ${key} = ${JSON.stringify(applied)}`);
106
+ return 0;
107
+ }
108
+ console.error(`unsnooze config: unknown action "${action}" (list | get | set)`);
109
+ return 2;
110
+ } catch (err) {
111
+ console.error(err.message);
112
+ return 1;
113
+ }
114
+ }
package/src/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ function envInt(name, def) {
5
+ const v = parseInt(process.env[name] ?? '', 10);
6
+ return Number.isFinite(v) ? v : def;
7
+ }
8
+
9
+ export const STATE_DIR = process.env.UNSNOOZE_STATE_DIR || join(homedir(), '.unsnooze');
10
+ export const STATE_FILE = join(STATE_DIR, 'state.json');
11
+ export const LOCK_DIR = join(STATE_DIR, 'state.lock');
12
+ export const LOG_FILE = join(STATE_DIR, 'unsnooze.log');
13
+ export const EVENTS_DIR = join(STATE_DIR, 'events');
14
+ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
15
+
16
+ export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR || join(homedir(), '.claude');
17
+ export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
18
+
19
+ export const TMUX_SESSION_NAME = process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
20
+
21
+ // Timing (ms unless noted)
22
+ export const RESET_MARGIN_MS = envInt('UNSNOOZE_RESET_MARGIN_MS', 60_000);
23
+ export const POLL_INTERVAL_MS = envInt('UNSNOOZE_POLL_INTERVAL_MS', 30_000); // resumer epoch polling
24
+ export const SCRAPE_INTERVAL_MS = envInt('UNSNOOZE_SCRAPE_INTERVAL_MS', 5_000); // monitor pane scraping
25
+ export const FALLBACK_RESET_MS = envInt('UNSNOOZE_FALLBACK_RESET_MS', 5 * 3_600_000);
26
+ export const STAGGER_MS = envInt('UNSNOOZE_STAGGER_MS', 8_000);
27
+ export const VERIFY_DELAY_MS = envInt('UNSNOOZE_VERIFY_DELAY_MS', 20_000);
28
+ export const BUSY_DEFER_MS = envInt('UNSNOOZE_BUSY_DEFER_MS', 60_000);
29
+ export const READY_TIMEOUT_MS = envInt('UNSNOOZE_READY_TIMEOUT_MS', 60_000);
30
+ export const EVENT_MARKER_TTL_MS = envInt('UNSNOOZE_EVENT_MARKER_TTL_MS', 120_000);
31
+
32
+ // Pane scanning
33
+ export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
34
+ export const CAPTURE_LINES = envInt('UNSNOOZE_CAPTURE_LINES', 200);
35
+
36
+ // Limits & retries
37
+ export const MAX_RESUME_ATTEMPTS = envInt('UNSNOOZE_MAX_RESUME_ATTEMPTS', 5);
38
+ export const MAX_BUSY_DEFERS = envInt('UNSNOOZE_MAX_BUSY_DEFERS', 10);
39
+ export const OVERLOAD_BACKOFF_S = (process.env.UNSNOOZE_OVERLOAD_BACKOFF_S || '30,60,120,240,300')
40
+ .split(',').map(Number).filter(Number.isFinite);
41
+ export const OVERLOAD_JITTER = 0.15;
42
+ export const DEDUPE_WINDOW_MS = envInt('UNSNOOZE_DEDUPE_WINDOW_MS', 120_000);
43
+ export const PRUNE_AFTER_MS = envInt('UNSNOOZE_PRUNE_AFTER_MS', 7 * 86_400_000);
44
+ export const STALE_LOCK_MS = envInt('UNSNOOZE_STALE_LOCK_MS', 10_000);
45
+
46
+
package/src/hook.js ADDED
@@ -0,0 +1,105 @@
1
+ // StopFailure hook handler (`unsnooze _hook-stopfailure [--agent <id>]`).
2
+ // The agent CLI (Claude Code, or Grok Build via its Claude-compatible hooks)
3
+ // invokes this with JSON on stdin when a turn ends in failure matching the
4
+ // configured matcher (overloaded|server_error|rate_limit).
5
+ // Must exit 0 quickly and never block or crash the calling CLI.
6
+
7
+ import { mkdirSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { EVENTS_DIR, FALLBACK_RESET_MS, RESET_MARGIN_MS, CAPTURE_LINES, PANE_SCAN_LINES, TMUX_SESSION_NAME } from './config.js';
10
+ import { detectLimit } from './patterns.js';
11
+ import { getAgent } from './agents/index.js';
12
+ import { getConfig } from './settings.js';
13
+ import { parseResetTime, resetAtMs } from './time-parser.js';
14
+ import { upsertSession } from './state.js';
15
+ import { capturePane } from './tmux.js';
16
+ import { spawnResumerIfNeeded } from './spawn.js';
17
+ import { makeLogger } from './logger.js';
18
+
19
+ const log = makeLogger('hook');
20
+
21
+ function readStdin(timeoutMs = 2000) {
22
+ return new Promise(resolve => {
23
+ let data = '';
24
+ const timer = setTimeout(() => resolve(data), timeoutMs);
25
+ process.stdin.on('data', c => { data += c; });
26
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
27
+ process.stdin.on('error', () => { clearTimeout(timer); resolve(data); });
28
+ });
29
+ }
30
+
31
+ function classify(payload, raw) {
32
+ const hay = JSON.stringify(payload) + raw;
33
+ if (/rate.?limit|usage.?limit/i.test(hay)) return 'rate_limit';
34
+ if (/overloaded|server_error|529|503/i.test(hay)) return 'overload';
35
+ return 'unknown';
36
+ }
37
+
38
+ export async function runHook(rest = []) {
39
+ try {
40
+ const agentIdx = rest.indexOf('--agent');
41
+ const agent = getAgent(agentIdx !== -1 ? rest[agentIdx + 1] : 'claude');
42
+ if (!getConfig(`agents.${agent.id}`)) return 0; // agent disabled in settings
43
+ const raw = await readStdin();
44
+ let payload = {};
45
+ try { payload = JSON.parse(raw); } catch { /* tolerate non-JSON */ }
46
+
47
+ const pane = process.env.TMUX_PANE || payload.tmux_pane || null;
48
+ const kind = classify(payload, raw);
49
+ log(`StopFailure: kind=${kind} pane=${pane} session=${payload.session_id || '?'}`);
50
+
51
+ if (kind === 'overload') {
52
+ // Seconds-scale problem — leave a marker for the pane's monitor, do NOT
53
+ // record in state.json.
54
+ if (pane) {
55
+ mkdirSync(EVENTS_DIR, { recursive: true });
56
+ writeFileSync(join(EVENTS_DIR, `${pane.replace(/[^%\w]/g, '_')}.json`),
57
+ JSON.stringify({ pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }));
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ // rate_limit (or unknown — treat conservatively as a limit if we can see a banner)
63
+ const cwd = payload.cwd || process.cwd();
64
+ const detectedAt = Date.now();
65
+ let resetLine = null;
66
+ let limitType = 'unknown';
67
+ if (pane) {
68
+ try {
69
+ const text = await capturePane(pane, CAPTURE_LINES);
70
+ const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
71
+ if (d.hit) { resetLine = d.resetLine; limitType = d.limitType; }
72
+ } catch { /* pane not capturable (no tmux) — fall back below */ }
73
+ }
74
+
75
+ if (kind === 'unknown' && !resetLine) return 0; // nothing actionable
76
+
77
+ const { at, source } = resetAtMs(parseResetTime(resetLine), {
78
+ marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
79
+ });
80
+ const sessionId = payload.session_id || agent.latestSessionId(cwd, detectedAt);
81
+
82
+ upsertSession({
83
+ sessionId: sessionId || null,
84
+ cwd,
85
+ pane,
86
+ agent: agent.id,
87
+ tmuxSession: TMUX_SESSION_NAME,
88
+ status: 'stopped',
89
+ limitType,
90
+ detectedVia: 'hook',
91
+ detectedAt,
92
+ resetAt: at,
93
+ resetSource: source,
94
+ attempts: 0,
95
+ lastAttemptAt: null,
96
+ lastError: null,
97
+ });
98
+ log(`recorded limit stop: session=${sessionId} resetAt=${new Date(at).toISOString()} (${source})`);
99
+ spawnResumerIfNeeded();
100
+ return 0;
101
+ } catch (err) {
102
+ log(`hook error (swallowed): ${err.stack || err}`);
103
+ return 0; // never fail the hook
104
+ }
105
+ }
package/src/install.js ADDED
@@ -0,0 +1,218 @@
1
+ // Install / uninstall: wires unsnooze into the shell and the agent CLIs, and
2
+ // migrates off claude-auto-retry / the pre-release csg.
3
+ // - ~/.claude/settings.json: StopFailure hook → unsnooze _hook-stopfailure
4
+ // (removes legacy hook entries; preserves everything else; backs up first;
5
+ // atomic write)
6
+ // - ~/.zshrc + ~/.bashrc: one fence-marked block with a wrapper function per
7
+ // enabled agent (claude/codex/grok), routed through `unsnooze _run`
8
+ // - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
9
+ // --settings <path> / --zshrc <path> override targets (used by tests).
10
+
11
+ import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join, dirname } from 'node:path';
14
+ import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
15
+ import { getConfig, configFileExists } from './settings.js';
16
+ import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
17
+ import { UNSNOOZE_BIN } from './spawn.js';
18
+
19
+ const FENCE_OPEN = '# >>> unsnooze >>>';
20
+ const FENCE_CLOSE = '# <<< unsnooze <<<';
21
+ // Fenced blocks left by tools this one replaces: claude-auto-retry, and the
22
+ // pre-release "claude-session-guard" (csg) incarnation of unsnooze itself.
23
+ const LEGACY_FENCES = [
24
+ { open: '# >>> claude-auto-retry >>>', close: '# <<< claude-auto-retry <<<' },
25
+ { open: '# >>> claude-session-guard >>>', close: '# <<< claude-session-guard <<<' },
26
+ ];
27
+ const OLD_FENCE_OPEN = LEGACY_FENCES[0].open;
28
+
29
+ function parseArgs(rest) {
30
+ const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false };
31
+ for (let i = 0; i < rest.length; i++) {
32
+ if (rest[i] === '--yes' || rest[i] === '-y') opts.yes = true;
33
+ else if (rest[i] === '--purge') opts.purge = true;
34
+ else if (rest[i] === '--settings') opts.settings = rest[++i];
35
+ else if (rest[i] === '--zshrc') opts.zshrc = rest[++i];
36
+ }
37
+ return opts;
38
+ }
39
+
40
+ function atomicWrite(path, content) {
41
+ const tmp = join(dirname(path), `.${Date.now()}.tmp`);
42
+ writeFileSync(tmp, content);
43
+ renameSync(tmp, path);
44
+ }
45
+
46
+ // --- settings.json hook management ---
47
+
48
+ function isOurs(entry) {
49
+ return (entry.hooks || []).some(h => /unsnooze\.js"? _hook-stopfailure/.test(h.command || ''));
50
+ }
51
+ function isLegacy(entry) {
52
+ return (entry.hooks || []).some(h => /claude-auto-retry|csg\.js _hook-stopfailure/.test(h.command || ''));
53
+ }
54
+
55
+ export function mergeHookIntoSettings(settingsJson) {
56
+ const settings = JSON.parse(settingsJson);
57
+ settings.hooks = settings.hooks || {};
58
+ const list = (settings.hooks.StopFailure || []).filter(e => !isLegacy(e) && !isOurs(e));
59
+ list.push({
60
+ matcher: 'overloaded|server_error|rate_limit',
61
+ // Guarded like the shell wrapper: a vanished entry point must exit 0, not
62
+ // spray MODULE_NOT_FOUND errors into every Claude Code turn.
63
+ hooks: [{ type: 'command', command: `test -f "${UNSNOOZE_BIN}" && node "${UNSNOOZE_BIN}" _hook-stopfailure || exit 0`, timeout: 5 }],
64
+ });
65
+ settings.hooks.StopFailure = list;
66
+ return JSON.stringify(settings, null, 2) + '\n';
67
+ }
68
+
69
+ export function removeHookFromSettings(settingsJson) {
70
+ const settings = JSON.parse(settingsJson);
71
+ if (settings.hooks?.StopFailure) {
72
+ settings.hooks.StopFailure = settings.hooks.StopFailure.filter(e => !isOurs(e));
73
+ if (settings.hooks.StopFailure.length === 0) delete settings.hooks.StopFailure;
74
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
75
+ }
76
+ return JSON.stringify(settings, null, 2) + '\n';
77
+ }
78
+
79
+ // --- zshrc block management ---
80
+
81
+ export function wrapperBlock(agents = ['claude']) {
82
+ // The missing-file guard is load-bearing: if unsnooze is ever uninstalled,
83
+ // moved, or renamed without cleaning the rc file, the wrapper must degrade
84
+ // to the plain CLI — never brick the user's `claude`/`codex` command.
85
+ const fns = agents.map(id => `unalias ${id} 2>/dev/null || true
86
+ ${id}() {
87
+ if [ "\${UNSNOOZE_ACTIVE}" = "1" ] || [ ! -f "${UNSNOOZE_BIN}" ]; then
88
+ command ${id} "$@"
89
+ return $?
90
+ fi
91
+ node "${UNSNOOZE_BIN}" _run ${id} "$@"
92
+ }`).join('\n');
93
+ return `${FENCE_OPEN}
94
+ # unsnooze wrappers: route every interactive launch of the CLIs below through
95
+ # unsnooze so limit stops are recorded and auto-resumed.
96
+ ${fns}
97
+ ${FENCE_CLOSE}`;
98
+ }
99
+
100
+ export function stripFencedBlock(content, open, close) {
101
+ const lines = content.split('\n');
102
+ const out = [];
103
+ let inside = false;
104
+ let found = false;
105
+ for (const line of lines) {
106
+ if (!inside && line.trim() === open) { inside = true; found = true; continue; }
107
+ if (inside && line.trim() === close) { inside = false; continue; }
108
+ if (!inside) out.push(line);
109
+ }
110
+ return { content: out.join('\n'), found };
111
+ }
112
+
113
+ export function installZshrcBlock(content, agents = ['claude']) {
114
+ let cleaned = content;
115
+ let oldRemoved = false;
116
+ for (const { open, close } of LEGACY_FENCES) {
117
+ const r = stripFencedBlock(cleaned, open, close);
118
+ cleaned = r.content;
119
+ oldRemoved = oldRemoved || r.found;
120
+ }
121
+ ({ content: cleaned } = stripFencedBlock(cleaned, FENCE_OPEN, FENCE_CLOSE));
122
+ const result = cleaned.replace(/\n+$/, '\n') + '\n' + wrapperBlock(agents) + '\n';
123
+ return { content: result, oldRemoved };
124
+ }
125
+
126
+ // --- commands ---
127
+
128
+ export function enabledAgents() {
129
+ return ['claude', 'codex', 'grok'].filter(id => getConfig(`agents.${id}`));
130
+ }
131
+
132
+ // rc files to touch: the explicit --zshrc target, or every rc file that exists
133
+ // (zsh + bash) so the wrappers work regardless of the user's shell.
134
+ function rcTargets(opts, explicit) {
135
+ if (explicit) return [opts.zshrc];
136
+ const candidates = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')];
137
+ const existing = candidates.filter(p => existsSync(p));
138
+ return existing.length > 0 ? existing : [opts.zshrc];
139
+ }
140
+
141
+ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
142
+ const opts = parseArgs(rest);
143
+ const explicitRc = rest.includes('--zshrc');
144
+
145
+ // First run in a real terminal with no saved settings → hand over to the
146
+ // interactive setup wizard (it calls back here with --yes afterwards).
147
+ if (!opts.yes && !configFileExists() && process.stdout.isTTY && process.stdin.isTTY) {
148
+ return import('./wizard.js').then(({ runWizard }) => runWizard());
149
+ }
150
+
151
+ // 1. Claude Code hook (also consumed by Grok Build's Claude-compatible hooks
152
+ // when installed below).
153
+ if (agents.includes('claude')) {
154
+ if (existsSync(opts.settings)) {
155
+ copyFileSync(opts.settings, `${opts.settings}.unsnooze-bak`);
156
+ const before = readFileSync(opts.settings, 'utf-8');
157
+ atomicWrite(opts.settings, mergeHookIntoSettings(before));
158
+ console.log(`unsnooze: StopFailure hook installed in ${opts.settings} (backup: ${opts.settings}.unsnooze-bak)`);
159
+ } else {
160
+ atomicWrite(opts.settings, mergeHookIntoSettings('{}'));
161
+ console.log(`unsnooze: created ${opts.settings} with StopFailure hook`);
162
+ }
163
+ }
164
+
165
+ // 2. Grok Build hook file.
166
+ if (agents.includes('grok')) {
167
+ const file = installGrokHooks({ unsnoozeBin: UNSNOOZE_BIN });
168
+ console.log(`unsnooze: Grok StopFailure hook installed at ${file}`);
169
+ }
170
+
171
+ // 3. Shell wrappers (zsh + bash).
172
+ for (const rc of rcTargets(opts, explicitRc)) {
173
+ const rcContent = existsSync(rc) ? readFileSync(rc, 'utf-8') : '';
174
+ const hasOld = rcContent.includes(OLD_FENCE_OPEN) || rcContent.includes('CLAUDE_AUTO_RETRY_ACTIVE');
175
+ if (hasOld && !opts.yes) {
176
+ console.log(`unsnooze: found the old claude-auto-retry wrapper in ${rc}.`);
177
+ console.log('unsnooze: re-run with --yes to replace it, or remove the fenced');
178
+ console.log(`unsnooze: "${OLD_FENCE_OPEN}" block manually first.`);
179
+ return 1;
180
+ }
181
+ if (existsSync(rc)) copyFileSync(rc, `${rc}.unsnooze-bak`);
182
+ const { content, oldRemoved } = installZshrcBlock(rcContent, agents);
183
+ atomicWrite(rc, content);
184
+ console.log(`unsnooze: wrappers (${agents.join(', ')}) installed in ${rc}${oldRemoved ? ' (legacy wrapper block removed)' : ''}`);
185
+ }
186
+
187
+ console.log('\nunsnooze: done. Reload your shell:');
188
+ console.log(' exec $SHELL');
189
+ return 0;
190
+ }
191
+
192
+ export function cmdUninstall(rest) {
193
+ const opts = parseArgs(rest);
194
+ const explicitRc = rest.includes('--zshrc');
195
+
196
+ if (existsSync(opts.settings)) {
197
+ const before = readFileSync(opts.settings, 'utf-8');
198
+ atomicWrite(opts.settings, removeHookFromSettings(before));
199
+ console.log(`unsnooze: StopFailure hook removed from ${opts.settings}`);
200
+ }
201
+
202
+ uninstallGrokHooks();
203
+
204
+ for (const rc of rcTargets(opts, explicitRc)) {
205
+ if (!existsSync(rc)) continue;
206
+ const { content, found } = stripFencedBlock(readFileSync(rc, 'utf-8'), FENCE_OPEN, FENCE_CLOSE);
207
+ if (found) {
208
+ atomicWrite(rc, content);
209
+ console.log(`unsnooze: wrappers removed from ${rc}`);
210
+ }
211
+ }
212
+
213
+ if (opts.purge) {
214
+ rmSync(STATE_DIR, { recursive: true, force: true });
215
+ console.log(`unsnooze: state dir ${STATE_DIR} removed`);
216
+ }
217
+ return 0;
218
+ }
@@ -0,0 +1,71 @@
1
+ // Default `unsnooze _run <agent> [args...]` path: run the agent CLI under watch.
2
+ // - outside tmux: re-exec inside a new tmux session (the monitor needs a pane
3
+ // to scrape) — guarded by UNSNOOZE_ACTIVE to prevent recursion
4
+ // - inside tmux: spawn a detached per-pane monitor, then run the CLI,
5
+ // propagating its exit code
6
+ // - -p/--print: pure pass-through, no monitor (nothing interactive to scrape)
7
+
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import { insideTmux, currentPaneId, tmuxAvailable } from './tmux.js';
10
+ import { getAgent } from './agents/index.js';
11
+ import { getConfig } from './settings.js';
12
+ import { spawnDetached } from './spawn.js';
13
+ import { makeLogger } from './logger.js';
14
+
15
+ const log = makeLogger('launcher');
16
+
17
+ function isPrintMode(args) {
18
+ return args.includes('-p') || args.includes('--print');
19
+ }
20
+
21
+ export function runLauncher(args, agentId = 'claude') {
22
+ const agent = getAgent(agentId);
23
+
24
+ // Recursion / nested-launch guard: inside an unsnooze-managed session, a
25
+ // plain `claude`/`codex`/`unsnooze` call goes straight through. Same for an
26
+ // agent the user disabled in settings — run it, don't watch it.
27
+ if (process.env.UNSNOOZE_ACTIVE === '1' || isPrintMode(args) || !getConfig(`agents.${agent.id}`)) {
28
+ const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
29
+ return r.status ?? 1;
30
+ }
31
+
32
+ if (!insideTmux()) {
33
+ if (!tmuxAvailable()) {
34
+ // Degrade gracefully: run the CLI unwatched rather than dying.
35
+ process.stderr.write('unsnooze: tmux not found — running without limit-watch.\n');
36
+ if (process.platform === 'win32') {
37
+ process.stderr.write('unsnooze: native Windows is not supported; run inside WSL (https://learn.microsoft.com/windows/wsl/install) where tmux works.\n');
38
+ } else {
39
+ process.stderr.write('unsnooze: install tmux to enable auto-resume (brew install tmux / apt install tmux).\n');
40
+ }
41
+ const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
42
+ return r.status ?? 1;
43
+ }
44
+ // Re-enter under tmux: `tmux new-session unsnooze _run <agent> <args...>` —
45
+ // the inner unsnooze lands in the insideTmux() branch below.
46
+ log(`not in tmux — wrapping into a tmux session`);
47
+ const inner = ['new-session', process.execPath, process.argv[1], '_run', agent.id, ...args];
48
+ const r = spawnSync('tmux', inner, { stdio: 'inherit' });
49
+ return r.status ?? 1;
50
+ }
51
+
52
+ const pane = currentPaneId();
53
+ if (pane) {
54
+ spawnDetached(['_monitor', pane, agent.id], { UNSNOOZE_CWD: process.cwd() });
55
+ log(`launching ${agent.id} in pane ${pane}, monitor spawned`);
56
+ } else {
57
+ log(`inside tmux but TMUX_PANE unset — launching ${agent.id} without monitor`);
58
+ }
59
+
60
+ const child = spawn(agent.bin, args, {
61
+ stdio: 'inherit',
62
+ env: { ...process.env, UNSNOOZE_ACTIVE: '1', UNSNOOZE_PANE: pane || '' },
63
+ });
64
+ return new Promise(resolve => {
65
+ child.on('exit', code => resolve(code ?? 1));
66
+ child.on('error', err => {
67
+ process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
68
+ resolve(127);
69
+ });
70
+ });
71
+ }