返回 CodeWhale
darwin.mjs
根目录 / crates / tui / plugins / computer-use / src / backends / darwin.mjs
1 // macOS backend. Zero third-party dependencies:
2 // - observation and input: native Accessibility and CoreGraphics APIs
3 // - stills: /usr/sbin/screencapture
4 // - video: ScreenCaptureKit in the signed helper (macOS 13+, no overlay)
5 // - crop: sips - clipboard: pbcopy/pbpaste
6 // Helper requests travel as one JSON argument without shell interpolation.
7 import fs from "node:fs";
8 import os from "node:os";
9 import path from "node:path";
10 import crypto from "node:crypto";
11 import { fileURLToPath } from "node:url";
12 import { spawn } from "node:child_process";
13 import { run, runOk, ExecError, tryJson, have, withSignal, wait, throwIfAborted, currentSignal } from "../exec.mjs";
14 import { stateDir } from "../registry.mjs";
15 import { createBrowser } from "../browser-cdp.mjs";
16
17 /** Base64 expands 3 bytes to 4, padded to a multiple of 4. */
18 const encodedSize = (bytes) => Math.ceil(bytes / 3) * 4;
19
20 /**
21 * Largest base64 payload a single JSON-RPC message may carry. Stdio hosts cap
22 * what a server may write between message boundaries (Claude Code disconnects
23 * at 16MB) and model image APIs cap well below that. Mirrors
24 * CODEWHALE_CU_MAX_IMAGE_BYTES in mcp/server.mjs, which keeps the hard guard.
25 */
26 const rasterByteBudget = () => (Number(process.env.CODEWHALE_CU_MAX_IMAGE_BYTES) > 0
27 ? Number(process.env.CODEWHALE_CU_MAX_IMAGE_BYTES)
28 : 5_000_000);
29
30 /**
31 * Pixel dimensions from a PNG IHDR or a JPEG frame header, reading only the
32 * bytes that carry them rather than the whole raster.
33 */
34 function imagePixels(file) {
35 const fd = fs.openSync(file, "r");
36 try {
37 const head = Buffer.alloc(24);
38 fs.readSync(fd, head, 0, 24, 0);
39 if (head[0] === 0x89 && head.toString("ascii", 1, 4) === "PNG") {
40 return { w: head.readUInt32BE(16), h: head.readUInt32BE(20) };
41 }
42 if (head[0] !== 0xff || head[1] !== 0xd8) throw new ExecError(`unrecognized raster format: ${file}`);
43 // Walk JPEG segments to the frame header. SOF0/1/2/3/5..7/9..11/13..15
44 // carry the dimensions; DHT/DQT and the rest are skipped by their length.
45 const size = fs.fstatSync(fd).size;
46 const seg = Buffer.alloc(9);
47 for (let at = 2; at + 9 <= size; ) {
48 fs.readSync(fd, seg, 0, 9, at);
49 if (seg[0] !== 0xff) throw new ExecError(`malformed JPEG at byte ${at}: ${file}`);
50 const marker = seg[1];
51 if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
52 return { w: seg.readUInt16BE(7), h: seg.readUInt16BE(5) };
53 }
54 if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { at += 2; continue; }
55 at += 2 + seg.readUInt16BE(2);
56 }
57 throw new ExecError(`JPEG carries no frame header: ${file}`);
58 } finally {
59 fs.closeSync(fd);
60 }
61 }
62
63 /**
64 * Shrink a raster until it fits the single-message budget.
65 *
66 * A 5K display captures to ~22MB of PNG, which is ~29MB of base64 — past every
67 * host limit, so the alternative is handing back a receipt with no picture and
68 * a screenshot tool that never shows anything. Downscaling here, before the
69 * caller reads the PNG header, keeps coordinates exact by construction:
70 * `pixels` comes from the header, `points` stays in screen points, and `scale`
71 * is derived from the two, so raster-to-point conversion follows automatically.
72 *
73 * PNG bytes track pixel count, so the long edge shrinks by the square root of
74 * the overshoot. The estimate is verified rather than trusted — screen content
75 * compresses unevenly — and gives up rather than shrinking past legibility.
76 */
77 async function fitRasterToBudget(file) {
78 for (let attempt = 0; attempt < 4; attempt += 1) {
79 const budget = rasterByteBudget();
80 const size = fs.statSync(file).size;
81 if (encodedSize(size) <= budget) return;
82 const { w, h } = imagePixels(file);
83 const longest = Math.max(w, h);
84 if (longest <= 640) return;
85 const overshoot = encodedSize(size) / budget;
86 const target = Math.max(640, Math.floor((longest / Math.sqrt(overshoot)) * 0.9));
87 if (target >= longest) return;
88 await runOk("sips", ["-Z", String(target), file], { timeoutMs: 20_000 });
89 }
90 }
91
92 const KEY_CODES = {
93 return: 36, enter: 36, tab: 48, space: 49, escape: 53, esc: 53, delete: 51,
94 backspace: 51, forwarddelete: 117, home: 115, end: 119, pageup: 116, pagedown: 121,
95 left: 123, right: 124, down: 125, up: 126, clear: 71, capslock: 57, f1: 122,
96 f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100, f9: 101, f10: 109,
97 f11: 103, f12: 111, volumeup: 72, volumedown: 73, mute: 74, help: 114,
98 a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9, b: 11, q: 12,
99 w: 13, e: 14, r: 15, y: 16, t: 17, "1": 18, "2": 19, "3": 20, "4": 21,
100 "5": 23, "6": 22, "7": 26, "8": 28, "9": 25, "0": 29, "-": 27, "=": 24,
101 "[": 33, "]": 30, "\\": 42, ";": 41, "'": 39, ",": 43, ".": 47, "/": 44,
102 o: 31, u: 32, i: 34, p: 35, l: 37, j: 38, k: 40, n: 45, m: 46,
103 };
104 const MODIFIERS = {
105 cmd: 1 << 20, command: 1 << 20, win: 1 << 20, meta: 1 << 20,
106 shift: 1 << 17, ctrl: 1 << 18, control: 1 << 18, alt: 1 << 19, opt: 1 << 19, option: 1 << 19,
107 fn: 1 << 23, function: 1 << 23,
108 };
109 // CGEventType values (CGEventTypes.h). The dragged codes are easy to get
110 // wrong: 6 is LeftMouseDragged and 7 is RightMouseDragged, so a left drag sent
111 // as 7 is delivered as a right-button drag and no view ever sees it.
112 const MOUSE = {
113 left: { down: 1, up: 2, dragged: 6 },
114 right: { down: 3, up: 4, dragged: 7 },
115 middle: { down: 25, up: 26, dragged: 27 },
116 };
117 const MOUSE_MOVED = 5;
118
119 /**
120 * Native refusals carry exception reasons; these map to stable codes so
121 * receipts and callers can branch without parsing prose. Unknown reasons stay
122 * uncoded (the message is the contract there).
123 */
124 export function nativeErrorCode(message) {
125 const m = String(message ?? "");
126 if (/^background_focus_required:/.test(m)) return "background_focus_required";
127 if (/^user_busy:/.test(m)) return "user_busy";
128 if (/ambiguous/i.test(m)) return "window_ambiguous";
129 if (/not capturable/i.test(m)) return "window_not_capturable";
130 if (/several running applications match/i.test(m)) return "ambiguous_application";
131 if (/cannot be terminated by this plugin/i.test(m)) return "protected_application";
132 if (/refused the window frame change/i.test(m)) return "frame_refused";
133 if (/no accessibility geometry/i.test(m)) return "window_target_not_found";
134 if (/application not found|no running application/i.test(m)) return "app_not_found";
135 return null;
136 }
137
138 /**
139 * Choose the menu element for an exact title: a menu bar item at level 0, an
140 * open menu's item below it. Exact match only — a fuzzy match would activate
141 * the wrong command, and menu titles are stable enough to state precisely.
142 * Exported for tests; the walk itself is native.
143 */
144 export function pickMenuElement(elements, label, menuBar) {
145 const role = menuBar ? "AXMenuBarItem" : "AXMenuItem";
146 return elements.find((el) => el?.label === label && el?.role === role) ?? null;
147 }
148
149 /**
150 * Regular apps are what "open an app" means; accessories and daemons answer
151 * menu-bar and background questions. Keep the signal, drop the XPC soup.
152 * A helper that predates the activation_policy field returns the list whole.
153 */
154 export function selectApps(apps, all) {
155 if (all || !apps.some((a) => a.activation_policy)) return apps;
156 return apps.filter((a) => a.activation_policy === "regular" || a.frontmost === true);
157 }
158
159 /**
160 * Front-lease interference verdict (SHA-6643 slice 1). The native helper
161 * reports the borrow window (lease_ms) and the HID idle clock around it
162 * (idle_before_s/idle_after_s); synthesized events do not tick that clock,
163 * so a clock that fails to advance across the window means hardware input
164 * arrived mid-lease. Null when the reply carries no accounting (no borrow,
165 * or a helper that predates it) — receipts stay quiet then.
166 */
167 const LEASE_IDLE_EPSILON_S = 0.25;
168 export function leaseVerdict(r) {
169 if (r?.front_lease !== true) return null;
170 const leaseMs = r.lease_ms, before = r.idle_before_s, after = r.idle_after_s;
171 if (![leaseMs, before, after].every(Number.isFinite)) return null;
172 return after < before + leaseMs / 1000 - LEASE_IDLE_EPSILON_S;
173 }
174
175 /**
176 * Threads the interference accounting from a native lease reply into a
177 * model-facing receipt. Call sites that build receipts field-by-field
178 * spread this; verbatim flows (type) already carry it via native().
179 */
180 export function leaseAccounting(r) {
181 if (r?.front_lease !== true) return {};
182 const out = {};
183 for (const k of ["lease_ms", "idle_before_s", "idle_after_s", "yield_ms"]) {
184 if (Number.isFinite(r[k])) out[k] = r[k];
185 }
186 if (typeof r.user_input_during_lease === "boolean") out.user_input_during_lease = r.user_input_during_lease;
187 return out;
188 }
189
190 export function create({ exec }) {
191 const runL = (cmd, args, opts) => exec.run(cmd, args, opts);
192 // The preview panel is on by default: while a session is bound to an app,
193 // every action updates the floating capture and its drawn cursor so the
194 // person can watch without the real pointer moving. `preview(enabled:false)`
195 // mutes it for the session.
196 // The preview panel is live while a session is bound: after the first
197 // successful capture a timer keeps refreshing it, so the person watches the
198 // app instead of a frozen still. CODEWHALE_CU_PREVIEW_REFRESH_MS=0 disables
199 // the loop (tests, headless); the floor keeps a hostile value tolerable.
200 const state = { activeDisplay: 1, lastRaster: null, inputApp: null, foregroundInput: false, previewEnabled: true, pointer: null, pointerLease: null };
201 // Shared-surface politeness: front leases, real-pointer gestures,
202 // foreground keys and activations wait for a gap in the user's hardware
203 // input rather than interleave with their typing. The helper reads the
204 // same HID idle clock it uses for interference accounting. gap<=0 turns
205 // the wait off entirely; wait_ms bounds it and refuses user_busy if the
206 // person is still active. Successful waits report yield_ms in the receipt.
207 const yieldArgs = {
208 yield_gap_ms: Number(process.env.CODEWHALE_CU_YIELD_GAP_MS ?? 450),
209 yield_wait_ms: Number(process.env.CODEWHALE_CU_YIELD_WAIT_MS ?? 2500),
210 };
211 let previewLoop = null;
212 let previewBusy = false;
213 function stopPreviewLoop() { if (previewLoop) { clearInterval(previewLoop); previewLoop = null; } }
214 // A hide must not race an in-flight capture: its late preview_notify would
215 // re-show a panel that was just dismissed.
216 async function quiescePreview() { for (let i = 0; i < 20 && previewBusy; i++) await wait(25); }
217 const browser = createBrowser();
218 function startPreviewLoop() {
219 if (previewLoop) return;
220 const ms = Number(process.env.CODEWHALE_CU_PREVIEW_REFRESH_MS ?? 1000);
221 if (!Number.isFinite(ms) || ms <= 0) return;
222 previewLoop = setInterval(() => {
223 if (previewBusy || !state.previewEnabled || !state.inputApp) return;
224 previewBusy = true;
225 updatePreview(false).catch(() => {}).finally(() => { previewBusy = false; });
226 }, Math.max(50, ms));
227 previewLoop.unref?.();
228 }
229
230 async function nativeHelper() {
231 let helper = process.env.CODEWHALE_CU_APP_BUNDLE
232 ? path.join(process.env.CODEWHALE_CU_APP_BUNDLE, "Contents", "MacOS", "accessibility") : null;
233 if (!helper || !fs.existsSync(helper)) {
234 const packaged = fileURLToPath(new URL("../../bin/darwin/accessibility", import.meta.url));
235 if (fs.existsSync(packaged)) helper = packaged;
236 }
237 // A source checkout (plugin installs in other hosts) self-compiles an
238 // unsigned helper, which has no TCC grant. Prefer the installed app's
239 // signed helper so accessibility and screen-recording grants carry over.
240 if (!helper || !fs.existsSync(helper)) {
241 const installed = path.join(os.homedir(), "Applications", "Codewhale Computer Use.app", "Contents", "MacOS", "accessibility");
242 if (fs.existsSync(installed)) helper = installed;
243 }
244 if (!helper || !fs.existsSync(helper)) {
245 const source = fileURLToPath(new URL("./darwin-accessibility.m", import.meta.url));
246 const hash = crypto.createHash("sha256").update(fs.readFileSync(source)).update(fs.readFileSync(new URL("./darwin-recording.h", import.meta.url))).update(fs.readFileSync(new URL("./darwin-ocr.h", import.meta.url))).digest("hex").slice(0, 16);
247 const dir = path.join(os.homedir(), ".codewhale-cu", "bin");
248 fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
249 helper = path.join(dir, `accessibility-${hash}`);
250 if (!fs.existsSync(helper)) {
251 const tmp = `${helper}-${process.pid}`;
252 const r = await runL("clang", ["-fobjc-arc", "-Os", "-framework", "Cocoa", "-framework", "ApplicationServices", "-framework", "ScreenCaptureKit", "-framework", "AVFoundation", "-framework", "CoreMedia", "-framework", "Vision", source, "-o", tmp], { timeoutMs: 60_000 });
253 if (r.code !== 0) throw new ExecError(`native accessibility helper needs a built app or Xcode Command Line Tools: ${r.stderr}`, r);
254 fs.renameSync(tmp, helper);
255 }
256 }
257 return helper;
258 }
259
260 function requireFocusControl() {
261 if (!state.foregroundInput) throw Object.assign(new ExecError("This action would take keyboard focus and was not sent in background mode. Use an accessibility action, browser control, or a separate computer."), { code: "background_focus_required" });
262 }
263
264 async function native(tool, args = {}) {
265 // Window-addressed events still borrow keyboard focus. Block before even
266 // starting an older installed helper, including the app-scoped fallback.
267 if (["bg_pointer", "bg_key"].includes(tool) || (tool === "pointer_sequence" && args.app_scoped)) requireFocusControl();
268 // Every resolved target (element center or screen point) is where the
269 // action lands; tracking it here means the preview cursor follows element
270 // actions, not just raw pointer events.
271 if (tool === "type" && !state.foregroundInput && (await native("input_capabilities"))?.background_focus_guard !== 1) {
272 throw Object.assign(new ExecError("Update the Computer Use helper before background typing; this helper may borrow keyboard focus."), { code: "app_upgrade_required" });
273 }
274 const t = args?.target;
275 if (t && Number.isFinite(t.x) && Number.isFinite(t.y)) state.pointer = { x: t.x, y: t.y };
276 if (tool === "bg_pointer") {
277 const last = [...(args.steps ?? [])].reverse().find((s) => Number.isFinite(s?.x) && Number.isFinite(s?.y));
278 if (last) state.pointer = { x: last.x, y: last.y };
279 }
280 if (tool === "pointer_sequence" && !args.app_scoped) requireSharedPointer();
281 const helper = await nativeHelper();
282 const r = await runL(helper, [JSON.stringify({ tool, args: { ...args, ...yieldArgs, input_app_ref: state.inputApp, foreground_input: state.foregroundInput, owner_pipe: true } })], { timeoutMs: 20_000, ownerPipe: true });
283 if (r.aborted || r.timedOut || r.code !== 0) {
284 const error = new ExecError(r.aborted ? "computer request cancelled" : r.timedOut ? "native accessibility helper timed out" : r.stderr.trim() || "native accessibility helper failed", r);
285 if (r.aborted) error.code = "cancelled";
286 else error.code = nativeErrorCode(error.message) ?? undefined;
287 // A deterministic native refusal sent no input. A killed/timed-out
288 // helper may have posted the press before losing its response.
289 const postsPress = (tool === "key_event" && args.down) || ["type", "perform_action", "click_element", "scroll_element", "set_value", "focus_element", "select_text", "bg_pointer", "bg_key"].includes(tool) || (tool === "hit_test" && args.perform) || (tool === "pointer_sequence" && args.steps?.some((step) => [1, 3, 25].includes(step.type)));
290 error.inputMayHaveBeenSent = postsPress && r.spawned === true && (r.aborted || r.timedOut);
291 if (error.inputMayHaveBeenSent) error.message += "; input may already have been sent — observe the target before doing anything else";
292 throw error;
293 }
294 const result = tryJson(r.stdout, null);
295 const interference = leaseVerdict(result);
296 if (interference !== null) result.user_input_during_lease = interference;
297 if (state.previewEnabled && state.inputApp && ["type", "key_event", "pointer_sequence", "bg_pointer", "bg_key", "set_value", "select_text", "perform_action", "hit_test", "click_element", "scroll_element", "focus_element"].includes(tool)) {
298 try { await updatePreview(); } catch (error) { result.preview_error = error.message; }
299 }
300 return result;
301 }
302
303 async function requireBackgroundActions() {
304 if ((await native("input_capabilities"))?.background_actions !== 1) {
305 throw Object.assign(new ExecError("Update the Computer Use helper to use background focus, selection, context menus and scrolling."), { code: "app_upgrade_required" });
306 }
307 }
308
309 function assertBoundElement(target) {
310 if (!state.inputApp || target.app_ref?.pid !== state.inputApp.pid) throw new ExecError("element does not belong to the bound application — open_application and observe again");
311 if (!Array.isArray(target.path) || !Number.isInteger(target.windowIndex) || !target.role) throw new ExecError("element has no resolved accessibility identity");
312 }
313
314 async function nativeLease(tool, args) {
315 if (tool === "pointer_sequence") requireSharedPointer();
316 if (!exec.runInputLease) throw new ExecError("This executor cannot safely own held input; update Computer Use");
317 if ((await native("input_capabilities"))?.input_lease !== 1) throw new ExecError("The native helper needs an update for disconnect-safe held input");
318 const helper = await nativeHelper();
319 try {
320 return await exec.runInputLease(helper, [JSON.stringify({ tool, args: { ...args, ...yieldArgs, input_app_ref: state.inputApp, foreground_input: state.foregroundInput, owner_pipe: true, input_lease: true } })]);
321 } catch (error) {
322 error.code = nativeErrorCode(error.message) ?? error.code;
323 throw error;
324 }
325 }
326
327 async function updatePreview(show = false) {
328 const win = await native("window_info", { app_ref: state.inputApp });
329 const dir = path.join(stateDir(), "preview");
330 fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
331 const temp = path.join(dir, "next.png"), file = path.join(dir, "latest.png");
332 const r = await runL("screencapture", ["-x", "-o", "-l", String(win.window_id), "-t", "png", temp], { timeoutMs: 8000 });
333 if (r.code !== 0) throw new ExecError(`background preview capture failed: ${r.stderr}`);
334 fs.renameSync(temp, file);
335 const p = state.pointer;
336 // The user's own hardware cursor goes on the preview too, so the panel
337 // shows both pointers in the same window-relative space.
338 let userCursor = null;
339 try { userCursor = await native("cursor_position"); } catch {}
340 await native("preview_notify", { enabled: true, show, title: `Codewhale · ${win.name} · ${state.foregroundInput ? "Shared desktop control" : "Background app control"}`, x: p ? (p.x-win.points.x)/win.points.w : -1, y: p ? (p.y-win.points.y)/win.points.h : -1,
341 user_x: userCursor && Number.isFinite(userCursor.x) ? (userCursor.x-win.points.x)/win.points.w : -1,
342 user_y: userCursor && Number.isFinite(userCursor.y) ? (userCursor.y-win.points.y)/win.points.h : -1 });
343 // Any successful capture (bind, explicit preview, action refresh) starts
344 // the live refresh; the tick itself re-enters this function as a no-op.
345 if (state.previewEnabled && state.inputApp) startPreviewLoop();
346 return { enabled: true, file, app: state.inputApp, pointer: p };
347 }
348
349 // ---------- pointer input ----------
350 // Our qualified raw pointer path uses the shared event tap, which moves
351 // the user's real cursor. Process/window-directed mouse delivery has not
352 // passed the independent fixture. So the pointer path is:
353 // 1. accessibility action on the element under the point (quiet, exact),
354 // 2. otherwise refuse in background mode. Explicit foreground control
355 // permits a global gesture only when the bound application owns the
356 // window under the point. Restoring the cursor is not isolation.
357 // Every receipt says which of the two happened.
358 function mouseName(button) { return { left: "left", right: "right", middle: "middle" }[button] ?? "left"; }
359
360 function assertInScreen(x, y) {
361 if (!Number.isFinite(x) || !Number.isFinite(y)) throw new ExecError("coordinates must be finite numbers");
362 }
363
364 function buttonCode(button) { return button === "middle" ? 2 : button === "right" ? 1 : 0; }
365
366 function requireSharedPointer() {
367 if (!state.foregroundInput) throw Object.assign(new ExecError("This action needs the shared macOS pointer and was not sent in background mode. Use an accessibility action or a separate computer; foreground control requires exclusive desktop use authorized by the user."), { code: "shared_pointer_required" });
368 }
369
370 /** Refuse a global gesture whose landing point belongs to another application. */
371 async function assertOwnsPoint(x, y) {
372 if (!state.inputApp) throw new ExecError("open_application first to choose which application receives input");
373 const w = await native("window_at_point", { x, y });
374 if (!w?.found) throw new ExecError(`no window at (${x}, ${y}) — take a fresh screenshot and choose a point inside the target window`);
375 if (w.owner_pid !== state.inputApp.pid) {
376 throw new ExecError(`(${x}, ${y}) is covered by a window owned by ${w.owner_name || "another application"} (pid ${w.owner_pid}) — use an accessibility element target or a separate computer; no pointer input was sent`);
377 }
378 return w;
379 }
380
381 /** What a global gesture cost the user: their cursor, and briefly their foreground. */
382 function pointerCost(r) {
383 return {
384 pointer_moved: true,
385 pointer_restored: !!r?.restored,
386 foreground_taken: !!r?.foreground_taken,
387 ...(r?.foreground_before ? { foreground_before: r.foreground_before } : {}),
388 ...(r?.foreground_after ? { foreground_after: r.foreground_after } : {}),
389 ...(Number.isFinite(r?.yield_ms) && r.yield_ms > 0 ? { yield_ms: r.yield_ms } : {}),
390 };
391 }
392
393 async function gesture(steps, { restore = true, guard = null } = {}) {
394 requireSharedPointer();
395 if (guard) await assertOwnsPoint(guard.x, guard.y);
396 const r = await native("pointer_sequence", { steps, restore });
397 const last = [...steps].reverse().find((s) => s.x != null);
398 if (last) state.pointer = { x: last.x, y: last.y };
399 return r;
400 }
401
402 function clickSteps(button, x, y, clicks) {
403 const m = MOUSE[button] ?? MOUSE.left;
404 const b = buttonCode(button);
405 const steps = [{ type: MOUSE_MOVED, x, y, button: b, clickState: 0 }];
406 for (let i = 1; i <= clicks; i++) {
407 steps.push({ type: m.down, x, y, button: b, clickState: i });
408 steps.push({ type: m.up, x, y, button: b, clickState: i });
409 }
410 return steps;
411 }
412
413 /**
414 * Coordinate pointer click. A left single click is first hit-tested against
415 * the bound application's accessibility tree: when the point names a
416 * pressable element we perform its semantic action, which needs no pointer
417 * and no foreground. strategy="a11y" requires that and fails closed;
418 * strategy="event" goes straight to the guarded global gesture.
419 */
420 async function pointerClick(button, x, y, clicks, strategy = "auto") {
421 assertInScreen(x, y);
422 if (!["auto", "a11y", "event", "app"].includes(strategy)) throw new ExecError(`strategy must be auto, a11y, app or event (got ${JSON.stringify(strategy)})`);
423 let a11yReason = null;
424 if (strategy !== "event" && ["left", "right"].includes(button) && clicks === 1) {
425 if (button === "right") await requireBackgroundActions();
426 const hit = await native("hit_test", { x, y, perform: true, ...(button === "right" ? { operation: "context" } : {}) });
427 if (hit?.action_sent) {
428 return { action_sent: true, strategy: "a11y", action: hit.action, pointer_moved: false, at: { x, y }, button, clicks,
429 element: { role: hit.element?.role ?? null, label: hit.element?.label ?? null } };
430 }
431 a11yReason = hit?.reason ?? "not_found";
432 if (strategy === "a11y") {
433 throw new ExecError(`no supported accessibility click at (${x}, ${y}) in the bound application (${a11yReason}) — observe the available actions, use strategy "app" for a window-scoped pointer click, or a separate computer`);
434 }
435 } else if (strategy === "a11y") {
436 throw new ExecError(`strategy "a11y" is only available for a left single click on this backend; ${mouseName(button)} x${clicks} has no accessibility equivalent`);
437 }
438 if (strategy === "app" || (strategy === "auto" && !state.foregroundInput)) {
439 // Window-routed record delivery: AppKit accepts the events as genuine
440 // input, the cursor never moves. A momentary no-raise front lease is
441 // taken and restored inside the helper; it is reported, not hidden.
442 if ((await native("input_capabilities"))?.window_record === 1) {
443 // Ownership is enforced by window containment inside the helper: the
444 // events are addressed to a window id of the bound app, so a covered
445 // background window is still safe — they cannot land on the coverer.
446 const r = await native("bg_pointer", { steps: clickSteps(button, x, y, clicks),
447 ...(a11yReason === "web_popup_requires_real_click" ? { menu_poll_ms: 6000 } : {}) });
448 return { action_sent: true, strategy: "window-record", input_scope: "application-window",
449 at: { x, y }, button, clicks, pointer_moved: false, front_lease: r.front_lease ?? true,
450 ...leaseAccounting(r),
451 ...(r.menu_lease_held ? { menu_lease_held: true } : {}),
452 window: r.window ?? null,
453 ...(a11yReason ? { a11y_reason: a11yReason } : {}) };
454 }
455 if (strategy !== "app") {
456 // auto in background still fails closed for raw pointer; app is the
457 // explicit missing middle.
458 requireSharedPointer();
459 }
460 const owner = await assertOwnsPoint(x, y);
461 const r = await native("pointer_sequence", { steps: clickSteps(button, x, y, clicks), restore: true, app_scoped: true });
462 const last = { x, y };
463 state.pointer = last;
464 return { action_sent: true, strategy: "app-pointer", input_scope: "application-window",
465 at: last, button, clicks, window: { id: owner.window_id, owner_pid: owner.owner_pid },
466 ...pointerCost(r), ...(a11yReason ? { a11y_reason: a11yReason } : {}) };
467 }
468 const r = await gesture(clickSteps(button, x, y, clicks), { restore: true, guard: { x, y } });
469 return { action_sent: true, strategy: "event", at: { x, y }, button, clicks, ...pointerCost(r),
470 ...(a11yReason ? { a11y_reason: a11yReason } : {}) };
471 }
472
473 async function withPressedKey(code, flags, action) {
474 const lease = await nativeLease("key_event", { code, flags, down: true });
475 try {
476 await action();
477 // The acknowledgement carries the yield_ms the helper waited for a
478 // hardware-input gap before posting the press.
479 return lease.receipt;
480 } finally {
481 await withSignal(null, () => lease.release());
482 }
483 }
484
485 function parseChord(text) {
486 const parts = String(text).split("+").map((s) => s.trim().toLowerCase()).filter(Boolean);
487 if (!parts.length) throw new ExecError("empty key text");
488 let flags = 0;
489 let key = null;
490 for (const p of parts) {
491 if (MODIFIERS[p] != null) flags |= MODIFIERS[p];
492 else if (KEY_CODES[p] != null) { if (key) throw new ExecError(`multiple non-modifier keys in "${text}"`); key = p; }
493 else throw new ExecError(`unknown key "${p}" (supported: ${Object.keys(KEY_CODES).join(", ")} + modifiers cmd/ctrl/alt/shift/fn)`);
494 }
495 if (key == null) throw new ExecError(`no non-modifier key in "${text}"`);
496 return { flags, code: KEY_CODES[key], key };
497 }
498
499 // ---------- displays ----------
500 async function displayInfo() { return native("displays"); }
501
502 // ---------- screenshots ----------
503 function recordingsDir() {
504 return process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
505 }
506
507 async function screenshot({ display, region, app_ref, window_id, path: outPath } = {}) {
508 // Once an app is selected, ordinary observations follow it behind the
509 // user's work. An explicit display/region remains a deliberate desktop capture.
510 if (app_ref === undefined && display === undefined && region === undefined) app_ref = state.inputApp ?? undefined;
511 const dir = recordingsDir();
512 fs.mkdirSync(dir, { recursive: true });
513 // JPEG, not PNG. A screen is photographic content — gradients, wallpaper,
514 // antialiased text — and lossless compression of it is enormous: the same
515 // 5760x3240 frame is 21.8MB as PNG and 2.1MB as JPEG, at full resolution
516 // and with terminal text still crisp. PNG stays available by asking for a
517 // `.png` path, which is what a pixel-exact comparison wants.
518 const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpg`);
519 if (!/\.(png|jpe?g)$/i.test(file)) throw new ExecError("screenshot path must end in .png, .jpg or .jpeg");
520 const args = ["-x", "-t", /\.png$/i.test(file) ? "png" : "jpg"];
521 const disp = display ?? state.activeDisplay;
522 // An explicit app reference resolves first and alone: nothing may run
523 // before it and redirect the capture to another target.
524 const window = app_ref !== undefined ? await native("window_info", { app_ref, window_id }) : null;
525 if (window && region) throw new ExecError("choose app_ref or region, not both");
526 // On the display path, resolve displays before capturing so an unknown
527 // index is a clean error instead of a raster silently labelled with another
528 // display's geometry — list_displays reports `index` and `id` separately,
529 // and a caller passing the id would otherwise get points and scale that
530 // mis-target every later coordinate. A window capture ignores `display`.
531 let displays = null;
532 if (!window) {
533 displays = await displayInfo();
534 if (disp != null && disp !== "all" && !displays.some((x) => x.index === disp)) {
535 throw new ExecError(`no display ${disp}; have [${displays.map((x) => x.index).join(", ")}] — screenshot takes the display index from list_displays, not its id`);
536 }
537 }
538 if (window) args.push("-o", "-l", String(window.window_id));
539 else if (disp && disp !== "all") args.push("-D", String(disp));
540 if (region) {
541 if (!region.every((n) => Number.isFinite(n) && n >= 0) || region.length !== 4) {
542 throw new ExecError("region must be [x, y, w, h] in screen points");
543 }
544 args.push("-R", region.join(","));
545 }
546 args.push(file);
547 const r = await runL("screencapture", args, { timeoutMs: 20_000 });
548 if (r.code !== 0) throw new ExecError(`screencapture exited ${r.code}: ${r.stderr.trim().slice(0, 300)}`, r);
549 await fitRasterToBudget(file);
550 const stat = fs.statSync(file);
551 displays ??= await displayInfo();
552 const d = displays.find((x) => x.index === (disp === "all" ? 1 : disp)) ?? displays[0];
553 const scale = d?.scale ?? 1;
554 state.lastRaster = {
555 file,
556 ...(window ? { app_ref, window_index: window_id ?? 0 } : {}),
557 bytes: stat.size,
558 display: disp ?? 1,
559 // Region and window rasters describe that rect, not the whole display.
560 // The PNG header is the pixel ground truth; scale is derived from
561 // pixels/points below so Retina and mixed-DPI stay exact.
562 points: window?.points ?? (region ? { x: region[0], y: region[1], w: region[2], h: region[3] } : d?.points ?? null),
563 pixels: imagePixels(file),
564 scale: d?.scale ?? 1,
565 capturedAt: new Date().toISOString(),
566 };
567 if (state.lastRaster.points?.w) state.lastRaster.scale = state.lastRaster.pixels.w / state.lastRaster.points.w;
568 return { ...state.lastRaster, path: file };
569 }
570
571 async function zoom({ source, region, path: outPath }) {
572 if (!source && !state.lastRaster) throw new ExecError("no screenshot taken yet on this computer — call screenshot first");
573 const [x, y, w, h] = region;
574 if (![x, y, w, h].every((n) => Number.isInteger(n) && n >= 0) || !w || !h || x + w > state.lastRaster.pixels.w || y + h > state.lastRaster.pixels.h) throw new ExecError("region must be [x, y, w, h] in last-raster pixels");
575 const src = source ?? state.lastRaster.file;
576 const dir = recordingsDir();
577 fs.mkdirSync(dir, { recursive: true });
578 const out = outPath || path.join(dir, `zoom-${crypto.randomBytes(4).toString("hex")}.png`);
579 await runOk("sips", ["-s", "format", "png", "-c", String(Math.round(h)), String(Math.round(w)), "--cropOffset", String(Math.round(y)), String(Math.round(x)), src, "--out", out], { timeoutMs: 15_000 });
580 const parent = state.lastRaster;
581 state.lastRaster = { file: out, bytes: fs.statSync(out).size, source: src, region,
582 points: { x: (parent.points?.x ?? 0) + x / parent.scale, y: (parent.points?.y ?? 0) + y / parent.scale, w: w / parent.scale, h: h / parent.scale },
583 pixels: { w, h }, scale: parent.scale, capturedAt: new Date().toISOString() };
584 return state.lastRaster;
585 }
586
587 // ---------- recording ----------
588 const rec = new Map(); // Includes starting children so session close owns them too.
589
590 function requestRecordingStop(r) {
591 if (r.child.exitCode == null && r.child.signalCode == null) {
592 r.child.stdin.end();
593 r.child.kill("SIGINT");
594 }
595 }
596
597 async function waitForRecordingStop(r, timeoutMs) {
598 let timer;
599 try {
600 return await Promise.race([r.completion, new Promise(resolve => {
601 timer = setTimeout(async () => {
602 r.child.kill("SIGKILL");
603 let reapTimer;
604 const terminated = await Promise.race([r.completion.then(() => true), new Promise(done => { reapTimer = setTimeout(() => done(false), 500); })]);
605 clearTimeout(reapTimer);
606 resolve({ code: -1, terminated, error: terminated ? "screen recorder finalization timed out; partial file retained" : "screen recorder could not be terminated; recording ownership retained for retry" });
607 }, timeoutMs);
608 })]);
609 } finally { clearTimeout(timer); }
610 }
611
612 async function recordingStart({ display, durationSec, region, app_ref, window_id } = {}) {
613 const dir = recordingsDir();
614 fs.mkdirSync(dir, { recursive: true });
615 const id = crypto.randomBytes(4).toString("hex");
616 const file = path.join(dir, `rec-${id}.mov`);
617 const displays = await displayInfo();
618 // app_ref scopes the recording to the app's window rect: resolved once at
619 // start through the same window_info the AX path uses, so a background
620 // window records behind the user's work. The rect is fixed at start —
621 // it does not track later moves or resizes.
622 let window = null;
623 if (app_ref !== undefined || window_id != null) {
624 window = await native("window_info", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref, window_id });
625 if (!window?.points || !(window.points.w > 0) || !(window.points.h > 0)) throw new ExecError("the selected application has no capturable window — call list_windows");
626 if (region) throw new ExecError("choose app_ref or region, not both");
627 region = [window.points.x, window.points.y, window.points.w, window.points.h];
628 }
629 let disp = display ?? state.activeDisplay;
630 if (window && display == null) {
631 const cx = region[0] + region[2] / 2, cy = region[1] + region[3] / 2;
632 const host = displays.find(d => d.points && cx >= d.points.x && cx < d.points.x + d.points.w && cy >= d.points.y && cy < d.points.y + d.points.h);
633 if (host) disp = host.index;
634 }
635 const selected = displays.find(d => d.index === disp);
636 if (!selected) throw new ExecError("choose one available display for recording");
637 if (durationSec != null && (!Number.isFinite(durationSec) || durationSec <= 0)) throw new ExecError("durationSec must be positive");
638 const capabilities = await native("input_capabilities");
639 if (capabilities?.record_owner_pipe !== 1) throw new ExecError("native screen recorder cannot own its client lifetime; update Computer Use before recording");
640 const helper = await nativeHelper();
641 throwIfAborted();
642 const child = spawn(helper, [JSON.stringify({ tool: "record", args: { file, displayID: selected.id, region, durationSec, owner_pipe: true } })], { stdio: ["pipe", "pipe", "pipe"] });
643 child.stdin.on("error", () => {});
644 const startedAt = new Date().toISOString();
645 let stderr = "", output = "", ready = false;
646 const completion = new Promise(resolve => {
647 child.once("error", error => resolve({ code: -1, error: error.message }));
648 child.once("close", code => resolve({ code, error: stderr.trim() }));
649 });
650 child.stderr.on("data", chunk => { stderr = (stderr + chunk).slice(-4000); });
651 const recording = { child, completion, pid: child.pid, file, startedAt, mode: "ScreenCaptureKit", display: disp };
652 rec.set(id, recording);
653 const signal = currentSignal();
654 let timer, abort;
655 try {
656 await new Promise((resolve, reject) => {
657 abort = () => { requestRecordingStop(recording); reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" })); };
658 signal?.addEventListener("abort", abort, { once: true });
659 if (signal?.aborted) { abort(); return; }
660 timer = setTimeout(() => reject(new ExecError("screen recorder startup timed out")), 20_000);
661 child.stdout.on("data", chunk => {
662 output += chunk;
663 let i;
664 while ((i = output.indexOf("\n")) >= 0) {
665 const line = output.slice(0, i); output = output.slice(i + 1);
666 try { if (JSON.parse(line).ready) { ready = true; resolve(); } } catch {}
667 }
668 });
669 completion.then(result => { if (!ready) reject(new ExecError(result.error || "screen recorder exited before capture started")); });
670 });
671 throwIfAborted();
672 return { id, pid: child.pid, file, display: disp, durationSec: durationSec ?? null, region: region ?? null, fps: 30, mode: "ScreenCaptureKit", startedAt,
673 ...(window ? { window: { id: window.window_id ?? null, name: window.name ?? null }, note: "Recording the window's rect as it was at start; it does not track moves or resizes." } : {}) };
674 } catch (error) {
675 requestRecordingStop(recording);
676 const result = await waitForRecordingStop(recording, 2_000);
677 if (result.terminated !== false) rec.delete(id);
678 throw error;
679 } finally {
680 clearTimeout(timer);
681 signal?.removeEventListener("abort", abort);
682 }
683 }
684
685 async function recordingStop({ id }) {
686 const r = rec.get(id);
687 if (!r) throw new ExecError(`unknown or already-finished recording "${id}"`);
688 requestRecordingStop(r);
689 const result = await waitForRecordingStop(r, 20_000);
690 if (result.code !== 0) throw new ExecError(result.error || "screen recorder failed; partial file retained");
691 const size = fs.existsSync(r.file) ? fs.statSync(r.file).size : 0;
692 if (!size) throw new ExecError("screen recorder produced no video");
693 rec.delete(id);
694 return { id, file: r.file, mp4: null, bytes: size, mode: r.mode, startedAt: r.startedAt, stoppedAt: new Date().toISOString() };
695 }
696
697 async function closeSession() {
698 // The preview this session showed must not outlive the session; a panel
699 // from a dead session has no owner to refresh or hide it.
700 stopPreviewLoop();
701 await quiescePreview();
702 if (state.previewEnabled && state.inputApp) {
703 try { await native("preview_notify", { enabled: false }); } catch { /* hiding is best-effort */ }
704 }
705 state.previewEnabled = false;
706 await browser.close().catch(() => {});
707 const owned = [...rec.entries()];
708 for (const [, recording] of owned) requestRecordingStop(recording);
709 const results = await Promise.all(owned.map(async ([id, recording]) => {
710 const result = await waitForRecordingStop(recording, 2_000);
711 if (result.terminated !== false) rec.delete(id);
712 return result;
713 }));
714 const failed = results.find(result => result.code !== 0);
715 if (failed) throw new ExecError(failed.error || "screen recorder failed; partial file retained");
716 }
717
718 async function recordingStatus({ id }) {
719 const r = rec.get(id);
720 if (!r) return { id, running: false };
721 const alive = r.child.exitCode == null && r.child.signalCode == null;
722 return { id, running: alive, pid: r.pid, file: r.file, bytes: fs.existsSync(r.file) ? fs.statSync(r.file).size : 0, startedAt: r.startedAt };
723 }
724
725 async function recordingList() {
726 const dir = recordingsDir();
727 const out = [];
728 for (const f of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
729 const full = path.join(dir, f);
730 const st = fs.statSync(full);
731 if (st.isFile() && /\.(mov|mp4|png|jpe?g)$/i.test(f)) out.push({ file: full, bytes: st.size, modifiedAt: st.mtime.toISOString() });
732 }
733 out.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
734 return { dir, recordings: out.slice(0, 50), running: [...rec.keys()] };
735 }
736
737 // ---------- apps / windows ----------
738 async function listApps(args = {}) {
739 if (args?.installed === true) {
740 const r = await native("installed_apps", {});
741 const apps = Array.isArray(r?.apps) ? r.apps : [];
742 return {
743 apps,
744 total: apps.length,
745 installed: true,
746 note: "Installed catalog from /Applications, /System/Applications and ~/Applications; running flags reflect this moment. This scan takes a moment.",
747 };
748 }
749 const r = await native("list_apps");
750 const apps = Array.isArray(r?.apps) ? r.apps : [];
751 const shown = selectApps(apps, args?.all === true);
752 return {
753 apps: shown,
754 total: apps.length,
755 filtered: args?.all === true ? "all" : "regular",
756 ...(shown.length !== apps.length ? { note: "Regular (user-facing) apps only — pass all:true to include menu-bar helpers and background processes." } : {}),
757 };
758 }
759
760 async function listWindows({ app_ref } = {}) { return native("list_windows", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref }); }
761
762 async function openApplication({ name, bundle_id: bid, pid, url: urlArg, activate = false } = {}) {
763 if (!name && !bid && !pid) throw new ExecError("open_application needs name, bundle_id or pid");
764 // Failed selection must not leave an earlier app armed for shared input.
765 state.foregroundInput = false;
766 state.inputApp = null;
767 // pid is the most specific identity and the only one that separates two
768 // processes of the same bundle (e.g. a second Chrome on its own profile),
769 // so it wins when given.
770 const find = {};
771 if (pid) find.pid = pid; else if (bid) find.bundle_id = bid; else find.name = String(name).replace(/\.app$/, "");
772 let p;
773 let launched = false;
774 // Binding an already-running app must not ask LaunchServices to reopen
775 // it: reopen can raise windows even with open -g on some applications.
776 if (!urlArg) {
777 try { p = await native("app_info", { app_ref: find, activate }); }
778 catch (error) {
779 if (!error.message.includes("application not found")) throw error;
780 }
781 }
782 if (!p?.found) {
783 if (!name && !bid) throw Object.assign(new ExecError(`no running application with pid ${pid}; call list_apps for the current processes`), { code: "app_not_found" });
784 const args = [];
785 if (urlArg) args.push(urlArg);
786 if (bid) args.unshift("-b", bid); else args.unshift("-a", name);
787 if (!activate) args.unshift("-g");
788 const r = await runL("open", args, { timeoutMs: 25_000 });
789 if (r.code !== 0) {
790 const stderr = (r.stderr ?? "").trim();
791 // A name or bundle id that resolves nowhere is a stable refusal code,
792 // not a generic opener failure — agents branch on the code.
793 const code = /Unable to find application|failed while trying to determine the application/i.test(stderr)
794 ? "app_not_found" : undefined;
795 throw Object.assign(new ExecError(`open failed: ${stderr.slice(0, 200)}`), { code });
796 }
797 launched = true;
798 await new Promise((res) => setTimeout(res, 600));
799 p = await native("app_info", { app_ref: find, activate });
800 }
801 if (activate && p?.frontmost === false) throw Object.assign(new ExecError("The selected application did not become frontmost; no input mode was enabled. Continue with background control or wait for the user."), { code: "activation_not_confirmed" });
802 if (p?.bundle_id === "net.codewhale.computer-use") throw Object.assign(new ExecError("The Computer Use setup and safety controls belong to the user and cannot be operated by this plugin."), { code: "protected_application" });
803 // A bare executable has no bundle id; carrying an empty one would make the
804 // identity unmatchable.
805 state.inputApp = { pid: p.pid, ...(p.bundle_id ? { bundle_id: p.bundle_id } : {}), ...(p.name ? { name: p.name } : {}) };
806 state.foregroundInput = !!activate;
807 // Surface the watch panel on bind; a capture failure (e.g. missing Screen
808 // Recording) must never block the bind itself. The first successful
809 // capture also starts the refresh loop so the panel stays live while bound.
810 if (state.previewEnabled) {
811 previewBusy = true;
812 updatePreview(true).catch(() => {}).finally(() => { previewBusy = false; });
813 }
814 return { launched, activate, keyboard_delivery: activate ? "foreground-guarded" : "process", input_scope: activate ? "shared-desktop" : "application", shared_pointer: !!activate, isolated_desktop: false, url: urlArg ?? null, resolved: p?.found ? { name: p.name, pid: p.pid, bundle_id: p.bundle_id, frontmost: p.frontmost } : null,
815 ...(Number.isFinite(p?.yield_ms) && p.yield_ms > 0 ? { yield_ms: p.yield_ms } : {}) };
816 }
817
818 /**
819 * Menu items by title path, through accessibility only: no key events, no
820 * focus lease. Menus expose items only while open, so each level is pressed
821 * and the next is polled for. Exact titles; an ellipsis is part of the title.
822 */
823 async function invokeMenu(menuPath) {
824 if (!state.inputApp) throw new ExecError("open_application first — invoke_menu acts on the bound application");
825 if (!Array.isArray(menuPath) || menuPath.length < 1 || menuPath.length > 3 || menuPath.some((s) => typeof s !== "string" || !s.trim())) {
826 throw new ExecError('invoke_menu needs path: 1..3 non-empty menu titles, e.g. ["File","New"]');
827 }
828 const titles = menuPath.map((s) => s.trim());
829 const app_ref = state.inputApp;
830 const pressed = [];
831 for (let level = 0; level < titles.length; level++) {
832 const found = await findMenuItem(app_ref, titles[level], level === 0);
833 if (!found) {
834 throw Object.assign(new ExecError(`menu item "${titles[level]}" not found ${pressed.length ? `under ${pressed.join(" ▸ ")}` : "on the menu bar"} — menus expose items only while open; check the exact title with get_app_state (an ellipsis is part of the title)`), { code: "menu_item_not_found" });
835 }
836 if (found.enabled === false) {
837 throw Object.assign(new ExecError(`menu item "${titles[level]}" is present but disabled right now — the app validates it against its current state (in background mode that is often a missing key window for window-targeted commands like Close). Use an element action on the window's own control instead of pressing a disabled item.`), { code: "menu_item_disabled" });
838 }
839 const target = { app_ref, windowIndex: found.windowIndex ?? 0, path: found.path, role: found.role, label: found.label };
840 assertBoundElement(target);
841 const action = found.role === "AXMenuItem" && (found.actions ?? []).includes("AXPick") ? "AXPick" : "AXPress";
842 await native("perform_action", { target, action });
843 pressed.push(titles[level]);
844 if (level < titles.length - 1) await wait(140);
845 }
846 return { action_sent: true, strategy: "a11y", route: "accessibility", delivery: "background", menu: pressed, front_lease: false,
847 note: "Menu activation used accessibility only — no key events or focus lease. Verify the app effect (list_windows / get_app_state) before reporting success." };
848 }
849
850 /** Poll for the exact menu element; opens and submenu population are async. */
851 async function findMenuItem(app_ref, label, menuBar) {
852 const deadline = Date.now() + 4_000;
853 for (;;) {
854 const obs = await native("get_app_state", { app_ref, detail: "full" });
855 const hit = pickMenuElement(obs?.elements ?? [], label, menuBar);
856 if (hit) return hit;
857 if (Date.now() >= deadline) return null;
858 await wait(120);
859 }
860 }
861
862 // ---------- clipboard / cursor / waits ----------
863 async function readClipboard() {
864 const r = await runL("pbpaste", [], { timeoutMs: 5_000, maxBuffer: 4 * 1024 * 1024 });
865 return { text: r.stdout, encoding: "utf8" };
866 }
867 async function writeClipboard({ text }) {
868 const child = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
869 child.stdin.end(String(text ?? ""));
870 await new Promise((res, rej) => { child.on("close", res); child.on("error", rej); });
871 return { written: String(text ?? "").length };
872 }
873 async function cursorPosition() { return native("cursor_position"); }
874
875 // ---------- app scripting ----------
876 // The programmatic interface into apps that ship a scripting dictionary:
877 // osascript runs AppleScript (default) or JXA. It never moves the pointer,
878 // needs no Accessibility grant, and returns values instead of "sent"
879 // receipts — which is why it ranks above clicking wherever a dictionary
880 // exists. The script travels as one argv entry; no shell ever parses it.
881 async function appScript({ script, language = "applescript", timeout } = {}) {
882 if (typeof script !== "string" || !script.trim()) {
883 throw Object.assign(new ExecError("app_script needs a non-empty script string"), { code: "bad_args" });
884 }
885 const lang = language === "javascript" ? ["-l", "JavaScript"] : language === "applescript" ? [] : null;
886 if (!lang) throw Object.assign(new ExecError('app_script language must be "applescript" or "javascript"'), { code: "bad_args" });
887 const timeoutMs = Math.min(Math.max(Number(timeout) > 0 ? Number(timeout) : 30, 1), 120) * 1000;
888 const r = await runL("osascript", [...lang, "-e", script], { timeoutMs, maxBuffer: 8 * 1024 * 1024 });
889 if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
890 if (r.timedOut) throw Object.assign(new ExecError(`app_script timed out after ${Math.round(timeoutMs / 1000)}s — the script or a consent dialog was still open`, r), { code: "script_timeout" });
891 if (r.code !== 0) {
892 const stderr = (r.stderr || r.stdout || "").trim();
893 if (/-1743|not authorized to send apple events|not permitted/i.test(stderr)) {
894 throw Object.assign(new ExecError(`${stderr} — Automation consent was refused or is missing; allow the responsible app to control the target in System Settings → Privacy & Security → Automation`, r), { code: "automation_denied" });
895 }
896 if (/\(-?128\)|user canceled/i.test(stderr)) {
897 throw Object.assign(new ExecError(stderr || "the script was cancelled by the user", r), { code: "script_cancelled" });
898 }
899 throw Object.assign(new ExecError(stderr || `osascript exited ${r.code}`, r), { code: "script_error" });
900 }
901 return { language, result: r.stdout.trim(), stderr: r.stderr.trim() || null };
902 }
903
904 // ---------- probe ----------
905 async function probe() {
906 const caps = { screenshot: true, recording: true, accessibility_tree: true, clipboard: true, displays: true, app_script: true };
907 const perms = {};
908 try {
909 const ax = await native("permissions");
910 perms.accessibility = ax.trusted ? "granted" : "denied";
911 } catch { perms.accessibility = "denied_or_unavailable"; }
912 caps.accessibility_tree = perms.accessibility === "granted";
913 caps.raw_input = caps.accessibility_tree;
914 try {
915 const t = os.tmpdir() + `/cu-probe-${crypto.randomBytes(3).toString("hex")}.png`;
916 const r = await runL("screencapture", ["-x", "-R0,0,2,2", "-t", "png", t], { timeoutMs: 8_000 });
917 perms.screen_capture = r.code === 0 ? "ok" : "failed";
918 try { fs.rmSync(t, { force: true }); } catch {}
919 } catch { perms.screen_capture = "failed"; }
920 caps.screenshot = perms.screen_capture === "ok";
921 caps.recording = caps.screenshot;
922 return { platform: "darwin", capabilities: caps, permissions: perms, note: "macOS does not expose Screen-Recording TCC state to CLI; a black/empty screenshot means Screen Recording permission is missing. Background mode (open_application activate:false) uses process-bound keyboard events and accessibility actions; shared pointer gestures are refused. Foreground control (activate:true) uses the shared desktop and requires exclusive use. Neither mode is an isolated desktop. App-specific behavior still requires verification." };
923 }
924
925 return {
926 platform: "darwin",
927 probe,
928 list_displays: displayInfo,
929 async switch_display({ index }) {
930 const ds = await displayInfo();
931 if (!ds.some((d) => d.index === index)) throw new ExecError(`no display ${index}; have [${ds.map((d) => d.index).join(", ")}]`);
932 state.activeDisplay = index;
933 return { activeDisplay: index };
934 },
935 list_apps: listApps,
936 set_window_frame: async ({ app_ref, window_id, frame } = {}) => {
937 if (!frame || !Number.isFinite(frame.x) || !Number.isFinite(frame.y) || !Number.isFinite(frame.w) || !Number.isFinite(frame.h) || frame.w <= 0 || frame.h <= 0) {
938 throw Object.assign(new ExecError("set_window_frame needs frame {x,y,w,h} with positive w/h"), { code: "bad_args" });
939 }
940 if (!Number.isSafeInteger(window_id) || window_id < 0) {
941 throw Object.assign(new ExecError("set_window_frame needs window_id (a non-negative window index from list_windows)"), { code: "bad_args" });
942 }
943 const r = await native("set_window_frame", { app_ref, window_id, frame });
944 return { ...r, verified: r?.verified === true, note: r?.note ?? "the after frame is the app's own readback; cross-check with list_windows before relying on it" };
945 },
946 list_windows: listWindows,
947 open_application: openApplication,
948 get_app_state: async ({ app_ref, detail, depth, window_id, include_ocr = false, ocr_region } = {}) => {
949 const t0 = Date.now();
950 const t = await native("get_app_state", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref, detail, window_id });
951 if (process.env.CODEWHALE_CU_DEBUG_OBSERVE) console.error(`observe ${Date.now() - t0}ms elements=${t.elements?.length} truncated=${t.truncated}`);
952 if (!t.found) throw new ExecError("application not found — call list_apps for exact names/pids");
953 if (include_ocr) {
954 // Resolve once through AX, then capture only that exact application's
955 // selected window. A changing foreground cannot redirect this image.
956 let raster;
957 try {
958 if (!Number.isSafeInteger(t.pid) || t.pid <= 0) throw new ExecError("The observed application did not provide an exact process identity for OCR");
959 if ((await native("input_capabilities"))?.window_ocr !== 1) throw new ExecError("The native helper needs an update for selected-window text recognition");
960 // PNG here, against the JPEG default: this raster is fed to text
961 // recognition, not to a viewer, and lossless glyph edges are what
962 // Vision reads. A single window is small enough that the size the
963 // JPEG default exists to solve does not arise.
964 const ocrDir = path.join(recordingsDir(), "captures");
965 fs.mkdirSync(ocrDir, { recursive: true });
966 raster = await screenshot({
967 ...(ocr_region
968 ? { region: ocr_region }
969 : { app_ref: { pid: t.pid, ...(t.bundle_id ? { bundle_id: t.bundle_id } : {}) }, window_id }),
970 path: path.join(ocrDir, `ocr-${crypto.randomBytes(4).toString("hex")}.png`),
971 });
972 const ocr = await native("recognize_text", { file: raster.file });
973 if (ocr?.status === "ok" && ocr.pixels?.w === raster.pixels.w && ocr.pixels?.h === raster.pixels.h && Array.isArray(ocr.blocks)) {
974 t.ocr = { ...ocr, raster, blocks: ocr.blocks.map(block => ({ ...block, target: {
975 type: "coordinate", x: Math.floor(block.bounds.x + block.bounds.w / 2), y: Math.floor(block.bounds.y + block.bounds.h / 2),
976 } })) };
977 } else {
978 t.ocr = { status: "unavailable", engine: "apple_vision", reason: ocr?.reason ?? "The native OCR helper needs an update or returned mismatched image dimensions", blocks: [], raster };
979 }
980 } catch (error) {
981 throwIfAborted();
982 if (error.code === "cancelled") throw error;
983 t.ocr = { status: "unavailable", engine: "apple_vision", reason: error.message, blocks: [], ...(raster ? { raster } : {}) };
984 }
985 }
986 return t;
987 },
988 resolve_element: async ({ app_ref, windowIndex, path: pathArr } = {}) => {
989 const r = await native("resolve_element", { app_ref, windowIndex: windowIndex ?? 0, path: pathArr ?? [] });
990 return { found: !!r?.found, element: r?.element ?? null, reason: r?.reason ?? null };
991 },
992 preview: async ({ enabled = true } = {}) => {
993 state.previewEnabled = enabled;
994 if (!enabled) { stopPreviewLoop(); await quiescePreview(); await native("preview_notify", { enabled: false }); return { enabled: false }; }
995 if (!state.inputApp) throw new ExecError("open_application first to choose the preview app");
996 return updatePreview(true);
997 },
998 screenshot,
999 zoom,
1000 left_click: async ({ target, strategy = "auto" } = {}) => {
1001 if (target?.type !== "element" || strategy === "event" || strategy === "app") return pointerClick("left", target?.x, target?.y, 1, strategy);
1002 if (!["auto", "a11y"].includes(strategy)) throw new ExecError(`strategy must be auto, a11y, app or event (got ${JSON.stringify(strategy)})`);
1003 try {
1004 assertBoundElement(target);
1005 if ((await native("input_capabilities"))?.element_identity !== 1) throw new ExecError("native helper needs an update for element identity validation");
1006 const semantic = ["AXTextField", "AXTextArea", "AXComboBox", "AXRow", "AXCell", "AXMenuItem"].includes(target.role);
1007 if (semantic) await requireBackgroundActions();
1008 const receipt = await native(semantic ? "click_element" : "perform_action", { target, action: "AXPress" });
1009 if (!receipt?.action_sent) throw new ExecError("element press was not acknowledged");
1010 return { ...receipt, action: receipt.action ?? "AXPress", strategy: "a11y", pointer_moved: false,
1011 element: { role: target.role, label: target.label ?? null }, verified: receipt.verified ?? false, verification_required: "observation" };
1012 } catch (error) {
1013 // An AX frame can cover other controls. Never turn a refused or
1014 // ambiguous element press into another element's press or a raw click.
1015 error.message += ' — no coordinate fallback was sent; take a fresh screenshot or OCR observation and choose an advertised action or a separate computer';
1016 throw error;
1017 }
1018 },
1019 double_click: ({ target } = {}) => pointerClick("left", target?.x, target?.y, 2),
1020 triple_click: ({ target } = {}) => pointerClick("left", target?.x, target?.y, 3),
1021 right_click: async ({ target } = {}) => {
1022 if (target?.type !== "element") return pointerClick("right", target?.x, target?.y, 1);
1023 assertBoundElement(target);
1024 await requireBackgroundActions();
1025 return native("click_element", { target, context: true });
1026 },
1027 middle_click: ({ target } = {}) => pointerClick("middle", target?.x, target?.y, 1),
1028 mouse_move: async ({ target } = {}) => {
1029 assertInScreen(target?.x, target?.y);
1030 requireSharedPointer();
1031 if (state.pointerLease) {
1032 try {
1033 const r = await state.pointerLease.send({ point: target });
1034 state.pointer = { x: target.x, y: target.y };
1035 return { action_sent: true, strategy: "event", at: state.pointer, ...pointerCost(r) };
1036 } catch (error) { state.pointerLease = null; throw error; }
1037 }
1038 // A hover has to leave the pointer where it was asked to go.
1039 const r = await gesture([{ type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0 }], { restore: false, guard: target });
1040 return { action_sent: true, strategy: "event", at: { x: target.x, y: target.y }, ...pointerCost(r) };
1041 },
1042 left_mouse_down: async ({ target } = {}) => {
1043 assertInScreen(target?.x, target?.y);
1044 requireSharedPointer();
1045 if (state.pointerLease) throw new ExecError("this session already holds the left pointer button; release it first");
1046 await assertOwnsPoint(target.x, target.y);
1047 state.pointerLease = await nativeLease("pointer_sequence", { steps: [
1048 { type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0 },
1049 { type: MOUSE.left.down, x: target.x, y: target.y, button: 0, clickState: 1 },
1050 ], restore: false });
1051 state.pointer = { x: target.x, y: target.y };
1052 return { action_sent: true, strategy: "event", at: state.pointer, ...pointerCost(state.pointerLease.receipt) };
1053 },
1054 left_mouse_up: async ({ target } = {}) => {
1055 if (!state.pointerLease) throw new ExecError("no agent pointer button is held by this session");
1056 const loc = target ?? state.pointer;
1057 if (!loc) throw new ExecError("no agent pointer position — mouse_move or left_mouse_down first");
1058 assertInScreen(loc.x, loc.y);
1059 // No ownership guard: the button is already held, and the drag may have
1060 // legitimately left the originating window.
1061 try { await withSignal(null, () => state.pointerLease.release({ point: loc })); }
1062 finally { state.pointerLease = null; }
1063 state.pointer = { x: loc.x, y: loc.y };
1064 return { action_sent: true, strategy: "event", at: state.pointer, pointer_moved: true, pointer_restored: false };
1065 },
1066 left_click_drag: async ({ from_target: from, to } = {}) => {
1067 assertInScreen(from?.x, from?.y); assertInScreen(to?.x, to?.y);
1068 const steps = [
1069 { type: MOUSE_MOVED, x: from.x, y: from.y, button: 0, clickState: 0 },
1070 { type: MOUSE.left.down, x: from.x, y: from.y, button: 0, clickState: 1, delayMs: 60 },
1071 ];
1072 const n = 12;
1073 for (let i = 1; i <= n; i++) {
1074 steps.push({ type: MOUSE.left.dragged, x: from.x + ((to.x - from.x) * i) / n, y: from.y + ((to.y - from.y) * i) / n, button: 0, clickState: 1, delayMs: 45 });
1075 }
1076 steps.push({ type: MOUSE.left.up, x: to.x, y: to.y, button: 0, clickState: 1, delayMs: 80 });
1077 if (!state.foregroundInput && (await native("input_capabilities"))?.window_record === 1) {
1078 const r = await native("bg_pointer", { steps });
1079 return { action_sent: true, strategy: "window-record", input_scope: "application-window",
1080 from, to, pointer_moved: false, front_lease: r.front_lease === true, window: r.window ?? null,
1081 ...leaseAccounting(r),
1082 ...(typeof r.front_restored === "boolean" ? { front_restored: r.front_restored } : {}) };
1083 }
1084 const r = await gesture(steps, { restore: true, guard: from });
1085 return { action_sent: true, strategy: "event", from, to, ...pointerCost(r) };
1086 },
1087 scroll: async ({ target, direction = "down", amount = 5 } = {}) => {
1088 assertInScreen(target?.x, target?.y);
1089 if (!state.foregroundInput) {
1090 await requireBackgroundActions();
1091 if (target.type === "element") {
1092 assertBoundElement(target);
1093 return native("scroll_element", { target, direction, amount });
1094 }
1095 const receipt = await native("hit_test", { x: target.x, y: target.y, perform: true, direction, amount,
1096 operation: ["left", "right"].includes(direction) ? "scroll-horizontal" : "scroll-vertical" });
1097 if (receipt?.action_sent) return receipt;
1098 // No AX scrollbar here (overlay scrollers, web pages): wheel events
1099 // still reach the view through the window-record route.
1100 if ((await native("input_capabilities"))?.window_record === 1) {
1101 const dx = direction === "left" ? amount : direction === "right" ? -amount : 0;
1102 const dy = direction === "up" ? amount : direction === "down" ? -amount : 0;
1103 const notches = Math.max(1, Math.min(100, Math.round(amount)));
1104 const steps = [];
1105 for (let i = 0; i < notches; i++) steps.push({ scroll: [Math.sign(dx), Math.sign(dy)], x: target.x, y: target.y, delayMs: 15 });
1106 const r = await native("bg_pointer", { steps });
1107 return { action_sent: true, strategy: "window-record", input_scope: "application-window",
1108 direction, amount, pointer_moved: false, front_lease: r.front_lease === true, window: r.window ?? null,
1109 verified: false, verification_required: "observation", ...leaseAccounting(r),
1110 ...(typeof r.front_restored === "boolean" ? { front_restored: r.front_restored } : {}) };
1111 }
1112 throw Object.assign(new ExecError(`No background scrollbar at this point (${receipt?.reason ?? "not_found"}); choose an observed scroll area or a separate computer.`), { code: "background_scroll_unavailable" });
1113 }
1114 const dx = direction === "left" ? -amount : direction === "right" ? amount : 0;
1115 const dy = direction === "up" ? amount : direction === "down" ? -amount : 0;
1116 // A wheel sends one notch at a time. One event carrying the whole amount
1117 // is clamped by the scroll view's momentum handling and moves a fraction
1118 // of the distance, so emit the notches.
1119 const notches = Math.max(1, Math.min(100, Math.round(amount)));
1120 const steps = [{ type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0, delayMs: 40 }];
1121 for (let i = 0; i < notches; i++) {
1122 steps.push({ scroll: [Math.sign(dx), Math.sign(dy)], delayMs: 15 });
1123 }
1124 const r = await gesture(steps, { restore: true, guard: target });
1125 return { action_sent: true, strategy: "event", direction, amount, ...pointerCost(r) };
1126 },
1127 type: (args = {}) => native("type", args),
1128 key: async ({ text, repeat = 1, target } = {}) => {
1129 const { flags, code, key } = parseChord(text);
1130 const n = Math.max(1, Math.min(100, repeat));
1131 // Modified and window-targeted keys need a key window. Background
1132 // mode must never make one by borrowing the user's keyboard focus.
1133 if (flags !== 0 || target != null) requireFocusControl();
1134 let yieldMs = 0;
1135 for (let i = 0; i < n; i++) {
1136 const press = await withPressedKey(code, flags, () => {});
1137 if (Number.isFinite(press?.yield_ms)) yieldMs = Math.max(yieldMs, press.yield_ms);
1138 if (i < n - 1) await wait(30);
1139 }
1140 return { action_sent: true, key, code, keyboard_delivery: state.foregroundInput ? "foreground-guarded" : "process", repeat: n,
1141 ...(yieldMs > 0 ? { yield_ms: yieldMs } : {}),
1142 ...(flags !== 0 && !state.foregroundInput ? { note: "process delivery (no focus lease was taken); menu key equivalents can be dropped without a key window. Verify the effect before retrying, or use invoke_menu for app menu commands." } : {}) };
1143 },
1144 hold_key: async ({ text, duration } = {}) => {
1145 const { flags, code, key } = parseChord(text);
1146 if (flags !== 0) requireFocusControl();
1147 const d = Math.max(0.05, Math.min(30, Number(duration) || 1));
1148 const press = await withPressedKey(code, flags, () => wait(d * 1000));
1149 return { action_sent: true, key, keyboard_delivery: state.foregroundInput ? "foreground-guarded" : "process", heldSec: d,
1150 ...(Number.isFinite(press?.yield_ms) && press.yield_ms > 0 ? { yield_ms: press.yield_ms } : {}) };
1151 },
1152 set_value: async (args = {}) => {
1153 if (args.target?.type !== "element") throw new ExecError("set_value needs an element target — {type:'element',index} from get_app_state");
1154 try {
1155 return await native("set_value", args);
1156 } catch (error) {
1157 // Web text controls ignore AXValue writes, so the native side refuses
1158 // before dispatch. The replacement path is focus + select-all + type
1159 // with a read-back verify — the same shape kimi-cu uses, with the
1160 // value proven rather than asserted.
1161 if (!/web area/i.test(error.message)) throw error;
1162 if (args.target?.type !== "element") throw error;
1163 requireFocusControl();
1164 const value = String(args.value ?? "");
1165 await native("focus_element", { target: args.target });
1166 // cmd+a through the record channel: menu key equivalents only
1167 // validate against a key window, which the lease provides. bg_key
1168 // posts a complete press (down and up); the `down` field is unused.
1169 await native("bg_key", { code: 0, flags: 1 << 20 });
1170 await new Promise((r) => setTimeout(r, 60));
1171 await native("type", { text: value });
1172 const back = await native("get_value", { target: args.target });
1173 const verified = back?.value === value;
1174 return { action_sent: true, strategy: "focus-type-replace", role: back?.role ?? null,
1175 after: back?.value ?? null, verified,
1176 ...(verified ? {} : { note: "replacement did not verify against the control's own value; observe before relying on it" }) };
1177 }
1178 },
1179 focus: (args = {}) => native("focus_element", args),
1180 get_value: (args = {}) => native("get_value", args),
1181 select_text: async (args = {}) => {
1182 if (args.target?.type !== "element") throw new ExecError("select_text needs an element target — {type:'element',index} from get_app_state");
1183 return native("select_text", args);
1184 },
1185 perform_action: async (args = {}) => {
1186 if (args.target?.type !== "element") throw new ExecError("perform_action needs an element target — {type:'element',index} from get_app_state");
1187 return native("perform_action", args);
1188 },
1189 invoke_menu: async ({ path: menuPath } = {}) => invokeMenu(menuPath),
1190 app_script: appScript,
1191 read_clipboard: readClipboard,
1192 write_clipboard: writeClipboard,
1193 cursor_position: cursorPosition,
1194 recordingStart,
1195 recordingStop,
1196 recordingStatus,
1197 recordingList,
1198 closeSession,
1199 list_sessions: async () => ({
1200 via: "direct",
1201 count: 1,
1202 sessions: [{
1203 target: state.inputApp ? { pid: state.inputApp.pid, ...(state.inputApp.bundle_id ? { bundle_id: state.inputApp.bundle_id } : {}), ...(state.inputApp.name ? { name: state.inputApp.name } : {}) } : null,
1204 mode: state.foregroundInput ? "foreground" : "background",
1205 action: null,
1206 ageSec: 0,
1207 inputHeld: !!state.pointerLease,
1208 }],
1209 }),
1210 kill_app: async (args = {}) => {
1211 const { name, bundle_id, pid, force } = args;
1212 if (!name && !bundle_id && pid == null) throw Object.assign(new ExecError("kill_app needs name, bundle_id or pid"), { code: "bad_args" });
1213 return native("kill_app", { name, bundle_id, pid, force: force === true });
1214 },
1215 browser_start: browser.start,
1216 browser_status: browser.status,
1217 browser_navigate: browser.navigate,
1218 browser_click: browser.click,
1219 browser_type: browser.type,
1220 browser_screenshot: browser.screenshot,
1221 browser_stop: browser.stop,
1222 releaseInput: async () => {
1223 if (!state.pointerLease) return;
1224 try { await withSignal(null, () => state.pointerLease.release({ point: state.pointer })); }
1225 finally { state.pointerLease = null; }
1226 },
1227 };
1228 }
1229
1230 export default { create };
1231
1231 lines Plain Text