ucu-mcp 0.5.2 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/dist/src/mcp/tools/app-tools.js +7 -0
- package/dist/src/mcp/tools/helpers.js +32 -22
- package/dist/src/mcp/tools/input-tools.js +16 -10
- package/dist/src/mcp/tools/keyboard-tools.js +8 -4
- package/dist/src/mcp/tools/screen-tools.js +85 -53
- package/dist/src/platform/base.d.ts +17 -6
- package/dist/src/platform/macos/base.d.ts +12 -3
- package/dist/src/platform/macos/base.js +10 -2
- package/dist/src/platform/macos/element.d.ts +2 -0
- package/dist/src/platform/macos/element.js +67 -68
- package/dist/src/platform/macos/helpers.js +10 -5
- package/dist/src/platform/macos/input.d.ts +7 -6
- package/dist/src/platform/macos/input.js +31 -7
- package/dist/src/platform/macos/screen.js +10 -2
- package/dist/src/platform/macos/window.d.ts +2 -0
- package/dist/src/platform/macos/window.js +49 -26
- package/dist/src/safety/permissions.d.ts +2 -0
- package/dist/src/safety/permissions.js +34 -2
- package/dist/src/utils/input.d.ts +14 -8
- package/dist/src/utils/input.js +102 -43
- package/native/skylight/main.swift +402 -0
- package/native/skylight/skylight-helper +0 -0
- package/package.json +2 -2
- package/skills/ucu-mcp/SKILL.md +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,74 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.2] - 2026-06-17
|
|
9
|
+
|
|
10
|
+
修 `normalizeAppName` 名字匹配 bug + 清理死代码 + 补 TTL 测试。
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **selectWindowForApp 名字匹配 bug**(`helpers.ts`):`normalizeAppName` 只做 `trim().toLowerCase()`,导致 `"cc-switch"`(用户输入)无法匹配 `"CC Switch"`(`NSRunningApplication.localizedName`)。fix:drop 所有非字母数字字符(`.replace(/[^a-z0-9]/g, "")`),所有变体归一化到 `"ccswitch"`。之前 `focus_app("cc-switch")` 因名字 mismatch 退到 tray fallback,误诊为 per-process 失效。
|
|
15
|
+
- **appNameMatches 死代码清理**:去掉 startsWith/includes 带空格的条件(normalize 后无空格,永不命中),改为 includes 双向匹配。
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **denied/granted TTL 时效测试**(`permissions.test.ts`):用 fake timers 验证 denied 1s / granted 5s 后 cache 失效重查(之前 Sync 73 记录说写了但未 commit)。
|
|
20
|
+
|
|
21
|
+
### 真机验证
|
|
22
|
+
|
|
23
|
+
- per-process 对 ZCode(原生 SwiftUI)完全生效:click(735,478) 返回 dispatch:per-pid,Warp 始终前台不被抢。
|
|
24
|
+
|
|
25
|
+
## [0.6.1] - 2026-06-17
|
|
26
|
+
|
|
27
|
+
响应流程并行化 + 短期缓存,降低 describe_screen / screenshot(describe) / focus_app 延迟。
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **并发收集 describe_screen 4 源**:OCR + AX + foreground + screen 用 `Promise.all` 并发(之前串行)。
|
|
32
|
+
- **并发 getSafetyContext**:listWindows + browser context 并行。
|
|
33
|
+
- **并发 screenshot + describe**:`screenshot(describe:true)` 的截图和描述生成并行。
|
|
34
|
+
- **listWindows in-flight 去重**:并发请求共享同一个 promise,避免重复 windowlist-helper 子进程。
|
|
35
|
+
- **getScreenSize 实例缓存**:5s TTL。
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **permission check cache**(`permissions.ts`):granted 5s / denied 1s TTL。denied 短 TTL 让用户授权后快速生效。
|
|
40
|
+
- **AXPress spin 可调**:80ms→50ms 默认,env `UCU_AXPRESS_SPIN_MS` 可调(max 80ms)。
|
|
41
|
+
|
|
42
|
+
### Tests
|
|
43
|
+
|
|
44
|
+
- permission cache 测试、AXPress spin timing 测试。326 passed。
|
|
45
|
+
|
|
46
|
+
## [0.6.0] - 2026-06-17
|
|
47
|
+
|
|
48
|
+
对标 Codex 原生 computer use:输入注入走 per-process 路径,不移动全局光标、不抢前台。仅 Mac 端(与 Codex mac 版一致,Win 版只前台)。
|
|
49
|
+
|
|
50
|
+
### Added — 核心后台能力
|
|
51
|
+
|
|
52
|
+
- **skylight-helper(native/skylight/main.swift)**:第 4 个 Swift native helper。dlopen SkyLight.framework,`SLEventPostToPid`(私有 SPI)按 PID 投递鼠标事件——绕开全局 HID 流,光标不动。完整 cua-driver 配方:NSEvent bridge 构建(绕开 Chromium renderer 过滤)+ field 40 pid latch + `CGEventSetWindowLocation` + focus-without-raise(`SLPSPostEventRecordTo` 248-byte event record,不调 `SLPSSetFrontProcessWithOptions`)+ (-1,-1) primer 欺骗 Chromium user-activation gate。键盘走公开 `CGEventPostToPid`。frontmost/canvas app 自动 fallback HID-tap。
|
|
53
|
+
- **pid/windowNumber 透传链**:`WindowInfo`/`AppTarget` 加 `windowNumber`(真实 CGWindowID,window.ts 原本丢弃了)。`macos/input.ts` 读 `activeTarget` 透传到 `utils/input.ts`。`utils/input.ts` 函数加 `target` 参数,pid>0 走 skylight-helper,否则 fallback cgevent-helper(HID-tap)。
|
|
54
|
+
- **AX 保活(方向4b 根因修复)**:skylight-helper 的 `keepAlive` 命令写 `AXManualAccessibility`/`AXEnhancedUserInterface` + 订阅 remote-aware AX observer(`_AXObserverAddNotificationAndCheckRemote`,符号缺失时 fallback 公开 API)。focusApp 成功后调用,保活目标 AX 树(被遮挡的 Electron/Tauri 不再冻结 → AXPress 不再静默失败)。
|
|
55
|
+
- **tray per-process**:`MenuBarExtraItem` 加 `pid` 字段,findMenuBarExtra 捕获 SystemUIServer pid。focusApp 建 tray target 时用真实 host pid(非 0),tray 坐标点击也走 per-process。
|
|
56
|
+
- **dispatch 信号透出**:所有输入工具返回 `result.dispatch`(`"per-pid" | "hid-tap"`)。hid-tap 时附 warning。doctor 检测 skylight-helper 可用性。
|
|
57
|
+
- **DispatchMethod 类型**(base.ts):Platform 接口 mouse/keyboard 方法返回 `Promise<DispatchMethod | void>`。
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **element.ts 坐标 fallback 迁移**:`JXA_COORDINATE_CLICK`(JXA HID-tap)重命名为 `JXA_BOUNDS_CENTER`(只算中心坐标)。clickElement/clickMenuBarExtra 的坐标点击退出 JXA,改在 TS 层调 `this.click()`(走 per-process)。
|
|
62
|
+
- SKILL.md:加 "Dispatch method (v0.6.0+)" 节 + Operating Rules 加 "focus_app before input"。
|
|
63
|
+
|
|
64
|
+
### 真机验证(2026-06-17)
|
|
65
|
+
|
|
66
|
+
- ✅ skylight-helper ping 返回 `{"ok":true,"skylight":true}`(SPI 全加载)。
|
|
67
|
+
- ✅ 对后台 Finder pid click(100,100) 返回 `method:per-pid`,光标不跳到 (100,100)(HID-tap 会直接跳过去)——核心承诺验证。
|
|
68
|
+
- ✅ keepAlive 对 Finder pid 返回 `method:ax-keepalive`。
|
|
69
|
+
- ✅ 310 tests passed(4 native helpers 全编译)。
|
|
70
|
+
|
|
71
|
+
### 已知限制
|
|
72
|
+
|
|
73
|
+
- Canvas/GPU app(Blender/Unity/游戏)的 event loop 只接受 HID-tap → 自动 fallback,返回 `dispatch:"hid-tap"` + warning。
|
|
74
|
+
- `_AXObserverAddNotificationAndCheckRemote` 符号在本机未通过 dlsym 解析(remote:false),但 AXManualAccessibility/AXEnhancedUserInterface 属性写入生效(覆盖非 Chromium 场景)。
|
|
75
|
+
|
|
8
76
|
## [0.5.2] - 2026-06-17
|
|
9
77
|
|
|
10
78
|
Agent Skill 重写为 CLI agent 优先(Claude Code / Codex / OpenCode 都是 stdio CLI 环境)。
|
|
@@ -122,10 +122,12 @@ export function registerAppTools(registerTool) {
|
|
|
122
122
|
const cgevent = resolveHelperPath(["native", "cgevent", "cgevent-helper"]);
|
|
123
123
|
const ocr = resolveHelperPath(["native", "ocr", "ocr-helper"]);
|
|
124
124
|
const windowlist = resolveHelperPath(["native", "windowlist", "windowlist-helper"]);
|
|
125
|
+
const skylight = resolveHelperPath(["native", "skylight", "skylight-helper"]);
|
|
125
126
|
nativeHelpers = {
|
|
126
127
|
cgevent: { ok: cgevent.path !== null, path: cgevent.path, tried: cgevent.tried },
|
|
127
128
|
ocr: { ok: ocr.path !== null, path: ocr.path, tried: ocr.tried },
|
|
128
129
|
windowlist: { ok: windowlist.path !== null, path: windowlist.path, tried: windowlist.tried },
|
|
130
|
+
skylight: { ok: skylight.path !== null, path: skylight.path, tried: skylight.tried },
|
|
129
131
|
};
|
|
130
132
|
}
|
|
131
133
|
let readiness = "ready";
|
|
@@ -153,6 +155,11 @@ export function registerAppTools(registerTool) {
|
|
|
153
155
|
readiness = readiness === "ready" ? "degraded" : readiness;
|
|
154
156
|
issues.push("Native windowlist helper not found (window enumeration will fall back to slow JXA). Run `npm run build` to compile it.");
|
|
155
157
|
}
|
|
158
|
+
if (!nativeHelpers.skylight.ok) {
|
|
159
|
+
// skylight missing → per-process posting unavailable; input falls back to
|
|
160
|
+
// HID-tap (moves cursor). Note as info, not degraded, since HID-tap still works.
|
|
161
|
+
issues.push("Native skylight helper not found (per-process event posting unavailable — clicks/typing will move the cursor via HID-tap). Run `npm run build` to compile it.");
|
|
162
|
+
}
|
|
156
163
|
}
|
|
157
164
|
const electronHint = "If the target app is Electron (e.g. CC Switch, VS Code, Discord), list_windows may return [] even with Accessibility granted to your terminal. Grant Accessibility to the Electron app itself in System Settings > Privacy & Security > Accessibility, and restart the app. Pixel-level workaround: use screenshot + ocr to locate UI elements by text, then click(x, y) at the detected bounding box coordinates. Alternatively, modify the app\'s config file or database directly.";
|
|
158
165
|
const clients = {};
|
|
@@ -42,28 +42,38 @@ export async function resolvePoint(x, y, windowId) {
|
|
|
42
42
|
export async function getSafetyContext(windowId) {
|
|
43
43
|
const target = activeTargetContext;
|
|
44
44
|
const effectiveWindowId = windowId ?? target?.windowId;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
45
|
+
// Resolve window title and browser URL concurrently; they have no dependency.
|
|
46
|
+
const [windowTitleResult, urlResult] = await Promise.all([
|
|
47
|
+
(async () => {
|
|
48
|
+
if (!effectiveWindowId)
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const windows = await getPlatform().listWindows();
|
|
52
|
+
const win = windows.find((w) => w.id === effectiveWindowId);
|
|
53
|
+
return win?.title;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* best effort */
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
})(),
|
|
60
|
+
(async () => {
|
|
61
|
+
const platform = getPlatform();
|
|
62
|
+
if (!platform.getActiveBrowserContext)
|
|
63
|
+
return undefined;
|
|
64
|
+
try {
|
|
65
|
+
const appName = target?.appName;
|
|
66
|
+
const ctx = await platform.getActiveBrowserContext(appName);
|
|
67
|
+
return ctx?.url;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* best effort */
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
})(),
|
|
74
|
+
]);
|
|
75
|
+
const windowTitle = windowTitleResult || target?.title;
|
|
76
|
+
const url = urlResult;
|
|
67
77
|
return { windowTitle, url };
|
|
68
78
|
}
|
|
69
79
|
export function jsonText(value) {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getPlatform, resolvePoint, getSafetyContext, withSafety, actionResponse, captureAfterFields, } from "./helpers.js";
|
|
3
|
+
/** Warning text emitted when an input action had to fall back to HID-tap (moves the cursor). */
|
|
4
|
+
const HID_TAP_WARNING = "Event dispatched via global HID tap (moved the cursor / may disturb foreground). This happens for frontmost or canvas/GPU apps where per-process posting is filtered.";
|
|
5
|
+
function dispatchWarnings(dispatch) {
|
|
6
|
+
// undefined means JXA fallback (HID-tap) or no target — warn in both cases.
|
|
7
|
+
return dispatch === "per-pid" ? [] : [HID_TAP_WARNING];
|
|
8
|
+
}
|
|
3
9
|
export function registerInputTools(registerTool) {
|
|
4
10
|
registerTool("click", "Click at screen coordinates", {
|
|
5
11
|
x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"),
|
|
@@ -9,8 +15,8 @@ export function registerInputTools(registerTool) {
|
|
|
9
15
|
}, async (params) => {
|
|
10
16
|
const pt = await resolvePoint(params.x, params.y, params.windowId);
|
|
11
17
|
const safetyCtx = await getSafetyContext(params.windowId);
|
|
12
|
-
await withSafety({ action: "click", params: { x: pt.x, y: pt.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().click(pt.x, pt.y, params.button) });
|
|
13
|
-
return actionResponse("click", { clicked: true, x: pt.x, y: pt.y }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
18
|
+
const dispatch = await withSafety({ action: "click", params: { x: pt.x, y: pt.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().click(pt.x, pt.y, params.button) });
|
|
19
|
+
return actionResponse("click", { clicked: true, x: pt.x, y: pt.y, dispatch: dispatch ?? "hid-tap" }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth, dispatchWarnings(dispatch));
|
|
14
20
|
});
|
|
15
21
|
registerTool("double_click", "Double-click at screen coordinates", {
|
|
16
22
|
x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"),
|
|
@@ -20,8 +26,8 @@ export function registerInputTools(registerTool) {
|
|
|
20
26
|
}, async (params) => {
|
|
21
27
|
const pt = await resolvePoint(params.x, params.y, params.windowId);
|
|
22
28
|
const safetyCtx = await getSafetyContext(params.windowId);
|
|
23
|
-
await withSafety({ action: "double_click", params: { x: pt.x, y: pt.y, doubleClick: true, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().click(pt.x, pt.y, params.button, true) });
|
|
24
|
-
return actionResponse("double_click", { doubleClicked: true, x: pt.x, y: pt.y }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
29
|
+
const dispatch = await withSafety({ action: "double_click", params: { x: pt.x, y: pt.y, doubleClick: true, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().click(pt.x, pt.y, params.button, true) });
|
|
30
|
+
return actionResponse("double_click", { doubleClicked: true, x: pt.x, y: pt.y, dispatch: dispatch ?? "hid-tap" }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth, dispatchWarnings(dispatch));
|
|
25
31
|
});
|
|
26
32
|
registerTool("scroll", "Scroll at coordinates", {
|
|
27
33
|
x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"),
|
|
@@ -32,8 +38,8 @@ export function registerInputTools(registerTool) {
|
|
|
32
38
|
const pt = await resolvePoint(params.x, params.y, params.windowId);
|
|
33
39
|
const deltaX = params.deltaX ?? 0;
|
|
34
40
|
const safetyCtx = await getSafetyContext(params.windowId);
|
|
35
|
-
await withSafety({ action: "scroll", params: { x: pt.x, y: pt.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().scroll(pt.x, pt.y, deltaX, params.deltaY) });
|
|
36
|
-
return actionResponse("scroll", { scrolled: true, x: pt.x, y: pt.y }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
41
|
+
const dispatch = await withSafety({ action: "scroll", params: { x: pt.x, y: pt.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().scroll(pt.x, pt.y, deltaX, params.deltaY) });
|
|
42
|
+
return actionResponse("scroll", { scrolled: true, x: pt.x, y: pt.y, dispatch: dispatch ?? "hid-tap" }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth, dispatchWarnings(dispatch));
|
|
37
43
|
});
|
|
38
44
|
registerTool("drag", "Drag from one point to another", {
|
|
39
45
|
startX: z.number().describe("Start X"), startY: z.number().describe("Start Y"),
|
|
@@ -46,8 +52,8 @@ export function registerInputTools(registerTool) {
|
|
|
46
52
|
const start = await resolvePoint(params.startX, params.startY, params.windowId);
|
|
47
53
|
const end = await resolvePoint(params.endX, params.endY, params.windowId);
|
|
48
54
|
const safetyCtx = await getSafetyContext(params.windowId);
|
|
49
|
-
await withSafety({ action: "drag", params: { startX: start.x, startY: start.y, endX: end.x, endY: end.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().drag(start.x, start.y, end.x, end.y, params.button, params.duration) });
|
|
50
|
-
return actionResponse("drag", { dragged: true, startX: start.x, startY: start.y, endX: end.x, endY: end.y }, { startX: start.x, startY: start.y, endX: end.x, endY: end.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
55
|
+
const dispatch = await withSafety({ action: "drag", params: { startX: start.x, startY: start.y, endX: end.x, endY: end.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().drag(start.x, start.y, end.x, end.y, params.button, params.duration) });
|
|
56
|
+
return actionResponse("drag", { dragged: true, startX: start.x, startY: start.y, endX: end.x, endY: end.y, dispatch: dispatch ?? "hid-tap" }, { startX: start.x, startY: start.y, endX: end.x, endY: end.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth, dispatchWarnings(dispatch));
|
|
51
57
|
});
|
|
52
58
|
registerTool("move", "Move cursor to coordinates", {
|
|
53
59
|
x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"),
|
|
@@ -56,8 +62,8 @@ export function registerInputTools(registerTool) {
|
|
|
56
62
|
}, async (params) => {
|
|
57
63
|
const pt = await resolvePoint(params.x, params.y, params.windowId);
|
|
58
64
|
const safetyCtx = await getSafetyContext(params.windowId);
|
|
59
|
-
await withSafety({ action: "move", params: { x: pt.x, y: pt.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().move(pt.x, pt.y) });
|
|
60
|
-
return actionResponse("move", { moved: true, x: pt.x, y: pt.y }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
65
|
+
const dispatch = await withSafety({ action: "move", params: { x: pt.x, y: pt.y, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().move(pt.x, pt.y) });
|
|
66
|
+
return actionResponse("move", { moved: true, x: pt.x, y: pt.y, dispatch: dispatch ?? "hid-tap" }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth, dispatchWarnings(dispatch));
|
|
61
67
|
});
|
|
62
68
|
registerTool("get_cursor_position", "Get current cursor position", {}, async () => {
|
|
63
69
|
const pos = await withSafety({ action: "get_cursor_position", params: {}, execute: () => Promise.resolve(getPlatform().getCursorPosition()) });
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { UnsupportedParameterError } from "../../util/errors.js";
|
|
3
3
|
import { getPlatform, getSafetyContext, withSafety, actionResponse, captureAfterFields, } from "./helpers.js";
|
|
4
|
+
function keyDispatchWarnings(dispatch) {
|
|
5
|
+
// undefined means JXA fallback (HID-tap) or no target — warn in both cases.
|
|
6
|
+
return dispatch === "per-pid" ? [] : ["Key event dispatched via global HID tap (no target pid available; may affect foreground). Use focus_app first to enable per-process posting."];
|
|
7
|
+
}
|
|
4
8
|
export function registerKeyboardTools(registerTool) {
|
|
5
9
|
registerTool("type_text", "Type text at the current cursor position", {
|
|
6
10
|
text: z.string().describe("Text to type"), delay: z.number().optional().describe("Delay between keystrokes in ms"),
|
|
@@ -10,8 +14,8 @@ export function registerKeyboardTools(registerTool) {
|
|
|
10
14
|
if (params.windowId)
|
|
11
15
|
throw new UnsupportedParameterError("windowId-targeted keyboard typing is not implemented");
|
|
12
16
|
const safetyCtx = await getSafetyContext();
|
|
13
|
-
await withSafety({ action: "type_text", params: { text: params.text, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().type(params.text, params.delay) });
|
|
14
|
-
return actionResponse("type_text", { typed: true, charCount: params.text.length }, {}, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
17
|
+
const dispatch = await withSafety({ action: "type_text", params: { text: params.text, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().type(params.text, params.delay) });
|
|
18
|
+
return actionResponse("type_text", { typed: true, charCount: params.text.length, dispatch: dispatch ?? "hid-tap" }, {}, params.captureAfter, params.captureFormat, params.captureMaxWidth, keyDispatchWarnings(dispatch));
|
|
15
19
|
});
|
|
16
20
|
registerTool("press_key", "Press a keyboard shortcut", {
|
|
17
21
|
keys: z.array(z.string()).optional().describe("Keys to press simultaneously — special keys (enter/escape/tab/f1-f12/arrows...), single letters a-z, or single digits 0-9"),
|
|
@@ -29,7 +33,7 @@ export function registerKeyboardTools(registerTool) {
|
|
|
29
33
|
if (keys.length === 0)
|
|
30
34
|
throw new UnsupportedParameterError("press_key requires at least one key");
|
|
31
35
|
const safetyCtx = await getSafetyContext();
|
|
32
|
-
await withSafety({ action: "press_key", params: { keys, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().key(keys) });
|
|
33
|
-
return actionResponse("press_key", { pressed: true, keys }, {}, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
36
|
+
const dispatch = await withSafety({ action: "press_key", params: { keys, ...safetyCtx }, requiresAccessibility: true, execute: () => getPlatform().key(keys) });
|
|
37
|
+
return actionResponse("press_key", { pressed: true, keys, dispatch: dispatch ?? "hid-tap" }, {}, params.captureAfter, params.captureFormat, params.captureMaxWidth, keyDispatchWarnings(dispatch));
|
|
34
38
|
});
|
|
35
39
|
}
|
|
@@ -24,73 +24,102 @@ function maskSensitiveFields(el) {
|
|
|
24
24
|
* is collected independently inside try/catch. Failures go to `errors` and set the
|
|
25
25
|
* corresponding status; the function never throws. This is the text fallback for
|
|
26
26
|
* environments where image content blocks are downgraded to URLs.
|
|
27
|
+
*
|
|
28
|
+
* Sources are collected concurrently because they have no data dependencies.
|
|
27
29
|
*/
|
|
28
30
|
async function buildScreenDescription(opts) {
|
|
29
31
|
const platform = getPlatform();
|
|
30
32
|
const errors = [];
|
|
31
33
|
const capturedAt = new Date().toISOString();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (platform.listApps) {
|
|
34
|
+
const collectScreen = async () => {
|
|
35
|
+
try {
|
|
36
|
+
return { ok: true, value: platform.getScreenSize(opts.display) };
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return { ok: false, error: `getScreenSize failed: ${e.message}` };
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const collectForeground = async () => {
|
|
43
|
+
try {
|
|
44
|
+
if (!platform.listApps)
|
|
45
|
+
return { ok: true, value: undefined };
|
|
45
46
|
const apps = await platform.listApps();
|
|
46
47
|
const front = apps.find((a) => a.isFrontmost);
|
|
47
|
-
if (front)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
48
|
+
if (!front)
|
|
49
|
+
return { ok: true, value: undefined };
|
|
50
|
+
const wins = await platform.listWindows(true);
|
|
51
|
+
return { ok: true, value: wins.find((w) => w.processName === front.name && w.isOnScreen) };
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return { ok: false, error: `foreground window resolution failed: ${e.message}` };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const collectOcr = async () => {
|
|
58
|
+
if (!opts.runOcr) {
|
|
59
|
+
return { ok: true, value: { blocks: [], fullText: "", status: "skipped" } };
|
|
51
60
|
}
|
|
52
|
-
}
|
|
53
|
-
catch (e) {
|
|
54
|
-
errors.push({ source: "foreground", message: `foreground window resolution failed: ${e.message}` });
|
|
55
|
-
}
|
|
56
|
-
// OCR — cap blocks to ocrBlocks via slice
|
|
57
|
-
let ocr;
|
|
58
|
-
if (opts.runOcr) {
|
|
59
61
|
try {
|
|
60
62
|
const result = await platform.ocr(opts.display);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
value: {
|
|
66
|
+
blocks: result.elements.slice(0, opts.ocrBlocks),
|
|
67
|
+
fullText: result.fullText,
|
|
68
|
+
status: "ok",
|
|
69
|
+
},
|
|
65
70
|
};
|
|
66
71
|
}
|
|
67
72
|
catch (e) {
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
value: { blocks: [], fullText: "", status: "failed" },
|
|
76
|
+
error: `ocr failed: ${e.message}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const collectAx = async () => {
|
|
81
|
+
if (!opts.includeAx) {
|
|
82
|
+
return { ok: true, value: { status: "skipped" } };
|
|
70
83
|
}
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
ocr = { blocks: [], fullText: "", status: "skipped" };
|
|
74
|
-
}
|
|
75
|
-
// AX — getWindowState with depth cap, password masking applied
|
|
76
|
-
let ax;
|
|
77
|
-
if (opts.includeAx) {
|
|
78
84
|
const effectiveWindowId = opts.windowId ?? getActiveTarget()?.windowId;
|
|
79
85
|
try {
|
|
80
86
|
const cappedDepth = Math.min(opts.axDepth, 10);
|
|
81
87
|
const state = await platform.getWindowState(effectiveWindowId, cappedDepth, true);
|
|
82
88
|
maskSensitiveFields(state.tree);
|
|
83
89
|
maskSensitiveFields(state.focusedElement);
|
|
84
|
-
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
value: { elements: state.tree, status: "ok", windowId: effectiveWindowId },
|
|
93
|
+
};
|
|
85
94
|
}
|
|
86
95
|
catch (e) {
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
value: { status: "failed", windowId: effectiveWindowId },
|
|
99
|
+
error: `getWindowState failed: ${e.message}`,
|
|
100
|
+
};
|
|
89
101
|
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
};
|
|
103
|
+
const [screenResult, foregroundResult, ocrResult, axResult] = await Promise.all([
|
|
104
|
+
collectScreen(),
|
|
105
|
+
collectForeground(),
|
|
106
|
+
collectOcr(),
|
|
107
|
+
collectAx(),
|
|
108
|
+
]);
|
|
109
|
+
const screen = screenResult.ok
|
|
110
|
+
? screenResult.value
|
|
111
|
+
: { width: 0, height: 0, scaleFactor: 1, estimated: true };
|
|
112
|
+
if (!screenResult.ok)
|
|
113
|
+
errors.push({ source: "screen", message: screenResult.error });
|
|
114
|
+
const foregroundWindow = foregroundResult.ok ? foregroundResult.value : undefined;
|
|
115
|
+
if (!foregroundResult.ok)
|
|
116
|
+
errors.push({ source: "foreground", message: foregroundResult.error });
|
|
117
|
+
const ocr = ocrResult.ok ? ocrResult.value : ocrResult.value ?? { blocks: [], fullText: "", status: "failed" };
|
|
118
|
+
if (!ocrResult.ok)
|
|
119
|
+
errors.push({ source: "ocr", message: ocrResult.error });
|
|
120
|
+
const ax = axResult.ok ? axResult.value : axResult.value ?? { status: "failed" };
|
|
121
|
+
if (!axResult.ok)
|
|
122
|
+
errors.push({ source: "ax", message: axResult.error });
|
|
94
123
|
return { capturedAt, screen, foregroundWindow, ocr, ax, errors };
|
|
95
124
|
}
|
|
96
125
|
export function registerScreenTools(registerTool) {
|
|
@@ -110,7 +139,7 @@ export function registerScreenTools(registerTool) {
|
|
|
110
139
|
if (params.windowId && params.region)
|
|
111
140
|
throw new UnsupportedParameterError("screenshot windowId cannot be combined with region");
|
|
112
141
|
const options = { format: params.format, maxWidth: params.maxWidth };
|
|
113
|
-
const
|
|
142
|
+
const screenshotPromise = withSafety({
|
|
114
143
|
action: "screenshot",
|
|
115
144
|
params,
|
|
116
145
|
requiresScreenRecording: true,
|
|
@@ -120,17 +149,20 @@ export function registerScreenTools(registerTool) {
|
|
|
120
149
|
: Promise.reject(new UnsupportedParameterError("window screenshots are not implemented on this platform"))
|
|
121
150
|
: getPlatform().screenshot(params.display, params.region, options),
|
|
122
151
|
});
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const desc = await buildScreenDescription({
|
|
152
|
+
const describeOpts = params.describeOptions ?? { axDepth: 3, ocrBlocks: 50, includeAx: true };
|
|
153
|
+
const descriptionPromise = params.describe
|
|
154
|
+
? buildScreenDescription({
|
|
127
155
|
display: params.display,
|
|
128
156
|
runOcr: true,
|
|
129
|
-
includeAx:
|
|
130
|
-
axDepth:
|
|
131
|
-
ocrBlocks:
|
|
157
|
+
includeAx: describeOpts.includeAx ?? true,
|
|
158
|
+
axDepth: describeOpts.axDepth ?? 3,
|
|
159
|
+
ocrBlocks: describeOpts.ocrBlocks ?? 50,
|
|
132
160
|
windowId: params.windowId,
|
|
133
|
-
})
|
|
161
|
+
})
|
|
162
|
+
: Promise.resolve(undefined);
|
|
163
|
+
const [buf, desc] = await Promise.all([screenshotPromise, descriptionPromise]);
|
|
164
|
+
const content = [{ type: "image", data: buf.toString("base64"), mimeType: `image/${params.format}` }];
|
|
165
|
+
if (desc) {
|
|
134
166
|
content.push(jsonText(desc));
|
|
135
167
|
}
|
|
136
168
|
return { content };
|
|
@@ -31,6 +31,8 @@ export interface WindowInfo {
|
|
|
31
31
|
};
|
|
32
32
|
isMinimized: boolean;
|
|
33
33
|
isOnScreen: boolean;
|
|
34
|
+
/** Real CGWindowID (kCGWindowNumber) — needed for per-process event posting (focus-without-raise). Undefined for JXA-fallback enumeration. */
|
|
35
|
+
windowNumber?: number;
|
|
34
36
|
}
|
|
35
37
|
export interface AppInfo {
|
|
36
38
|
name: string;
|
|
@@ -45,6 +47,8 @@ export interface AppTarget {
|
|
|
45
47
|
windowId?: string;
|
|
46
48
|
title?: string;
|
|
47
49
|
capturedAt: string;
|
|
50
|
+
/** Real CGWindowID for per-process event posting (focus-without-raise). Absent for tray targets. */
|
|
51
|
+
windowNumber?: number;
|
|
48
52
|
}
|
|
49
53
|
export interface BrowserContext {
|
|
50
54
|
appName: string;
|
|
@@ -162,6 +166,13 @@ export interface ScreenDescription {
|
|
|
162
166
|
message: string;
|
|
163
167
|
}>;
|
|
164
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* How an input event was dispatched. "per-pid" = posted to the target process
|
|
171
|
+
* via SLEventPostToPid/CGEventPostToPid (no global cursor move, no foreground
|
|
172
|
+
* theft — Codex-style background operation). "hid-tap" = posted to the global
|
|
173
|
+
* HID event tap (moves the cursor; fallback when no pid or skylight unavailable).
|
|
174
|
+
*/
|
|
175
|
+
export type DispatchMethod = "per-pid" | "hid-tap";
|
|
165
176
|
export interface Platform {
|
|
166
177
|
screenshot(display?: number, region?: ScreenRegion, options?: ScreenshotOptions): Promise<Buffer>;
|
|
167
178
|
screenshotWindow?(windowId: string, options?: ScreenshotOptions): Promise<Buffer>;
|
|
@@ -171,14 +182,14 @@ export interface Platform {
|
|
|
171
182
|
getActiveBrowserContext?(app?: string): Promise<BrowserContext | undefined>;
|
|
172
183
|
listWindows(includeMinimized?: boolean): Promise<WindowInfo[]>;
|
|
173
184
|
getWindowState(windowId?: string, depth?: number, includeBounds?: boolean): Promise<WindowState>;
|
|
174
|
-
click(x: number, y: number, button?: "left" | "right" | "middle", doubleClick?: boolean): Promise<void>;
|
|
175
|
-
move(x: number, y: number): Promise<void>;
|
|
176
|
-
drag(startX: number, startY: number, endX: number, endY: number, button?: "left" | "right" | "middle", duration?: number): Promise<void>;
|
|
177
|
-
scroll(x: number, y: number, deltaX: number, deltaY: number): Promise<void>;
|
|
185
|
+
click(x: number, y: number, button?: "left" | "right" | "middle", doubleClick?: boolean): Promise<DispatchMethod | void>;
|
|
186
|
+
move(x: number, y: number): Promise<DispatchMethod | void>;
|
|
187
|
+
drag(startX: number, startY: number, endX: number, endY: number, button?: "left" | "right" | "middle", duration?: number): Promise<DispatchMethod | void>;
|
|
188
|
+
scroll(x: number, y: number, deltaX: number, deltaY: number): Promise<DispatchMethod | void>;
|
|
178
189
|
getCursorPosition(): CursorPosition;
|
|
179
190
|
ocr(display?: number, region?: ScreenRegion): Promise<OcrResult>;
|
|
180
|
-
type(text: string, delay?: number): Promise<void>;
|
|
181
|
-
key(keys: string[]): Promise<void>;
|
|
191
|
+
type(text: string, delay?: number): Promise<DispatchMethod | void>;
|
|
192
|
+
key(keys: string[]): Promise<DispatchMethod | void>;
|
|
182
193
|
findElement(options: FindElementOptions): Promise<FindElementResponse>;
|
|
183
194
|
clickElement(elementId: string, app?: string): Promise<ClickResult>;
|
|
184
195
|
typeInElement(elementId: string, text: string, app?: string, clearFirst?: boolean): Promise<void>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Platform, WindowInfo, AppTarget } from "../base.js";
|
|
1
|
+
import type { Platform, WindowInfo, AppTarget, ScreenSize } from "../base.js";
|
|
2
2
|
import type { CachedElementDescriptor, MacOSPlatformOptions } from "./helpers.js";
|
|
3
3
|
import { saveFocus, restoreFocus } from "./focus.js";
|
|
4
4
|
import { screenshot, screenshotWindow, getScreenSize, isScreenLocked, ocr } from "./screen.js";
|
|
5
|
-
import { listApps, focusApp, getActiveBrowserContext, listWindows } from "./window.js";
|
|
5
|
+
import { listApps, focusApp, getActiveBrowserContext, listWindows, keepTargetAxAlive } from "./window.js";
|
|
6
6
|
import { getWindowState, findElement } from "./ax-tree.js";
|
|
7
7
|
import { click, move, drag, scroll, getCursorPosition, type as typeMethod, key } from "./input.js";
|
|
8
8
|
import { clickElement, typeInElement, setElementValue, findMenuBarExtra, clickMenuBarExtra } from "./element.js";
|
|
@@ -18,7 +18,13 @@ export declare class MacOSPlatform implements Platform {
|
|
|
18
18
|
cachedAt: number;
|
|
19
19
|
windows: WindowInfo[];
|
|
20
20
|
} | undefined;
|
|
21
|
-
|
|
21
|
+
windowCachePromise: Promise<WindowInfo[]> | undefined;
|
|
22
|
+
readonly screenSizeCacheTtlMs = 5000;
|
|
23
|
+
screenSizeCache: {
|
|
24
|
+
display: number;
|
|
25
|
+
cachedAt: number;
|
|
26
|
+
size: ScreenSize;
|
|
27
|
+
} | undefined;
|
|
22
28
|
activeTarget: AppTarget | undefined;
|
|
23
29
|
savedFocus: {
|
|
24
30
|
appName: string;
|
|
@@ -29,6 +35,8 @@ export declare class MacOSPlatform implements Platform {
|
|
|
29
35
|
evictOverflowCacheEntries(): void;
|
|
30
36
|
isCacheEntryExpired(descriptor: CachedElementDescriptor): boolean;
|
|
31
37
|
validateActiveTarget(): Promise<void>;
|
|
38
|
+
/** @internal Test hook to clear the screen-size cache. */
|
|
39
|
+
__resetScreenSizeCache(): void;
|
|
32
40
|
saveFocus: typeof saveFocus;
|
|
33
41
|
restoreFocus: typeof restoreFocus;
|
|
34
42
|
screenshot: typeof screenshot;
|
|
@@ -39,6 +47,7 @@ export declare class MacOSPlatform implements Platform {
|
|
|
39
47
|
listApps: typeof listApps;
|
|
40
48
|
focusApp: typeof focusApp;
|
|
41
49
|
getActiveBrowserContext: typeof getActiveBrowserContext;
|
|
50
|
+
keepTargetAxAlive: typeof keepTargetAxAlive;
|
|
42
51
|
listWindows: typeof listWindows;
|
|
43
52
|
getWindowState: typeof getWindowState;
|
|
44
53
|
findElement: typeof findElement;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TargetStaleError } from "../../util/errors.js";
|
|
2
2
|
import { saveFocus, restoreFocus } from "./focus.js";
|
|
3
3
|
import { screenshot, screenshotWindow, getScreenSize, isScreenLocked, ocr } from "./screen.js";
|
|
4
|
-
import { listApps, focusApp, getActiveBrowserContext, listWindows } from "./window.js";
|
|
4
|
+
import { listApps, focusApp, getActiveBrowserContext, listWindows, keepTargetAxAlive } from "./window.js";
|
|
5
5
|
import { getWindowState, findElement } from "./ax-tree.js";
|
|
6
6
|
import { click, move, drag, scroll, getCursorPosition, type as typeMethod, key } from "./input.js";
|
|
7
7
|
import { clickElement, typeInElement, setElementValue, findMenuBarExtra, clickMenuBarExtra } from "./element.js";
|
|
@@ -13,7 +13,9 @@ export class MacOSPlatform {
|
|
|
13
13
|
elementCacheMaxSize = 100;
|
|
14
14
|
windowCacheTtlMs = 300;
|
|
15
15
|
windowCache;
|
|
16
|
-
|
|
16
|
+
windowCachePromise;
|
|
17
|
+
screenSizeCacheTtlMs = 5000;
|
|
18
|
+
screenSizeCache;
|
|
17
19
|
activeTarget;
|
|
18
20
|
savedFocus;
|
|
19
21
|
constructor(options) {
|
|
@@ -58,6 +60,7 @@ export class MacOSPlatform {
|
|
|
58
60
|
if (this.activeTarget.windowId === "tray")
|
|
59
61
|
return;
|
|
60
62
|
this.windowCache = undefined;
|
|
63
|
+
this.windowCachePromise = undefined;
|
|
61
64
|
const windows = await this.listWindows(true);
|
|
62
65
|
const match = windows.find(w => w.id === this.activeTarget.windowId);
|
|
63
66
|
if (!match) {
|
|
@@ -67,6 +70,10 @@ export class MacOSPlatform {
|
|
|
67
70
|
throw new TargetStaleError(this.activeTarget.windowId);
|
|
68
71
|
}
|
|
69
72
|
}
|
|
73
|
+
/** @internal Test hook to clear the screen-size cache. */
|
|
74
|
+
__resetScreenSizeCache() {
|
|
75
|
+
this.screenSizeCache = undefined;
|
|
76
|
+
}
|
|
70
77
|
// ── Bound methods from domain modules ────────────────────────────────────
|
|
71
78
|
saveFocus = saveFocus;
|
|
72
79
|
restoreFocus = restoreFocus;
|
|
@@ -78,6 +85,7 @@ export class MacOSPlatform {
|
|
|
78
85
|
listApps = listApps;
|
|
79
86
|
focusApp = focusApp;
|
|
80
87
|
getActiveBrowserContext = getActiveBrowserContext;
|
|
88
|
+
keepTargetAxAlive = keepTargetAxAlive;
|
|
81
89
|
listWindows = listWindows;
|
|
82
90
|
getWindowState = getWindowState;
|
|
83
91
|
findElement = findElement;
|
|
@@ -14,6 +14,8 @@ export interface MenuBarExtraItem {
|
|
|
14
14
|
height: number;
|
|
15
15
|
/** Which process hosts this status item. "self" = app's own menu bar; "systemuiserver" = third-party tray hosted by SystemUIServer. */
|
|
16
16
|
host: "self" | "systemuiserver";
|
|
17
|
+
/** The hosting process pid — app's own pid for host:"self", SystemUIServer's pid for host:"systemuiserver". Enables per-process event posting. */
|
|
18
|
+
pid: number;
|
|
17
19
|
}
|
|
18
20
|
export interface MenuBarExtraSelector {
|
|
19
21
|
description?: string;
|