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.
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { BASE_DIR, ensureBaseDir } from "../config.js";
5
+
6
+ /**
7
+ * Windows sender: PowerShell Set-Clipboard + SendKeys, hardened:
8
+ *
9
+ * - Runtime values travel as base64(JSON) in argv — sidesteps BOTH string
10
+ * interpolation and the stdin OEM-codepage mojibake that garbled non-ASCII
11
+ * messages (hostile review).
12
+ * - Window matching: title must END WITH the editor name (VS Code convention),
13
+ * not a loose substring ("Cursor" matched browser tabs).
14
+ * - AppActivate's return value is NOT trusted: Windows focus-stealing
15
+ * prevention lets it "succeed" while the real foreground window is
16
+ * unchanged. We verify via GetForegroundWindow → PID and abort otherwise.
17
+ * - Clipboard is set only AFTER the foreground verification.
18
+ *
19
+ * Status: community-verify (implemented to spec, no live QA environment).
20
+ */
21
+
22
+ const SENDER_PS1 = path.join(BASE_DIR, "sender.ps1");
23
+
24
+ const PS_SOURCE = `
25
+ $ErrorActionPreference = "Stop"
26
+ try {
27
+ $req = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($args[0])) | ConvertFrom-Json
28
+ } catch { Write-Output "ERR: bad request: $_"; exit 1 }
29
+
30
+ Add-Type @"
31
+ using System;
32
+ using System.Runtime.InteropServices;
33
+ public class CWWin {
34
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
35
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
36
+ }
37
+ "@
38
+
39
+ function Get-ForegroundPid {
40
+ $h = [CWWin]::GetForegroundWindow()
41
+ $fgPid = 0
42
+ [void][CWWin]::GetWindowThreadProcessId($h, [ref]$fgPid)
43
+ return $fgPid
44
+ }
45
+
46
+ if ($req.uri -and $req.uri.Length -gt 0) {
47
+ try { Start-Process $req.uri } catch { Write-Output "ERR: open uri: $_"; exit 1 }
48
+ Start-Sleep -Milliseconds $req.delayMs
49
+ }
50
+
51
+ # Editor windows: title ends with the product name (strict suffix, not substring).
52
+ $targets = Get-Process | Where-Object {
53
+ $_.MainWindowTitle -and $_.MainWindowTitle.EndsWith($req.title, [StringComparison]::OrdinalIgnoreCase)
54
+ }
55
+ if (-not $targets) { Write-Output "ERR: no window title ending with '$($req.title)'"; exit 2 }
56
+ $targetPids = @($targets | ForEach-Object { $_.Id })
57
+
58
+ # If the URI focus already put the editor in the foreground, don't fight it
59
+ # with AppActivate; otherwise try AppActivate once.
60
+ if ($targetPids -notcontains (Get-ForegroundPid)) {
61
+ $ws = New-Object -ComObject WScript.Shell
62
+ [void]$ws.AppActivate($targets[0].Id)
63
+ Start-Sleep -Milliseconds 500
64
+ }
65
+
66
+ # Trust only the actual foreground window (AppActivate lies under
67
+ # focus-stealing prevention).
68
+ if ($targetPids -notcontains (Get-ForegroundPid)) {
69
+ Write-Output "ERR: editor is not the foreground window"; exit 2
70
+ }
71
+
72
+ try { Set-Clipboard -Value $req.message } catch { Write-Output "ERR: clipboard: $_"; exit 1 }
73
+ $ws2 = New-Object -ComObject WScript.Shell
74
+ Start-Sleep -Milliseconds 300
75
+ $ws2.SendKeys("^v")
76
+ Start-Sleep -Milliseconds 250
77
+ $ws2.SendKeys("{ENTER}")
78
+ Write-Output "OK"
79
+ `;
80
+
81
+ function ensureSenderScript() {
82
+ ensureBaseDir();
83
+ if (
84
+ !fs.existsSync(SENDER_PS1) ||
85
+ fs.readFileSync(SENDER_PS1, "utf8") !== PS_SOURCE
86
+ ) {
87
+ fs.writeFileSync(SENDER_PS1, PS_SOURCE, { mode: 0o600 });
88
+ }
89
+ }
90
+
91
+ function runPs(inputObj, timeoutMs) {
92
+ ensureSenderScript();
93
+ const b64 = Buffer.from(JSON.stringify(inputObj), "utf8").toString("base64");
94
+ const r = spawnSync(
95
+ "powershell.exe",
96
+ [
97
+ "-NoProfile",
98
+ "-NonInteractive",
99
+ "-ExecutionPolicy",
100
+ "Bypass",
101
+ "-File",
102
+ SENDER_PS1,
103
+ b64,
104
+ ],
105
+ { encoding: "utf8", timeout: timeoutMs },
106
+ );
107
+ if (r.error) return `ERR: powershell: ${r.error.message}`;
108
+ return (r.stdout || "").trim() || `ERR: exit ${r.status}`;
109
+ }
110
+
111
+ export function checkPermissions() {
112
+ const r = spawnSync(
113
+ "powershell.exe",
114
+ ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major"],
115
+ { encoding: "utf8", timeout: 15000 },
116
+ );
117
+ if (r.status !== 0)
118
+ return { ok: false, detail: "powershell.exe not available" };
119
+ return { ok: true, detail: `powershell ${String(r.stdout).trim()}` };
120
+ }
121
+
122
+ export function send({ uri, editor, message, focusDelayMs }) {
123
+ const out = runPs(
124
+ {
125
+ message,
126
+ uri: uri || "",
127
+ title: editor.windowTitle,
128
+ delayMs: Math.round(focusDelayMs),
129
+ },
130
+ focusDelayMs + 30000,
131
+ );
132
+ return { ok: out.startsWith("OK"), detail: out };
133
+ }
package/lib/state.js ADDED
@@ -0,0 +1,139 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { STATE_FILE, ensureBaseDir } from "./config.js";
4
+
5
+ const MAX_FIRED = 500;
6
+ const MAX_UUIDS = 1000;
7
+ /**
8
+ * Two fire times for the same session closer than this are the same limit
9
+ * episode. Duplicate rate-limit lines for one episode carry timestamps and
10
+ * relative phrases that drift by up to a few minutes — exact-key dedupe alone
11
+ * would double-fire (found in hostile review).
12
+ */
13
+ const PROXIMITY_MS = 15 * 60_000;
14
+
15
+ export function emptyState() {
16
+ return { offsets: {}, pending: [], fired: [], uuids: [] };
17
+ }
18
+
19
+ /** Bookkeeping key: session + fire minute. Dedupe itself also uses proximity. */
20
+ export function episodeKey(sessionId, fireEpochMs) {
21
+ return `${sessionId}@${Math.floor(fireEpochMs / 60000)}`;
22
+ }
23
+
24
+ function keyParts(key) {
25
+ const at = key.lastIndexOf("@");
26
+ if (at === -1) return null;
27
+ const minute = Number(key.slice(at + 1));
28
+ if (!Number.isFinite(minute)) return null;
29
+ return { sessionId: key.slice(0, at), fireEpochMs: minute * 60000 };
30
+ }
31
+
32
+ export function loadState(file = STATE_FILE) {
33
+ let st;
34
+ try {
35
+ st = JSON.parse(fs.readFileSync(file, "utf8"));
36
+ } catch {
37
+ st = null;
38
+ }
39
+ if (typeof st !== "object" || st === null) st = emptyState();
40
+ if (typeof st.offsets !== "object" || st.offsets === null) st.offsets = {};
41
+ if (!Array.isArray(st.pending)) st.pending = [];
42
+ if (!Array.isArray(st.fired)) st.fired = [];
43
+ if (!Array.isArray(st.uuids)) st.uuids = [];
44
+
45
+ // Defensive on load: drop malformed entries, collapse per-episode duplicates.
46
+ const kept = [];
47
+ for (const p of st.pending) {
48
+ if (!p || typeof p.sessionId !== "string" || !Number.isFinite(p.fire))
49
+ continue;
50
+ p.key = p.key || episodeKey(p.sessionId, p.fire);
51
+ if (
52
+ isDuplicateEpisode({ ...st, pending: kept }, p.sessionId, p.fire, p.uuid)
53
+ )
54
+ continue;
55
+ kept.push(p);
56
+ }
57
+ st.pending = kept;
58
+ return st;
59
+ }
60
+
61
+ export function saveState(st, file = STATE_FILE) {
62
+ ensureBaseDir();
63
+ const tmp = file + ".tmp";
64
+ fs.writeFileSync(tmp, JSON.stringify(st), { mode: 0o600 });
65
+ fs.renameSync(tmp, file); // atomic on POSIX; near-atomic on Windows
66
+ }
67
+
68
+ /** True if this (session, fire time, uuid) matches an episode we already track. */
69
+ function isDuplicateEpisode(st, sessionId, fireEpochMs, uuid) {
70
+ if (uuid && st.uuids.includes(uuid)) return true;
71
+ for (const p of st.pending) {
72
+ if (
73
+ p.sessionId === sessionId &&
74
+ Math.abs(p.fire - fireEpochMs) < PROXIMITY_MS
75
+ )
76
+ return true;
77
+ }
78
+ for (const key of st.fired) {
79
+ const parts = keyParts(key);
80
+ if (
81
+ parts &&
82
+ parts.sessionId === sessionId &&
83
+ Math.abs(parts.fireEpochMs - fireEpochMs) < PROXIMITY_MS
84
+ )
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /** Queue an episode unless it duplicates a pending/fired one. Returns true if added. */
91
+ export function addEpisode(
92
+ st,
93
+ { sessionId, fireEpochMs, transcriptPath, eventEpochMs, uuid },
94
+ ) {
95
+ if (isDuplicateEpisode(st, sessionId, fireEpochMs, uuid)) {
96
+ rememberUuid(st, uuid); // a duplicate line still consumes its uuid
97
+ return false;
98
+ }
99
+ st.pending.push({
100
+ key: episodeKey(sessionId, fireEpochMs),
101
+ sessionId,
102
+ fire: fireEpochMs,
103
+ transcriptPath,
104
+ event: eventEpochMs,
105
+ uuid: uuid || undefined,
106
+ });
107
+ rememberUuid(st, uuid);
108
+ return true;
109
+ }
110
+
111
+ function rememberUuid(st, uuid) {
112
+ if (!uuid || st.uuids.includes(uuid)) return;
113
+ st.uuids.push(uuid);
114
+ if (st.uuids.length > MAX_UUIDS) st.uuids = st.uuids.slice(-MAX_UUIDS);
115
+ }
116
+
117
+ export function markFired(st, key) {
118
+ st.pending = st.pending.filter((p) => p.key !== key);
119
+ st.fired.push(key);
120
+ if (st.fired.length > MAX_FIRED) st.fired = st.fired.slice(-MAX_FIRED);
121
+ }
122
+
123
+ export function duePending(st, nowMs) {
124
+ return st.pending
125
+ .filter((p) => p.fire <= nowMs)
126
+ .sort((a, b) => a.fire - b.fire);
127
+ }
128
+
129
+ /** Forget offsets for transcript files that no longer exist (keeps state small). */
130
+ export function pruneOffsets(st, existingPaths) {
131
+ const keep = new Set(existingPaths);
132
+ for (const p of Object.keys(st.offsets)) {
133
+ if (!keep.has(p)) delete st.offsets[p];
134
+ }
135
+ }
136
+
137
+ export function stateDir() {
138
+ return path.dirname(STATE_FILE);
139
+ }
package/lib/watch.js ADDED
@@ -0,0 +1,276 @@
1
+ import fs from "node:fs";
2
+ import {
3
+ LOCK_FILE,
4
+ LOG_FILE,
5
+ ensureBaseDir,
6
+ projectsDir,
7
+ isPaused,
8
+ loadConfig,
9
+ } from "./config.js";
10
+ import { getEditor } from "./editors.js";
11
+ import { listTranscripts, scanFile, sessionResumedAfter } from "./detect.js";
12
+ import { parseResetEpochMs } from "./parse-reset.js";
13
+ import {
14
+ addEpisode,
15
+ duePending,
16
+ loadState,
17
+ markFired,
18
+ pruneOffsets,
19
+ saveState,
20
+ } from "./state.js";
21
+ import { sendResume } from "./send/index.js";
22
+
23
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
+
25
+ /* ---------------- single-instance lock ----------------
26
+ * pid + heartbeat mtime: the watcher touches the lock every loop, so a lock
27
+ * whose mtime is stale is dead regardless of what its pid now points at
28
+ * (pid-reuse after crash/reboot permanently blocked the daemon — hostile
29
+ * review). */
30
+
31
+ // A lock is stale if its heartbeat is older than this. Set at watch start to
32
+ // comfortably exceed the poll interval AND a long firePass, so the owner never
33
+ // starves its own lock (which would let a second daemon steal it → double fire).
34
+ let lockStaleMs = 3 * 60_000;
35
+
36
+ export function setLockStaleFromPoll(pollSeconds) {
37
+ lockStaleMs = Math.max(3 * 60_000, Math.round(pollSeconds * 1000 * 4));
38
+ }
39
+
40
+ function lockIsLive() {
41
+ try {
42
+ const stat = fs.statSync(LOCK_FILE);
43
+ if (Date.now() - stat.mtimeMs > lockStaleMs) return { live: false };
44
+ const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf8"), 10);
45
+ return { live: true, pid };
46
+ } catch {
47
+ return { live: false };
48
+ }
49
+ }
50
+
51
+ export function acquireLock() {
52
+ ensureBaseDir();
53
+ const cur = lockIsLive();
54
+ if (cur.live && cur.pid !== process.pid) return { ok: false, pid: cur.pid };
55
+ fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o600 });
56
+ const release = () => {
57
+ try {
58
+ if (parseInt(fs.readFileSync(LOCK_FILE, "utf8"), 10) === process.pid) {
59
+ fs.rmSync(LOCK_FILE, { force: true });
60
+ }
61
+ } catch {
62
+ /* best effort */
63
+ }
64
+ };
65
+ for (const sig of ["SIGINT", "SIGTERM"]) {
66
+ process.on(sig, () => {
67
+ release();
68
+ process.exit(0);
69
+ });
70
+ }
71
+ process.on("exit", release);
72
+ return { ok: true };
73
+ }
74
+
75
+ export function touchLock() {
76
+ try {
77
+ const now = new Date();
78
+ fs.utimesSync(LOCK_FILE, now, now);
79
+ } catch {
80
+ /* re-created next write */
81
+ }
82
+ }
83
+
84
+ /* ---------------- core passes ---------------- */
85
+
86
+ /**
87
+ * One scan pass: read appended transcript bytes, queue new limit episodes.
88
+ * Exported for tests. Mutates `st`.
89
+ */
90
+ export function scanPass(st, cfg, log, nowMs = Date.now()) {
91
+ const files = listTranscripts(projectsDir(cfg));
92
+ if (files === null) return; // transient readdir failure: keep offsets intact
93
+ pruneOffsets(st, files);
94
+
95
+ for (const file of files) {
96
+ if (st.offsets[file] === undefined) {
97
+ // New file appearing while we run: a brand-new session transcript —
98
+ // scan it from the start (its history IS new). First-run priming of
99
+ // pre-existing files happens in primeOffsets().
100
+ st.offsets[file] = 0;
101
+ }
102
+ const { events, newOffset } = scanFile(file, st.offsets[file]);
103
+ st.offsets[file] = newOffset;
104
+
105
+ for (const ev of events) {
106
+ if (nowMs - ev.eventEpochMs > cfg.maxEventAgeHours * 3600_000) continue;
107
+
108
+ // Reference the EVENT time, not scan time — see parse-reset.js header.
109
+ let reset = parseResetEpochMs(ev.text, ev.eventEpochMs);
110
+ if (reset === null) {
111
+ reset = ev.eventEpochMs + cfg.fallbackWaitHours * 3600_000;
112
+ log(
113
+ `could not parse reset from ${JSON.stringify(ev.text.slice(0, 120))}; ` +
114
+ `falling back to event+${cfg.fallbackWaitHours}h`,
115
+ );
116
+ }
117
+ const fire = reset + cfg.marginSeconds * 1000;
118
+ if (
119
+ addEpisode(st, {
120
+ sessionId: ev.sessionId,
121
+ fireEpochMs: fire,
122
+ transcriptPath: ev.transcriptPath,
123
+ eventEpochMs: ev.eventEpochMs,
124
+ uuid: ev.uuid,
125
+ })
126
+ ) {
127
+ log(
128
+ `detected limit session=${ev.sessionId} :: ${JSON.stringify(ev.text.slice(0, 140))} ` +
129
+ `-> fire ${new Date(fire).toLocaleString()}`,
130
+ );
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Fire everything due. Mutates `st`. */
137
+ export async function firePass(
138
+ st,
139
+ cfg,
140
+ log,
141
+ { dryRun = false, nowMs = Date.now() } = {},
142
+ ) {
143
+ const due = duePending(st, nowMs);
144
+ const editor = getEditor(cfg.editor);
145
+ let attempts = 0;
146
+
147
+ for (const p of due) {
148
+ if (
149
+ cfg.skipIfResumed &&
150
+ p.transcriptPath &&
151
+ sessionResumedAfter(p.transcriptPath, p.event)
152
+ ) {
153
+ log(`skip session=${p.sessionId} (user already resumed after the limit)`);
154
+ markFired(st, p.key);
155
+ continue;
156
+ }
157
+ if (attempts > 0) await sleep(cfg.fireSpacingSeconds * 1000);
158
+ attempts++;
159
+ touchLock(); // heartbeat during a long fire burst so the lock never goes stale
160
+ log(`RESUME session=${p.sessionId}${dryRun ? " (dry-run)" : ""}`);
161
+ try {
162
+ const res = await sendResume({
163
+ sessionId: p.sessionId,
164
+ editor,
165
+ message: cfg.message,
166
+ focusDelayMs: cfg.focusDelayMs,
167
+ allowUnverifiedPaste: cfg.allowUnverifiedPaste,
168
+ dryRun,
169
+ log,
170
+ });
171
+ if (!res.ok) log(`send failed session=${p.sessionId}: ${res.detail}`);
172
+ } catch (err) {
173
+ log(`send error session=${p.sessionId}: ${err.message}`);
174
+ }
175
+ touchLock(); // a send can take tens of seconds — re-stamp so the lock
176
+ // heartbeat never goes stale mid-burst (would let a 2nd daemon steal it)
177
+ // Fired OR failed: retire the episode either way. A failed send (e.g.
178
+ // wrong window focused at 3am) must not retry-loop forever into someone's
179
+ // typing; the guarded message + next limit event give the next chance.
180
+ markFired(st, p.key);
181
+ }
182
+ return attempts;
183
+ }
184
+
185
+ /** Prime offsets to EOF on first run so history is never replayed. */
186
+ export function primeOffsets(st, cfg, log) {
187
+ if (Object.keys(st.offsets).length > 0) return;
188
+ const files = listTranscripts(projectsDir(cfg)) || [];
189
+ for (const f of files) {
190
+ try {
191
+ st.offsets[f] = fs.statSync(f).size;
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ }
196
+ log(
197
+ `primed offsets for ${files.length} transcript files (watching for NEW limits only)`,
198
+ );
199
+ }
200
+
201
+ /** Keep the daemon's side-channel files from growing unbounded. */
202
+ function truncateSideLogs() {
203
+ for (const suffix of [".stdout", ".stderr"]) {
204
+ try {
205
+ const f = LOG_FILE + suffix;
206
+ if (fs.statSync(f).size > 5 * 1024 * 1024) fs.truncateSync(f, 0);
207
+ } catch {
208
+ /* absent */
209
+ }
210
+ }
211
+ }
212
+
213
+ /* ---------------- main loop ---------------- */
214
+
215
+ export async function watchLoop(cfg, log, { dryRun = false } = {}) {
216
+ setLockStaleFromPoll(cfg.pollSeconds);
217
+ const lock = acquireLock();
218
+ if (!lock.ok) {
219
+ // Exit 0: "already running" is success. A non-zero exit here made
220
+ // launchd/systemd (KeepAlive / Restart=on-failure) respawn-flap every
221
+ // 10s for as long as a manual watch held the lock (hostile review).
222
+ log(
223
+ `another claude-wake watcher is already running (pid ${lock.pid}) — exiting`,
224
+ );
225
+ process.exitCode = 0;
226
+ return;
227
+ }
228
+ // Startup I/O wrapped: a persistent failure here (disk full, perms) must not
229
+ // reject → non-zero exit → daemon restart flap. Log and exit 0 instead.
230
+ let st;
231
+ try {
232
+ truncateSideLogs();
233
+ log(
234
+ `claude-wake watching ${projectsDir(cfg)} ` +
235
+ `(editor=${cfg.editor}, margin=${cfg.marginSeconds}s${dryRun ? ", DRY-RUN" : ""})`,
236
+ );
237
+ st = loadState();
238
+ primeOffsets(st, cfg, log);
239
+ saveState(st);
240
+ } catch (err) {
241
+ log(`startup failed (not restarting): ${err.message}`);
242
+ process.exitCode = 0;
243
+ return;
244
+ }
245
+
246
+ let wasPaused = false;
247
+ for (;;) {
248
+ try {
249
+ // Live-reload config each cycle so message/margin/editor edits (e.g. from
250
+ // the desktop app) apply without a restart. Cheap: one small file read.
251
+ cfg = loadConfig({ warn: () => {} });
252
+ setLockStaleFromPoll(cfg.pollSeconds);
253
+ touchLock(); // heartbeat even while paused, so the lock stays owned
254
+ if (isPaused()) {
255
+ if (!wasPaused) log("paused — auto-resume suspended");
256
+ wasPaused = true;
257
+ } else {
258
+ if (wasPaused) {
259
+ // Resume: ignore anything that happened while paused (you paused on
260
+ // purpose) by re-priming offsets to EOF and dropping stale pending.
261
+ st.offsets = {};
262
+ st.pending = [];
263
+ primeOffsets(st, cfg, log);
264
+ log("resumed — watching for new limits");
265
+ }
266
+ wasPaused = false;
267
+ scanPass(st, cfg, log);
268
+ await firePass(st, cfg, log, { dryRun });
269
+ }
270
+ saveState(st);
271
+ } catch (err) {
272
+ log(`loop error: ${err.stack || err.message}`);
273
+ }
274
+ await sleep(cfg.pollSeconds * 1000);
275
+ }
276
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "claude-wake",
3
+ "version": "0.1.0",
4
+ "description": "Auto-resume Claude Code (VS Code extension panels) when your usage limit resets — while you sleep.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "usage-limit",
9
+ "rate-limit",
10
+ "auto-resume",
11
+ "auto-retry",
12
+ "vscode",
13
+ "anthropic"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Kazuki Nakayashiki",
17
+ "type": "module",
18
+ "bin": {
19
+ "claude-wake": "bin/claude-wake.js"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "lib",
24
+ "docs/design.md",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/kazuki-sf/claude-wake.git"
37
+ }
38
+ }