ucu-mcp 0.6.0 → 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 CHANGED
@@ -5,6 +5,44 @@ 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
+
8
46
  ## [0.6.0] - 2026-06-17
9
47
 
10
48
  对标 Codex 原生 computer use:输入注入走 per-process 路径,不移动全局光标、不抢前台。仅 Mac 端(与 Codex mac 版一致,Win 版只前台)。
@@ -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
- let windowTitle;
46
- if (effectiveWindowId) {
47
- try {
48
- const windows = await getPlatform().listWindows();
49
- const win = windows.find(w => w.id === effectiveWindowId);
50
- windowTitle = win?.title;
51
- }
52
- catch { /* best effort */ }
53
- }
54
- if (!windowTitle && target?.title) {
55
- windowTitle = target.title;
56
- }
57
- let url;
58
- const platform = getPlatform();
59
- if (platform.getActiveBrowserContext) {
60
- try {
61
- const appName = target?.appName;
62
- const ctx = await platform.getActiveBrowserContext(appName);
63
- url = ctx?.url;
64
- }
65
- catch { /* best effort */ }
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) {
@@ -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
- // screen sync, almost never fails
33
- let screen;
34
- try {
35
- screen = platform.getScreenSize(opts.display);
36
- }
37
- catch (e) {
38
- screen = { width: 0, height: 0, scaleFactor: 1, estimated: true };
39
- errors.push({ source: "screen", message: `getScreenSize failed: ${e.message}` });
40
- }
41
- // foreground window — listApps() isFrontmost → listWindows() filter by processName + isOnScreen
42
- let foregroundWindow;
43
- try {
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
- const wins = await platform.listWindows(true);
49
- foregroundWindow = wins.find((w) => w.processName === front.name && w.isOnScreen);
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
- ocr = {
62
- blocks: result.elements.slice(0, opts.ocrBlocks),
63
- fullText: result.fullText,
64
- status: "ok",
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
- ocr = { blocks: [], fullText: "", status: "failed" };
69
- errors.push({ source: "ocr", message: `ocr failed: ${e.message}` });
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
- ax = { elements: state.tree, status: "ok", windowId: effectiveWindowId };
90
+ return {
91
+ ok: true,
92
+ value: { elements: state.tree, status: "ok", windowId: effectiveWindowId },
93
+ };
85
94
  }
86
95
  catch (e) {
87
- ax = { status: "failed", windowId: effectiveWindowId };
88
- errors.push({ source: "ax", message: `getWindowState failed: ${e.message}` });
96
+ return {
97
+ ok: false,
98
+ value: { status: "failed", windowId: effectiveWindowId },
99
+ error: `getWindowState failed: ${e.message}`,
100
+ };
89
101
  }
90
- }
91
- else {
92
- ax = { status: "skipped" };
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 buf = await withSafety({
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 content = [{ type: "image", data: buf.toString("base64"), mimeType: `image/${params.format}` }];
124
- if (params.describe) {
125
- const opts = params.describeOptions ?? { axDepth: 3, ocrBlocks: 50, includeAx: true };
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: opts.includeAx ?? true,
130
- axDepth: opts.axDepth ?? 3,
131
- ocrBlocks: opts.ocrBlocks ?? 50,
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 };
@@ -1,4 +1,4 @@
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";
@@ -18,7 +18,13 @@ export declare class MacOSPlatform implements Platform {
18
18
  cachedAt: number;
19
19
  windows: WindowInfo[];
20
20
  } | undefined;
21
- windowCacheInFlight: boolean;
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;
@@ -13,7 +13,9 @@ export class MacOSPlatform {
13
13
  elementCacheMaxSize = 100;
14
14
  windowCacheTtlMs = 300;
15
15
  windowCache;
16
- windowCacheInFlight = false;
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;
@@ -2,6 +2,17 @@ import { execFileSync } from "node:child_process";
2
2
  import { ElementNotFoundError } from "../../util/errors.js";
3
3
  import { rethrowElementActionError } from "./helpers.js";
4
4
  import { jxaElementActionHelpers } from "../jxa-helpers.js";
5
+ const DEFAULT_AXPRESS_SPIN_MS = 50;
6
+ const MAX_AXPRESS_SPIN_MS = 80;
7
+ function getAxPressSpinMs() {
8
+ const env = process.env.UCU_AXPRESS_SPIN_MS;
9
+ if (!env)
10
+ return DEFAULT_AXPRESS_SPIN_MS;
11
+ const parsed = parseInt(env, 10);
12
+ if (isNaN(parsed))
13
+ return DEFAULT_AXPRESS_SPIN_MS;
14
+ return Math.max(0, Math.min(parsed, MAX_AXPRESS_SPIN_MS));
15
+ }
5
16
  /**
6
17
  * App-name hints for which AXPress is known to be silently swallowed
7
18
  * (Tauri custom window decorations, some Electron controls). When the target
@@ -104,7 +115,7 @@ export async function clickElement(elementId, app) {
104
115
  var c1 = boundsCenter(elem);
105
116
  _result = {success: true, needCoordinateClick: true, cx: c1.x, cy: c1.y};
106
117
  } else {
107
- spinMs(80);
118
+ spinMs(${getAxPressSpinMs()});
108
119
  var sigAfter = stateSignature(elem);
109
120
  if (sigBefore !== '' && sigAfter !== sigBefore) {
110
121
  _result = {success: true, method: "axpress", verified: true};
@@ -523,7 +534,7 @@ export async function clickMenuBarExtra(app, selector = {}) {
523
534
  var c1 = boundsCenter(item);
524
535
  _result = {success: true, needCoordinateClick: true, cx: c1.x, cy: c1.y};
525
536
  } else {
526
- spinMs(80);
537
+ spinMs(${getAxPressSpinMs()});
527
538
  var sigAfter = stateSignature(item);
528
539
  if (sigBefore !== '' && sigAfter !== sigBefore) {
529
540
  _result = {success: true, method: "axpress", verified: true};
@@ -35,17 +35,22 @@ export function rethrowInputError(error, operation) {
35
35
  throw new InputSynthesisError(`${operation} failed: ${errorMessage(error)}`);
36
36
  }
37
37
  export function normalizeAppName(name) {
38
- return name.trim().toLowerCase();
38
+ // Drop all non-alphanumeric chars so app name variants match across
39
+ // formattings: "CC Switch" / "cc-switch" / "cc_switch" / "CC.Switch"
40
+ // all collapse to "ccswitch". This is what users (and LLMs) typically
41
+ // produce from casual memory, and it's the only way focus_app can
42
+ // resolve tray vs. windowed app identity reliably.
43
+ return name.trim().toLowerCase().replace(/[^a-z0-9]/g, "");
39
44
  }
40
45
  export function appNameMatches(processName, requestedApp) {
41
46
  const process = normalizeAppName(processName);
42
47
  const requested = normalizeAppName(requestedApp);
43
48
  if (!process || !requested)
44
49
  return false;
45
- return process === requested ||
46
- process.startsWith(`${requested} `) ||
47
- process.startsWith(`${requested}-`) ||
48
- process.includes(` ${requested} `);
50
+ // normalizeAppName strips all non-alphanumerics, so equality is the only
51
+ // meaningful comparison. (Prior startsWith/includes with spaces were dead code
52
+ // since normalized strings contain no spaces/hyphens.)
53
+ return process === requested || process.includes(requested) || requested.includes(process);
49
54
  }
50
55
  export function selectWindowForApp(windows, requestedApp) {
51
56
  const requested = normalizeAppName(requestedApp);
@@ -26,8 +26,14 @@ export async function screenshotWindow(windowId, options) {
26
26
  return this.screenshot(undefined, win.bounds, options);
27
27
  }
28
28
  export function getScreenSize(display) {
29
+ const idx = display ?? 0;
30
+ const now = Date.now();
31
+ if (this.screenSizeCache &&
32
+ this.screenSizeCache.display === idx &&
33
+ now - this.screenSizeCache.cachedAt <= this.screenSizeCacheTtlMs) {
34
+ return this.screenSizeCache.size;
35
+ }
29
36
  try {
30
- const idx = display ?? 0;
31
37
  const out = execFileSync("osascript", [
32
38
  "-l", "JavaScript",
33
39
  "-e",
@@ -40,7 +46,9 @@ export function getScreenSize(display) {
40
46
  var scaleFactor = screen.backingScaleFactor;
41
47
  JSON.stringify({width:Math.round(frame.size.width),height:Math.round(frame.size.height),scaleFactor:scaleFactor})`,
42
48
  ], { encoding: "utf-8", timeout: 5000 }).trim();
43
- return JSON.parse(out);
49
+ const size = JSON.parse(out);
50
+ this.screenSizeCache = { display: idx, cachedAt: Date.now(), size };
51
+ return size;
44
52
  }
45
53
  catch (error) {
46
54
  logger.warn("getScreenSize failed, using fallback", { error: errorMessage(error) });
@@ -178,34 +178,30 @@ export async function listWindows(_includeMinimized) {
178
178
  bounds: { ...window.bounds },
179
179
  }));
180
180
  }
181
- if (this.windowCacheInFlight) {
182
- return this.windowCache?.windows.map((w) => ({ ...w, bounds: { ...w.bounds } })) ?? [];
181
+ if (this.windowCachePromise) {
182
+ return this.windowCachePromise.then((windows) => windows.map((window) => ({ ...window, bounds: { ...window.bounds } })));
183
183
  }
184
- this.windowCacheInFlight = true;
185
- try {
186
- let windows;
187
- const nativeResult = listWindowsNative.call(this);
188
- if (nativeResult !== null) {
189
- windows = nativeResult;
184
+ this.windowCachePromise = (async () => {
185
+ try {
186
+ const nativeResult = listWindowsNative.call(this);
187
+ const windows = nativeResult !== null ? nativeResult : await listWindowsJxa.call(this);
188
+ this.windowCache = {
189
+ cachedAt: Date.now(),
190
+ windows: windows.map((window) => ({
191
+ ...window,
192
+ bounds: { ...window.bounds },
193
+ })),
194
+ };
195
+ return windows;
190
196
  }
191
- else {
192
- windows = await listWindowsJxa.call(this);
197
+ catch {
198
+ return [];
193
199
  }
194
- this.windowCache = {
195
- cachedAt: Date.now(),
196
- windows: windows.map((window) => ({
197
- ...window,
198
- bounds: { ...window.bounds },
199
- })),
200
- };
201
- return windows;
202
- }
203
- catch {
204
- return [];
205
- }
206
- finally {
207
- this.windowCacheInFlight = false;
208
- }
200
+ finally {
201
+ this.windowCachePromise = undefined;
202
+ }
203
+ })();
204
+ return this.windowCachePromise.then((windows) => windows.map((window) => ({ ...window, bounds: { ...window.bounds } })));
209
205
  }
210
206
  function listWindowsNative() {
211
207
  try {
@@ -8,6 +8,8 @@ export interface PermissionDetail {
8
8
  granted: boolean;
9
9
  instructions: string;
10
10
  }
11
+ /** @internal Test hook to reset the permission cache. */
12
+ export declare function __resetPermissionCache(): void;
11
13
  /**
12
14
  * Get the name of the terminal app that the user needs to authorize.
13
15
  */
@@ -1,6 +1,30 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
+ // Short-lived cache for individual permission checks. Permissions rarely change
5
+ // within a single tool sequence, but we keep TTL short so that a user granting
6
+ // permission mid-session is picked up quickly.
7
+ const PERMISSION_CACHE_TTL_MS = { granted: 5000, denied: 1000 };
8
+ const permissionCache = {};
9
+ /** @internal Test hook to reset the permission cache. */
10
+ export function __resetPermissionCache() {
11
+ permissionCache.accessibility = undefined;
12
+ permissionCache.screenRecording = undefined;
13
+ }
14
+ function getCachedPermission(type) {
15
+ const entry = permissionCache[type];
16
+ if (!entry)
17
+ return undefined;
18
+ const ttl = entry.granted ? PERMISSION_CACHE_TTL_MS.granted : PERMISSION_CACHE_TTL_MS.denied;
19
+ if (Date.now() - entry.cachedAt > ttl) {
20
+ permissionCache[type] = undefined;
21
+ return undefined;
22
+ }
23
+ return entry.granted;
24
+ }
25
+ function setCachedPermission(type, granted) {
26
+ permissionCache[type] = { granted, cachedAt: Date.now() };
27
+ }
4
28
  /**
5
29
  * Get the name of the terminal app that the user needs to authorize.
6
30
  */
@@ -133,7 +157,11 @@ export async function checkPermission(type) {
133
157
  }
134
158
  const appName = getTerminalAppName();
135
159
  if (type === "accessibility") {
136
- const granted = await checkAccessibility();
160
+ const cached = getCachedPermission("accessibility");
161
+ const granted = cached ?? await checkAccessibility();
162
+ if (cached === undefined) {
163
+ setCachedPermission("accessibility", granted);
164
+ }
137
165
  if (!granted) {
138
166
  // Trigger the macOS system prompt for Accessibility
139
167
  await requestAccessibilityWithPrompt();
@@ -146,7 +174,11 @@ export async function checkPermission(type) {
146
174
  }
147
175
  return { granted: true };
148
176
  }
149
- const granted = await checkScreenRecording();
177
+ const cached = getCachedPermission("screenRecording");
178
+ const granted = cached ?? await checkScreenRecording();
179
+ if (cached === undefined) {
180
+ setCachedPermission("screenRecording", granted);
181
+ }
150
182
  if (!granted) {
151
183
  // Open System Settings for Screen Recording
152
184
  await openPermissionSettings("screenRecording");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucu-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "MCP server for Universal Computer Use — desktop automation for AI agents via Model Context Protocol",
5
5
  "type": "module",
6
6
  "bin": {