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 +21 -0
- package/README.md +204 -0
- package/bin/claude-wake.js +290 -0
- package/docs/design.md +181 -0
- package/lib/config.js +174 -0
- package/lib/detect.js +223 -0
- package/lib/doctor.js +135 -0
- package/lib/editors.js +87 -0
- package/lib/install.js +315 -0
- package/lib/log.js +43 -0
- package/lib/parse-reset.js +192 -0
- package/lib/send/darwin.js +233 -0
- package/lib/send/index.js +47 -0
- package/lib/send/linux.js +149 -0
- package/lib/send/win32.js +133 -0
- package/lib/state.js +139 -0
- package/lib/watch.js +276 -0
- package/package.json +38 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Claude Code limit messages into an absolute epoch (ms).
|
|
3
|
+
*
|
|
4
|
+
* Observed renders (verified against real transcripts):
|
|
5
|
+
* "You've hit your session limit · resets 1:20am (America/Los_Angeles)"
|
|
6
|
+
* "5-hour limit reached - resets 3pm (UTC)"
|
|
7
|
+
* "You've hit your weekly limit · resets Oct 9, 10am"
|
|
8
|
+
* "Claude usage limit reached. Resets at 2pm"
|
|
9
|
+
* "Please try again in 5 hours"
|
|
10
|
+
*
|
|
11
|
+
* Pure module. Timezone math uses only Intl (DST-safe iterative offset
|
|
12
|
+
* correction — same technique as date-fns-tz). No dependencies.
|
|
13
|
+
*
|
|
14
|
+
* `nowMs` should be the EVENT timestamp (when the message was rendered), not
|
|
15
|
+
* scan time: relative phrases ("in 5 hours") are relative to the render, and
|
|
16
|
+
* scan-time reference points made duplicate lines parse to drifting epochs
|
|
17
|
+
* (double-fire bug found in hostile review).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MONTHS = {
|
|
21
|
+
jan: 1,
|
|
22
|
+
feb: 2,
|
|
23
|
+
mar: 3,
|
|
24
|
+
apr: 4,
|
|
25
|
+
may: 5,
|
|
26
|
+
jun: 6,
|
|
27
|
+
jul: 7,
|
|
28
|
+
aug: 8,
|
|
29
|
+
sep: 9,
|
|
30
|
+
oct: 10,
|
|
31
|
+
nov: 11,
|
|
32
|
+
dec: 12,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const ABSOLUTE_RE =
|
|
36
|
+
/reset(?:s)?\s+(?:at\s+)?(?:([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?(?:\s*\(([^)]+)\))?/i;
|
|
37
|
+
|
|
38
|
+
const RELATIVE_RE = /try again in\s+(\d+)\s*(hour|minute|min)\w*/i;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Monthly limits can reset up to ~a month out; anything further is a
|
|
42
|
+
* year-boundary mis-parse ("Dec 31" seen in January). 32 days covers monthly
|
|
43
|
+
* while still catching the wrap.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_FUTURE_MS = 32 * 24 * 3600_000;
|
|
46
|
+
|
|
47
|
+
/** Cap on relative "try again in N hours" so a garbage/forged N can't park a
|
|
48
|
+
* pending entry with an un-representable (Invalid Date) fire time. */
|
|
49
|
+
const MAX_RELATIVE_HOURS = 24 * 14;
|
|
50
|
+
|
|
51
|
+
/** Wall-clock parts of `utcMs` as seen in `timeZone`. */
|
|
52
|
+
function wallClockInZone(utcMs, timeZone) {
|
|
53
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
54
|
+
timeZone,
|
|
55
|
+
year: "numeric",
|
|
56
|
+
month: "2-digit",
|
|
57
|
+
day: "2-digit",
|
|
58
|
+
hour: "2-digit",
|
|
59
|
+
minute: "2-digit",
|
|
60
|
+
second: "2-digit",
|
|
61
|
+
hour12: false,
|
|
62
|
+
});
|
|
63
|
+
const parts = {};
|
|
64
|
+
for (const p of dtf.formatToParts(new Date(utcMs))) parts[p.type] = p.value;
|
|
65
|
+
return {
|
|
66
|
+
year: +parts.year,
|
|
67
|
+
month: +parts.month,
|
|
68
|
+
day: +parts.day,
|
|
69
|
+
hour: parts.hour === "24" ? 0 : +parts.hour,
|
|
70
|
+
minute: +parts.minute,
|
|
71
|
+
second: +parts.second,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function offsetMs(timeZone, utcMs) {
|
|
76
|
+
const w = wallClockInZone(utcMs, timeZone);
|
|
77
|
+
const asUtc = Date.UTC(
|
|
78
|
+
w.year,
|
|
79
|
+
w.month - 1,
|
|
80
|
+
w.day,
|
|
81
|
+
w.hour,
|
|
82
|
+
w.minute,
|
|
83
|
+
w.second,
|
|
84
|
+
);
|
|
85
|
+
return asUtc - Math.floor(utcMs / 1000) * 1000;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Epoch ms for wall time (y,mo,d,hh,mm) in `timeZone`. DST-safe. */
|
|
89
|
+
export function zonedEpochMs(year, month, day, hour, minute, timeZone) {
|
|
90
|
+
const wallAsUtc = Date.UTC(year, month - 1, day, hour, minute, 0);
|
|
91
|
+
let guess = wallAsUtc;
|
|
92
|
+
for (let i = 0; i < 3; i++) {
|
|
93
|
+
const next = wallAsUtc - offsetMs(timeZone, guess);
|
|
94
|
+
if (next === guess) break;
|
|
95
|
+
guess = next;
|
|
96
|
+
}
|
|
97
|
+
return guess;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isValidTimeZone(tz) {
|
|
101
|
+
try {
|
|
102
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse `text` → epoch ms of the reset, or null.
|
|
111
|
+
* May return a PAST epoch (reset already elapsed) — callers fire immediately,
|
|
112
|
+
* which is the correct behavior for "the reset already happened".
|
|
113
|
+
*/
|
|
114
|
+
export function parseResetEpochMs(text, nowMs = Date.now()) {
|
|
115
|
+
if (typeof text !== "string" || !text) return null;
|
|
116
|
+
|
|
117
|
+
// Absolute forms first: when a message somehow contains both, the printed
|
|
118
|
+
// wall-clock reset is the precise one.
|
|
119
|
+
const m = ABSOLUTE_RE.exec(text);
|
|
120
|
+
if (m) {
|
|
121
|
+
const abs = parseAbsolute(m, nowMs);
|
|
122
|
+
if (abs !== null) return abs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rel = RELATIVE_RE.exec(text);
|
|
126
|
+
if (rel) {
|
|
127
|
+
const isHour = rel[2].toLowerCase().startsWith("hour");
|
|
128
|
+
const maxN = isHour ? MAX_RELATIVE_HOURS : MAX_RELATIVE_HOURS * 60;
|
|
129
|
+
const n = Math.min(+rel[1], maxN);
|
|
130
|
+
return nowMs + n * (isHour ? 3600_000 : 60_000);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseAbsolute(m, nowMs) {
|
|
137
|
+
const [, monName, dayStr, hhStr, mmStr, ampmRaw, tzRaw] = m;
|
|
138
|
+
|
|
139
|
+
let hour = +hhStr;
|
|
140
|
+
const minute = mmStr ? +mmStr : 0;
|
|
141
|
+
const ampm = ampmRaw ? ampmRaw.toLowerCase().replace(/\./g, "") : null;
|
|
142
|
+
if (ampm === "pm" && hour !== 12) hour += 12;
|
|
143
|
+
if (ampm === "am" && hour === 12) hour = 0;
|
|
144
|
+
if (hour > 23 || minute > 59) return null;
|
|
145
|
+
// Bare small number without am/pm ("resets 3") is too ambiguous — require
|
|
146
|
+
// either am/pm, a minutes part, or a 24h-looking hour.
|
|
147
|
+
if (!ampm && !mmStr && hour < 10) return null;
|
|
148
|
+
|
|
149
|
+
const tz =
|
|
150
|
+
tzRaw && isValidTimeZone(tzRaw.trim())
|
|
151
|
+
? tzRaw.trim()
|
|
152
|
+
: Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
153
|
+
|
|
154
|
+
const today = wallClockInZone(nowMs, tz);
|
|
155
|
+
|
|
156
|
+
if (monName) {
|
|
157
|
+
const month = MONTHS[monName.slice(0, 3).toLowerCase()];
|
|
158
|
+
if (!month || +dayStr < 1 || +dayStr > 31) return null;
|
|
159
|
+
let epoch = zonedEpochMs(today.year, month, +dayStr, hour, minute, tz);
|
|
160
|
+
// Year-boundary correction (hostile review): "resets Dec 31" scanned in
|
|
161
|
+
// January must mean LAST year's Dec 31 (already passed → fire now), not
|
|
162
|
+
// 12 months out. Bound: real resets are at most ~7 days away.
|
|
163
|
+
if (epoch > nowMs + MAX_FUTURE_MS) {
|
|
164
|
+
epoch = zonedEpochMs(today.year - 1, month, +dayStr, hour, minute, tz);
|
|
165
|
+
}
|
|
166
|
+
return epoch;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let epoch = zonedEpochMs(
|
|
170
|
+
today.year,
|
|
171
|
+
today.month,
|
|
172
|
+
today.day,
|
|
173
|
+
hour,
|
|
174
|
+
minute,
|
|
175
|
+
tz,
|
|
176
|
+
);
|
|
177
|
+
if (epoch <= nowMs) {
|
|
178
|
+
// Same wall time tomorrow. Tomorrow's DATE comes from calendar arithmetic
|
|
179
|
+
// on today's wall parts — deriving it from nowMs+24h skipped a day on
|
|
180
|
+
// 23-hour DST days (hostile review).
|
|
181
|
+
const t = new Date(Date.UTC(today.year, today.month - 1, today.day + 1));
|
|
182
|
+
epoch = zonedEpochMs(
|
|
183
|
+
t.getUTCFullYear(),
|
|
184
|
+
t.getUTCMonth() + 1,
|
|
185
|
+
t.getUTCDate(),
|
|
186
|
+
hour,
|
|
187
|
+
minute,
|
|
188
|
+
tz,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return epoch;
|
|
192
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
|
+
import { BASE_DIR, ensureBaseDir } from "../config.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* macOS sender — the reference implementation (live-verified).
|
|
8
|
+
*
|
|
9
|
+
* Why a compiled .app: webview inputs ignore synthetic keystrokes but accept a
|
|
10
|
+
* paste (Cmd+V). Sending keys needs the Accessibility permission, and macOS
|
|
11
|
+
* grants TCC permissions to the *responsible app bundle*. A stable compiled
|
|
12
|
+
* KeySender.app is granted once and then works from any context (foreground
|
|
13
|
+
* watch, launchd daemon). osacompile ships with macOS — no dependency.
|
|
14
|
+
*
|
|
15
|
+
* Hardening (from hostile review):
|
|
16
|
+
* - The editor's identity (app name + accepted BUNDLE IDS) is compiled into
|
|
17
|
+
* the app, not passed per-request — a frontmost check on bundle ids can't
|
|
18
|
+
* be fooled by other Electron apps (whose process name is also "Electron"),
|
|
19
|
+
* and same-user processes can't retarget the app via the request file.
|
|
20
|
+
* - The applet deletes the request file immediately after reading it, so a
|
|
21
|
+
* stray double-click of KeySender.app replays nothing.
|
|
22
|
+
* - The clipboard is set INSIDE the applet, AFTER the frontmost verification
|
|
23
|
+
* (no clobbering the user's clipboard on an aborted send), via AppleScript
|
|
24
|
+
* `set the clipboard to` — never through a shell string.
|
|
25
|
+
* - Delay crosses the boundary as integer milliseconds: AppleScript's
|
|
26
|
+
* string→number coercion is locale-dependent ("2.5" throws on comma-decimal
|
|
27
|
+
* locales, as a modal dialog at 3am).
|
|
28
|
+
*
|
|
29
|
+
* Request file (0600), one value per line:
|
|
30
|
+
* 1: mode "send" | "check" 2: focus URI ("" to skip)
|
|
31
|
+
* 3: delay in ms (integer) 4: the message (single line, enforced upstream)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const APP_DIR = path.join(BASE_DIR, "KeySender.app");
|
|
35
|
+
const REQUEST_FILE = path.join(BASE_DIR, "send-request.txt");
|
|
36
|
+
const STATUS_FILE = path.join(BASE_DIR, "sender-status.txt");
|
|
37
|
+
const SRC_FILE = path.join(BASE_DIR, "keysender.applescript");
|
|
38
|
+
|
|
39
|
+
function appleScriptSource(editor) {
|
|
40
|
+
const bundleIdsCSV = editor.bundleIds.join(",");
|
|
41
|
+
// appName/bundleIds come from code presets only (editors.js) — still,
|
|
42
|
+
// refuse to compile anything that could break out of the AppleScript
|
|
43
|
+
// string literal or the CSV contract.
|
|
44
|
+
for (const v of [editor.appName, bundleIdsCSV]) {
|
|
45
|
+
if (!/^[A-Za-z0-9 ._-]+(,[A-Za-z0-9 ._-]+)*$/.test(v)) {
|
|
46
|
+
throw new Error(`unsafe editor identity: ${v}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return String.raw`
|
|
50
|
+
property expectedApp : "${editor.appName}"
|
|
51
|
+
property expectedBundleIds : "${bundleIdsCSV}"
|
|
52
|
+
|
|
53
|
+
on writeStatus(msg)
|
|
54
|
+
set statusFile to (POSIX path of (path to home folder)) & ".claude-wake/sender-status.txt"
|
|
55
|
+
try
|
|
56
|
+
do shell script "printf %s " & quoted form of msg & " > " & quoted form of statusFile
|
|
57
|
+
end try
|
|
58
|
+
end writeStatus
|
|
59
|
+
|
|
60
|
+
on splitCSV(csv)
|
|
61
|
+
set oldDelims to AppleScript's text item delimiters
|
|
62
|
+
set AppleScript's text item delimiters to ","
|
|
63
|
+
set parts to text items of csv
|
|
64
|
+
set AppleScript's text item delimiters to oldDelims
|
|
65
|
+
return parts
|
|
66
|
+
end splitCSV
|
|
67
|
+
|
|
68
|
+
try
|
|
69
|
+
set base to (POSIX path of (path to home folder)) & ".claude-wake/"
|
|
70
|
+
set reqFile to base & "send-request.txt"
|
|
71
|
+
|
|
72
|
+
try
|
|
73
|
+
set reqData to do shell script "cat " & quoted form of reqFile
|
|
74
|
+
on error
|
|
75
|
+
return -- no request (e.g. double-clicked in Finder): do nothing
|
|
76
|
+
end try
|
|
77
|
+
-- consume the request so it can never replay
|
|
78
|
+
try
|
|
79
|
+
do shell script "rm -f " & quoted form of reqFile
|
|
80
|
+
end try
|
|
81
|
+
|
|
82
|
+
set req to paragraphs of reqData
|
|
83
|
+
if (count of req) < 4 then
|
|
84
|
+
my writeStatus("ERR: malformed request")
|
|
85
|
+
return
|
|
86
|
+
end if
|
|
87
|
+
set modeLine to item 1 of req
|
|
88
|
+
set uriLine to item 2 of req
|
|
89
|
+
set delayMs to (item 3 of req) as integer
|
|
90
|
+
set messageLine to item 4 of req
|
|
91
|
+
|
|
92
|
+
if modeLine is "check" then
|
|
93
|
+
-- Permission probe: a bare modifier press is invisible to apps but
|
|
94
|
+
-- still requires Accessibility (errors with 1002 if not granted).
|
|
95
|
+
try
|
|
96
|
+
tell application "System Events"
|
|
97
|
+
key down control
|
|
98
|
+
key up control
|
|
99
|
+
end tell
|
|
100
|
+
my writeStatus("OK")
|
|
101
|
+
on error errMsg
|
|
102
|
+
my writeStatus("ERR: " & errMsg)
|
|
103
|
+
end try
|
|
104
|
+
return
|
|
105
|
+
end if
|
|
106
|
+
|
|
107
|
+
if uriLine is not "" then
|
|
108
|
+
try
|
|
109
|
+
do shell script "open " & quoted form of uriLine
|
|
110
|
+
on error errMsg
|
|
111
|
+
my writeStatus("ERR: open failed: " & errMsg)
|
|
112
|
+
return
|
|
113
|
+
end try
|
|
114
|
+
delay (delayMs / 1000)
|
|
115
|
+
end if
|
|
116
|
+
|
|
117
|
+
try
|
|
118
|
+
tell application expectedApp to activate
|
|
119
|
+
end try
|
|
120
|
+
delay 0.6
|
|
121
|
+
|
|
122
|
+
-- Never type into an unknown window: verify the frontmost app's BUNDLE ID.
|
|
123
|
+
set frontOk to false
|
|
124
|
+
try
|
|
125
|
+
tell application "System Events"
|
|
126
|
+
set frontBundle to bundle identifier of first application process whose frontmost is true
|
|
127
|
+
end tell
|
|
128
|
+
repeat with candidate in my splitCSV(expectedBundleIds)
|
|
129
|
+
if frontBundle is (candidate as text) then set frontOk to true
|
|
130
|
+
end repeat
|
|
131
|
+
on error
|
|
132
|
+
set frontOk to false
|
|
133
|
+
end try
|
|
134
|
+
if not frontOk then
|
|
135
|
+
my writeStatus("ERR: frontmost app is not the expected editor")
|
|
136
|
+
return
|
|
137
|
+
end if
|
|
138
|
+
|
|
139
|
+
-- Clipboard only now, after verification (no clobber on aborted sends).
|
|
140
|
+
set the clipboard to messageLine
|
|
141
|
+
delay 0.1
|
|
142
|
+
|
|
143
|
+
try
|
|
144
|
+
tell application "System Events"
|
|
145
|
+
keystroke "v" using command down
|
|
146
|
+
delay 0.25
|
|
147
|
+
key code 36 -- Return
|
|
148
|
+
end tell
|
|
149
|
+
my writeStatus("OK")
|
|
150
|
+
on error errMsg
|
|
151
|
+
my writeStatus("ERR: " & errMsg)
|
|
152
|
+
end try
|
|
153
|
+
on error topErr
|
|
154
|
+
my writeStatus("ERR: " & topErr)
|
|
155
|
+
end try
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compile KeySender.app if missing or its source changed (editor identity is
|
|
161
|
+
* baked in, so switching config `editor` recompiles — and needs a one-time
|
|
162
|
+
* re-grant of Accessibility, which doctor explains).
|
|
163
|
+
* Returns true when (re)compiled.
|
|
164
|
+
*/
|
|
165
|
+
export function ensureKeySenderApp(editor) {
|
|
166
|
+
ensureBaseDir();
|
|
167
|
+
const source = appleScriptSource(editor);
|
|
168
|
+
const current = fs.existsSync(SRC_FILE)
|
|
169
|
+
? fs.readFileSync(SRC_FILE, "utf8")
|
|
170
|
+
: null;
|
|
171
|
+
if (current === source && fs.existsSync(APP_DIR)) return false;
|
|
172
|
+
fs.writeFileSync(SRC_FILE, source, { mode: 0o600 });
|
|
173
|
+
fs.rmSync(APP_DIR, { recursive: true, force: true });
|
|
174
|
+
execFileSync("/usr/bin/osacompile", ["-o", APP_DIR, SRC_FILE], {
|
|
175
|
+
stdio: "pipe",
|
|
176
|
+
});
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function runKeySender(requestLines, timeoutMs) {
|
|
181
|
+
fs.rmSync(STATUS_FILE, { force: true });
|
|
182
|
+
fs.writeFileSync(REQUEST_FILE, requestLines.join("\n") + "\n", {
|
|
183
|
+
mode: 0o600,
|
|
184
|
+
});
|
|
185
|
+
// `open -n -W` waits for the applet to exit, bounded by our own timeout.
|
|
186
|
+
const r = spawnSync("/usr/bin/open", ["-n", "-W", APP_DIR], {
|
|
187
|
+
timeout: timeoutMs,
|
|
188
|
+
});
|
|
189
|
+
if (r.error) throw new Error(`open KeySender.app failed: ${r.error.message}`);
|
|
190
|
+
try {
|
|
191
|
+
return fs.readFileSync(STATUS_FILE, "utf8").trim();
|
|
192
|
+
} catch {
|
|
193
|
+
return "ERR: no status written (app blocked, crashed, or timed out?)";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Strip newlines defensively — a newline in the request breaks the line contract. */
|
|
198
|
+
function oneLine(s) {
|
|
199
|
+
return String(s).replace(/[\r\n]+/g, " ");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Accessibility/permission probe. Returns { ok, detail }. */
|
|
203
|
+
export function checkPermissions(editor) {
|
|
204
|
+
ensureKeySenderApp(editor);
|
|
205
|
+
const status = runKeySender(["check", "", "0", ""], 15000);
|
|
206
|
+
return { ok: status === "OK", detail: status };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Full send: focus panel via URI, verify frontmost bundle id, clipboard ←
|
|
211
|
+
* message, paste + Enter. Returns { ok, detail }.
|
|
212
|
+
*/
|
|
213
|
+
export function send({ uri, editor, message, focusDelayMs }) {
|
|
214
|
+
ensureKeySenderApp(editor);
|
|
215
|
+
const status = runKeySender(
|
|
216
|
+
[
|
|
217
|
+
"send",
|
|
218
|
+
oneLine(uri || ""),
|
|
219
|
+
String(Math.round(focusDelayMs)),
|
|
220
|
+
oneLine(message),
|
|
221
|
+
],
|
|
222
|
+
Math.ceil(focusDelayMs + 30000),
|
|
223
|
+
);
|
|
224
|
+
return { ok: status === "OK", detail: status };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Reveal the Accessibility pane + the app in Finder (used by doctor). */
|
|
228
|
+
export function openPermissionUi() {
|
|
229
|
+
spawnSync("/usr/bin/open", [
|
|
230
|
+
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
|
|
231
|
+
]);
|
|
232
|
+
spawnSync("/usr/bin/open", ["-R", APP_DIR]);
|
|
233
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildFocusUri, getEditor } from "../editors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform dispatch for the send layer.
|
|
5
|
+
* Backends expose: checkPermissions(editor?) and
|
|
6
|
+
* send({uri, editor, message, focusDelayMs, allowUnverifiedPaste}).
|
|
7
|
+
*/
|
|
8
|
+
async function backend() {
|
|
9
|
+
switch (process.platform) {
|
|
10
|
+
case "darwin":
|
|
11
|
+
return import("./darwin.js");
|
|
12
|
+
case "linux":
|
|
13
|
+
return import("./linux.js");
|
|
14
|
+
case "win32":
|
|
15
|
+
return import("./win32.js");
|
|
16
|
+
default:
|
|
17
|
+
throw new Error(`unsupported platform: ${process.platform}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function checkSendPermissions(editor = getEditor("vscode")) {
|
|
22
|
+
return (await backend()).checkPermissions(editor);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Focus the session's panel and paste+submit the message.
|
|
27
|
+
* dryRun: log-only — the full pipeline minus any real side effect.
|
|
28
|
+
*/
|
|
29
|
+
export async function sendResume({
|
|
30
|
+
sessionId,
|
|
31
|
+
editor,
|
|
32
|
+
message,
|
|
33
|
+
focusDelayMs,
|
|
34
|
+
allowUnverifiedPaste = false,
|
|
35
|
+
dryRun,
|
|
36
|
+
log,
|
|
37
|
+
}) {
|
|
38
|
+
const uri = buildFocusUri(editor, sessionId);
|
|
39
|
+
if (dryRun) {
|
|
40
|
+
log(
|
|
41
|
+
`DRY-RUN: would open ${uri}, wait ${focusDelayMs}ms, verify frontmost, paste + Enter`,
|
|
42
|
+
);
|
|
43
|
+
return { ok: true, detail: "dry-run" };
|
|
44
|
+
}
|
|
45
|
+
const b = await backend();
|
|
46
|
+
return b.send({ uri, editor, message, focusDelayMs, allowUnverifiedPaste });
|
|
47
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Linux sender. X11 = solid path (xdotool verifies the active window before
|
|
5
|
+
* typing). Wayland = experimental AND blind (no portable active-window
|
|
6
|
+
* check), so it requires the explicit `allowUnverifiedPaste: true` config.
|
|
7
|
+
*
|
|
8
|
+
* Gotchas encoded here (from hostile review):
|
|
9
|
+
* - xclip/xsel/wl-copy daemonize to serve the selection and INHERIT stdio
|
|
10
|
+
* pipes; spawnSync waits for pipe EOF, not child exit → with default stdio
|
|
11
|
+
* the watcher hangs until something else takes the clipboard. Always spawn
|
|
12
|
+
* them with stdout/stderr ignored.
|
|
13
|
+
* - Window title check is a strict SUFFIX match (VS Code titles end with the
|
|
14
|
+
* product name); a bare substring match on "Cursor" hit browser tabs etc.
|
|
15
|
+
* - Clipboard is set only AFTER the active-window verification.
|
|
16
|
+
*
|
|
17
|
+
* Message text only ever travels via stdin (never a shell string).
|
|
18
|
+
* Status: community-verify (implemented to spec, no live QA environment).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function has(cmd) {
|
|
22
|
+
return (
|
|
23
|
+
spawnSync(
|
|
24
|
+
"/bin/sh",
|
|
25
|
+
["-c", 'command -v -- "$1" >/dev/null 2>&1', "sh", cmd],
|
|
26
|
+
{
|
|
27
|
+
stdio: "ignore",
|
|
28
|
+
},
|
|
29
|
+
).status === 0
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isWayland() {
|
|
34
|
+
return (
|
|
35
|
+
process.env.XDG_SESSION_TYPE === "wayland" || !!process.env.WAYLAND_DISPLAY
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function tooling() {
|
|
40
|
+
const wayland = isWayland();
|
|
41
|
+
const clipboard = wayland
|
|
42
|
+
? has("wl-copy") && "wl-copy"
|
|
43
|
+
: (has("xclip") && "xclip") || (has("xsel") && "xsel");
|
|
44
|
+
const keys = wayland ? has("wtype") && "wtype" : has("xdotool") && "xdotool";
|
|
45
|
+
const opener = has("xdg-open") && "xdg-open";
|
|
46
|
+
return { wayland, clipboard, keys, opener };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setClipboard(tool, text) {
|
|
50
|
+
const argv =
|
|
51
|
+
tool === "wl-copy"
|
|
52
|
+
? ["wl-copy"]
|
|
53
|
+
: tool === "xclip"
|
|
54
|
+
? ["xclip", "-selection", "clipboard"]
|
|
55
|
+
: ["xsel", "--clipboard", "--input"];
|
|
56
|
+
// stdout/stderr MUST be ignored: these tools fork a daemon that would
|
|
57
|
+
// otherwise hold the pipes open and block spawnSync indefinitely.
|
|
58
|
+
const r = spawnSync(argv[0], argv.slice(1), {
|
|
59
|
+
input: text,
|
|
60
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
61
|
+
});
|
|
62
|
+
if (r.status !== 0) throw new Error(`${tool} failed`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function activeWindowMatchesSuffix(title) {
|
|
66
|
+
// X11 only. Fail closed: if we can't tell, we don't type.
|
|
67
|
+
const r = spawnSync("xdotool", ["getactivewindow", "getwindowname"], {
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
});
|
|
70
|
+
if (r.status !== 0) return false;
|
|
71
|
+
return (r.stdout || "").trim().toLowerCase().endsWith(title.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sleepMs = (ms) => spawnSync("sleep", [String(Math.max(0, ms) / 1000)]);
|
|
75
|
+
|
|
76
|
+
export function checkPermissions() {
|
|
77
|
+
const t = tooling();
|
|
78
|
+
if (!t.opener) return { ok: false, detail: "xdg-open not found" };
|
|
79
|
+
if (!t.clipboard)
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
detail: t.wayland
|
|
83
|
+
? "wl-clipboard not installed (wl-copy)"
|
|
84
|
+
: "xclip or xsel not installed",
|
|
85
|
+
};
|
|
86
|
+
if (!t.keys)
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
detail: t.wayland
|
|
90
|
+
? "wtype not installed (Wayland is experimental; GNOME may block it entirely)"
|
|
91
|
+
: "xdotool not installed",
|
|
92
|
+
};
|
|
93
|
+
if (t.wayland)
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
detail:
|
|
97
|
+
"wayland (experimental; sends are BLIND — requires allowUnverifiedPaste:true in config)",
|
|
98
|
+
};
|
|
99
|
+
return { ok: true, detail: "x11" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function send({
|
|
103
|
+
uri,
|
|
104
|
+
editor,
|
|
105
|
+
message,
|
|
106
|
+
focusDelayMs,
|
|
107
|
+
allowUnverifiedPaste,
|
|
108
|
+
}) {
|
|
109
|
+
const t = tooling();
|
|
110
|
+
const perm = checkPermissions();
|
|
111
|
+
if (!perm.ok) return perm;
|
|
112
|
+
|
|
113
|
+
if (t.wayland && !allowUnverifiedPaste) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
detail:
|
|
117
|
+
"wayland send is blind (no active-window verification available); " +
|
|
118
|
+
'set {"allowUnverifiedPaste": true} in ~/.claude-wake/config.json to accept that risk',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (uri) {
|
|
123
|
+
const r = spawnSync("xdg-open", [uri], { stdio: "ignore" });
|
|
124
|
+
if (r.status !== 0) return { ok: false, detail: "xdg-open failed" };
|
|
125
|
+
sleepMs(focusDelayMs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!t.wayland) {
|
|
129
|
+
if (!activeWindowMatchesSuffix(editor.windowTitle)) {
|
|
130
|
+
return { ok: false, detail: "active window is not the expected editor" };
|
|
131
|
+
}
|
|
132
|
+
setClipboard(t.clipboard, message); // after verification only
|
|
133
|
+
let r = spawnSync("xdotool", ["key", "--clearmodifiers", "ctrl+v"]);
|
|
134
|
+
if (r.status !== 0) return { ok: false, detail: "xdotool paste failed" };
|
|
135
|
+
sleepMs(250);
|
|
136
|
+
r = spawnSync("xdotool", ["key", "--clearmodifiers", "Return"]);
|
|
137
|
+
if (r.status !== 0) return { ok: false, detail: "xdotool Return failed" };
|
|
138
|
+
return { ok: true, detail: "sent (x11)" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Wayland (experimental, explicitly opted in): blind send after URI focus.
|
|
142
|
+
setClipboard(t.clipboard, message);
|
|
143
|
+
let r = spawnSync("wtype", ["-M", "ctrl", "v", "-m", "ctrl"]);
|
|
144
|
+
if (r.status !== 0) return { ok: false, detail: "wtype paste failed" };
|
|
145
|
+
sleepMs(250);
|
|
146
|
+
r = spawnSync("wtype", ["-P", "Return", "-p", "Return"]);
|
|
147
|
+
if (r.status !== 0) return { ok: false, detail: "wtype Return failed" };
|
|
148
|
+
return { ok: true, detail: "sent (wayland, unverified)" };
|
|
149
|
+
}
|