claude-resume-hub 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ibrahim Kalemci
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,104 @@
1
+ <h1 align="center">⏰ claude-resume-hub</h1>
2
+
3
+ <p align="center">
4
+ <b>Stop babysitting Claude Code usage limits.</b><br>
5
+ Hit a limit at 2am? <b>Wake up to finished work.</b>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://www.npmjs.com/package/claude-resume-hub"><img src="https://img.shields.io/npm/v/claude-resume-hub?color=c96442" alt="npm"></a>
10
+ <img src="https://img.shields.io/badge/node-%3E%3D16-3fb950" alt="node >= 16">
11
+ <img src="https://img.shields.io/badge/Windows%20%7C%20macOS%20%7C%20Linux-8b93a7" alt="platforms">
12
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT"></a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <img src="https://raw.githubusercontent.com/IbrahimKalemci/claude-resume-hub/main/docs/dashboard.svg" alt="live dashboard with a big countdown, status and streaming log" width="620">
17
+ </p>
18
+
19
+ <p align="center"><b>🇬🇧 <a href="#-english">English</a> &nbsp;·&nbsp; 🇹🇷 <a href="#-türkçe">Türkçe</a></b></p>
20
+
21
+ ---
22
+
23
+ ## 🇬🇧 English
24
+
25
+ Claude Code hits your limit and just… **stops** — until you come back and type `continue`.
26
+ **claude-resume-hub** waits for the exact reset time and continues **for you**, then pings your desktop when it's rolling again. One command, zero setup, every OS.
27
+
28
+ ### Install & run — 3 steps
29
+
30
+ **1.** Make sure Claude Code works (open a terminal, type `claude --version`). Don't have it? [Get it here](https://docs.claude.com/en/docs/claude-code).
31
+
32
+ **2.** Go to your project folder and run:
33
+
34
+ ```bash
35
+ npx claude-resume-hub --web
36
+ ```
37
+
38
+ **3.** A dashboard opens in your browser — click **“Enable alerts”**, then walk away. 🎉
39
+
40
+ That's it. No install, no config. It works on **Windows, macOS and Linux**.
41
+
42
+ ### Why you'll love it
43
+
44
+ - ⏱️ **Exact countdown** — reads the real reset time, not a guess
45
+ - 🔔 **Desktop alerts** — close the terminal, go live your life
46
+ - 🖥️ **Beautiful live dashboard** — countdown, status, and a streaming log
47
+ - 🪶 **Zero dependencies**, 100% local — nothing leaves your machine
48
+
49
+ ### Handy commands
50
+
51
+ ```bash
52
+ npx claude-resume-hub # no dashboard, just auto-resume
53
+ npx claude-resume-hub --web # with dashboard + alerts
54
+ npx claude-resume-hub -t "run all tests and fix failures" # start a task
55
+ npx claude-resume-hub --help # all options
56
+ ```
57
+
58
+ Prefer a permanent command? `npm i -g claude-resume-hub` gives you `crh --web`.
59
+
60
+ ---
61
+
62
+ ## 🇹🇷 Türkçe
63
+
64
+ Claude Code limitine takılınca **durur** — sen gelip `continue` yazana kadar bekler.
65
+ **claude-resume-hub** limitin tam açılma saatini bekleyip **senin yerine** devam eder, iş yeniden başlayınca da masaüstüne bildirim atar. Tek komut, sıfır ayar, her işletim sistemi.
66
+
67
+ ### Kurulum & çalıştırma — 3 adım
68
+
69
+ **1.** Claude Code çalışıyor mu bak (terminal aç, `claude --version` yaz). Yoksa [buradan kur](https://docs.claude.com/en/docs/claude-code).
70
+
71
+ **2.** Projenin klasörüne gir ve şunu çalıştır:
72
+
73
+ ```bash
74
+ npx claude-resume-hub --web
75
+ ```
76
+
77
+ **3.** Tarayıcıda bir panel açılır — **“Enable alerts”**e bas ve arkana yaslan. 🎉
78
+
79
+ Hepsi bu. Kurulum yok, ayar yok. **Windows, macOS ve Linux**'ta çalışır.
80
+
81
+ ### Neden bayılacaksın
82
+
83
+ - ⏱️ **Net geri sayım** — tahmin değil, gerçek reset saatini okur
84
+ - 🔔 **Masaüstü bildirimi** — terminali kapat, hayatına dön
85
+ - 🖥️ **Şık canlı panel** — geri sayım, durum ve canlı log
86
+ - 🪶 **Sıfır bağımlılık**, %100 yerel — hiçbir veri makineden çıkmaz
87
+
88
+ ### İşine yarayacak komutlar
89
+
90
+ ```bash
91
+ npx claude-resume-hub # panelsiz, sadece otomatik devam
92
+ npx claude-resume-hub --web # panel + bildirim
93
+ npx claude-resume-hub -t "tüm testleri çalıştır ve hataları düzelt" # görev başlat
94
+ npx claude-resume-hub --help # tüm seçenekler
95
+ ```
96
+
97
+ Kalıcı komut ister misin? `npm i -g claude-resume-hub` sana `crh --web` verir.
98
+
99
+ ---
100
+
101
+ <p align="center">
102
+ Made for everyone who's tired of waiting on the reset clock.<br>
103
+ <b>MIT</b> © Ibrahim Kalemci · <a href="CHANGELOG.md">Changelog</a>
104
+ </p>
package/bin/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * claude-auto-resume — run Claude Code and auto-continue the moment a usage/
6
+ * session limit resets. Optional live dashboard with desktop notifications.
7
+ *
8
+ * Zero dependencies. Windows / macOS / Linux.
9
+ */
10
+
11
+ const { AutoResumeEngine } = require("../lib/engine");
12
+ const { startDashboard } = require("../lib/dashboard");
13
+ const VERSION = require("../package.json").version;
14
+
15
+ function parseArgs(argv) {
16
+ const opts = {
17
+ prompt: "continue",
18
+ task: null,
19
+ dir: process.cwd(),
20
+ buffer: 30,
21
+ poll: 5,
22
+ maxCycles: 100,
23
+ verbose: false,
24
+ web: false,
25
+ port: 4177,
26
+ open: true,
27
+ passthrough: [],
28
+ };
29
+ const args = [...argv];
30
+ const sep = args.indexOf("--");
31
+ if (sep !== -1) { opts.passthrough = args.splice(sep + 1); args.splice(sep, 1); }
32
+
33
+ for (let i = 0; i < args.length; i++) {
34
+ const a = args[i];
35
+ const next = () => args[++i];
36
+ switch (a) {
37
+ case "-p": case "--prompt": opts.prompt = next(); break;
38
+ case "-t": case "--task": opts.task = next(); break;
39
+ case "-d": case "--dir": opts.dir = next(); break;
40
+ case "-b": case "--buffer": opts.buffer = parseInt(next(), 10); break;
41
+ case "--poll": opts.poll = parseInt(next(), 10); break;
42
+ case "-m": case "--max-cycles": opts.maxCycles = parseInt(next(), 10); break;
43
+ case "-w": case "--web": opts.web = true; break;
44
+ case "--port": opts.port = parseInt(next(), 10); break;
45
+ case "--no-open": opts.open = false; break;
46
+ case "--verbose": opts.verbose = true; break;
47
+ case "-h": case "--help": printHelp(); process.exit(0);
48
+ case "-v": case "--version": console.log(VERSION); process.exit(0);
49
+ default:
50
+ console.error(`Unknown option: ${a}\nRun with --help for usage.`);
51
+ process.exit(2);
52
+ }
53
+ }
54
+ return opts;
55
+ }
56
+
57
+ function printHelp() {
58
+ console.log(`
59
+ claude-resume-hub v${VERSION}
60
+
61
+ Run Claude Code and auto-continue the moment a usage/session limit resets.
62
+
63
+ USAGE
64
+ claude-resume-hub [options] [-- <extra claude args>]
65
+
66
+ OPTIONS
67
+ -p, --prompt <text> Message sent to continue after a reset (default: "continue")
68
+ -t, --task <text> Initial task; starts a fresh session on the first run,
69
+ then continues it. Omit to attach to the most recent session.
70
+ -d, --dir <path> Working directory / project (default: current dir)
71
+ -b, --buffer <seconds> Safety margin added after the reset time (default: 30)
72
+ --poll <minutes> Retry interval if a reset time can't be determined (default: 5)
73
+ -m, --max-cycles <n> Max limit->wait->continue cycles (default: 100)
74
+ -w, --web Open a live dashboard (countdown + desktop alerts)
75
+ --port <n> Dashboard port (default: 4177)
76
+ --no-open Don't auto-open the browser for the dashboard
77
+ --verbose Print detection diagnostics
78
+ -h, --help Show this help
79
+ -v, --version Show version
80
+
81
+ EXAMPLES
82
+ claude-resume-hub # keep the latest session going across resets
83
+ claude-resume-hub --web # ...with a live dashboard + desktop alerts
84
+ claude-resume-hub -t "run all the tests and fix failures"
85
+ claude-resume-hub -- --model opus # forward flags to claude
86
+ `);
87
+ }
88
+
89
+ const C = { cyan: "\x1b[36m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", reset: "\x1b[0m" };
90
+ const stamp = () => new Date().toLocaleString();
91
+
92
+ async function main() {
93
+ const opts = parseArgs(process.argv.slice(2));
94
+ const engine = new AutoResumeEngine(opts);
95
+
96
+ // Colorize a few known log lines for the terminal.
97
+ engine.on("log", (line) => {
98
+ let color = C.cyan;
99
+ if (/^Done|complete/i.test(line)) color = C.green;
100
+ else if (/limit hit|Waiting|Retrying/i.test(line)) color = C.yellow;
101
+ else if (/not found|error|Stopping/i.test(line)) color = C.red;
102
+ console.log(`${color}[auto-resume ${stamp()}]${C.reset} ${line}`);
103
+ });
104
+ engine.on("output", (chunk) => process.stdout.write(chunk));
105
+
106
+ if (opts.web) {
107
+ const { url, error } = await startDashboard(engine, { port: opts.port, open: opts.open });
108
+ if (url) console.log(`${C.green}[auto-resume]${C.reset} Dashboard: ${url} (leave it open for desktop alerts)`);
109
+ else console.log(`${C.red}[auto-resume]${C.reset} Could not start dashboard: ${error && error.message}`);
110
+ }
111
+
112
+ process.on("SIGINT", () => { console.log("\n[auto-resume] Interrupted. Bye."); engine.stop(); process.exit(130); });
113
+
114
+ const result = await engine.run();
115
+ // Keep the process (and dashboard) alive briefly so the browser sees the final state.
116
+ if (opts.web) setTimeout(() => process.exit(result.code), 1500);
117
+ else process.exit(result.code);
118
+ }
119
+
120
+ main().catch((err) => { console.error("[auto-resume] Fatal:", err); process.exit(1); });
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const { spawn } = require("child_process");
5
+
6
+ /**
7
+ * Zero-dependency live dashboard for the auto-resume engine.
8
+ * Serves a single page on localhost that shows a live countdown, current
9
+ * status, streaming logs, and fires a desktop notification (via the browser
10
+ * Notification API) when the limit resets or the task finishes.
11
+ *
12
+ * Communicates with the browser over Server-Sent Events — no client libs.
13
+ */
14
+ function startDashboard(engine, { port = 4177, open = true } = {}) {
15
+ const logs = [];
16
+ const clients = new Set();
17
+
18
+ const push = (event, data) => {
19
+ const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
20
+ for (const res of clients) res.write(chunk);
21
+ };
22
+
23
+ engine.on("state", (s) => push("state", s));
24
+ engine.on("log", (line) => {
25
+ const entry = { t: new Date().toLocaleTimeString(), line };
26
+ logs.push(entry);
27
+ if (logs.length > 300) logs.shift();
28
+ push("log", entry);
29
+ });
30
+
31
+ const server = http.createServer((req, res) => {
32
+ if (req.url === "/events") {
33
+ res.writeHead(200, {
34
+ "Content-Type": "text/event-stream",
35
+ "Cache-Control": "no-cache",
36
+ Connection: "keep-alive",
37
+ });
38
+ res.write("retry: 2000\n\n");
39
+ // Snapshot for late joiners.
40
+ res.write(`event: state\ndata: ${JSON.stringify(engine.state)}\n\n`);
41
+ for (const e of logs) res.write(`event: log\ndata: ${JSON.stringify(e)}\n\n`);
42
+ clients.add(res);
43
+ req.on("close", () => clients.delete(res));
44
+ return;
45
+ }
46
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
47
+ res.end(PAGE);
48
+ });
49
+
50
+ return new Promise((resolve) => {
51
+ const tryListen = (p, attempts) => {
52
+ server.once("error", (err) => {
53
+ if (err.code === "EADDRINUSE" && attempts > 0) return tryListen(p + 1, attempts - 1);
54
+ resolve({ url: null, close: () => server.close(), error: err });
55
+ });
56
+ server.listen(p, "127.0.0.1", () => {
57
+ const url = `http://127.0.0.1:${p}`;
58
+ if (open) openBrowser(url);
59
+ resolve({ url, close: () => server.close() });
60
+ });
61
+ };
62
+ tryListen(port, 15);
63
+ });
64
+ }
65
+
66
+ function openBrowser(url) {
67
+ try {
68
+ const cmd =
69
+ process.platform === "win32" ? "cmd" :
70
+ process.platform === "darwin" ? "open" : "xdg-open";
71
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
72
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
73
+ } catch {}
74
+ }
75
+
76
+ const PAGE = `<!doctype html>
77
+ <html lang="en"><head>
78
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
79
+ <title>claude-auto-resume</title>
80
+ <style>
81
+ :root { --bg:#0b0e14; --panel:#131826; --line:#232a3d; --txt:#e6e9f0; --dim:#8b93a7;
82
+ --accent:#c96442; --ok:#3fb950; --warn:#d29922; --err:#f85149; }
83
+ * { box-sizing:border-box; }
84
+ body { margin:0; font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
85
+ background:radial-gradient(1200px 600px at 50% -10%, #16203a 0%, var(--bg) 60%); color:var(--txt); min-height:100vh; }
86
+ .wrap { max-width:760px; margin:0 auto; padding:28px 20px 40px; }
87
+ header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:22px; }
88
+ .brand { display:flex; align-items:center; gap:10px; font-weight:700; letter-spacing:.2px; }
89
+ .dot { width:10px; height:10px; border-radius:50%; background:var(--accent); box-shadow:0 0 12px var(--accent); }
90
+ .pill { font-size:12px; font-weight:600; padding:5px 11px; border-radius:999px; border:1px solid var(--line); color:var(--dim); }
91
+ .pill.running { color:var(--accent); border-color:var(--accent); }
92
+ .pill.waiting { color:var(--warn); border-color:var(--warn); }
93
+ .pill.done { color:var(--ok); border-color:var(--ok); }
94
+ .pill.error { color:var(--err); border-color:var(--err); }
95
+ .card { background:linear-gradient(180deg, var(--panel), #0f1420); border:1px solid var(--line);
96
+ border-radius:18px; padding:34px 26px; text-align:center; box-shadow:0 20px 60px rgba(0,0,0,.35); }
97
+ .label { font-size:13px; text-transform:uppercase; letter-spacing:1.5px; color:var(--dim); margin-bottom:10px; }
98
+ .timer { font-variant-numeric:tabular-nums; font-weight:800; font-size:clamp(48px,12vw,88px); line-height:1;
99
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
100
+ .msg { margin-top:14px; color:var(--dim); min-height:20px; }
101
+ .meta { display:flex; gap:22px; justify-content:center; flex-wrap:wrap; margin-top:22px; color:var(--dim); font-size:13px; }
102
+ .meta b { color:var(--txt); font-weight:600; }
103
+ .pulse { animation:pulse 1.6s ease-in-out infinite; }
104
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
105
+ .row { display:flex; align-items:center; justify-content:space-between; margin:22px 2px 8px; }
106
+ .row h2 { font-size:13px; text-transform:uppercase; letter-spacing:1.2px; color:var(--dim); margin:0; }
107
+ button { background:var(--accent); color:#fff; border:0; border-radius:9px; padding:8px 14px; font-weight:600;
108
+ font-size:13px; cursor:pointer; }
109
+ button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); }
110
+ #log { background:#0a0d15; border:1px solid var(--line); border-radius:12px; padding:14px; height:230px; overflow:auto;
111
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12.5px; line-height:1.6; }
112
+ #log div { white-space:pre-wrap; word-break:break-word; }
113
+ #log .t { color:var(--dim); margin-right:8px; }
114
+ footer { text-align:center; color:var(--dim); font-size:12px; margin-top:20px; }
115
+ a { color:var(--accent); }
116
+ </style></head>
117
+ <body><div class="wrap">
118
+ <header>
119
+ <div class="brand"><span class="dot" id="dot"></span> claude-auto-resume</div>
120
+ <div style="display:flex; gap:8px; align-items:center;">
121
+ <span class="pill" id="pill">connecting…</span>
122
+ <button class="ghost" id="notifyBtn">🔔 Enable alerts</button>
123
+ </div>
124
+ </header>
125
+
126
+ <div class="card">
127
+ <div class="label" id="cardLabel">Status</div>
128
+ <div class="timer" id="timer">—</div>
129
+ <div class="msg" id="msg">Waiting for engine…</div>
130
+ <div class="meta">
131
+ <div>Cycle <b id="cycle">–</b></div>
132
+ <div>Resets at <b id="resetAt">–</b></div>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="row"><h2>Live log</h2><button class="ghost" id="clearBtn">clear</button></div>
137
+ <div id="log"></div>
138
+
139
+ <footer>Running locally · leave this tab open so alerts can fire · <a href="https://github.com/IbrahimKalemci/claude-resume-hub" target="_blank">github</a></footer>
140
+ </div>
141
+ <script>
142
+ const $ = (id) => document.getElementById(id);
143
+ let state = { phase: "starting" }, wakeAt = null, lastPhase = null;
144
+
145
+ function fmt(ms){ ms=Math.max(0,ms); const s=Math.floor(ms/1000);
146
+ const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;
147
+ return String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(x).padStart(2,'0'); }
148
+
149
+ function render(){
150
+ const p = state.phase || "starting";
151
+ $("pill").className = "pill " + p;
152
+ $("pill").textContent = p;
153
+ $("dot").style.background = getComputedStyle(document.documentElement)
154
+ .getPropertyValue(p==="done"?"--ok":p==="error"?"--err":p==="waiting"?"--warn":"--accent");
155
+ $("cycle").textContent = state.cycle ? (state.cycle + " / " + state.maxCycles) : "–";
156
+ $("resetAt").textContent = state.resetAt ? new Date(state.resetAt).toLocaleTimeString() : "–";
157
+ $("msg").textContent = state.message || "";
158
+ const t = $("timer"); t.classList.remove("pulse");
159
+ if (p === "waiting" && wakeAt) { $("cardLabel").textContent = "Resuming in"; t.textContent = fmt(wakeAt - Date.now()); }
160
+ else if (p === "running") { $("cardLabel").textContent = "Status"; t.textContent = "▶"; t.classList.add("pulse"); }
161
+ else if (p === "done") { $("cardLabel").textContent = "Status"; t.textContent = "✓"; }
162
+ else if (p === "error") { $("cardLabel").textContent = "Status"; t.textContent = "✕"; }
163
+ else { $("cardLabel").textContent = "Status"; t.textContent = "…"; }
164
+ }
165
+
166
+ function notify(title, body){
167
+ try { if (window.Notification && Notification.permission === "granted") new Notification(title, { body }); } catch {}
168
+ }
169
+ function beep(){ try { const a=new (window.AudioContext||window.webkitAudioContext)();
170
+ const o=a.createOscillator(), g=a.createGain(); o.connect(g); g.connect(a.destination);
171
+ o.frequency.value=660; g.gain.value=.05; o.start(); setTimeout(()=>{o.stop();a.close();},180);} catch {} }
172
+
173
+ function onPhaseChange(prev, cur){
174
+ if (cur === "waiting") notify("⏳ Usage limit hit", state.message || "Waiting for reset");
175
+ if (cur === "running" && prev === "waiting") { notify("▶ Limit reset — resumed!", "Claude is continuing your task."); beep(); }
176
+ if (cur === "done") { notify("✅ Task complete", state.message || "All done."); beep(); }
177
+ if (cur === "error") notify("⚠ Stopped", state.message || "An error occurred.");
178
+ }
179
+
180
+ const es = new EventSource("/events");
181
+ es.addEventListener("state", (e) => {
182
+ const prev = lastPhase; state = JSON.parse(e.data);
183
+ wakeAt = state.wakeAt ? new Date(state.wakeAt).getTime() : null;
184
+ if (state.phase !== prev) { onPhaseChange(prev, state.phase); lastPhase = state.phase; }
185
+ render();
186
+ });
187
+ es.addEventListener("log", (e) => {
188
+ const { t, line } = JSON.parse(e.data);
189
+ const d = document.createElement("div");
190
+ d.innerHTML = '<span class="t">' + t + '</span>';
191
+ d.appendChild(document.createTextNode(line));
192
+ const box = $("log"); box.appendChild(d); box.scrollTop = box.scrollHeight;
193
+ });
194
+
195
+ $("notifyBtn").onclick = () => { if (window.Notification) Notification.requestPermission().then(p => {
196
+ $("notifyBtn").textContent = p === "granted" ? "🔔 Alerts on" : "🔕 Alerts blocked"; }); };
197
+ $("clearBtn").onclick = () => { $("log").innerHTML = ""; };
198
+
199
+ setInterval(() => { if (state.phase === "waiting" && wakeAt) $("timer").textContent = fmt(wakeAt - Date.now()); }, 1000);
200
+ render();
201
+ // Notifications are enabled explicitly via the "Enable alerts" button
202
+ // (browsers discourage auto-prompting on load).
203
+ if (window.Notification && Notification.permission === "granted") $("notifyBtn").textContent = "🔔 Alerts on";
204
+ </script>
205
+ </body></html>`;
206
+
207
+ module.exports = { startDashboard };
package/lib/detect.js ADDED
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Usage-limit detection for Claude Code output.
5
+ *
6
+ * Detection priority:
7
+ * 1. Machine-readable marker "Claude AI usage limit reached|<unix_ts>"
8
+ * -> exact, timezone-proof reset time.
9
+ * 2. Human prose "…resets 3:45pm" / "reset at 2pm" / "resets Mon 12:00am".
10
+ * 3. A limit phrase with no time -> caller falls back to polling.
11
+ */
12
+
13
+ /** Inspect combined stdout+stderr. Returns { hit, resetAt: Date|null, source }. */
14
+ function detectLimit(text) {
15
+ if (!text) return { hit: false };
16
+
17
+ // 1) Machine-readable marker (seconds or milliseconds epoch).
18
+ const marker = text.match(/Claude AI usage limit reached\s*\|\s*(\d{10,13})/i);
19
+ if (marker) {
20
+ let n = parseInt(marker[1], 10);
21
+ if (n < 1e12) n *= 1000; // seconds -> ms
22
+ return { hit: true, resetAt: new Date(n), source: "epoch marker" };
23
+ }
24
+
25
+ // 2) Prose limit phrase, optionally with a clock time.
26
+ if (/hit your.*limit|usage limit reached|reached your.*limit|limit .*(reset|reached)/i.test(text)) {
27
+ const t = parseClockTime(text);
28
+ return { hit: true, resetAt: t, source: t ? "prose time" : "prose (no time)" };
29
+ }
30
+
31
+ return { hit: false };
32
+ }
33
+
34
+ /**
35
+ * Parse a human reset time such as "resets 3:45pm", "reset at 2pm",
36
+ * "resets Mon 12:00am". Best-effort fallback; the epoch marker is authoritative.
37
+ * Returns a Date (local) or null.
38
+ */
39
+ function parseClockTime(text) {
40
+ const m = text.match(
41
+ /reset(?:s|\s+at)?\s+(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
42
+ );
43
+ if (!m) return null;
44
+
45
+ const day = m[1];
46
+ let h = parseInt(m[2], 10);
47
+ const min = m[3] ? parseInt(m[3], 10) : 0;
48
+ const ap = m[4].toLowerCase();
49
+ if (ap === "pm" && h < 12) h += 12;
50
+ if (ap === "am" && h === 12) h = 0;
51
+
52
+ const now = new Date();
53
+ const target = new Date(now);
54
+ target.setHours(h, min, 0, 0);
55
+
56
+ if (day) {
57
+ const map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
58
+ const want = map[day.slice(0, 3)];
59
+ while (target.getDay() !== want || target <= now) {
60
+ target.setDate(target.getDate() + 1);
61
+ }
62
+ } else if (target <= now) {
63
+ // Session windows are only a few hours long, never a full day.
64
+ if (now - target <= 15 * 60 * 1000) return now; // just reset -> resume now
65
+ target.setDate(target.getDate() + 1); // else it's a past-midnight time (tomorrow)
66
+ }
67
+ return target;
68
+ }
69
+
70
+ function fmtDuration(ms) {
71
+ const s = Math.max(0, Math.round(ms / 1000));
72
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
73
+ return [h ? `${h}h` : "", m ? `${m}m` : "", `${sec}s`].filter(Boolean).join(" ") || "0s";
74
+ }
75
+
76
+ module.exports = { detectLimit, parseClockTime, fmtDuration };
package/lib/engine.js ADDED
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+
3
+ const { spawn } = require("child_process");
4
+ const { EventEmitter } = require("events");
5
+ const { detectLimit, fmtDuration } = require("./detect");
6
+
7
+ /**
8
+ * AutoResumeEngine runs Claude Code in a loop, waiting out usage limits.
9
+ *
10
+ * Events:
11
+ * "log" (line) human-readable log line
12
+ * "state" ({phase, cycle, maxCycles, resetAt, wakeAt, message})
13
+ * phase ∈ starting|running|waiting|done|error
14
+ * "output"(chunk) raw claude stdout/stderr passthrough
15
+ */
16
+ class AutoResumeEngine extends EventEmitter {
17
+ constructor(opts) {
18
+ super();
19
+ this.opts = opts;
20
+ this.state = {
21
+ phase: "starting",
22
+ cycle: 0,
23
+ maxCycles: opts.maxCycles,
24
+ resetAt: null,
25
+ wakeAt: null,
26
+ message: "",
27
+ };
28
+ this._timer = null;
29
+ this._stopped = false;
30
+ }
31
+
32
+ _setState(patch) {
33
+ this.state = { ...this.state, ...patch };
34
+ this.emit("state", this.state);
35
+ }
36
+
37
+ log(line) {
38
+ this.emit("log", line);
39
+ }
40
+
41
+ quoteArg(s) {
42
+ s = String(s);
43
+ if (process.platform === "win32") return `"${s.replace(/"/g, '""')}"`;
44
+ return `'${s.replace(/'/g, `'\\''`)}'`;
45
+ }
46
+
47
+ _runClaude(claudeArgs) {
48
+ return new Promise((resolve) => {
49
+ const cmd = ["claude", ...claudeArgs.map((a) => this.quoteArg(a))].join(" ");
50
+ const child = spawn(cmd, { cwd: this.opts.dir, shell: true, stdio: ["inherit", "pipe", "pipe"] });
51
+ this._child = child;
52
+
53
+ let output = "";
54
+ const tee = (stream) =>
55
+ stream.on("data", (d) => {
56
+ const s = d.toString();
57
+ output += s;
58
+ this.emit("output", s);
59
+ });
60
+ tee(child.stdout);
61
+ tee(child.stderr);
62
+
63
+ child.on("error", (err) => resolve({ code: -1, output, error: err }));
64
+ child.on("close", (code) => resolve({ code, output }));
65
+ });
66
+ }
67
+
68
+ _sleepUntil(wakeAt) {
69
+ return new Promise((resolve) => {
70
+ const tick = () => {
71
+ if (this._stopped) return resolve();
72
+ const remaining = wakeAt - new Date();
73
+ if (remaining <= 0) return resolve();
74
+ this.log(`Waiting ${fmtDuration(remaining)} — will resume at ${wakeAt.toLocaleTimeString()}`);
75
+ this._timer = setTimeout(tick, Math.min(remaining, 15 * 60 * 1000));
76
+ };
77
+ tick();
78
+ });
79
+ }
80
+
81
+ stop() {
82
+ this._stopped = true;
83
+ if (this._timer) clearTimeout(this._timer);
84
+ if (this._child) try { this._child.kill(); } catch {}
85
+ }
86
+
87
+ async run() {
88
+ const o = this.opts;
89
+ this.log(`Starting. dir="${o.dir}" prompt="${o.prompt}"`);
90
+ if (o.task) this.log(`Initial task: "${o.task}"`);
91
+
92
+ for (let cycle = 1; cycle <= o.maxCycles && !this._stopped; cycle++) {
93
+ const claudeArgs =
94
+ cycle === 1 && o.task
95
+ ? ["-p", o.task, ...o.passthrough]
96
+ : ["-c", "-p", o.prompt, ...o.passthrough];
97
+
98
+ this._setState({ phase: "running", cycle, resetAt: null, wakeAt: null, message: "Running Claude…" });
99
+ this.log(`Cycle ${cycle}/${o.maxCycles} — running claude…`);
100
+
101
+ const { code, output, error } = await this._runClaude(claudeArgs);
102
+ if (this._stopped) break;
103
+
104
+ if (error && error.code === "ENOENT") {
105
+ this._setState({ phase: "error", message: "`claude` not found on PATH." });
106
+ this.log("`claude` not found on PATH. Install Claude Code: https://docs.claude.com/en/docs/claude-code");
107
+ return { ok: false, code: 127 };
108
+ }
109
+
110
+ const limit = detectLimit(output);
111
+ if (o.verbose) this.log(`detect: ${JSON.stringify({ code, ...limit })}`);
112
+
113
+ if (!limit.hit) {
114
+ if (code === 0) {
115
+ this._setState({ phase: "done", message: "Task complete — no limit hit." });
116
+ this.log("Done — no limit hit, task complete.");
117
+ return { ok: true, code: 0 };
118
+ }
119
+ this._setState({ phase: "error", message: `claude exited ${code} (not a limit error).` });
120
+ this.log(`claude exited with code ${code} and no usage-limit marker. Stopping (not a limit error).`);
121
+ return { ok: false, code: code === -1 ? 1 : code || 1 };
122
+ }
123
+
124
+ // Limit hit — schedule resume.
125
+ let wakeAt;
126
+ if (limit.resetAt) {
127
+ wakeAt = new Date(limit.resetAt.getTime() + o.buffer * 1000);
128
+ this._setState({ phase: "waiting", resetAt: limit.resetAt, wakeAt, message: `Limit hit — resuming at ${wakeAt.toLocaleTimeString()}` });
129
+ this.log(`Usage limit hit (via ${limit.source}). Resets ${limit.resetAt.toLocaleString()} — resuming at ${wakeAt.toLocaleTimeString()} (+${o.buffer}s).`);
130
+ } else {
131
+ wakeAt = new Date(Date.now() + o.poll * 60 * 1000);
132
+ this._setState({ phase: "waiting", resetAt: null, wakeAt, message: `Limit hit — retrying in ${o.poll} min` });
133
+ this.log(`Usage limit hit but reset time unknown (${limit.source}). Retrying in ${o.poll} min.`);
134
+ }
135
+ await this._sleepUntil(wakeAt);
136
+ }
137
+
138
+ if (!this._stopped) {
139
+ this._setState({ phase: "done", message: `Reached max cycles (${o.maxCycles}).` });
140
+ this.log(`Reached max cycles (${o.maxCycles}). Stopping.`);
141
+ }
142
+ return { ok: true, code: 0 };
143
+ }
144
+ }
145
+
146
+ module.exports = { AutoResumeEngine };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "claude-resume-hub",
3
+ "version": "1.0.0",
4
+ "description": "Run Claude Code and auto-continue the moment a usage/session limit resets — with a live dashboard and desktop alerts. Cross-platform, zero-dependency.",
5
+ "bin": {
6
+ "claude-resume-hub": "bin/cli.js",
7
+ "crh": "bin/cli.js"
8
+ },
9
+ "type": "commonjs",
10
+ "engines": {
11
+ "node": ">=16"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "lib",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "start": "node bin/cli.js",
21
+ "test": "node --test"
22
+ },
23
+ "keywords": [
24
+ "claude",
25
+ "claude-code",
26
+ "anthropic",
27
+ "cli",
28
+ "rate-limit",
29
+ "usage-limit",
30
+ "session-limit",
31
+ "auto-resume",
32
+ "automation",
33
+ "continue"
34
+ ],
35
+ "author": "Ibrahim Kalemci",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/IbrahimKalemci/claude-resume-hub.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/IbrahimKalemci/claude-resume-hub/issues"
43
+ },
44
+ "homepage": "https://github.com/IbrahimKalemci/claude-resume-hub#readme"
45
+ }