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,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
+ }