claude-wake 0.1.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 Kazuki Nakayashiki
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,204 @@
1
+ # claude-wake
2
+
3
+ **Auto-resume Claude Code in the VS Code panel when your usage limit resets β€” while you sleep.**
4
+
5
+ You're deep in a task and Claude Code stops:
6
+
7
+ ```
8
+ You've hit your session limit Β· resets 3:30am (America/Los_Angeles)
9
+ ```
10
+
11
+ It's midnight. The limit lifts at 3:30am. Nobody's awake to type "continue" β€” so
12
+ six hours evaporate. **claude-wake** waits for the reset, re-focuses that exact
13
+ panel, and submits a resume message for you. You wake up to finished work.
14
+
15
+ There are two ways to run it: a **menu-bar app** or a **CLI** β€” same engine,
16
+ same `~/.claude-wake` state, so pick whichever you like.
17
+
18
+ ---
19
+
20
+ ## πŸš€ Fastest setup: let Claude Code do it
21
+
22
+ Open a Claude Code session (anywhere) and paste this. It clones, installs, and
23
+ enables claude-wake for you, then walks you through the one‑time permission:
24
+
25
+ ```text
26
+ Set up "claude-wake" for me β€” it auto-resumes my Claude Code (VS Code panel)
27
+ when my usage limit resets. Steps:
28
+
29
+ 1. Clone https://github.com/kazuki-sf/claude-wake into ~/GitHub/claude-wake
30
+ (if it already exists, cd in and `git pull` instead).
31
+ 2. It's a zero-dependency Node CLI (needs Node 18+). Install it globally:
32
+ `cd ~/GitHub/claude-wake && npm install -g .`
33
+ If a global npm install is blocked, symlink it instead:
34
+ `mkdir -p ~/.local/bin && ln -sf ~/GitHub/claude-wake/bin/claude-wake.js ~/.local/bin/claude-wake && chmod +x ~/GitHub/claude-wake/bin/claude-wake.js`
35
+ (make sure ~/.local/bin is on PATH).
36
+ 3. Enable auto-resume at login: `claude-wake install`.
37
+ 4. Run `claude-wake doctor` and follow it to grant macOS Accessibility to the
38
+ KeySender.app it points to (one-time β€” required so it can paste into the panel).
39
+ 5. Confirm end-to-end with `claude-wake test` (pastes a harmless test message
40
+ into my most recent Claude Code panel), then show me `claude-wake status`.
41
+ 6. Ask me whether I also want the **menu-bar app** β€” a GUI dashboard with a live
42
+ countdown to the next resume, pause/resume, and message editing. If yes and
43
+ the Rust toolchain (`cargo`) is available, build it:
44
+ `cd app/src-tauri && cargo install tauri-cli --version '^2' --locked && cargo tauri build`,
45
+ then move the produced `.app` to ~/Applications and open it (it shares the
46
+ same daemon, so nothing double-runs). If Rust isn't installed, point me to
47
+ https://rustup.rs or let me skip it β€” the CLI already handles auto-resume.
48
+ If I say no, skip this step.
49
+
50
+ Don't commit anything or modify my repos β€” everything lives under ~/.claude-wake.
51
+ Then tell me it's running and that it'll resume a limited panel shortly after
52
+ the limit resets.
53
+ ```
54
+
55
+ > What this changes on your machine: it registers a **login item** (the
56
+ > background watcher) and asks for a one-time macOS **Accessibility** grant to a
57
+ > dedicated `KeySender.app` (so it can paste into the panel). Everything lives
58
+ > under `~/.claude-wake`; remove it any time with `claude-wake uninstall`.
59
+
60
+ > Prefer a GUI? After the CLI is set up, the **menu-bar app** is an optional
61
+ > build β€” see [The menu-bar app](#the-menu-bar-app).
62
+
63
+ ---
64
+
65
+ ## Install it yourself
66
+
67
+ ### CLI
68
+
69
+ Once published:
70
+
71
+ ```bash
72
+ npm install -g claude-wake
73
+ ```
74
+
75
+ From source (today):
76
+
77
+ ```bash
78
+ git clone https://github.com/kazuki-sf/claude-wake ~/GitHub/claude-wake
79
+ cd ~/GitHub/claude-wake
80
+ npm install -g . # zero dependencies; needs Node 18+
81
+ claude-wake install # start on login
82
+ claude-wake doctor # verify + grant Accessibility (guided)
83
+ ```
84
+
85
+ ### The menu-bar app
86
+
87
+ A small tray/menu-bar dashboard (Tauri: Rust + plain HTML, no npm frontend).
88
+ Build it from source β€” needs the Rust toolchain:
89
+
90
+ ```bash
91
+ cd ~/GitHub/claude-wake/app/src-tauri
92
+ cargo install tauri-cli --version '^2' --locked # once
93
+ cargo tauri build # β†’ target/release/bundle/…
94
+ ```
95
+
96
+ That produces a `.app` (macOS). It's unsigned (OSS) β€” first launch: right-click
97
+ β†’ Open. The app bundles the engine and shares `~/.claude-wake` with the CLI, so
98
+ **don't run two watchers** β€” use the app *or* the CLI daemon, not both. See
99
+ [`app/README.md`](app/README.md).
100
+
101
+ ### macOS: grant permission once
102
+
103
+ Pasting into the panel needs the **Accessibility** permission. claude-wake
104
+ compiles a tiny dedicated `KeySender.app` on your machine so you grant it to
105
+ **one app only** β€” not your terminal, not Node. `claude-wake doctor` opens the
106
+ right settings pane and shows exactly what to add:
107
+
108
+ > System Settings β†’ Privacy & Security β†’ Accessibility β†’ add & enable
109
+ > `~/.claude-wake/KeySender.app`
110
+
111
+ ---
112
+
113
+ ## How it works
114
+
115
+ 1. **Detect** β€” Claude Code writes each session's transcript to
116
+ `~/.claude/projects/**/*.jsonl`. A usage limit is a structured entry whose
117
+ text carries the reset time **with timezone**. claude-wake tails these files
118
+ (cheap append-only reads) β€” no screen scraping.
119
+ 2. **Focus** β€” at reset time it opens
120
+ `vscode://anthropic.claude-code/open?session=<id>`, which brings the exact
121
+ panel to the front, even with many panels open.
122
+ 3. **Send** β€” the panel's input ignores fake keystrokes but accepts a paste, so
123
+ it puts the message on the clipboard and does **paste + Enter** β€” only after
124
+ confirming your editor is frontmost.
125
+
126
+ It fires **once per limit** (duplicate events are de-duplicated), and if you
127
+ already resumed manually, it notices and skips. The default message is
128
+ deliberately **safe if it fires when it didn't need to**:
129
+
130
+ > _"If you were stopped by a usage or token limit, continue from exactly where
131
+ > you left off. If you were not stopped, or the task is already complete, ignore
132
+ > this message."_
133
+
134
+ ## Using it
135
+
136
+ **Menu-bar app** β€” click the ⚑ icon to open the popover:
137
+
138
+ - Live **countdown** to the next resume, current state (Watching / Paused /
139
+ Not enabled), and recent activity.
140
+ - Edit the **resume message** (applies live), **Pause/Resume**, **Send test**,
141
+ **Grant permission**, **Start on login**, open logs/config, GitHub, Quit.
142
+
143
+ **CLI:**
144
+
145
+ | Command | What it does |
146
+ | ----------------------- | ----------------------------------------------------------------- |
147
+ | `claude-wake watch` | Watch and auto-resume in the foreground (`--dry-run` to log only). |
148
+ | `claude-wake install` | Start on login (launchd / systemd / Startup) and run now. |
149
+ | `claude-wake uninstall` | Stop and remove the login service. |
150
+ | `claude-wake status` | Daemon state + scheduled resumes (`--json` for machines). |
151
+ | `claude-wake pause` / `resume` | Suspend / un-suspend auto-resume. |
152
+ | `claude-wake doctor` | Check dependencies + permissions, with fixes. |
153
+ | `claude-wake test` | Fire a harmless test message into your latest session now. |
154
+ | `claude-wake set-message "…"` | Set the resume message (blank resets to default). |
155
+ | `claude-wake logs [-f]` | Recent log lines (`-f` to follow). |
156
+
157
+ ## Configuration
158
+
159
+ Optional `~/.claude-wake/config.json` (the app edits `message` for you):
160
+
161
+ ```jsonc
162
+ {
163
+ "message": "Continue from where you left off.", // resume text (one line)
164
+ "editor": "vscode", // vscode | vscode-insiders | cursor | windsurf
165
+ "marginSeconds": 60, // extra wait after the printed reset time
166
+ "pollSeconds": 20, // transcript scan interval
167
+ "focusDelayMs": 2500, // wait between opening the panel and pasting
168
+ "skipIfResumed": true, // skip if you already continued manually
169
+ "allowUnverifiedPaste": false // Wayland only β€” see Platform support
170
+ }
171
+ ```
172
+
173
+ Invalid values fall back to safe defaults. Config changes apply live.
174
+
175
+ ## Platform support
176
+
177
+ | Platform | Status |
178
+ | ------------------- | --------------------------------------------------------------- |
179
+ | **macOS** | βœ… Reference β€” verified end-to-end. |
180
+ | **Linux (X11)** | πŸ§ͺ Implemented (`xdotool` + `xclip`/`xsel`). Community-verify. |
181
+ | **Linux (Wayland)** | πŸ§ͺ Experimental; blind send, needs `allowUnverifiedPaste`. |
182
+ | **Windows** | πŸ§ͺ Implemented (PowerShell `SendKeys`). Community-verify. |
183
+
184
+ ## Security
185
+
186
+ Zero runtime dependencies, no network, no `eval`. **The text it pastes is always
187
+ your static `message`** β€” never derived from transcript or web content, so
188
+ summarizing a malicious page can't make it paste anything. Keystrokes go only
189
+ after verifying the editor is frontmost. Full trust model in
190
+ [`SECURITY.md`](SECURITY.md); design in [`docs/design.md`](docs/design.md).
191
+
192
+ ## Uninstall
193
+
194
+ ```bash
195
+ claude-wake uninstall # stop + remove the login service
196
+ npm rm -g claude-wake # (or: rm ~/.local/bin/claude-wake)
197
+ rm -rf ~/.claude-wake # state, logs, KeySender.app
198
+ # macOS: also remove KeySender from System Settings β†’ Accessibility
199
+ # app: drag claude-wake.app to the Trash
200
+ ```
201
+
202
+ ## License
203
+
204
+ MIT Β© Kazuki Nakayashiki
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ BASE_DIR,
7
+ CONFIG_FILE,
8
+ LOG_FILE,
9
+ DEFAULT_MESSAGE,
10
+ loadConfig,
11
+ projectsDir,
12
+ isPaused,
13
+ setPaused,
14
+ sanitizeMessage,
15
+ readConfigRaw,
16
+ writeConfigRaw,
17
+ } from "../lib/config.js";
18
+ import { createLogger } from "../lib/log.js";
19
+ import { getEditor, isValidSessionId, EDITOR_NAMES } from "../lib/editors.js";
20
+ import { watchLoop } from "../lib/watch.js";
21
+ import { loadState } from "../lib/state.js";
22
+ import { listTranscripts } from "../lib/detect.js";
23
+ import { sendResume } from "../lib/send/index.js";
24
+ import {
25
+ installDaemon,
26
+ uninstallDaemon,
27
+ daemonStatus,
28
+ } from "../lib/install.js";
29
+ import { runDoctor } from "../lib/doctor.js";
30
+
31
+ const HELP = `claude-wake β€” auto-resume Claude Code (VS Code panels) when your usage limit resets
32
+
33
+ Usage: claude-wake <command> [options]
34
+
35
+ Commands
36
+ watch Watch transcripts and auto-resume limited panels (foreground)
37
+ --dry-run detect + schedule, but only log instead of sending
38
+ --quiet no stdout (log file only; used by the daemon)
39
+ install Register the watcher to start at login (launchd / systemd / Startup)
40
+ uninstall Remove the login registration and stop the daemon
41
+ status Show daemon state, pending resumes, and config summary (--json for machines)
42
+ pause Suspend auto-resume (watcher keeps running)
43
+ resume Un-suspend auto-resume
44
+ set-message <t> Set the resume message (blank resets to default)
45
+ doctor Check dependencies and permissions, with fix instructions
46
+ test Fire one resume into the most recent session right now
47
+ --session <id> target a specific session id
48
+ logs Print the last 40 log lines (-f to follow)
49
+
50
+ Config: ${CONFIG_FILE} (see README for keys)
51
+ Editors: ${EDITOR_NAMES.join(", ")} (config "editor")
52
+ `;
53
+
54
+ const argv = process.argv.slice(2);
55
+ const cmd = argv[0];
56
+ const flags = new Set(argv.slice(1).filter((a) => a.startsWith("-")));
57
+ function flagValue(name) {
58
+ const i = argv.indexOf(name);
59
+ return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith("--")
60
+ ? argv[i + 1]
61
+ : null;
62
+ }
63
+
64
+ const selfPath = fileURLToPath(import.meta.url);
65
+
66
+ function pkgVersion() {
67
+ try {
68
+ return JSON.parse(
69
+ fs.readFileSync(
70
+ path.join(path.dirname(selfPath), "..", "package.json"),
71
+ "utf8",
72
+ ),
73
+ ).version;
74
+ } catch {
75
+ return "0.0.0";
76
+ }
77
+ }
78
+
79
+ async function main() {
80
+ switch (cmd) {
81
+ case "watch": {
82
+ const log = createLogger({ quiet: flags.has("--quiet") });
83
+ const cfg = loadConfig({ warn: log });
84
+ await watchLoop(cfg, log, { dryRun: flags.has("--dry-run") });
85
+ break;
86
+ }
87
+
88
+ case "install": {
89
+ // argv[1] = the bin path the user invoked (npm's stable symlink), NOT
90
+ // import.meta.url (a version-pinned realpath that dies on node upgrades).
91
+ const { dest, warnings } = installDaemon(process.argv[1] || selfPath);
92
+ console.log(`installed: ${dest}`);
93
+ for (const w of warnings) console.log(`warning: ${w}`);
94
+ console.log("the watcher now starts at login and is running.");
95
+ console.log("next: claude-wake doctor (verify permissions)");
96
+ break;
97
+ }
98
+
99
+ case "uninstall": {
100
+ uninstallDaemon();
101
+ console.log("daemon stopped and login registration removed.");
102
+ console.log(
103
+ `(files in ${BASE_DIR} were kept β€” delete manually for a full removal)`,
104
+ );
105
+ break;
106
+ }
107
+
108
+ case "status": {
109
+ const cfg = loadConfig({
110
+ warn: flags.has("--json") ? () => {} : (m) => console.log(`warn: ${m}`),
111
+ });
112
+ const d = daemonStatus();
113
+ const st = loadState();
114
+ if (flags.has("--json")) {
115
+ // Stable machine-readable contract for the desktop app.
116
+ process.stdout.write(
117
+ JSON.stringify({
118
+ version: pkgVersion(),
119
+ daemonInstalled: d.installed,
120
+ daemonRunning: d.running,
121
+ paused: isPaused(),
122
+ editor: getEditor(cfg.editor).name,
123
+ message: cfg.message,
124
+ defaultMessage: DEFAULT_MESSAGE,
125
+ messageIsDefault: cfg.message === DEFAULT_MESSAGE,
126
+ watching: projectsDir(cfg),
127
+ pending: st.pending.map((p) => ({
128
+ sessionId: p.sessionId,
129
+ fire: p.fire,
130
+ event: p.event,
131
+ })),
132
+ }) + "\n",
133
+ );
134
+ break;
135
+ }
136
+ console.log(
137
+ `daemon: ${d.installed ? (d.running ? "running" : "installed, NOT running") : "not installed"}`,
138
+ );
139
+ console.log(`state: ${isPaused() ? "PAUSED" : "active"}`);
140
+ console.log(`editor: ${getEditor(cfg.editor).name}`);
141
+ console.log(`watching: ${projectsDir(cfg)}`);
142
+ console.log(`pending: ${st.pending.length}`);
143
+ for (const p of st.pending) {
144
+ console.log(
145
+ ` ${p.sessionId} fires ${new Date(p.fire).toLocaleString()}`,
146
+ );
147
+ }
148
+ break;
149
+ }
150
+
151
+ case "pause": {
152
+ setPaused(true);
153
+ console.log("paused β€” auto-resume suspended (the watcher keeps running)");
154
+ break;
155
+ }
156
+
157
+ case "resume": {
158
+ setPaused(false);
159
+ console.log("resumed β€” watching for new limits");
160
+ break;
161
+ }
162
+
163
+ case "set-message": {
164
+ // The message is argv[1] (read directly so a leading "-" isn't eaten).
165
+ const clean = sanitizeMessage(argv[1] ?? "");
166
+ const raw = readConfigRaw();
167
+ if (clean) raw.message = clean;
168
+ else delete raw.message; // blank β†’ fall back to the default
169
+ writeConfigRaw(raw);
170
+ console.log(clean ? "message updated" : "message reset to default");
171
+ break;
172
+ }
173
+
174
+ case "doctor": {
175
+ const ok = await runDoctor();
176
+ process.exitCode = ok ? 0 : 1;
177
+ break;
178
+ }
179
+
180
+ case "test": {
181
+ const cfg = loadConfig({ warn: (m) => console.log(`warn: ${m}`) });
182
+ let sessionId = flagValue("--session");
183
+ if (sessionId && !isValidSessionId(sessionId)) {
184
+ console.error("invalid --session id (expected a UUID)");
185
+ process.exitCode = 1;
186
+ break;
187
+ }
188
+ if (!sessionId) {
189
+ // Most recently modified transcript = most recently active session.
190
+ const files = (listTranscripts(projectsDir(cfg)) || [])
191
+ .map((f) => {
192
+ try {
193
+ return { f, m: fs.statSync(f).mtimeMs };
194
+ } catch {
195
+ return null;
196
+ }
197
+ })
198
+ .filter(Boolean)
199
+ .sort((a, b) => b.m - a.m);
200
+ if (!files.length) {
201
+ console.error(
202
+ `no session transcripts found under ${projectsDir(cfg)}`,
203
+ );
204
+ process.exitCode = 1;
205
+ break;
206
+ }
207
+ sessionId = path.basename(files[0].f, ".jsonl");
208
+ if (!isValidSessionId(sessionId)) {
209
+ console.error(
210
+ `most recent transcript has a non-UUID name (${sessionId}); use --session <id>`,
211
+ );
212
+ process.exitCode = 1;
213
+ break;
214
+ }
215
+ }
216
+ console.log(`sending a test resume into session ${sessionId} …`);
217
+ const res = await sendResume({
218
+ sessionId,
219
+ editor: getEditor(cfg.editor),
220
+ message:
221
+ "[claude-wake] Test message β€” setup works. You can ignore this.",
222
+ focusDelayMs: cfg.focusDelayMs,
223
+ allowUnverifiedPaste: cfg.allowUnverifiedPaste,
224
+ dryRun: flags.has("--dry-run"),
225
+ log: (m) => console.log(m),
226
+ });
227
+ console.log(res.ok ? `ok: ${res.detail}` : `FAILED: ${res.detail}`);
228
+ if (!res.ok) process.exitCode = 1;
229
+ break;
230
+ }
231
+
232
+ case "logs": {
233
+ if (!fs.existsSync(LOG_FILE)) {
234
+ console.log(`no log yet (${LOG_FILE})`);
235
+ break;
236
+ }
237
+ if (flags.has("-f") || flags.has("--follow")) {
238
+ if (process.platform === "win32") {
239
+ // no `tail` on Windows: naive size-diff follower
240
+ let pos = fs.statSync(LOG_FILE).size;
241
+ process.stdout.write(
242
+ fs
243
+ .readFileSync(LOG_FILE, "utf8")
244
+ .split("\n")
245
+ .slice(-10)
246
+ .join("\n") + "\n",
247
+ );
248
+ setInterval(() => {
249
+ try {
250
+ const size = fs.statSync(LOG_FILE).size;
251
+ if (size > pos) {
252
+ const fd = fs.openSync(LOG_FILE, "r");
253
+ const buf = Buffer.alloc(size - pos);
254
+ fs.readSync(fd, buf, 0, buf.length, pos);
255
+ fs.closeSync(fd);
256
+ process.stdout.write(buf.toString("utf8"));
257
+ pos = size;
258
+ } else if (size < pos) pos = 0;
259
+ } catch {
260
+ /* keep polling */
261
+ }
262
+ }, 1000);
263
+ return; // keep process alive
264
+ }
265
+ const { spawn } = await import("node:child_process");
266
+ spawn("tail", ["-f", LOG_FILE], { stdio: "inherit" });
267
+ } else {
268
+ const lines = fs.readFileSync(LOG_FILE, "utf8").trimEnd().split("\n");
269
+ console.log(lines.slice(-40).join("\n"));
270
+ }
271
+ break;
272
+ }
273
+
274
+ case "--version":
275
+ case "-v": {
276
+ console.log(pkgVersion());
277
+ break;
278
+ }
279
+
280
+ default:
281
+ console.log(HELP);
282
+ if (cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h")
283
+ process.exitCode = 1;
284
+ }
285
+ }
286
+
287
+ main().catch((err) => {
288
+ console.error(`error: ${err.message}`);
289
+ process.exit(1);
290
+ });