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/CHANGELOG.md ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-07-10
4
+
5
+ First public release (previously the private `claude-session-guard`/`csg`).
6
+
7
+ ### Multi-CLI auto-resume
8
+
9
+ - **Claude Code and OpenAI Codex CLI fully supported; xAI Grok Build
10
+ experimental** (generic patterns + `unsnooze report` to contribute real
11
+ banner captures).
12
+ - **Agent adapters** (`src/agents/`): per-CLI banner regexes, busy/idle
13
+ markers, resume invocation, session-store lookup, hook wiring.
14
+ - **Codex specifics**: verbatim banner strings from the Codex source; parses
15
+ `try again at 3:51 PM`, `Feb 23rd, 2026 9:01 PM`, and
16
+ `in 4 days 20 hours 9 minutes`; dead sessions revive via
17
+ `codex resume <id> "<message>"` — the prompt travels in argv, nothing is
18
+ typed into the pane.
19
+
20
+ ### Settings & UX
21
+
22
+ - **Settings**: `~/.unsnooze/config.json`, `unsnooze config list/get/set`,
23
+ toggles for autoResume, menuAutoAnswer, notifications, resumeMessage, and
24
+ per-agent enablement (env > file > default).
25
+ - **Setup wizard**: `unsnooze setup` — detects installed CLIs, warns when
26
+ `grok` is the community CLI rather than Grok Build, installs wrappers
27
+ (zsh + bash) and hooks.
28
+ - **Desktop notifications** on limit detected / session resumed / gave up
29
+ (macOS osascript, Linux notify-send, tmux fallback).
30
+
31
+ ### Windows / WSL
32
+
33
+ - **Windows via WSL**: native Windows toast notifications from inside WSL via
34
+ `powershell.exe` (no notify-send/X server needed), `where`-based CLI
35
+ detection, and a friendly "install tmux / use WSL" message instead of a
36
+ hard failure when tmux is missing (the agent CLI still runs, just
37
+ unwatched). The tmux-independent core is exercised on Windows in CI.
38
+
39
+ ### Safety hardening
40
+
41
+ - **Wrappers and hooks can never brick the wrapped CLI**: the shell wrapper
42
+ falls through to the real `claude`/`codex`/`grok` when the unsnooze entry
43
+ point is missing, and hook commands no-op (`exit 0`) instead of erroring on
44
+ every turn.
45
+ - **Menus are answered from the visible screen only**, never from tmux
46
+ scrollback — an already-answered menu in history can no longer trigger
47
+ stray keystrokes (relevant for non-alt-screen TUIs like
48
+ `codex --no-alt-screen`).
49
+ - **Session reopen uses absolute node + entry-point paths** instead of a PATH
50
+ lookup — a tmux server started without npm globals (or nvm's node) on PATH
51
+ can no longer break revival with command-not-found.
52
+ - Migrates cleanly off `claude-auto-retry` and the pre-release csg install
53
+ (fenced rc blocks and settings.json hook entries are replaced, with
54
+ backups).
55
+
56
+ ### Testing
57
+
58
+ - 104 unit tests plus a **12-scenario end-to-end suite**
59
+ (`scripts/e2e-simulate.sh`) that exercises every agent and safety path in
60
+ real tmux with real monitor/hook/resumer processes — banner detection for
61
+ all three CLIs, raw-key menu driving with selection verification, hook
62
+ ingestion, dead-pane and live-pane resume, both toggles, and the
63
+ 529-overload retry ladder (now env-tunable via
64
+ `UNSNOOZE_OVERLOAD_BACKOFF_S`).
65
+ - CI: Ubuntu + macOS (Node 20/22) and Windows (Node 22).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saaransh Menon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ <div align="center">
2
+
3
+ <img src="assets/banner.svg" alt="unsnooze — wakes every limit-stopped AI session the moment the limit resets" width="880"/>
4
+
5
+ <br/>
6
+
7
+ [![CI](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml/badge.svg)](https://github.com/saaranshM/unsnooze/actions/workflows/ci.yml)
8
+ [![npm](https://img.shields.io/npm/v/unsnooze?color=f59e0b)](https://www.npmjs.com/package/unsnooze)
9
+ [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3fb950)](package.json)
10
+ [![license](https://img.shields.io/badge/license-MIT-8b949e)](LICENSE)
11
+
12
+ **Claude Code · Codex CLI · Grok** — when they hit a usage limit, your session just… stops.<br/>
13
+ unsnooze tracks **every** limit-stopped session across all your projects and
14
+ **wakes each one up in tmux the moment the limit resets.**
15
+
16
+ ```sh
17
+ npm install -g unsnooze && unsnooze setup
18
+ ```
19
+
20
+ <img src="assets/demo.svg" alt="terminal demo: limit banners detected in two sessions, unsnooze waits for the reset, then wakes both — good morning, the work is done" width="880"/>
21
+
22
+ </div>
23
+
24
+ ## Why unsnooze
25
+
26
+ Overnight and long-running agent work dies at the 5-hour / weekly limit, and every
27
+ existing tool solves only a slice of it:
28
+
29
+ | | **unsnooze** | claude-auto-retry | autoclaude | hydra |
30
+ |---|:---:|:---:|:---:|:---:|
31
+ | Multi-CLI (Claude Code + Codex + Grok) | ✅ | ❌ Claude only | ❌ Claude only | ✅ |
32
+ | Waits for reset & resumes the **same** session | ✅ | ✅ | ✅ | ❌ switches provider |
33
+ | All sessions at once (shared ledger + one daemon) | ✅ | ❌ one pane | ✅ | ✅ |
34
+ | Revives sessions whose pane/process is **gone** | ✅ `--resume <id>` | ❌ | ❌ | ❌ |
35
+ | Survives laptop sleep & weekly-scale waits | ✅ epoch polling | partial | partial | n/a |
36
+ | Settings + first-run wizard | ✅ | ❌ | ❌ | ❌ |
37
+
38
+ ## Supported CLIs
39
+
40
+ - **Claude Code** — dual-channel detection: the `StopFailure` hook (authoritative,
41
+ carries `session_id`) plus tmux pane scraping for banners and the interactive
42
+ limit menu (always answered with *"Stop and wait for limit to reset"*, never a
43
+ blind Enter). Dead sessions revive via `claude --resume <id>`.
44
+ - **OpenAI Codex CLI** — scrape-based (Codex fires no event on limits). Detects
45
+ the exact `■ You've hit your usage limit …` banner strings from the Codex
46
+ source, parses `try again at 3:51 PM` / `Feb 23rd, 2026 9:01 PM` /
47
+ `in 4 days 20 hours 9 minutes`. Dead sessions revive via
48
+ `codex resume <id> "<message>"` — the prompt travels in argv.
49
+ - **Grok Build (xAI)** — ⚠️ *experimental*. Hook channel works (Grok reads
50
+ Claude-compatible hooks, including `StopFailure`); the limit banner text is
51
+ not publicly documented, so detection uses generic patterns with a safe
52
+ fallback. Hit a banner unsnooze missed? Run `unsnooze report` and paste the
53
+ capture into an issue — that's how this adapter gets good.
54
+
55
+ ## How it works
56
+
57
+ <div align="center">
58
+ <img src="assets/how-it-works.png" alt="architecture: claude/codex/grok panes are watched by unsnooze via hooks and banner scraping; stops land in state.json; the resumer daemon sleeps until the limit resets, then types into live panes or reopens dead ones until every session is running again" width="880"/>
59
+ </div>
60
+
61
+ <details>
62
+ <summary>text version</summary>
63
+
64
+ ```
65
+ claude / codex / grok (shell wrapper) ──► unsnooze _run <agent> ──► CLI in tmux pane
66
+
67
+ ├─ per-pane monitor (scrapes for limit
68
+ │ banners, drives Claude's limit menu,
69
+ │ seconds-scale retry on 5xx/overload)
70
+
71
+ StopFailure hook (claude, grok) ──────────────┤
72
+
73
+ ~/.unsnooze/state.json
74
+ { agent, sessionId, cwd, pane, resetAt, status }
75
+
76
+
77
+ resumer daemon (singleton, epoch-polling —
78
+ survives laptop sleep and weekly-scale waits)
79
+
80
+ ┌───────────────────┴────────────────────┐
81
+ pane still alive? pane gone?
82
+ send resume message into it tmux new-window,
83
+ (only if the CLI is foreground `unsnooze _run <agent>
84
+ and not mid-stream) --resume <id>`, verify
85
+ ```
86
+
87
+ </details>
88
+
89
+ Limit events are never persisted by the CLIs themselves; the reset time is
90
+ parsed from the banner text, DST-safe, with a 5-hour fallback when unparseable
91
+ — and every resume is verified afterwards (banner came back → reschedule from
92
+ the fresh banner, capped at 5 attempts).
93
+
94
+ ## Usage
95
+
96
+ ```sh
97
+ claude / codex / grok # normal usage — wrapped automatically
98
+ unsnooze status # tracked sessions + reset countdowns
99
+ unsnooze resume-now [id|--all] # don't wait for the reset time
100
+ unsnooze cancel [id|--all] # stop tracking a session
101
+ unsnooze config list # settings (see below)
102
+ unsnooze config set <k> <v> # e.g. autoResume off
103
+ unsnooze logs [-f] # what unsnooze has been doing
104
+ unsnooze report [agent] # capture a pane to report an undetected banner
105
+ unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
106
+ ```
107
+
108
+ ## Settings
109
+
110
+ `unsnooze setup` writes `~/.unsnooze/config.json`; change anything later with
111
+ `unsnooze config set`:
112
+
113
+ | key | default | meaning |
114
+ |---|---|---|
115
+ | `autoResume` | `true` | Master switch. Off = stops are still tracked, but nothing is resumed until you run `unsnooze resume-now` or turn it back on. |
116
+ | `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
117
+ | `notifications` | `true` | Desktop notification on limit detected / session resumed / gave up. |
118
+ | `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. |
119
+ | `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
120
+
121
+ Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
122
+ all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
123
+
124
+ ## Safety properties
125
+
126
+ - **Never injects blind**: keys are only sent when the pane's foreground
127
+ process is the agent CLI and no "esc to interrupt"-style busy footer is
128
+ visible. Recycled pane ids can't receive stray messages.
129
+ - **Never picks a menu option blind**: if Claude's limit menu layout can't be
130
+ read, unsnooze does not press Enter (that could confirm "Upgrade your plan").
131
+ - **Sleep-safe waits**: the resumer polls wall-clock against the target epoch
132
+ every 30s instead of one long timer — a laptop asleep past the reset fires
133
+ on the next tick. Weekly limits are just a bigger epoch.
134
+ - **Verified resumes**: after dispatch it re-captures the pane; if the limit
135
+ banner reappears (reset time misparsed / not actually reset), it reschedules
136
+ from the fresh banner, capped at 5 attempts.
137
+ - **Concurrent-writer safe**: state updates go through a mkdir lock + atomic
138
+ rename; corrupt state is quarantined, never fatal (the hook path must never
139
+ block the CLI).
140
+ - **Overload ≠ limit**: 5xx/529/429 transient errors take a seconds-scale
141
+ backoff path ([30,60,120,240,300]s ± jitter) and never enter the ledger.
142
+
143
+ ## Requirements
144
+
145
+ - Node ≥ 20 and tmux
146
+ - macOS, Linux, or **Windows via WSL** (see below)
147
+ - zsh or bash (the wrappers are installed into `~/.zshrc` / `~/.bashrc`)
148
+
149
+ ### Windows / WSL
150
+
151
+ unsnooze is built on tmux, so on Windows it runs inside
152
+ [WSL](https://learn.microsoft.com/windows/wsl/install) — which is where the
153
+ agent CLIs live on Windows anyway:
154
+
155
+ ```sh
156
+ # inside your WSL distro (Ubuntu etc.)
157
+ sudo apt install tmux
158
+ npm install -g unsnooze && unsnooze setup
159
+ ```
160
+
161
+ Everything works as on Linux, including desktop notifications: inside WSL,
162
+ unsnooze raises **native Windows toasts** through `powershell.exe` (no
163
+ `notify-send` or X server needed). Native Windows (PowerShell/cmd, no WSL) is
164
+ not supported — without tmux there is nothing to watch; unsnooze will tell you
165
+ so and run your CLI unwatched instead of breaking it.
166
+
167
+ ## Development
168
+
169
+ ```sh
170
+ npm test # unit tests (node:test)
171
+ ./scripts/e2e-simulate.sh # full detect → wait → re-open cycle in a
172
+ # scratch tmux session (no real limits needed)
173
+ ```
174
+
175
+ ## License
176
+
177
+ [MIT](LICENSE)
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // unsnooze — subcommand router; anything unrecognized is treated as claude
3
+ // args and passed through the launcher (back-compat with the zsh wrapper).
4
+
5
+ const [, , cmd, ...rest] = process.argv;
6
+
7
+ async function main() {
8
+ switch (cmd) {
9
+ case 'status': {
10
+ const { cmdStatus } = await import('../src/cli.js');
11
+ return cmdStatus();
12
+ }
13
+ case 'resume-now': {
14
+ const { cmdResumeNow } = await import('../src/cli.js');
15
+ return cmdResumeNow(rest[0]);
16
+ }
17
+ case 'cancel': {
18
+ const { cmdCancel } = await import('../src/cli.js');
19
+ return cmdCancel(rest[0]);
20
+ }
21
+ case 'logs': {
22
+ const { cmdLogs } = await import('../src/cli.js');
23
+ return cmdLogs(rest.includes('-f'));
24
+ }
25
+ case 'setup': {
26
+ const { runWizard } = await import('../src/wizard.js');
27
+ return runWizard();
28
+ }
29
+ case 'report': {
30
+ const { cmdReport } = await import('../src/report.js');
31
+ return cmdReport(rest);
32
+ }
33
+ case 'config': {
34
+ const { cmdConfig } = await import('../src/cli.js');
35
+ return cmdConfig(rest);
36
+ }
37
+ case 'install': {
38
+ const { cmdInstall } = await import('../src/install.js');
39
+ return cmdInstall(rest);
40
+ }
41
+ case 'uninstall': {
42
+ const { cmdUninstall } = await import('../src/install.js');
43
+ return cmdUninstall(rest);
44
+ }
45
+ case '_hook-stopfailure': {
46
+ const { runHook } = await import('../src/hook.js');
47
+ return runHook(rest);
48
+ }
49
+ case '_monitor': {
50
+ if (!rest[0]) { console.error('unsnooze _monitor: pane id required'); return 2; }
51
+ const { runMonitor } = await import('../src/monitor.js');
52
+ return runMonitor(rest[0], rest[1]);
53
+ }
54
+ case '_run': {
55
+ if (!rest[0]) { console.error('unsnooze _run: agent id required'); return 2; }
56
+ const { runLauncher } = await import('../src/launcher.js');
57
+ return runLauncher(rest.slice(1), rest[0]);
58
+ }
59
+ case '_resumer': {
60
+ const { runResumer } = await import('../src/resumer.js');
61
+ return runResumer();
62
+ }
63
+ case 'help':
64
+ case '--help-unsnooze': {
65
+ console.log(`unsnooze — wakes every limit-stopped AI coding session when the limit resets
66
+
67
+ Usage:
68
+ unsnooze [claude args...] run claude under limit-watch (default)
69
+ unsnooze _run <agent> [args...] run a specific agent CLI under limit-watch
70
+ unsnooze status list tracked sessions + reset countdowns
71
+ unsnooze resume-now [id|--all] resume stopped session(s) immediately
72
+ unsnooze cancel [id|--all] stop tracking session(s)
73
+ unsnooze logs [-f] show (or follow) the unsnooze log
74
+ unsnooze config [list|get|set] view or change settings (toggles, message)
75
+ unsnooze setup interactive setup wizard (agents + toggles)
76
+ unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
77
+ unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
78
+ unsnooze report [agent] [pane] capture a pane to report an undetected banner`);
79
+ return 0;
80
+ }
81
+ default: {
82
+ // Everything else (including no args, --resume, -c, plain prompts) is a
83
+ // claude invocation — back-compat for the plain wrapper.
84
+ const { runLauncher } = await import('../src/launcher.js');
85
+ const args = cmd === undefined ? [] : [cmd, ...rest];
86
+ return runLauncher(args, 'claude');
87
+ }
88
+ }
89
+ }
90
+
91
+ main().then(code => { process.exitCode = typeof code === 'number' ? code : 0; })
92
+ .catch(err => { console.error(`unsnooze: ${err.stack || err}`); process.exitCode = 1; });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "unsnooze",
3
+ "version": "1.0.0",
4
+ "description": "Wakes every limit-stopped AI coding session (Claude Code, Codex CLI, Grok) in tmux the moment the usage limit resets.",
5
+ "keywords": [
6
+ "claude-code",
7
+ "codex",
8
+ "codex-cli",
9
+ "grok",
10
+ "usage-limit",
11
+ "rate-limit",
12
+ "auto-resume",
13
+ "tmux",
14
+ "ai-agents",
15
+ "cli"
16
+ ],
17
+ "author": "Saaransh Menon <saaransh.dev2811@gmail.com>",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/saaranshM/unsnooze.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/saaranshM/unsnooze/issues"
25
+ },
26
+ "homepage": "https://github.com/saaranshM/unsnooze#readme",
27
+ "type": "module",
28
+ "bin": {
29
+ "unsnooze": "bin/unsnooze.js"
30
+ },
31
+ "files": [
32
+ "bin/",
33
+ "src/",
34
+ "README.md",
35
+ "LICENSE",
36
+ "CHANGELOG.md"
37
+ ],
38
+ "scripts": {
39
+ "test": "node --test"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "dependencies": {
45
+ "@clack/prompts": "^1.7.0"
46
+ }
47
+ }
@@ -0,0 +1,99 @@
1
+ // Claude Code adapter: everything unsnooze needs to know about one CLI lives
2
+ // in its adapter — banner regexes, busy/idle markers, resume invocation,
3
+ // session-id lookup, and (claude-only) the interactive limit-menu driver.
4
+
5
+ import { contentLines } from '../patterns.js';
6
+ import { latestSessionId } from '../sessions.js';
7
+
8
+ export const patterns = {
9
+ // Claude Code renders limits across multiple TUI lines, e.g.:
10
+ // "⚠ You've hit your 5-hour limit"
11
+ // "· resets 3pm (UTC)"
12
+ limitPatterns: [
13
+ /(?:hit|exceeded|reached).*(?:your|the)\s*(?:[\w-]+\s+){0,3}limit/i,
14
+ /\d+-hour limit/i,
15
+ /limit reached/i,
16
+ /usage limit/i,
17
+ /out of.*usage/i,
18
+ /rate limit/i,
19
+ /try again in/i,
20
+ ],
21
+ resetPatterns: [
22
+ /resets?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?/i,
23
+ /resets?\s+in[:\s]\s*\d/i,
24
+ /try again in \d+\s*(?:hours?|minutes?|h|m)/i,
25
+ /resets?\s+(?:on\s+)?(?:mon|tue|wed|thu|fri|sat|sun)/i, // weekly: "resets Tuesday 9am"
26
+ ],
27
+ weeklyPatterns: [
28
+ /week(?:ly)?\s+limit/i,
29
+ /resets?\s+(?:on\s+)?(?:mon|tue|wed|thu|fri|sat|sun)/i,
30
+ /limit.*(?:this|per)\s+week/i,
31
+ ],
32
+ fiveHourPatterns: [/\d+-hour limit/i, /session limit/i],
33
+ // While Claude is streaming ("esc to interrupt") or running its own internal
34
+ // retries ("Retrying in 5s · attempt 3/10"), never inject keys.
35
+ busyPatterns: [
36
+ /esc to interrupt/i,
37
+ /\besc\b.*\binterrupt\b/i,
38
+ /Retrying in\b/i,
39
+ /\battempt\s+\d+\/\d+/i,
40
+ ],
41
+ idleRegex: /[❯>]/,
42
+ // Anchored to Claude Code's error render ("API Error: 529") — never bare digits.
43
+ overloadPatterns: [
44
+ /API Error:?\s*\(?5\d\d/i,
45
+ /overloaded_error/i,
46
+ /API Error:?\s*\(?429/i,
47
+ ],
48
+ transientPatterns: [], // claude's transient errors are the overload set
49
+ };
50
+
51
+ // --- Interactive /rate-limit-options menu (Claude Code only) ---
52
+ // Newer Claude Code shows a selectable menu on limit hit:
53
+ // What do you want to do?
54
+ // ❯ 1. Upgrade your plan
55
+ // 2. Stop and wait for limit to reset
56
+ // Option order varies between versions — never assume a position; locate the
57
+ // cursor and target option and compute the moves. Never blind-Enter (could
58
+ // confirm "Upgrade your plan").
59
+
60
+ const MENU_CURSOR = '❯';
61
+ const WAIT_OPTION_REGEX = /stop and wait for limit to reset/i;
62
+ const MENU_OPTION_REGEX = /^\s*❯?\s*\d+\.\s/;
63
+
64
+ export function isRateLimitOptionsPrompt(text, tailLines = 12) {
65
+ const t = contentLines(text, tailLines).join('\n');
66
+ return /what do you want to do\?/i.test(t)
67
+ && WAIT_OPTION_REGEX.test(t)
68
+ && (/enter to confirm/i.test(t) || /esc to cancel/i.test(t) || t.includes(MENU_CURSOR));
69
+ }
70
+
71
+ // Steps (in options) from cursor to the "Stop and wait" option. Positive =>
72
+ // Down N times, negative => Up. null => layout unreadable, caller MUST NOT Enter.
73
+ export function menuStepsToWaitOption(text, tailLines = 12) {
74
+ const optionLines = contentLines(text, tailLines).filter(l => MENU_OPTION_REGEX.test(l));
75
+ if (optionLines.length === 0) return null;
76
+ const cursorPos = optionLines.findIndex(l => l.includes(MENU_CURSOR));
77
+ const waitPos = optionLines.findIndex(l => WAIT_OPTION_REGEX.test(l));
78
+ if (cursorPos === -1 || waitPos === -1) return null;
79
+ return waitPos - cursorPos;
80
+ }
81
+
82
+ export default {
83
+ id: 'claude',
84
+ name: 'Claude Code',
85
+ bin: process.env.UNSNOOZE_CLAUDE_BIN || 'claude',
86
+ experimental: false,
87
+ patterns,
88
+ menu: { isPrompt: isRateLimitOptionsPrompt, stepsToWait: menuStepsToWaitOption },
89
+ // How to reopen a dead session. messageViaPane: the resume prompt is typed
90
+ // into the TUI once it's ready (claude has no resume-with-prompt argv form).
91
+ resumeArgs(sessionId) {
92
+ return { args: sessionId ? ['--resume', sessionId] : ['-c'], messageViaPane: true };
93
+ },
94
+ latestSessionId,
95
+ // The foreground process for a claude session is `node` (nvm shim) or `claude`.
96
+ isForegroundCommand(cmd) {
97
+ return cmd === 'claude' || cmd === 'node' || cmd === 'unsnooze';
98
+ },
99
+ };
@@ -0,0 +1,112 @@
1
+ // OpenAI Codex CLI adapter.
2
+ //
3
+ // Codex has no hook that fires on usage limits (its `notify` config only emits
4
+ // agent-turn-complete), so detection is scrape-only. The TUI does NOT exit on
5
+ // a hard limit — it renders one red transcript line and sits at the composer:
6
+ // ■ You've hit your usage limit. …{ or try again at 3:51 PM.}
7
+ // Reset-time tails vary by plan: "try again at 3:51 PM." (same day),
8
+ // "try again at Feb 23rd, 2026 9:01 PM." (cross-day), "Try again in 4 days
9
+ // 20 hours 9 minutes." (older builds), or "Try again later." (no timestamp).
10
+ // Transient errors render "stream error: … retrying 4/5 in 1.4s" and must take
11
+ // the overload path, never the ledger.
12
+
13
+ import { openSync, readSync, closeSync, readdirSync, statSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
18
+
19
+ const LIMIT_ANCHORS = [
20
+ /You've hit your usage limit/i,
21
+ /Your workspace is out of credits/i,
22
+ /hit your spend cap/i,
23
+ ];
24
+
25
+ export const patterns = {
26
+ limitPatterns: LIMIT_ANCHORS,
27
+ // The whole banner is ONE line, so the anchors double as reset lines — the
28
+ // proximity engine then hands that line to time-parser (which falls back to
29
+ // the 5h default for "Try again later.").
30
+ resetPatterns: [
31
+ /try again at/i,
32
+ /try again in \d+/i,
33
+ /try again later/i,
34
+ ...LIMIT_ANCHORS,
35
+ ],
36
+ weeklyPatterns: [/weekly limit/i],
37
+ fiveHourPatterns: [/5h limit/i],
38
+ busyPatterns: [
39
+ /esc to interrupt/i, // "• Working (12s • esc to interrupt)"
40
+ /retrying\s+\d+\/\d+/i, // internal stream-error retry — don't inject
41
+ ],
42
+ idleRegex: /›/, // composer: "› Ask Codex to do anything"
43
+ overloadPatterns: [/stream error/i, /exceeded retry limit/i],
44
+ transientPatterns: [/stream error/i, /exceeded retry limit/i],
45
+ };
46
+
47
+ // Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{UUID}.jsonl;
48
+ // the first JSONL line carries the session cwd. Conservative: no cwd match →
49
+ // null (the resumer then uses `codex resume --last`, which codex itself scopes
50
+ // to the launch cwd).
51
+ const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
52
+
53
+ function fileHead(path, bytes = 4096) {
54
+ let fd;
55
+ try {
56
+ fd = openSync(path, 'r');
57
+ const buf = Buffer.alloc(bytes);
58
+ const n = readSync(fd, buf, 0, bytes, 0);
59
+ return buf.toString('utf-8', 0, n);
60
+ } catch {
61
+ return '';
62
+ } finally {
63
+ if (fd !== undefined) closeSync(fd);
64
+ }
65
+ }
66
+
67
+ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_DIR, 'sessions')) {
68
+ const files = [];
69
+ const walk = (dir, depth) => {
70
+ let entries;
71
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
72
+ for (const e of entries) {
73
+ const p = join(dir, e.name);
74
+ if (e.isDirectory() && depth < 3) { walk(p, depth + 1); continue; }
75
+ const m = e.isFile() && e.name.match(ROLLOUT_RE);
76
+ if (!m) continue;
77
+ let mtime;
78
+ try { mtime = statSync(p).mtimeMs; } catch { continue; }
79
+ if (aroundTs != null && Math.abs(mtime - aroundTs) > 30 * 60_000) continue;
80
+ files.push({ path: p, id: m[1], mtime });
81
+ }
82
+ };
83
+ walk(sessionsRoot, 0);
84
+ files.sort((a, b) => b.mtime - a.mtime);
85
+ // JSON-escape the cwd the way it appears inside the meta line.
86
+ const needle = cwd ? JSON.stringify(cwd).slice(1, -1) : null;
87
+ for (const f of files.slice(0, 20)) {
88
+ if (!needle || fileHead(f.path).includes(needle)) return f.id;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ export default {
94
+ id: 'codex',
95
+ name: 'OpenAI Codex CLI',
96
+ bin: process.env.UNSNOOZE_CODEX_BIN || 'codex',
97
+ experimental: false,
98
+ patterns,
99
+ menu: null, // no interactive limit menu
100
+ // Resume takes the prompt in argv — `codex resume <id> "msg"` starts the turn
101
+ // immediately, nothing to type into the TUI.
102
+ resumeArgs(sessionId, message) {
103
+ return {
104
+ args: sessionId ? ['resume', sessionId, message] : ['resume', '--last', message],
105
+ messageViaPane: false,
106
+ };
107
+ },
108
+ latestSessionId,
109
+ isForegroundCommand(cmd) {
110
+ return cmd === 'codex' || cmd === 'node' || cmd === 'unsnooze';
111
+ },
112
+ };