claude-resume-hub 1.2.0 → 1.4.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/README.md CHANGED
@@ -52,14 +52,17 @@ That's it. No install, no config. It works on **Windows, macOS and Linux**.
52
52
  ```bash
53
53
  npx claude-resume-hub # no dashboard, just auto-resume
54
54
  npx claude-resume-hub --web # with dashboard + alerts
55
- npx claude-resume-hub -t "run all tests and fix failures" # start a task
55
+ npx claude-resume-hub --tray # background system-tray icon (Windows)
56
+ npx claude-resume-hub -t "run all tests and fix failures" # start a NEW session (not resume)
56
57
  npx claude-resume-hub --list # list this project's sessions (+ their ids)
58
+ npx claude-resume-hub --web --smart # context-aware resume (picks up your last step)
57
59
  npx claude-resume-hub -s <id> # resume a specific session
58
60
  npx claude-resume-hub --help # all options
59
61
  ```
60
62
 
61
63
  Prefer a permanent command? `npm i -g claude-resume-hub` gives you `crh --web`.
62
64
  **Always up to date:** `npx claude-resume-hub@latest` pulls the newest version automatically.
65
+ **No Node?** Grab a standalone **.exe** from the [latest release](https://github.com/IbrahimKalemci/claude-resume-hub/releases) (Windows/macOS/Linux). It still needs the `claude` CLI on your PATH.
63
66
 
64
67
  ---
65
68
 
@@ -94,14 +97,17 @@ Hepsi bu. Kurulum yok, ayar yok. **Windows, macOS ve Linux**'ta çalışır.
94
97
  ```bash
95
98
  npx claude-resume-hub # panelsiz, sadece otomatik devam
96
99
  npx claude-resume-hub --web # panel + bildirim
97
- npx claude-resume-hub -t "tüm testleri çalıştır ve hataları düzelt" # görev başlat
100
+ npx claude-resume-hub --tray # arka planda sistem tepsisi ikonu (Windows)
101
+ npx claude-resume-hub -t "tüm testleri çalıştır ve hataları düzelt" # YENİ session başlat (devam DEĞİL)
98
102
  npx claude-resume-hub --list # bu projenin session'larını listele (+ id'leri)
103
+ npx claude-resume-hub --web --smart # bağlam-farkında devam (kaldığın adımdan sürer)
99
104
  npx claude-resume-hub -s <id> # belirli bir session'ı devam ettir
100
105
  npx claude-resume-hub --help # tüm seçenekler
101
106
  ```
102
107
 
103
108
  Kalıcı komut ister misin? `npm i -g claude-resume-hub` sana `crh --web` verir.
104
109
  **Hep güncel:** `npx claude-resume-hub@latest` otomatik en yeni sürümü çeker.
110
+ **Node yok mu?** [Son sürümden](https://github.com/IbrahimKalemci/claude-resume-hub/releases) standalone **.exe** indir (Windows/macOS/Linux). Yine de `claude` CLI'ın PATH'te olması gerekir.
105
111
 
106
112
  ---
107
113
 
package/bin/cli.js CHANGED
@@ -23,7 +23,9 @@ function parseArgs(argv) {
23
23
  maxCycles: 100,
24
24
  verbose: false,
25
25
  list: false,
26
+ smart: false,
26
27
  web: false,
28
+ tray: false,
27
29
  port: 4177,
28
30
  open: true,
29
31
  passthrough: [],
@@ -44,7 +46,9 @@ function parseArgs(argv) {
44
46
  case "--poll": opts.poll = parseInt(next(), 10); break;
45
47
  case "-m": case "--max-cycles": opts.maxCycles = parseInt(next(), 10); break;
46
48
  case "-l": case "--list": opts.list = true; break;
49
+ case "--smart": opts.smart = true; break;
47
50
  case "-w": case "--web": opts.web = true; break;
51
+ case "--tray": opts.tray = true; opts.web = true; break;
48
52
  case "--port": opts.port = parseInt(next(), 10); break;
49
53
  case "--no-open": opts.open = false; break;
50
54
  case "--verbose": opts.verbose = true; break;
@@ -69,8 +73,9 @@ USAGE
69
73
 
70
74
  OPTIONS
71
75
  -p, --prompt <text> Message sent to continue after a reset (default: "continue")
72
- -t, --task <text> Initial task; starts a fresh session on the first run,
73
- then continues it. Omit to attach to the most recent session.
76
+ -t, --task <text> Start a NEW session with this task (NOT for resuming an
77
+ existing one). To continue your stopped session, omit -t
78
+ and use plain run / --smart / --session instead.
74
79
  -s, --session <id> Resume a specific session id (instead of the most recent).
75
80
  Find ids by running: claude --resume
76
81
  -d, --dir <path> Working directory / project (default: current dir)
@@ -78,7 +83,12 @@ OPTIONS
78
83
  --poll <minutes> Retry interval if a reset time can't be determined (default: 5)
79
84
  -m, --max-cycles <n> Max limit->wait->continue cycles (default: 100)
80
85
  -l, --list List Claude Code sessions in this project and exit
86
+ --smart Context-aware resume: reads the session's last step and
87
+ nudges Claude to pick up exactly there (instead of a bare
88
+ "continue"). Reads the transcript locally; no AI/network.
81
89
  -w, --web Open a live dashboard (countdown + desktop alerts)
90
+ --tray Run a system-tray icon (Windows; implies --web). On
91
+ macOS/Linux this falls back to the browser dashboard.
82
92
  --port <n> Dashboard port (default: 4177)
83
93
  --no-open Don't auto-open the browser for the dashboard
84
94
  --verbose Print detection diagnostics
@@ -88,7 +98,9 @@ OPTIONS
88
98
  EXAMPLES
89
99
  claude-resume-hub # keep the latest session going across resets
90
100
  claude-resume-hub --web # ...with a live dashboard + desktop alerts
101
+ claude-resume-hub --tray # background system-tray icon (Windows)
91
102
  claude-resume-hub --list # see this project's sessions and their ids
103
+ claude-resume-hub --web --smart # context-aware resume (picks up your last step)
92
104
  claude-resume-hub -s <id> # resume a specific session id
93
105
  claude-resume-hub -t "run all the tests and fix failures"
94
106
  claude-resume-hub -- --model opus # forward flags to claude
@@ -128,7 +140,47 @@ async function main() {
128
140
  process.exit(0);
129
141
  }
130
142
 
143
+ // --task starts a fresh session; warn so people don't use it expecting a resume.
144
+ if (opts.task) {
145
+ console.log(`${C.yellow}[auto-resume] Note: --task starts a NEW session with this task — it does NOT resume an existing one.${C.reset}`);
146
+ console.log(`${C.dim} To continue your existing (stopped) session instead, run without -t: claude-resume-hub --smart (or pick one with --list / --session)${C.reset}`);
147
+ }
148
+
149
+ // Pin the target session up front so every cycle resumes the SAME conversation.
150
+ // Plain "-c" means "most recent by mtime" and can silently switch to the wrong
151
+ // session (the tool's own prior run, or a second terminal). Resolving + pinning
152
+ // a concrete id, and printing it, makes resume predictable.
153
+ if (!opts.session && !opts.task) {
154
+ const { pickActiveSession } = require("../lib/sessions");
155
+ const active = pickActiveSession(opts.dir);
156
+ if (active) {
157
+ opts.session = active.id;
158
+ console.log(`${C.cyan}[auto-resume]${C.reset} Resuming session ${C.green}${active.id}${C.reset}`);
159
+ console.log(`${C.dim} last activity ${active.mtime.toLocaleString()} · ${active.turns} prompts${active.preview ? ` · "${active.preview.slice(0, 48)}…"` : ""}${C.reset}`);
160
+ console.log(`${C.dim} not this one? → claude-resume-hub --list then --session <id>${C.reset}`);
161
+ } else {
162
+ console.log(`${C.dim}[auto-resume] No prior session found here — will use "claude -c" (most recent).${C.reset}`);
163
+ }
164
+ }
165
+
166
+ // --smart: build a context-aware resume prompt from the session's last step.
167
+ if (opts.smart && !opts.task) {
168
+ const { sessionRecap } = require("../lib/sessions");
169
+ const recap = sessionRecap(opts.dir);
170
+ if (recap) {
171
+ opts.prompt =
172
+ `Continue where you left off and finish the task you were working on. ` +
173
+ `If it is already complete, say so instead of inventing new work. ` +
174
+ `For context, your last message was: "${recap.slice(0, 300)}${recap.length > 300 ? "…" : ""}"`;
175
+ console.log(`${C.dim}[auto-resume] smart mode: resuming with context from your last step.${C.reset}`);
176
+ } else {
177
+ console.log(`${C.dim}[auto-resume] smart mode: no prior session found — using plain "continue".${C.reset}`);
178
+ }
179
+ }
180
+
131
181
  const engine = new AutoResumeEngine(opts);
182
+ let trayChild = null;
183
+ const cleanup = () => { if (trayChild) { try { trayChild.kill(); } catch {} trayChild = null; } };
132
184
 
133
185
  // Colorize a few known log lines for the terminal.
134
186
  engine.on("log", (line) => {
@@ -141,17 +193,28 @@ async function main() {
141
193
  engine.on("output", (chunk) => process.stdout.write(chunk));
142
194
 
143
195
  if (opts.web) {
144
- const { url, error } = await startDashboard(engine, { port: opts.port, open: opts.open });
145
- if (url) console.log(`${C.green}[auto-resume]${C.reset} Dashboard: ${url} (leave it open for desktop alerts)`);
146
- else console.log(`${C.red}[auto-resume]${C.reset} Could not start dashboard: ${error && error.message}`);
196
+ // When running the tray, don't also pop a browser tab the tray is the UI.
197
+ const { url, error } = await startDashboard(engine, { port: opts.port, open: opts.open && !opts.tray });
198
+ if (url) {
199
+ console.log(`${C.green}[auto-resume]${C.reset} Dashboard: ${url} (leave it open for desktop alerts)`);
200
+ if (opts.tray) {
201
+ const { startTray } = require("../lib/tray");
202
+ const t = startTray(url);
203
+ if (t.ok) { trayChild = t.child; console.log(`${C.green}[auto-resume]${C.reset} Tray icon started — check your system tray; right-click for options.`); }
204
+ else console.log(`${C.yellow}[auto-resume]${C.reset} Tray unavailable (${t.reason}); the dashboard is still at ${url}`);
205
+ }
206
+ } else {
207
+ console.log(`${C.red}[auto-resume]${C.reset} Could not start dashboard: ${error && error.message}`);
208
+ }
147
209
  }
148
210
 
149
- process.on("SIGINT", () => { console.log("\n[auto-resume] Interrupted. Bye."); engine.stop(); process.exit(130); });
211
+ process.on("SIGINT", () => { console.log("\n[auto-resume] Interrupted. Bye."); cleanup(); engine.stop(); process.exit(130); });
150
212
 
151
213
  const result = await engine.run();
152
- // Keep the process (and dashboard) alive briefly so the browser sees the final state.
153
- if (opts.web) setTimeout(() => process.exit(result.code), 1500);
154
- else process.exit(result.code);
214
+ // Keep the process (and dashboard) alive briefly so the browser sees the final
215
+ // state; the tray self-exits once /status stops responding, but kill it too.
216
+ if (opts.web) setTimeout(() => { cleanup(); process.exit(result.code); }, 1500);
217
+ else { cleanup(); process.exit(result.code); }
155
218
  }
156
219
 
157
220
  main().catch((err) => { console.error("[auto-resume] Fatal:", err); process.exit(1); });
package/lib/dashboard.js CHANGED
@@ -43,6 +43,12 @@ function startDashboard(engine, { port = 4177, open = true } = {}) {
43
43
  req.on("close", () => clients.delete(res));
44
44
  return;
45
45
  }
46
+ if (req.url === "/status") {
47
+ // Lightweight JSON snapshot for pollers (e.g. the --tray shim).
48
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache" });
49
+ res.end(JSON.stringify(engine.state));
50
+ return;
51
+ }
46
52
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
47
53
  res.end(PAGE);
48
54
  });
package/lib/detect.js CHANGED
@@ -36,7 +36,7 @@ function detectLimit(text) {
36
36
  * "resets Mon 12:00am". Best-effort fallback; the epoch marker is authoritative.
37
37
  * Returns a Date (local) or null.
38
38
  */
39
- function parseClockTime(text) {
39
+ function parseClockTime(text, now = new Date()) {
40
40
  const m = text.match(
41
41
  /reset(?:s|\s+at)?\s+(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
42
42
  );
@@ -49,7 +49,6 @@ function parseClockTime(text) {
49
49
  if (ap === "pm" && h < 12) h += 12;
50
50
  if (ap === "am" && h === 12) h = 0;
51
51
 
52
- const now = new Date();
53
52
  const target = new Date(now);
54
53
  target.setHours(h, min, 0, 0);
55
54
 
package/lib/sessions.js CHANGED
@@ -64,7 +64,12 @@ function listSessions(dir) {
64
64
  } catch {
65
65
  st = { mtime: new Date(0), size: 0 };
66
66
  }
67
- const turns = lines.reduce((n, l) => n + (/"type":"(user|assistant)"/.test(l) ? 1 : 0), 0);
67
+ // Count real human prompts (a "user" line that isn't a tool_result echo),
68
+ // not every message line — otherwise tool traffic inflates the number.
69
+ const turns = lines.reduce(
70
+ (n, l) => n + (/"type":"user"/.test(l) && !/"tool_result"/.test(l) ? 1 : 0),
71
+ 0
72
+ );
68
73
  return {
69
74
  id: f.replace(/\.jsonl$/, ""),
70
75
  mtime: st.mtime,
@@ -78,4 +83,56 @@ function listSessions(dir) {
78
83
  return out;
79
84
  }
80
85
 
81
- module.exports = { projectsRoot, encodeDir, findProjectFolder, listSessions };
86
+ /** Last non-empty assistant text in a transcript — used as a resume recap. */
87
+ function lastAssistantText(lines) {
88
+ for (let i = lines.length - 1; i >= 0; i--) {
89
+ try {
90
+ const o = JSON.parse(lines[i]);
91
+ if (o.type === "assistant" && o.message) {
92
+ let c = o.message.content;
93
+ if (Array.isArray(c)) c = c.filter((x) => x && x.type === "text").map((x) => x.text).join(" ");
94
+ if (typeof c === "string" && c.trim()) return c.trim().replace(/\s+/g, " ");
95
+ }
96
+ } catch { /* ignore malformed lines */ }
97
+ }
98
+ return "";
99
+ }
100
+
101
+ /**
102
+ * Recap of the most-recent session in `dir`: the last thing the assistant said.
103
+ * Used by --smart to build a context-aware resume prompt. Returns "" if none.
104
+ */
105
+ function sessionRecap(dir) {
106
+ const folder = findProjectFolder(dir);
107
+ if (!folder) return "";
108
+ let files = [];
109
+ try { files = fs.readdirSync(folder).filter((f) => f.endsWith(".jsonl")); } catch { return ""; }
110
+ let newest = null, newestM = -1;
111
+ for (const f of files) {
112
+ try {
113
+ const m = fs.statSync(path.join(folder, f)).mtimeMs;
114
+ if (m > newestM) { newestM = m; newest = f; }
115
+ } catch { /* skip */ }
116
+ }
117
+ if (!newest) return "";
118
+ try {
119
+ const lines = fs.readFileSync(path.join(folder, newest), "utf8").split("\n").filter(Boolean);
120
+ return lastAssistantText(lines);
121
+ } catch { return ""; }
122
+ }
123
+
124
+ /**
125
+ * The session to resume by default in `dir`: the most recently active one.
126
+ * Returned object is the same shape as listSessions() entries, or null.
127
+ * The engine PINS this id so later cycles don't race "most recent" (which can
128
+ * change if the tool's own run, or a second terminal, touches another session).
129
+ */
130
+ function pickActiveSession(dir) {
131
+ const all = listSessions(dir);
132
+ return all.length ? all[0] : null;
133
+ }
134
+
135
+ module.exports = {
136
+ projectsRoot, encodeDir, findProjectFolder, listSessions,
137
+ lastAssistantText, sessionRecap, pickActiveSession,
138
+ };
package/lib/tray.js ADDED
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ const { spawn } = require("child_process");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ /**
9
+ * A genuine Windows system-tray icon with ZERO bundled dependencies: a tiny
10
+ * PowerShell + System.Windows.Forms.NotifyIcon shim that rides the .NET already
11
+ * present on every Windows 10/11 machine. It polls the dashboard's /status
12
+ * endpoint, colours the tray icon by phase, and pops a balloon on phase changes.
13
+ * On macOS/Linux there is no zero-dep native tray in pure Node — callers should
14
+ * fall back to the browser dashboard.
15
+ */
16
+
17
+ // NOTE: written as a plain string (no backticks / no ${...}) so it survives the
18
+ // JS template and PowerShell parsing cleanly.
19
+ const PS_SCRIPT = [
20
+ 'param([string]$Url)',
21
+ 'Add-Type -AssemblyName System.Windows.Forms',
22
+ 'Add-Type -AssemblyName System.Drawing',
23
+ '',
24
+ 'function New-DotIcon([System.Drawing.Color]$color) {',
25
+ ' $bmp = New-Object System.Drawing.Bitmap 16,16',
26
+ ' $g = [System.Drawing.Graphics]::FromImage($bmp)',
27
+ " $g.SmoothingMode = 'AntiAlias'",
28
+ ' $g.Clear([System.Drawing.Color]::Transparent)',
29
+ ' $brush = New-Object System.Drawing.SolidBrush $color',
30
+ ' $g.FillEllipse($brush, 2, 2, 12, 12)',
31
+ ' $g.Dispose()',
32
+ ' return [System.Drawing.Icon]::FromHandle($bmp.GetHicon())',
33
+ '}',
34
+ '',
35
+ '$colors = @{',
36
+ ' starting = [System.Drawing.Color]::FromArgb(201,100,66)',
37
+ ' running = [System.Drawing.Color]::FromArgb(201,100,66)',
38
+ ' waiting = [System.Drawing.Color]::FromArgb(210,153,34)',
39
+ ' done = [System.Drawing.Color]::FromArgb(63,185,80)',
40
+ ' error = [System.Drawing.Color]::FromArgb(248,81,73)',
41
+ '}',
42
+ '',
43
+ '$ni = New-Object System.Windows.Forms.NotifyIcon',
44
+ '$ni.Icon = New-DotIcon $colors.starting',
45
+ '$ni.Text = "claude-resume-hub"',
46
+ '$ni.Visible = $true',
47
+ '',
48
+ '$menu = New-Object System.Windows.Forms.ContextMenuStrip',
49
+ '$open = $menu.Items.Add("Open dashboard")',
50
+ '$open.add_Click({ Start-Process $Url })',
51
+ '$quit = $menu.Items.Add("Quit tray")',
52
+ '$quit.add_Click({ $ni.Visible = $false; $ni.Dispose(); [System.Windows.Forms.Application]::Exit() })',
53
+ '$ni.ContextMenuStrip = $menu',
54
+ '$ni.add_MouseClick({ param($s,$e) if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Start-Process $Url } })',
55
+ '',
56
+ '$script:last = ""',
57
+ '$script:fails = 0',
58
+ '$timer = New-Object System.Windows.Forms.Timer',
59
+ '$timer.Interval = 2000',
60
+ '$timer.add_Tick({',
61
+ ' try {',
62
+ ' $s = Invoke-RestMethod -Uri ($Url + "/status") -TimeoutSec 3',
63
+ ' $script:fails = 0',
64
+ ' $phase = [string]$s.phase',
65
+ ' $msg = [string]$s.message',
66
+ ' if ($phase -ne $script:last) {',
67
+ ' if ($colors.ContainsKey($phase)) { $ni.Icon = New-DotIcon $colors[$phase] }',
68
+ ' $t = "claude-resume-hub - " + $phase',
69
+ ' if ($t.Length -gt 63) { $t = $t.Substring(0,63) }',
70
+ ' $ni.Text = $t',
71
+ ' if ($script:last -ne "") { $ni.ShowBalloonTip(4000, "claude-resume-hub", $msg, [System.Windows.Forms.ToolTipIcon]::Info) }',
72
+ ' $script:last = $phase',
73
+ ' }',
74
+ ' } catch {',
75
+ ' $script:fails = $script:fails + 1',
76
+ ' if ($script:fails -ge 6) { $ni.Visible = $false; $ni.Dispose(); [System.Windows.Forms.Application]::Exit() }',
77
+ ' }',
78
+ '})',
79
+ '$timer.Start()',
80
+ '',
81
+ '[System.Windows.Forms.Application]::Run((New-Object System.Windows.Forms.ApplicationContext))',
82
+ '',
83
+ ].join("\n");
84
+
85
+ function startTray(url) {
86
+ if (process.platform !== "win32") {
87
+ return { ok: false, reason: "native tray is Windows-only — use the --web dashboard on macOS/Linux" };
88
+ }
89
+ try {
90
+ const file = path.join(os.tmpdir(), `crh-tray-${process.pid}.ps1`);
91
+ fs.writeFileSync(file, PS_SCRIPT, "utf8");
92
+ // -STA is REQUIRED: WinForms' message loop / Timer won't pump in MTA.
93
+ // Do NOT use detached:true — a detached process's message pump doesn't run,
94
+ // so the tray never updates. windowsHide hides the console window instead.
95
+ const child = spawn(
96
+ "powershell",
97
+ ["-NoProfile", "-STA", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", file, url],
98
+ { stdio: "ignore", windowsHide: true }
99
+ );
100
+ return { ok: true, file, pid: child.pid, child };
101
+ } catch (e) {
102
+ return { ok: false, reason: e.message };
103
+ }
104
+ }
105
+
106
+ module.exports = { startTray, PS_SCRIPT };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-resume-hub",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
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
5
  "bin": {
6
6
  "claude-resume-hub": "bin/cli.js",
@@ -18,7 +18,9 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "start": "node bin/cli.js",
21
- "test": "node --test"
21
+ "test": "node --test",
22
+ "build:bundle": "esbuild bin/cli.js --bundle --platform=node --target=node20 --format=cjs --outfile=dist/bundle.cjs",
23
+ "build:exe": "node scripts/build-exe.js"
22
24
  },
23
25
  "keywords": [
24
26
  "claude",
@@ -41,5 +43,8 @@
41
43
  "bugs": {
42
44
  "url": "https://github.com/IbrahimKalemci/claude-resume-hub/issues"
43
45
  },
44
- "homepage": "https://github.com/IbrahimKalemci/claude-resume-hub#readme"
46
+ "homepage": "https://github.com/IbrahimKalemci/claude-resume-hub#readme",
47
+ "devDependencies": {
48
+ "esbuild": "^0.28.1"
49
+ }
45
50
  }