ucu-mcp 0.3.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/src/mcp/tools.js +9 -2
- package/dist/src/platform/macos.d.ts +1 -0
- package/dist/src/platform/macos.js +43 -20
- package/dist/src/safety/guard.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ 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.4.0] - 2026-06-11
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **JXA return values fixed (P0)**: Three JXA scripts (`click_element`, `type_in_element`, `set_value`) called `JSON.stringify({success:…})` as a bare statement — the result was computed but discarded, so the osascript output was empty and `JSON.parse(out)` would fail or return undefined. Now each script assigns to `_result` and calls `JSON.stringify(_result)` once at the end.
|
|
13
|
+
- **Rate-limit timestamp ordering (P0)**: `lastActionTime` was updated before the user-activity pause check. If the pause blocked the action, the rate-limit window was consumed anyway, causing subsequent retries to also be rate-limited. Now `lastActionTime` is set only after both checks pass.
|
|
14
|
+
- **Window cache concurrency guard (P0)**: `listWindows` could be called concurrently (e.g. `validateActiveTarget` + `list_windows` tool). Two overlapping calls could write `windowCache` at the same time, producing torn reads. Added `windowCacheInFlight` flag — concurrent callers return stale data instead of racing.
|
|
15
|
+
- **`validateActiveTarget` checks pid (P1)**: Previously only checked windowId, missing the case where an app restarts and the OS reuses the same window ID. Now also checks pid match.
|
|
16
|
+
- **`focusApp` failure clears stale target (P1)**: When `focusApp` threw `WindowNotFoundError`, the old `activeTarget` was retained. Subsequent AX tools would try to use the dead target. Now `activeTarget` is cleared on failure.
|
|
17
|
+
- **`get_screen_size` goes through `withSafety` (P1)**: Was the only tool that bypassed the safety/permission/retry pipeline. Now wrapped in `withSafety` for consistent error handling and rate limiting.
|
|
18
|
+
|
|
19
|
+
### Tests
|
|
20
|
+
|
|
21
|
+
- 225 unit tests pass (13 test files).
|
|
22
|
+
- MCP stdio smoke: `doctor`, `list_windows`, `list_apps`, `get_screen_size` all return valid responses.
|
|
23
|
+
- All 3 JXA scripts now produce valid JSON output (verified via stdio pipe test).
|
|
24
|
+
|
|
25
|
+
|
|
8
26
|
## [0.3.8] - 2026-06-08
|
|
9
27
|
|
|
10
28
|
### Fixed
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -208,7 +208,13 @@ async function withSafety(sa) {
|
|
|
208
208
|
}
|
|
209
209
|
if (sa.dryRun)
|
|
210
210
|
return `[DRY-RUN] ${await sa.dryRun()}`;
|
|
211
|
-
|
|
211
|
+
// Focus management is disabled by default: CGEvent input injection works
|
|
212
|
+
// at the HID level without requiring the target app to be frontmost, and
|
|
213
|
+
// AX operations target processes by name/PID via System Events. The user
|
|
214
|
+
// should remain in their current app while the agent works in the background.
|
|
215
|
+
// Re-enable saveFocus/restoreFocus only if a specific AX operation truly
|
|
216
|
+
// requires the target app to be frontmost (rare).
|
|
217
|
+
const shouldManageFocus = false;
|
|
212
218
|
if (shouldManageFocus)
|
|
213
219
|
await platform.saveFocus?.();
|
|
214
220
|
const start = Date.now();
|
|
@@ -634,7 +640,8 @@ export function registerTools(server) {
|
|
|
634
640
|
registerTool("get_screen_size", "Get screen dimensions and scale factor", {
|
|
635
641
|
display: z.number().optional().describe("Display index"),
|
|
636
642
|
}, async (params) => {
|
|
637
|
-
|
|
643
|
+
const result = await withSafety({ action: "get_screen_size", params: {}, execute: () => Promise.resolve(getPlatform().getScreenSize(params.display)) });
|
|
644
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
638
645
|
});
|
|
639
646
|
registry.register("get_screen_size");
|
|
640
647
|
registerTool("ocr", "Perform OCR on screen region", {
|
|
@@ -15,6 +15,7 @@ export declare class MacOSPlatform implements Platform {
|
|
|
15
15
|
private readonly elementCacheMaxSize;
|
|
16
16
|
private readonly windowCacheTtlMs;
|
|
17
17
|
private windowCache;
|
|
18
|
+
private windowCacheInFlight;
|
|
18
19
|
private activeTarget;
|
|
19
20
|
private savedFocus;
|
|
20
21
|
constructor(options?: MacOSPlatformOptions);
|
|
@@ -69,6 +69,7 @@ export class MacOSPlatform {
|
|
|
69
69
|
elementCacheMaxSize = 100;
|
|
70
70
|
windowCacheTtlMs = 300;
|
|
71
71
|
windowCache;
|
|
72
|
+
windowCacheInFlight = false;
|
|
72
73
|
activeTarget;
|
|
73
74
|
savedFocus;
|
|
74
75
|
constructor(options) {
|
|
@@ -114,8 +115,12 @@ export class MacOSPlatform {
|
|
|
114
115
|
return;
|
|
115
116
|
this.windowCache = undefined; // Bypass cache — stale detection must use fresh data
|
|
116
117
|
const windows = await this.listWindows(true);
|
|
117
|
-
const
|
|
118
|
-
if (!
|
|
118
|
+
const match = windows.find(w => w.id === this.activeTarget.windowId);
|
|
119
|
+
if (!match) {
|
|
120
|
+
throw new TargetStaleError(this.activeTarget.windowId);
|
|
121
|
+
}
|
|
122
|
+
// Also invalidate if pid changed (app restarted)
|
|
123
|
+
if (match.pid !== this.activeTarget.pid) {
|
|
119
124
|
throw new TargetStaleError(this.activeTarget.windowId);
|
|
120
125
|
}
|
|
121
126
|
}
|
|
@@ -239,13 +244,14 @@ export class MacOSPlatform {
|
|
|
239
244
|
async focusApp(app) {
|
|
240
245
|
const escapedApp = app.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
241
246
|
this.windowCache = undefined;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
// NOTE: We intentionally do NOT call AppleScript "activate" here.
|
|
248
|
+
// focus_app sets the internal target context so subsequent operations
|
|
249
|
+
// know which app/window to target. It does NOT bring the app to the
|
|
250
|
+
// foreground — the user should remain in their current app (terminal,
|
|
251
|
+
// Codex, etc.) while the agent works in the background.
|
|
252
|
+
// CGEvent input injection works at the HID level and doesn't require
|
|
253
|
+
// the target app to be frontmost. AX operations target processes by
|
|
254
|
+
// name/PID via System Events, also without needing frontmost status.
|
|
249
255
|
let target;
|
|
250
256
|
const deadline = Date.now() + 3000;
|
|
251
257
|
do {
|
|
@@ -262,6 +268,7 @@ export class MacOSPlatform {
|
|
|
262
268
|
// app name so the tool handler can surface a remediation hint. The
|
|
263
269
|
// bare WindowNotFoundError("CC Switch") was indistinguishable from
|
|
264
270
|
// "the app is not running", which led models to retry forever.
|
|
271
|
+
this.activeTarget = undefined; // Clear stale target on focus failure
|
|
265
272
|
const err = new WindowNotFoundError(app);
|
|
266
273
|
err.hint =
|
|
267
274
|
"list_windows returned no match for this app. If the app is running, " +
|
|
@@ -343,6 +350,12 @@ export class MacOSPlatform {
|
|
|
343
350
|
bounds: { ...window.bounds },
|
|
344
351
|
}));
|
|
345
352
|
}
|
|
353
|
+
// P0 #3: Prevent concurrent cache refreshes
|
|
354
|
+
if (this.windowCacheInFlight) {
|
|
355
|
+
// Another call is already refreshing; return stale or empty
|
|
356
|
+
return this.windowCache?.windows.map(w => ({ ...w, bounds: { ...w.bounds } })) ?? [];
|
|
357
|
+
}
|
|
358
|
+
this.windowCacheInFlight = true;
|
|
346
359
|
try {
|
|
347
360
|
// Try native Swift helper first (CGWindowListCopyWindowInfo, ~1ms).
|
|
348
361
|
// Falls back to JXA System Events if the helper is not available.
|
|
@@ -370,6 +383,9 @@ export class MacOSPlatform {
|
|
|
370
383
|
// Fallback: return empty list if both methods fail
|
|
371
384
|
return [];
|
|
372
385
|
}
|
|
386
|
+
finally {
|
|
387
|
+
this.windowCacheInFlight = false;
|
|
388
|
+
}
|
|
373
389
|
}
|
|
374
390
|
listWindowsNative() {
|
|
375
391
|
try {
|
|
@@ -1177,6 +1193,7 @@ export class MacOSPlatform {
|
|
|
1177
1193
|
const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
|
|
1178
1194
|
const jxaScript = `
|
|
1179
1195
|
var se = Application('System Events');
|
|
1196
|
+
var _result = null;
|
|
1180
1197
|
function childElements(elem) {
|
|
1181
1198
|
try { return elem.uiElements(); } catch(e1) {
|
|
1182
1199
|
try { return elem.elements(); } catch(e2) { return []; }
|
|
@@ -1354,11 +1371,11 @@ export class MacOSPlatform {
|
|
|
1354
1371
|
}
|
|
1355
1372
|
|
|
1356
1373
|
if (!elem) {
|
|
1357
|
-
|
|
1374
|
+
_result = {success: false, error: "Element not found: " + elemPath};
|
|
1358
1375
|
} else {
|
|
1359
1376
|
try {
|
|
1360
1377
|
elem.actions.AXPress.perform();
|
|
1361
|
-
|
|
1378
|
+
_result = {success: true};
|
|
1362
1379
|
} catch(e) {
|
|
1363
1380
|
try {
|
|
1364
1381
|
var pos = elem.position();
|
|
@@ -1372,12 +1389,13 @@ export class MacOSPlatform {
|
|
|
1372
1389
|
$.CGEventPost($.kCGHIDEventTap, down);
|
|
1373
1390
|
var up = $.CGEventCreateMouseEvent(src, $.kCGEventLeftMouseUp, pt, $.kCGMouseButtonLeft);
|
|
1374
1391
|
$.CGEventPost($.kCGHIDEventTap, up);
|
|
1375
|
-
|
|
1392
|
+
_result = {success: true};
|
|
1376
1393
|
} catch(e2) {
|
|
1377
|
-
|
|
1394
|
+
_result = {success: false, error: "Could not click element: " + String(e2.message || e2)};
|
|
1378
1395
|
}
|
|
1379
1396
|
}
|
|
1380
1397
|
}
|
|
1398
|
+
JSON.stringify(_result);
|
|
1381
1399
|
`;
|
|
1382
1400
|
try {
|
|
1383
1401
|
const out = execFileSync("osascript", [
|
|
@@ -1408,6 +1426,7 @@ export class MacOSPlatform {
|
|
|
1408
1426
|
const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
|
|
1409
1427
|
const jxaScript = `
|
|
1410
1428
|
var se = Application('System Events');
|
|
1429
|
+
var _result = null;
|
|
1411
1430
|
function childElements(elem) {
|
|
1412
1431
|
try { return elem.uiElements(); } catch(e1) {
|
|
1413
1432
|
try { return elem.elements(); } catch(e2) { return []; }
|
|
@@ -1587,7 +1606,7 @@ export class MacOSPlatform {
|
|
|
1587
1606
|
}
|
|
1588
1607
|
|
|
1589
1608
|
if (!elem) {
|
|
1590
|
-
|
|
1609
|
+
_result = {success: false, error: "Element not found: " + elemPath};
|
|
1591
1610
|
} else {
|
|
1592
1611
|
try {
|
|
1593
1612
|
elem.focused = true;
|
|
@@ -1614,13 +1633,15 @@ export class MacOSPlatform {
|
|
|
1614
1633
|
if (!didSet) {
|
|
1615
1634
|
try {
|
|
1616
1635
|
se.keystroke(textToType);
|
|
1636
|
+
_result = {success: true};
|
|
1617
1637
|
} catch(e) {
|
|
1618
|
-
|
|
1638
|
+
_result = {success: false, error: "Could not type into element: " + String(e.message || e)};
|
|
1619
1639
|
}
|
|
1640
|
+
} else {
|
|
1641
|
+
_result = {success: true};
|
|
1620
1642
|
}
|
|
1621
|
-
|
|
1622
|
-
JSON.stringify({success: true});
|
|
1623
1643
|
}
|
|
1644
|
+
JSON.stringify(_result);
|
|
1624
1645
|
`;
|
|
1625
1646
|
try {
|
|
1626
1647
|
const out = execFileSync("osascript", [
|
|
@@ -1669,6 +1690,7 @@ export class MacOSPlatform {
|
|
|
1669
1690
|
const cachedJson = JSON.stringify(this.elementCache.get(elementId) ?? null);
|
|
1670
1691
|
const jxaScript = `
|
|
1671
1692
|
var se = Application('System Events');
|
|
1693
|
+
var _result = null;
|
|
1672
1694
|
function childElements(elem) {
|
|
1673
1695
|
try { return elem.uiElements(); } catch(e1) {
|
|
1674
1696
|
try { return elem.elements(); } catch(e2) { return []; }
|
|
@@ -1842,15 +1864,16 @@ export class MacOSPlatform {
|
|
|
1842
1864
|
}
|
|
1843
1865
|
|
|
1844
1866
|
if (!elem) {
|
|
1845
|
-
|
|
1867
|
+
_result = {success: false, error: "Element not found: " + elemPath};
|
|
1846
1868
|
} else {
|
|
1847
1869
|
try {
|
|
1848
1870
|
elem.value = valueToSet;
|
|
1849
|
-
|
|
1871
|
+
_result = {success: true};
|
|
1850
1872
|
} catch(e) {
|
|
1851
|
-
|
|
1873
|
+
_result = {success: false, error: "Could not set AX value: " + String(e.message || e)};
|
|
1852
1874
|
}
|
|
1853
1875
|
}
|
|
1876
|
+
JSON.stringify(_result);
|
|
1854
1877
|
`;
|
|
1855
1878
|
try {
|
|
1856
1879
|
const out = execFileSync("osascript", [
|
package/dist/src/safety/guard.js
CHANGED
|
@@ -233,7 +233,6 @@ export class SafetyGuard {
|
|
|
233
233
|
reason: `Rate-limited: ${elapsed}ms since last action (min ${this.rateLimitMs}ms)`,
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
|
-
this.lastActionTime = now;
|
|
237
236
|
// 6. User activity pause (skipped for observe-class actions) -----------------
|
|
238
237
|
if (!options.skipUserActivityPause && this.isUserActivityPauseActive()) {
|
|
239
238
|
return {
|
|
@@ -241,6 +240,7 @@ export class SafetyGuard {
|
|
|
241
240
|
reason: `User activity detected — pausing automation for ${this.userActivityPauseMs}ms`,
|
|
242
241
|
};
|
|
243
242
|
}
|
|
243
|
+
this.lastActionTime = now;
|
|
244
244
|
return { allowed: true };
|
|
245
245
|
}
|
|
246
246
|
// -----------------------------------------------------------------------
|