snappy-ax skill
at-point app x yreadfind appreadfocused app?readmacos-use tool json-args?write-reversiblepermissionsreadpress appwrite-reversibleraw json-envelopereadset-value appwrite-reversibletext appreadtree appread$ npx snappy-skills install snappy-ax
$ npx snappy-skills install --all
$ npx snappy-skills update
Four shipping codebases left their AX scars in comments; this skill is the extraction.
Operating rules that matter most: **address elements by role + AX-prefixed attribute
names, never coordinates; activate the app and wait ~200 ms before any click**;
**AXPress is a separate path from a CGEvent click and is sometimes the only one that
works; AX coordinates are top-left global, AppKit is bottom-left; default AXorcist
search does not descend into tables/rows/cells** (use --scan-all); a wedged app blocks
forever without a messaging timeout; Safari is not an AX target — use JavaScript.
typescriptimport { axPermissions, axDumpTree, axFind, axFocused, axAtPoint, axPerformPress, axSetValue, axExtractText, axRaw, macosUse, locator } from "../snappy-ax/api.ts";
Or CLI:
bashnpx tsx ~/.claude/skills/snappy-ax/api.ts permissions
npx tsx ~/.claude/skills/snappy-ax/api.ts tree Finder --depth 2 --role AXButton
npx tsx ~/.claude/skills/snappy-ax/api.ts find Finder --role AXButton --title Back --contains
npx tsx ~/.claude/skills/snappy-ax/api.ts focused
npx tsx ~/.claude/skills/snappy-ax/api.ts at-point Finder 120 40
npx tsx ~/.claude/skills/snappy-ax/api.ts press Finder --role AXButton --title Back
npx tsx ~/.claude/skills/snappy-ax/api.ts set-value Finder --role AXTextField --value "report"
npx tsx ~/.claude/skills/snappy-ax/api.ts text Finder --depth 3
npx tsx ~/.claude/skills/snappy-ax/api.ts raw '{"command":"ping"}'
npx tsx ~/.claude/skills/snappy-ax/api.ts macos-use macos-use_open_application_and_traverse '{"identifier":"Finder"}'
| Function | Purpose |
|---|---|
axPermissions() |
{accessibility: bool} for THIS process via axorc permissions. The host (Terminal/iTerm/app) is what TCC grants. |
axDumpTree(app, {depth=3, role?, scanAll?}) |
Accessibility tree as JSON. Container-pruned by default; scanAll reaches tables (slow). |
axFind(app, {role?, title?, identifier?, value?, contains?, depth=10, attributes?}) |
One element. title is case-sensitive unless contains. |
axFocused(app?) |
AXFocusedUIElement; "none" is a valid success state. |
axAtPoint(app, x, y) |
Hit-test at top-left-origin global points; pid-verified. Does not reach Catalyst rows. |
axPerformPress(app, loc) |
AXPress on the first element matching locator(...). No retry on cannotComplete — by design. |
axSetValue(app, loc, value) |
Write AXValue (the "fastest" typing road). Not a native action. |
axExtractText(app, loc?, {depth=3}) |
Text under an element; default locator = first AXWindow (the command refuses to run without one). |
axRaw(envelope) |
The full JSON protocol (ping, query, getAttributes, describeElement, getElementAtPoint, getFocusedElement, performAction, batch, observe, collectAll, setFocusedValue, extractText). |
macosUse(tool, args) |
One macos-use_* tool call over stdio to mcp-server-macos-use (BSL 1.1 — non-commercial until 2028-04-09). Pass bundle ids for CoreServices apps (com.apple.finder); the SDK only scans /Applications by name. |
locator({role,title,identifier,value,contains}) |
Builds {match_all, criteria:[{attribute:"AXRole"…}]} with AX-prefixed names — the #1 query mistake avoided. |
at-point reads.press (AXPress) for buttons; set-value for fields; AXSelected for rows.kAXErrorCannotComplete — it may have fired.scanAll and expect slowness.axorc needs Accessibility granted to the host process; a re-signed binary is a new TCC identity.macos-use is BSL 1.1: fine to use locally; not for a commercial product without a license.snappy-voice-control · snappy-agent-host · snappy-cleanshot (ax.py ctypes fallback) · snappy-desktop (vision) · desktop-automation (AppleScript) · macos-patterns · swift-concurrency.
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-ax Index]|root: ~/.claude/skills/snappy-ax|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}|references:{extract-ax-layer.md}
<!-- SKILL-INDEX-END -->
snappy-voice-control<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
at-point |
app, x, y |
read |
npx tsx ~/.claude/skills/snappy-ax/api.ts at-point <app> <x> <y> |
find |
app |
read |
npx tsx ~/.claude/skills/snappy-ax/api.ts find <app> |
focused |
app? |
read |
npx tsx ~/.claude/skills/snappy-ax/api.ts focused |
macos-use |
tool, json-args? |
write-reversible |
npx tsx ~/.claude/skills/snappy-ax/api.ts macos-use <tool> |
permissions |
— | read |
npx tsx ~/.claude/skills/snappy-ax/api.ts permissions |
press |
app |
write-reversible |
npx tsx ~/.claude/skills/snappy-ax/api.ts press <app> |
raw |
json-envelope |
read |
npx tsx ~/.claude/skills/snappy-ax/api.ts raw <json-envelope> |
set-value |
app |
write-reversible |
npx tsx ~/.claude/skills/snappy-ax/api.ts set-value <app> |
text |
app |
read |
npx tsx ~/.claude/skills/snappy-ax/api.ts text <app> |
tree |
app |
read |
npx tsx ~/.claude/skills/snappy-ax/api.ts tree <app> |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
name: snappy-ax
role: Drive Mac apps through the Accessibility tree (AXUIElement) — the extracted, cited practice from AXorcist, MacosUseSDK/mcp-server-macos-use, and AgentAccess, plus a real axorc-backed api.ts
loaded-by: PreToolUse hook (auto-injected when "snappy-ax" is mentioned)
---
# snappy-ax — Agent Loader
Four shipping codebases left their AX scars in comments; this skill is the extraction.
Operating rules that matter most: **address elements by role + AX-prefixed attribute
names, never coordinates**; **activate the app and wait ~200 ms before any click**;
**`AXPress` is a separate path from a CGEvent click and is sometimes the only one that
works**; AX coordinates are top-left global, AppKit is bottom-left; **default AXorcist
search does not descend into tables/rows/cells** (use `--scan-all`); a wedged app blocks
forever without a messaging timeout; Safari is not an AX target — use JavaScript.
## API module
```typescript
import { axPermissions, axDumpTree, axFind, axFocused, axAtPoint, axPerformPress, axSetValue, axExtractText, axRaw, macosUse, locator } from "../snappy-ax/api.ts";
```
Or CLI:
```bash
npx tsx ~/.claude/skills/snappy-ax/api.ts permissions
npx tsx ~/.claude/skills/snappy-ax/api.ts tree Finder --depth 2 --role AXButton
npx tsx ~/.claude/skills/snappy-ax/api.ts find Finder --role AXButton --title Back --contains
npx tsx ~/.claude/skills/snappy-ax/api.ts focused
npx tsx ~/.claude/skills/snappy-ax/api.ts at-point Finder 120 40
npx tsx ~/.claude/skills/snappy-ax/api.ts press Finder --role AXButton --title Back
npx tsx ~/.claude/skills/snappy-ax/api.ts set-value Finder --role AXTextField --value "report"
npx tsx ~/.claude/skills/snappy-ax/api.ts text Finder --depth 3
npx tsx ~/.claude/skills/snappy-ax/api.ts raw '{"command":"ping"}'
npx tsx ~/.claude/skills/snappy-ax/api.ts macos-use macos-use_open_application_and_traverse '{"identifier":"Finder"}'
```
## API functions
| Function | Purpose |
|----------|---------|
| `axPermissions()` | `{accessibility: bool}` for THIS process via `axorc permissions`. The host (Terminal/iTerm/app) is what TCC grants. |
| `axDumpTree(app, {depth=3, role?, scanAll?})` | Accessibility tree as JSON. Container-pruned by default; `scanAll` reaches tables (slow). |
| `axFind(app, {role?, title?, identifier?, value?, contains?, depth=10, attributes?})` | One element. `title` is **case-sensitive** unless `contains`. |
| `axFocused(app?)` | `AXFocusedUIElement`; "none" is a valid success state. |
| `axAtPoint(app, x, y)` | Hit-test at top-left-origin global points; pid-verified. Does not reach Catalyst rows. |
| `axPerformPress(app, loc)` | `AXPress` on the first element matching `locator(...)`. No retry on `cannotComplete` — by design. |
| `axSetValue(app, loc, value)` | Write `AXValue` (the "fastest" typing road). Not a native action. |
| `axExtractText(app, loc?, {depth=3})` | Text under an element; default locator = first `AXWindow` (the command refuses to run without one). |
| `axRaw(envelope)` | The full JSON protocol (`ping, query, getAttributes, describeElement, getElementAtPoint, getFocusedElement, performAction, batch, observe, collectAll, setFocusedValue, extractText`). |
| `macosUse(tool, args)` | One `macos-use_*` tool call over stdio to `mcp-server-macos-use` (BSL 1.1 — non-commercial until 2028-04-09). Pass **bundle ids** for CoreServices apps (`com.apple.finder`); the SDK only scans /Applications by name. |
| `locator({role,title,identifier,value,contains})` | Builds `{match_all, criteria:[{attribute:"AXRole"…}]}` with AX-prefixed names — the #1 query mistake avoided. |
## Rules
- Elements by role + title/value/identifier + app. Coordinates only for `at-point` reads.
- Before a CGEvent click: activate the app, wait 200 ms. Prefer `press` (AXPress) for buttons; `set-value` for fields; `AXSelected` for rows.
- Never retry an action after `kAXErrorCannotComplete` — it may have fired.
- Tables/rows/cells are invisible to default search; pass `scanAll` and expect slowness.
- Safari/Chrome page content: not AX — JavaScript/AppleScript.
- `axorc` needs Accessibility granted to the **host** process; a re-signed binary is a new TCC identity.
- `macos-use` is BSL 1.1: fine to use locally; not for a commercial product without a license.
- Every api.ts call has a hard timeout (wedged apps hang AX calls indefinitely).
## Uses
`snappy-voice-control` · `snappy-agent-host` · `snappy-cleanshot` (`ax.py` ctypes fallback) · `snappy-desktop` (vision) · `desktop-automation` (AppleScript) · `macos-patterns` · `swift-concurrency`.
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-ax Index]|root: ~/.claude/skills/snappy-ax|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}|references:{extract-ax-layer.md}
<!-- SKILL-INDEX-END -->
## Used by
- `snappy-voice-control`
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `at-point` | `app`, `x`, `y` | `read` | `npx tsx ~/.claude/skills/snappy-ax/api.ts at-point <app> <x> <y>` |
| `find` | `app` | `read` | `npx tsx ~/.claude/skills/snappy-ax/api.ts find <app>` |
| `focused` | `app?` | `read` | `npx tsx ~/.claude/skills/snappy-ax/api.ts focused` |
| `macos-use` | `tool`, `json-args?` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-ax/api.ts macos-use <tool>` |
| `permissions` | — | `read` | `npx tsx ~/.claude/skills/snappy-ax/api.ts permissions` |
| `press` | `app` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-ax/api.ts press <app>` |
| `raw` | `json-envelope` | `read` | `npx tsx ~/.claude/skills/snappy-ax/api.ts raw <json-envelope>` |
| `set-value` | `app` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-ax/api.ts set-value <app>` |
| `text` | `app` | `read` | `npx tsx ~/.claude/skills/snappy-ax/api.ts text <app>` |
| `tree` | `app` | `read` | `npx tsx ~/.claude/skills/snappy-ax/api.ts tree <app>` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
Four codebases solved this and left the scars in comments. Everything below is cited
PREFIX/path:line; full reports in references/.
| Prefix | Repo | What it is | License |
|---|---|---|---|
AX/ |
~/projects/cloned-repos/AXorcist (steipete, 31K lines) |
Swift library + axorc CLI + JSON protocol. Chainable queries, observers, timeouts. |
MIT |
SDK/ |
~/projects/cloned-repos/MacosUseSDK (mediar-ai) |
BFS traversal → flat element list; CGEvent input; AX writes for Catalyst | MIT |
MCP/ |
~/projects/cloned-repos/mcp-server-macos-use (mediar-ai) |
MCP stdio server over the SDK: 9 macos-use_* tools, text+diff output |
BSL 1.1 (non-commercial until 2028-04-09; package.json wrongly says MIT) |
AA/ |
~/projects/cloned-repos/AgentAccess (AgentiLoop) |
Policy layer Agent! puts over AXorcist: no coordinates, fuzzy rescue, launch-vs-lookup | no LICENSE file |
Binaries built on the Mac mini (Xcode 26.4.1 / Swift 6.3.1) and verified on this MacBook:
~/projects/cloned-repos/bin/axorc (0.1.9), ~/projects/cloned-repos/bin/mcp-server-macos-use.
Zero-dependency fallback: snappy-cleanshot/ax.py (ctypes → AXUIElement, no PyObjC).
Need to drive a Mac app?
├─ Safari/Chrome web content → NOT AX. JavaScript/AppleScript (AgentAccess vetoes Safari) AA/…/AccessibilityService.swift:13-31
├─ Read what's on screen → api.ts tree / find (axorc; container pruning ON, add --scan-all for tables)
├─ Click/press something
│ ├─ normal AppKit/SwiftUI button → find + AXPress via api.ts press AX/…/Element+Actions.swift:31-48
│ ├─ Catalyst right pane / sandboxed app (clicks dropped) → AXPress or set AXValue MCP/…/main.swift:1442,1459
│ ├─ table/list row (no AXPress) → set AXSelected SDK/…/AccessibilityActions.swift:171-178
│ └─ need a real pointer event → CGEvent click at frame center, AFTER activating the app + 200 ms
├─ Type text → set AXValue first (fastest), else focus + keystrokes AA/…/Interaction.swift:384-421
└─ Watch for changes → AXObserver per PID (no system-wide observer exists) AX/README.md:514-515
Rule 1 (AgentAccess, paid for): address elements by role + title + value + bundle id, never coordinates; coordinates "were unreliable (window positions shift, retina scaling, multi-display setups)" (AA/…/AccessibilityService+Actions.swift:68-80).
AXUIElement is an opaque CF ref; equality is CFEqual, hashing CFHash — visited-sets depend on this (AX/…/Core/Element.swift:108-115). Stale handle → kAXErrorInvalidUIElement (AX/…/AccessibilityError.swift:85).AXUIElementCreateApplication(pid); system-wide AXUIElementCreateSystemWide() gives AXFocusedApplication. The system-wide element cannot receive notifications — observers are per-PID (AX/README.md:514-515; AX/…/AXObserverCenter.swift:171-186).AXUIElementCopyActionNames, not an attribute (AX/…/Element+Properties.swift:112-121). Parameterized attributes use unsuffixed names (AXStringForRange, AXBoundsForRange, AXCellForColumnAndRow) (AX/CHANGELOG.md:40). AXValue raw type 4 is both Boolean and CFRange — a raw-value switch corrupts AXSelectedTextRange (AX/…/ValueUnwrapper.swift:71-72). No AXFrame; compute from AXPosition+AXSize (AX/…/AccessibilityConstants.swift:127).AXChildren. AXorcist reads AXChildren then 14 alternatives (AXVisibleChildren, AXWebAreaChildren, AXRows, AXColumns, AXTabs, AXContents, …) and dedupes (AX/…/Element+Hierarchy.swift:112-120,184-206). For the app root it always injects AXWindows and AXFocusedUIElement: "Some Electron apps only expose the front-most window via kAXChildrenAttribute… searches remain[ed] inside the first window (depth ≈ 37)"; the focused element is "often a remote renderer proxy… crucial for Electron/Chromium" (:43-54). So the focused element appears at depth 1.AX/…/AXorcist+FocusedElementHandler.swift:34-43). AXorcist refuses to type unless focus is established or settable — "preventing keyboard events from reaching an unrelated focused app" (AX/CHANGELOG.md:25). NSRunningApplication.activate "sometimes reports false but works" (AX/…/Element+WindowOperations.swift:227).AXPosition and CGEvent points are global top-left; AppKit (NSScreen, NSWindow, NSEvent.mouseLocation) is bottom-left (SDK/…/DrawVisuals.swift:232-234). Converting with NSScreen.main.frame.height - y is right only on the primary display; AXorcist converts per display via CGDisplayBounds (AX/…/AppLocator.swift:124-158). Negative x is normal on multi-monitor. Screenshot pixels are window-relative and scaled: "NEVER estimate coordinates visually from screenshots" (MCP/…/main.swift:1503). Traversal x,y is the element's top-left; click at (x+w/2, y+h/2) (:1331-1334,1647-1652).AXUIElementCopyElementAtPosition takes Float; verify the returned element's pid (AX/…/AXorcist+GetElementAtPointHandler.swift:20-44). It does not penetrate Catalyst table rows — walk the tree for the smallest containing frame (SDK/…/AccessibilityActions.swift:47-49,91-92). But in-viewport tree hit-tests can pick a full-width overlay group over a sidebar item (MCP/…/main.swift:1189-1195)._AXUIElementGetWindow via @_silgen_name, fallback bounds-match with tolerance 1.0; AX window enumeration needs no Screen Recording (AX/…/AXWindowResolver.swift:15-17,57-77).AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt: true]). The SDK does this on every traversal (dialog spam) (SDK/…/AccessibilityTraversal.swift:135-142); AXorcist only on explicit request and suppresses it under XCTest (AX/…/AXPermissionHelpers.swift:42-48).getppid(): Terminal, iTerm, Claude Desktop, VS Code (AX/…/AccessibilityPermissions.swift:43-51; MCP/llms.txt:137).AXIsProcessTrusted() (1 s) — there is no notification (AX/…/AXPermissionHelpers.swift:142-175). AgentAccess caches the result forever and relaunches the app once granted (AA/…/AccessibilityService+Security.swift:12-24,26-72).anchor apple generic (AX/docs/releasing.md:3,44). Missing permission in the MCP server surfaces as CGEvent.tapCreate returning nil (MCP/…/InputGuard.swift:140). AXorcist exit code 10 for apiDisabled/notAuthorized (AX/…/AccessibilityError.swift:164).APP_SANDBOX_CONTAINER_ID; CGEvent Unicode typing works sandboxed, AX writes exist for "secure-input contexts where the HID tap is filtered" (SDK/…/AccessibilityActions.swift:9-10).AX/scripts/test-native-ax-only.sh:8-19).NSApplication.shared + setActivationPolicy(.accessory) (MCP/…/InputGuard.swift:203-205).AXorcist (axorc tree/find) |
MacosUseSDK / MCP | |
|---|---|---|
| Order | DFS (BFS available) | BFS, then sorted by y,x — tree structure is gone; "children follow the parent" is false (SDK/…/AccessibilityTraversal.swift:302-305,173-180) |
| Caps | depth 10 (search) / 3 (tree), children 50,000, 30 s, no element cap | depth 100, 2,000 elements, 5 s, 200 children/node; truncated flag |
| Pruning | shouldDescend only for container roles — AXTable/AXRow/AXCell/AXToolbar/AXTabGroup/AXMenu are NOT containers → invisible unless --scan-all ("May be extremely slow") (AX/…/ElementSearch.swift:410-412,599-614) |
non-interactable roles kept only if they have text |
| Messaging timeout | scoped AXUIElementSetMessagingTimeout, reset to 0 after; nested scope on same element throws (AX/…/AXTimeoutPolicy.swift:16-49,85-109) |
SDK: none (wedged app = 5 s wall clock); MCP sets 5.0 s on elements it creates (MCP/…/main.swift:245-350) |
| Text | computedName: AXTitle → AXValue(50) → AXIdentifier → AXDescription → AXHelp → placeholder → role (AX/…/Element+ComputedName.swift:17-50) |
text = join of AXValue+AXTitle+AXDescription+AXLabel+AXHelp; numeric AXValues vanish (:226-229,272-283) |
| Hidden | collectAll skips hidden subtrees unless asked (AX/…/ElementSearch.swift:564-567) |
in_viewport = top-left point inside a window frame, not "unobscured" (MCP/…/main.swift:513-544) |
depth == maxDepth are visited but not expanded (AX/…/AXTreeTraversal.swift:147-149).AX/…/ElementSearch.swift:387,414,433). A subtree whose AXChildren returns cannotComplete vanishes silently (AX/…/Element+Hierarchy.swift:101-107).AXUIElementCopyAttributeValues(…, 0, n)) "to avoid blocking on huge containers" (SDK/…/AccessibilityTraversal.swift:395-411).[Role] "text" x:N y:N w:W h:H visible lines in /tmp/macos-use/<ts>_<tool>.txt + PNG; diff prefixes + - ~; compact summary of 30 interactive + 10 static (MCP/…/main.swift:992-1010,954,1961-1983).{"attribute":"title"} reads a literal attribute named title and never matches — write AXTitle. Only role/subrole/identifier/id/pid/dom/computedname/name have aliases (AX/…/SingleCriterionMatching.swift:83-106,200-211). README overstates this (AX/README.md:271-293).AXTitle matching is case-sensitive; only role/subrole are insensitive (AX/…/AttributeMatchingFunctions.swift:26,45,63,134-151). contains "" does not match a missing attribute (AX/…/StringComparisonLogic.swift:48-56). No role normalization: button ≠ AXButton.match_type is the fallback for the rest (AX/…/ElementSearch.swift:231). No AXWindow[1] index syntax anywhere — always first match.AX/…/PathNavigationJSON.swift:73 vs AX/README.md:355); an unknown attribute in the JSON-path engine resolves to the first child (:169-172,185-198).Locator.computedNameContains is decoded but never read by the library search (AX/…/MatchingTypes.swift:106-166) — AgentAccess's performAction relies on it, so with role+title the first element of that role gets the action (AA/…/AccessibilityService+Actions.swift:46-58).click_and_traverse element: = lowercase substring over the five-attribute text, first match wins — "Open" can hit help text (MCP/…/main.swift:1620-1638).--no-stop-first returns the last preorder match in foundElement (AX/…/ElementSearch.swift:521-522).Element.click() posts down/up at frame.midX/midY to .cghidEventTap, 10 ms apart; multi-click = separate pairs with clickState 1 then 2 (AX/…/Element+UIAutomation.swift:49-82,117-121). SDK sends one pair with clickState 2 — they disagree. SDK sleeps 15 ms after every post "crucial for some applications" (SDK/…/InputController.swift:61-62).activate() + 200 ms (SDK/…/ActionCoordinator.swift:207-214; MCP does it before every action, MCP/…/main.swift:1655-1660).AXUIElementPerformAction, no retry — "The platform can return cannotComplete after dispatch, so classify once and never retry" (AX/…/AXorcist+ActionHandlers.swift:206). Actions must be discovered via AXUIElementCopyActionNames or SwiftUI buttons won't press (AX/CHANGELOG.md:28). Often "the only path that actuates buttons" in Catalyst/sandboxed apps (MCP/…/main.swift:1459).AXSelected but no AXPress → set the attribute; single-selection tables auto-deselect (SDK/…/AccessibilityActions.swift:171-206).AXValue first ("fastest"), fall back to keystrokes (AA/…/Interaction.swift:384-421). "AXSetValue" is a compat command, not a native action (AX/…/AccessibilityConstants.swift:23-24). AXorcist resolves physical keycodes from the live layout via UCKeyTranslate because "Unicode-only events" are "silently drop[ped]" on VM/headless paths (AX/…/Element+UIAutomation.swift:229-333); SDK is Unicode-only, one event per scalar because multi-char payloads "break IME/auto-complete" (SDK/…/InputController.swift:191-193), and its keycode table assumes US QWERTY (:239).AX/…/Element+UIAutomation.swift:437; AX/CHANGELOG.md:72).deltaY = up; SDK/MCP negative = up (AX/…/InputDriver.swift:166-180; SDK/…/InputController.swift:155). MCP scrolls off-screen targets into view 1–3 lines/step, ≤30 steps, re-finding by text each step (MCP/…/main.swift:1172-1305).AXMenuBar via menuBarWithTimeout(2.0); shortcuts reconstructed from AXMenuItemCmdChar+CmdModifiers; AgentAccess clickMenuItem("File > Save") matches exact→prefix→contains with trailing … stripped, presses intermediates with 0.15 s sleeps (AA/…/Window.swift:104-195).waitUntilActionable(5 s, poll 0.1) = enabled + nonzero frame + on a screen (AX/…/Element+UIAutomation.swift:130-164); MCP restores the previous frontmost app and cursor after disruptive tools (MCP/…/main.swift:1802-1809,1905-1920).InputGuard CGEventTap on .cghidEventTap swallows hardware input (synthetic events have non-zero stateID), must live on the main run loop, gets auto-disabled by macOS and must be re-enabled, Esc cancels, 30 s watchdog (MCP/…/InputGuard.swift:130-181,298-350).AXFullScreen → frame) (AX/…/Element+WindowOperations.swift:60-195).AXChildren, focused subtree via AXFocusedUIElement; AXDOMClassList/AXDOMIdentifier in the default fetch (AX/…/Element+Hierarchy.swift:43-54; AX/…/AccessibilityConstants.swift:413-414).AXWindow → AXWebArea (depth 5) then class-list criteria (AX/README.md:645-662). AgentAccess vetoes Safari entirely and routes to JavaScript (AA/…/AccessibilityService.swift:13-31).AXSelected; right-pane controls swallow synthetic clicks and typing (SDK/…/AccessibilityActions.swift:6-12,47-49,91-92).AXPress works only when actions are discovered via the dedicated API (AX/CHANGELOG.md:28); AXHostingView subtrees look empty unless you recurse children (AA/…/Interaction.swift:139-156).CGWindowListCreateImage loads ReplayKit which "spin[s] at ~19% CPU forever" in a long-lived process → capture in a subprocess with a 5 s kill (MCP/…/main.swift:382-385,475-489); screencapture blocks 50–200 ms — never on the main actor (AA/…/Screenshot.swift:9-18)./Applications, /System/Applications, …/Utilities — use bundle id or path otherwise (SDK/…/AppOpener.swift:105-107). AgentAccess: any name containing . is a bundle id and auto-launches; reads use lookupBundleId (no launch) to stop "Photo Booth keeps opening" (AA/…/AccessibilityService.swift:280-340). "DO NOT REPLACE THIS WITH showWindow(). It looks equivalent. It is not." — unminimize + activate + 1.0 s + 0.3 s sleeps, or the docked window never returns (:376-411).exit(0) (crashes) (SDK/Sources/ActionTool/main.swift:58-98).xcrun --toolchain com.apple.dt.toolchain.XcodeDefault swift; rebuilding an MCP binary does not update a running MCP connection (MCP/CLAUDE.md:15-27).AXObserverCreateWithInfoCallback, one observer per PID, run-loop source on main .defaultMode; handlers hop to a later main-actor turn (AX/…/AXObserverCenter.swift:838-949).AXObserverAddNotification can still succeed later and must be rolled back with a native remove or it leaks (AX/…/ObserverNativeWork.swift:6,58-71,93-107,185-186). Worker slots: 8 concurrent, 1 reserved for cleanup; a timed-out call keeps its slot until the late result returns (:216-248,365-376).proc_pidinfo(pid, 17, …) unique identifier (:19-32).NSWorkspace.shared.runningApplications (not didLaunchApplicationNotification); never request .new (synchronous fetch on the notifying thread); onLaunch fires twice per PID (AX/…/AXGlobalApplicationMonitor.swift:17-34,141-144,240). Retry delays 0.5/2/8 s (AX/…/NotificationWatcher.swift:6-8).AXWindowMiniaturized (not Minimized); AXTitleChanged "not a standard top-level notification"; 34 names total (AX/…/NotificationTypes.swift:8-43). No debounce exists. axorc observe stays alive only in DEBUG builds (AX/Sources/axorc/AXORCMain.swift:116-135).Task.sleep deadlines got starved by uncooperative work on Swift 6.2.1 → GCD timer (AX/…/AXTimeoutPolicy.swift:242-243).InputDriver and every coordinate path removed; drags "just don't work via AX" → Shortcut/AppleScript (AA/…/Actions.swift:68-80; AA/…/Interaction.swift:159-171).AXTitle+AXDescription for the role to depth 12, score exact=1000 / contains / Jaccard, threshold ≥50 ("we'd rather keep the original error than click the wrong button"), retry, and report auto_retry{requested_title, matched_title} so the model sees the substitution (Agent/…/NativeToolHandler.swift:68-174).AA/…/Interaction.swift:294-303,393-406,451-479).open_app returns the interactive element tree in one call (first 50, width>0 && height>0, depth 5) so the model rarely needs find_element (AA/…/Elements.swift:69-129).AX* names "that LLMs recognize from training data" (AA/…/AccessibilityService.swift:196-221).verify: parameters on clickElement/typeTextIntoElement are never read; nothing reads the value back (AA/…/Interaction.swift:238,384-421). Verification = re-query with find_element, opt-in screenshots off by default (Agent/…/ToolBatch.swift:198-199).Agent/…/NativeToolHandler.swift:176-202).findElement polls with Thread.sleep on @MainActor up to automationFinishTimeout = 18000 s (AA/…/AccessibilityConstants.swift:4-6).highlightElement draws an NSWindow from the AX frame with no coordinate flip — wrong on the primary display (AA/…/Window.swift:48,61-68).| Topic | AXorcist | SDK / MCP | AgentAccess |
|---|---|---|---|
| Y-flip | per display via CGDisplayBounds |
NSScreen.main height (primary only) |
none |
| Double-click | two pairs, clickState 1 then 2 | one pair, clickState 2 | — |
| Scroll sign | + = up | − = up | — |
| Hit-test | CopyElementAtPosition + pid check |
tree walk, smallest frame | system-wide point (any app) |
| Traversal | DFS, depth 10, prune containers, 30 s | BFS, 2000 el / 5 s / depth 100, sorted | AXorcist exact-match, depth 100, no timeout |
| Typing | physical keycodes (UCKeyTranslate), Unicode fallback | Unicode per scalar, US-QWERTY table | AXValue first, then keystrokes |
| Permission prompt | explicit only; suppressed in tests | every traversal | cached forever + relaunch |
| Click activation | never activates | activate if needed / always + 200 ms | launch+unminimize+activate 1.0 s+0.3 s |
| Apple Events | forbidden by CI | osascript in tests |
— |
bashA=~/.claude/skills/snappy-ax/api.ts
npx tsx $A permissions # {"accessibility":true}
npx tsx $A tree Finder --depth 2 [--role AXButton] # axorc tree (container-pruned)
npx tsx $A find Finder --role AXButton --title "Back" [--contains] [--depth 10]
npx tsx $A focused [app] # AXFocusedUIElement (absence is success)
npx tsx $A at-point <app> <x> <y> # top-left global points; pid-verified
npx tsx $A press <app> --role AXButton --title "Save" # AXPress via locator (AX* names, case-sensitive)
npx tsx $A set-value <app> --role AXTextField --title "Search" --value "hello" # AXValue write
npx tsx $A text <app> [--role R --title T] [--depth 3] # extractText
npx tsx $A raw '<json envelope>' # the full protocol (ping, query, batch, observe…)
npx tsx $A macos-use <tool> '<json args>' # mcp-server-macos-use over stdio (BSL 1.1!)
Live-verified 2026-09-02: permissions, focused, raw ping, text, Dock tree/find, and a macos-use tools/call over stdio. tree Finder --role AXButton returns 0 by design (toolbar buttons are pruned) — add --scan-all. macos-use needs com.apple.finder, not Finder.\n\nBinary resolution: $SNAPPY_AXORC → ~/projects/cloned-repos/bin/axorc → axorc on PATH; fails visibly with the build line otherwise. Every call has a hard timeout.
AX/…/AppLocator.swift:139-149).MCP/…/main.swift:1503).SDK/…/ActionCoordinator.swift:207-214).click() is a CGEvent; AXPress is separate and sometimes the only thing that works (MCP/…/main.swift:1459).AXSelected and no AXPress (SDK/…/AccessibilityActions.swift:171-178).AXSetValue is not an action; write AXValue (AX/…/AccessibilityConstants.swift:23-24).cannotComplete can arrive after the action fired — never retry (AX/…/AXorcist+ActionHandlers.swift:206).SDK/…/AccessibilityActions.swift:47-49).AXChildren hides Electron background windows and Chromium's focus (AX/…/Element+Hierarchy.swift:43-54).AX/…/ElementSearch.swift:599-614).attribute:"title" never matches; use AXTitle (AX/…/SingleCriterionMatching.swift:83-106).AXTitle is case-sensitive; contains "" fails on missing (AX/…/AttributeMatchingFunctions.swift:134-151).AX/…/AXTreeTraversal.swift:147-149; AX/…/ElementSearch.swift:414).AX/…/AXTimeoutPolicy.swift:126-133).AXValue raw type 4 = Boolean AND CFRange (AX/…/ValueUnwrapper.swift:71-72).Parameterized suffix; actions come from CopyActionNames (AX/CHANGELOG.md:40).AX/…/Element+UIAutomation.swift:229-230).clickState; build hotkeys fully before posting; ~15 ms between events (:117-121,437; SDK/…/InputController.swift:61-62).AX/…/InputDriver.swift:166-180; SDK/…/InputController.swift:155).AX/…/AccessibilityPermissions.swift:43-51; AX/docs/releasing.md:3).AX/README.md:514-517; AX/…/ObserverNativeWork.swift:19-32).AddNotification success must be rolled back (AX/…/ObserverNativeWork.swift:93-107).CGWindowListCreateImage → ReplayKit → 19% CPU forever; subprocess it (MCP/…/main.swift:382-385).SDK/…/AccessibilityTraversal.swift:226-229,272-283,302-305).activate() false can still work; activate right after unminimize races the AX queue (AX/…/AXorcist+FocusedElementHandler.swift:34-43; AA/…/AccessibilityService.swift:377-386).references/extract-ax-layer.md — 474 lines, 548 cites, §1–11 (incl. AgentAccess) + the exact public APIs, JSON protocol, and build lines.
snappy-voice-control · snappy-agent-host · snappy-cleanshot (its ax.py is the zero-dep ctypes road) · snappy-desktop (vision/pixels — the opposite approach) · desktop-automation (AppleScript) · macos-patterns · swift-concurrency.
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
snappy-agent-host |
Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable per-folder se... |
snappy-artifact-loop |
Build published Artifacts as I/O devices where the AGENT is the backend, not as static output documents |
snappy-browse |
THE DEFAULT for actually driving a browser on this machine -- Snappy stack and client sites via agent-brows... |
snappy-cleanshot |
CleanShot X local capture primitive |
snappy-content |
Interview-driven content production methodology, the writing engine for every Snappy channel: the 4-questio... |
snappy-corpus |
The Krisp transcript corpus + nugget mining pipeline: import calls into the corpus, find quotes, stories, o... |
snappy-desktop |
macOS desktop automation primitive for the Snappy stack via Midscene vision AI (npx @midscene/computer@1) |
snappy-docs |
THE DEFAULT for writing to Notion -- the Snappy stack's Notion primitive over the REST API (api.notion.com/v1) |
snappy-dom-cartographer |
Master DOM mapping agent for the Snappy swarm |
snappy-gmail |
Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gmail's REST API... |
snappy-hands |
THE HANDS OF AN AGENT ON THIS MAC -- how an agent in a Snappy room uses the kernel skills installed on the... |
snappy-image |
Centralized image generation, editing, and capture for Snappy: Nano Banana / Gemini, Grok / xAI edits, gpt... |
snappy-imessage |
iMessage on THIS Mac -- the one holding Messages.app -- through the hand's own verbs (`api.ts send/recent/c... |
snappy-infra |
Snappy infrastructure foundation -- Xano API surface (Slack, email, LinkedIn, FreshBooks, WhatsApp, calenda... |
snappy-jcode |
Dispatch GPT 5.6 (Luna/Sol) agents as sandboxed lane workers via the local jcode CLI, on this Mac or the Ma... |
snappy-nightshift |
The overnight orchestration operating system: one orchestrator drives a repo toward 100% all night with bui... |
snappy-os-operator |
Operate SnappyOS like a pro through product doors only: governed connector reads, staged writes with approv... |
snappy-pipeline |
Read-only QA agent for Orbiter enrichment pipeline data quality auditing |
snappy-resident |
The non-stop user seat: drive the Snappy OS app as a real user through a real browser (agent-browser), with... |
snappy-session-close |
Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md... |
snappy-swarm |
Orchestrate swarms of parallel AI agents for multi-wave quality passes across a project |
snappy-telegram |
Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
snappy-testimonials |
Snappy Testimonials -- scans client meeting transcripts (Krisp) and the knowledge graph for positive client... |
snappy-video |
Video and audio processing pipeline for Snappy, run on the Mac Mini via SSH (caption-video.sh wrapper aroun... |
snappy-voice-control |
Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Agent! by Agenti... |
snappy-watchtower |
Standing error monitors and the probes that lie: arm live typecheck, build, and test monitors at session st... |
snappy-xano-mcp |
THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
---
name: snappy-ax
description: "Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it — extracted with path:line citations from AXorcist (steipete), MacosUseSDK + mcp-server-macos-use (mediar-ai), and AgentAccess (the policy layer inside Agent!). Coordinates and their two origins, why AXChildren lies for Electron, container-role pruning that skips tables, AXPress vs CGEvent click vs AXSelected vs AXValue, messaging timeouts, observers, TCC identity, and the exact CLI/JSON protocol. Use when Robert says: /snappy-ax, 'click the button in <app>', 'read the UI of <app>', 'what's on screen in <app>', 'drive Finder / Mail / Messages / Slack', 'the click didn't land', 'it can't find the element', 'AXUIElement', 'accessibility tree', 'use axorc', 'use macos-use'. NOT vision/pixel automation (see snappy-desktop). NOT AppleScript (see desktop-automation). NOT voice input (see snappy-voice-control). NOT hosting Claude Code in the app (see snappy-agent-host). Triggers on: accessibility, AXUIElement, axorc, macos-use, AXorcist, click element, UI tree, AXPress."
---
# snappy-ax — controlling Mac apps through the accessibility tree, as shipped
Four codebases solved this and left the scars in comments. Everything below is cited
`PREFIX/path:line`; full reports in `references/`.
| Prefix | Repo | What it is | License |
|---|---|---|---|
| `AX/` | `~/projects/cloned-repos/AXorcist` (steipete, 31K lines) | Swift library + `axorc` CLI + JSON protocol. Chainable queries, observers, timeouts. | MIT |
| `SDK/` | `~/projects/cloned-repos/MacosUseSDK` (mediar-ai) | BFS traversal → flat element list; CGEvent input; AX writes for Catalyst | MIT |
| `MCP/` | `~/projects/cloned-repos/mcp-server-macos-use` (mediar-ai) | MCP stdio server over the SDK: 9 `macos-use_*` tools, text+diff output | **BSL 1.1** (non-commercial until 2028-04-09; `package.json` wrongly says MIT) |
| `AA/` | `~/projects/cloned-repos/AgentAccess` (AgentiLoop) | Policy layer Agent! puts over AXorcist: no coordinates, fuzzy rescue, launch-vs-lookup | **no LICENSE file** |
Binaries built on the Mac mini (Xcode 26.4.1 / Swift 6.3.1) and verified on this MacBook:
`~/projects/cloned-repos/bin/axorc` (0.1.9), `~/projects/cloned-repos/bin/mcp-server-macos-use`.
Zero-dependency fallback: `snappy-cleanshot/ax.py` (ctypes → AXUIElement, no PyObjC).
---
## 0. Decision tree
```
Need to drive a Mac app?
├─ Safari/Chrome web content → NOT AX. JavaScript/AppleScript (AgentAccess vetoes Safari) AA/…/AccessibilityService.swift:13-31
├─ Read what's on screen → api.ts tree / find (axorc; container pruning ON, add --scan-all for tables)
├─ Click/press something
│ ├─ normal AppKit/SwiftUI button → find + AXPress via api.ts press AX/…/Element+Actions.swift:31-48
│ ├─ Catalyst right pane / sandboxed app (clicks dropped) → AXPress or set AXValue MCP/…/main.swift:1442,1459
│ ├─ table/list row (no AXPress) → set AXSelected SDK/…/AccessibilityActions.swift:171-178
│ └─ need a real pointer event → CGEvent click at frame center, AFTER activating the app + 200 ms
├─ Type text → set AXValue first (fastest), else focus + keystrokes AA/…/Interaction.swift:384-421
└─ Watch for changes → AXObserver per PID (no system-wide observer exists) AX/README.md:514-515
```
**Rule 1 (AgentAccess, paid for):** address elements by **role + title + value + bundle id, never coordinates**; coordinates "were unreliable (window positions shift, retina scaling, multi-display setups)" (`AA/…/AccessibilityService+Actions.swift:68-80`).
---
## 1. Mental model
- **Handle**: `AXUIElement` is an opaque CF ref; equality is `CFEqual`, hashing `CFHash` — visited-sets depend on this (`AX/…/Core/Element.swift:108-115`). Stale handle → `kAXErrorInvalidUIElement` (`AX/…/AccessibilityError.swift:85`).
- **Scopes**: app root `AXUIElementCreateApplication(pid)`; system-wide `AXUIElementCreateSystemWide()` gives `AXFocusedApplication`. **The system-wide element cannot receive notifications** — observers are per-PID (`AX/README.md:514-515`; `AX/…/AXObserverCenter.swift:171-186`).
- **Roles/attributes/actions**: action names come from `AXUIElementCopyActionNames`, **not** an attribute (`AX/…/Element+Properties.swift:112-121`). Parameterized attributes use unsuffixed names (`AXStringForRange`, `AXBoundsForRange`, `AXCellForColumnAndRow`) (`AX/CHANGELOG.md:40`). `AXValue` raw type **4 is both Boolean and CFRange** — a raw-value switch corrupts `AXSelectedTextRange` (`AX/…/ValueUnwrapper.swift:71-72`). No `AXFrame`; compute from `AXPosition`+`AXSize` (`AX/…/AccessibilityConstants.swift:127`).
- **"Children" is not `AXChildren`.** AXorcist reads `AXChildren` then 14 alternatives (`AXVisibleChildren`, `AXWebAreaChildren`, `AXRows`, `AXColumns`, `AXTabs`, `AXContents`, …) and dedupes (`AX/…/Element+Hierarchy.swift:112-120,184-206`). For the app root it **always injects `AXWindows` and `AXFocusedUIElement`**: "Some Electron apps only expose the front-most window via kAXChildrenAttribute… searches remain[ed] inside the first window (depth ≈ 37)"; the focused element is "often a remote renderer proxy… crucial for Electron/Chromium" (`:43-54`). So the focused element appears at depth 1.
- **Focus**: "no focused element" is a **success** state (`AX/…/AXorcist+FocusedElementHandler.swift:34-43`). AXorcist refuses to type unless focus is established or settable — "preventing keyboard events from reaching an unrelated focused app" (`AX/CHANGELOG.md:25`). `NSRunningApplication.activate` "sometimes reports false but works" (`AX/…/Element+WindowOperations.swift:227`).
- **Coordinates — two origins.** AX `AXPosition` and CGEvent points are global **top-left**; AppKit (`NSScreen`, `NSWindow`, `NSEvent.mouseLocation`) is **bottom-left** (`SDK/…/DrawVisuals.swift:232-234`). Converting with `NSScreen.main.frame.height - y` is right **only on the primary display**; AXorcist converts per display via `CGDisplayBounds` (`AX/…/AppLocator.swift:124-158`). Negative x is normal on multi-monitor. Screenshot pixels are window-relative and scaled: "NEVER estimate coordinates visually from screenshots" (`MCP/…/main.swift:1503`). Traversal `x,y` is the element's **top-left**; click at `(x+w/2, y+h/2)` (`:1331-1334,1647-1652`).
- **Hit-testing** `AXUIElementCopyElementAtPosition` takes `Float`; verify the returned element's pid (`AX/…/AXorcist+GetElementAtPointHandler.swift:20-44`). It **does not penetrate Catalyst table rows** — walk the tree for the smallest containing frame (`SDK/…/AccessibilityActions.swift:47-49,91-92`). But in-viewport tree hit-tests can pick a full-width overlay group over a sidebar item (`MCP/…/main.swift:1189-1195`).
- **Windows ↔ CGWindowID**: private `_AXUIElementGetWindow` via `@_silgen_name`, fallback bounds-match with tolerance 1.0; AX window enumeration needs **no Screen Recording** (`AX/…/AXWindowResolver.swift:15-17,57-77`).
---
## 2. Permissions & TCC
- Prompt: `AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt: true])`. The SDK does this **on every traversal** (dialog spam) (`SDK/…/AccessibilityTraversal.swift:135-142`); AXorcist only on explicit request and **suppresses it under XCTest** (`AX/…/AXPermissionHelpers.swift:42-48`).
- **The process that needs the grant is the host (parent)** — AXorcist's hint names `getppid()`: Terminal, iTerm, Claude Desktop, VS Code (`AX/…/AccessibilityPermissions.swift:43-51`; `MCP/llms.txt:137`).
- **Changes are detected by polling** `AXIsProcessTrusted()` (1 s) — there is no notification (`AX/…/AXPermissionHelpers.swift:142-175`). AgentAccess caches the result **forever** and relaunches the app once granted (`AA/…/AccessibilityService+Security.swift:12-24,26-72`).
- **A changed code signature is a new TCC identity**: ship Developer-ID-signed, never the ad-hoc artifact; designated requirement must contain `anchor apple generic` (`AX/docs/releasing.md:3,44`). Missing permission in the MCP server surfaces as `CGEvent.tapCreate` returning nil (`MCP/…/InputGuard.swift:140`). AXorcist exit code **10** for `apiDisabled/notAuthorized` (`AX/…/AccessibilityError.swift:164`).
- Sandbox: detected via `APP_SANDBOX_CONTAINER_ID`; CGEvent Unicode typing works sandboxed, AX writes exist for "secure-input contexts where the HID tap is filtered" (`SDK/…/AccessibilityActions.swift:9-10`).
- AXorcist CI **forbids any Apple Events symbol** in source or binary — AX-only by policy (`AX/scripts/test-native-ax-only.sh:8-19`).
- To show overlay windows from a CLI/MCP process: `NSApplication.shared` + `setActivationPolicy(.accessory)` (`MCP/…/InputGuard.swift:203-205`).
---
## 3. Traversal — three engines, three behaviours
| | AXorcist (`axorc tree/find`) | MacosUseSDK / MCP |
|---|---|---|
| Order | DFS (BFS available) | **BFS**, then **sorted by y,x** — tree structure is gone; "children follow the parent" is false (`SDK/…/AccessibilityTraversal.swift:302-305,173-180`) |
| Caps | depth 10 (search) / 3 (tree), children 50,000, 30 s, no element cap | depth 100, **2,000 elements**, **5 s**, 200 children/node; `truncated` flag |
| Pruning | **`shouldDescend` only for container roles** — AXTable/AXRow/AXCell/AXToolbar/AXTabGroup/AXMenu are NOT containers → invisible unless `--scan-all` ("May be extremely slow") (`AX/…/ElementSearch.swift:410-412,599-614`) | non-interactable roles kept only if they have text |
| Messaging timeout | scoped `AXUIElementSetMessagingTimeout`, **reset to 0 after**; nested scope on same element throws (`AX/…/AXTimeoutPolicy.swift:16-49,85-109`) | SDK: **none** (wedged app = 5 s wall clock); MCP sets 5.0 s on elements it creates (`MCP/…/main.swift:245-350`) |
| Text | `computedName`: AXTitle → AXValue(50) → AXIdentifier → AXDescription → AXHelp → placeholder → role (`AX/…/Element+ComputedName.swift:17-50`) | `text` = join of AXValue+AXTitle+AXDescription+AXLabel+AXHelp; **numeric AXValues vanish** (`:226-229,272-283`) |
| Hidden | `collectAll` skips hidden subtrees unless asked (`AX/…/ElementSearch.swift:564-567`) | `in_viewport` = top-left point inside a window frame, not "unobscured" (`MCP/…/main.swift:513-544`) |
- Depth semantics: nodes at `depth == maxDepth` are visited **but not expanded** (`AX/…/AXTreeTraversal.swift:147-149`).
- **Timeouts are silent**: a timed-out search only logs; the result is discarded (`AX/…/ElementSearch.swift:387,414,433`). A subtree whose `AXChildren` returns `cannotComplete` **vanishes silently** (`AX/…/Element+Hierarchy.swift:101-107`).
- Ranged child fetch (`AXUIElementCopyAttributeValues(…, 0, n)`) "to avoid blocking on huge containers" (`SDK/…/AccessibilityTraversal.swift:395-411`).
- MCP output: `[Role] "text" x:N y:N w:W h:H visible` lines in `/tmp/macos-use/<ts>_<tool>.txt` + PNG; diff prefixes `+ - ~`; compact summary of 30 interactive + 10 static (`MCP/…/main.swift:992-1010,954,1961-1983`).
---
## 4. Finding elements — where queries silently miss
- **Criterion attribute names are not aliased.** `{"attribute":"title"}` reads a literal attribute named `title` and never matches — write `AXTitle`. Only `role/subrole/identifier/id/pid/dom/computedname/name` have aliases (`AX/…/SingleCriterionMatching.swift:83-106,200-211`). README overstates this (`AX/README.md:271-293`).
- **`AXTitle` matching is case-sensitive**; only role/subrole are insensitive (`AX/…/AttributeMatchingFunctions.swift:26,45,63,134-151`). `contains ""` does not match a missing attribute (`AX/…/StringComparisonLogic.swift:48-56`). No role normalization: `button` ≠ `AXButton`.
- Only the first criterion's `match_type` is the fallback for the rest (`AX/…/ElementSearch.swift:231`). No `AXWindow[1]` index syntax anywhere — always first match.
- Path hints: default step depth is **1, not the README's 3** (`AX/…/PathNavigationJSON.swift:73` vs `AX/README.md:355`); an unknown attribute in the JSON-path engine resolves to the **first child** (`:169-172,185-198`).
- `Locator.computedNameContains` is **decoded but never read** by the library search (`AX/…/MatchingTypes.swift:106-166`) — AgentAccess's `performAction` relies on it, so with role+title the **first element of that role** gets the action (`AA/…/AccessibilityService+Actions.swift:46-58`).
- MCP `click_and_traverse element:` = lowercase substring over the five-attribute `text`, first match wins — `"Open"` can hit help text (`MCP/…/main.swift:1620-1638`).
- `--no-stop-first` returns the **last** preorder match in `foundElement` (`AX/…/ElementSearch.swift:521-522`).
---
## 5. Acting
- **Click is a CGEvent, not AXPress.** `Element.click()` posts down/up at `frame.midX/midY` to `.cghidEventTap`, 10 ms apart; multi-click = separate pairs with `clickState` 1 then 2 (`AX/…/Element+UIAutomation.swift:49-82,117-121`). SDK sends one pair with clickState 2 — they disagree. SDK sleeps **15 ms after every post** "crucial for some applications" (`SDK/…/InputController.swift:61-62`).
- **Activate first or the click is eaten**: "macOS eats the first click just to activate the window" → `activate()` + **200 ms** (`SDK/…/ActionCoordinator.swift:207-214`; MCP does it before every action, `MCP/…/main.swift:1655-1660`).
- **AXPress**: thin `AXUIElementPerformAction`, no retry — "The platform can return cannotComplete after dispatch, so classify once and never retry" (`AX/…/AXorcist+ActionHandlers.swift:206`). Actions must be discovered via `AXUIElementCopyActionNames` or SwiftUI buttons won't press (`AX/CHANGELOG.md:28`). Often "the only path that actuates buttons" in Catalyst/sandboxed apps (`MCP/…/main.swift:1459`).
- **Rows**: `AXSelected` but no `AXPress` → set the attribute; single-selection tables auto-deselect (`SDK/…/AccessibilityActions.swift:171-206`).
- **Typing**: set `AXValue` first ("fastest"), fall back to keystrokes (`AA/…/Interaction.swift:384-421`). `"AXSetValue"` is a compat command, **not a native action** (`AX/…/AccessibilityConstants.swift:23-24`). AXorcist resolves **physical keycodes from the live layout** via `UCKeyTranslate` because "Unicode-only events" are "silently drop[ped]" on VM/headless paths (`AX/…/Element+UIAutomation.swift:229-333`); SDK is Unicode-only, one event per scalar because multi-char payloads "break IME/auto-complete" (`SDK/…/InputController.swift:191-193`), and its keycode table assumes US QWERTY (`:239`).
- **Hotkeys**: build the whole sequence before posting anything — "Event creation can fail; posting cannot" — or modifiers stick (`AX/…/Element+UIAutomation.swift:437`; `AX/CHANGELOG.md:72`).
- **Scroll sign disagrees**: AXorcist positive `deltaY` = up; SDK/MCP negative = up (`AX/…/InputDriver.swift:166-180`; `SDK/…/InputController.swift:155`). MCP scrolls off-screen targets into view 1–3 lines/step, ≤30 steps, re-finding by text each step (`MCP/…/main.swift:1172-1305`).
- **Menus**: `AXMenuBar` via `menuBarWithTimeout(2.0)`; shortcuts reconstructed from `AXMenuItemCmdChar`+`CmdModifiers`; AgentAccess `clickMenuItem("File > Save")` matches exact→prefix→contains with trailing `…` stripped, presses intermediates with 0.15 s sleeps (`AA/…/Window.swift:104-195`).
- **Waits**: `waitUntilActionable(5 s, poll 0.1)` = enabled + nonzero frame + on a screen (`AX/…/Element+UIAutomation.swift:130-164`); MCP restores the previous frontmost app and cursor after disruptive tools (`MCP/…/main.swift:1802-1809,1905-1920`).
- **Blocking the human** during automation: MCP `InputGuard` CGEventTap on `.cghidEventTap` swallows hardware input (synthetic events have non-zero `stateID`), must live on the **main run loop**, gets auto-disabled by macOS and must be re-enabled, Esc cancels, 30 s watchdog (`MCP/…/InputGuard.swift:130-181,298-350`).
- **Window ops fallback chains** (minimize → button else attribute; maximize → zoom → fullscreen button → `AXFullScreen` → frame) (`AX/…/Element+WindowOperations.swift:60-195`).
---
## 6. Framework quirks (only what's in source)
- **Electron/Chromium**: front-window-only `AXChildren`, focused subtree via `AXFocusedUIElement`; `AXDOMClassList`/`AXDOMIdentifier` in the default fetch (`AX/…/Element+Hierarchy.swift:43-54`; `AX/…/AccessibilityConstants.swift:413-414`).
- **Safari/WebKit**: path `AXWindow → AXWebArea (depth 5)` then class-list criteria (`AX/README.md:645-662`). AgentAccess **vetoes Safari entirely** and routes to JavaScript (`AA/…/AccessibilityService.swift:13-31`).
- **Catalyst (Messages)**: hit-test returns cell/static-text/window-group, not the row; rows select via `AXSelected`; right-pane controls swallow synthetic clicks and typing (`SDK/…/AccessibilityActions.swift:6-12,47-49,91-92`).
- **SwiftUI**: `AXPress` works only when actions are discovered via the dedicated API (`AX/CHANGELOG.md:28`); `AXHostingView` subtrees look empty unless you recurse children (`AA/…/Interaction.swift:139-156`).
- **Screenshots**: `CGWindowListCreateImage` loads ReplayKit which "spin[s] at ~19% CPU forever" in a long-lived process → capture in a subprocess with a 5 s kill (`MCP/…/main.swift:382-385,475-489`); `screencapture` blocks 50–200 ms — never on the main actor (`AA/…/Screenshot.swift:9-18`).
- **App lookup**: SDK only scans `/Applications`, `/System/Applications`, `…/Utilities` — use bundle id or path otherwise (`SDK/…/AppOpener.swift:105-107`). AgentAccess: any name containing `.` is a bundle id **and auto-launches**; reads use `lookupBundleId` (no launch) to stop "Photo Booth keeps opening" (`AA/…/AccessibilityService.swift:280-340`). "DO NOT REPLACE THIS WITH showWindow(). It looks equivalent. It is not." — unminimize + activate + 1.0 s + 0.3 s sleeps, or the docked window never returns (`:376-411`).
- **Overlays from CLI tools**: need a live run loop; don't close them right before `exit(0)` (crashes) (`SDK/Sources/ActionTool/main.swift:58-98`).
- Toolchain: "The system Swift is mismatched (SDK 6.2 vs compiler 6.1)" → `xcrun --toolchain com.apple.dt.toolchain.XcodeDefault swift`; rebuilding an MCP binary does **not** update a running MCP connection (`MCP/CLAUDE.md:15-27`).
- Chrome, VS Code, Java, Qt, games, Spaces, full-screen: **not in source** anywhere.
---
## 7. Observers (AXorcist only)
- `AXObserverCreateWithInfoCallback`, one observer per PID, run-loop source on **main** `.defaultMode`; handlers hop to a later main-actor turn (`AX/…/AXObserverCenter.swift:838-949`).
- **Every native observer call races a detached thread against a 500 ms deadline**; a timed-out `AXObserverAddNotification` can still succeed later and **must be rolled back with a native remove** or it leaks (`AX/…/ObserverNativeWork.swift:6,58-71,93-107,185-186`). Worker slots: 8 concurrent, 1 reserved for cleanup; a timed-out call keeps its slot until the late result returns (`:216-248,365-376`).
- PID reuse detected via `proc_pidinfo(pid, 17, …)` unique identifier (`:19-32`).
- "Global" watching = KVO on `NSWorkspace.shared.runningApplications` (not `didLaunchApplicationNotification`); never request `.new` (synchronous fetch on the notifying thread); `onLaunch` fires **twice** per PID (`AX/…/AXGlobalApplicationMonitor.swift:17-34,141-144,240`). Retry delays 0.5/2/8 s (`AX/…/NotificationWatcher.swift:6-8`).
- Names: `AXWindowMiniaturized` (not Minimized); `AXTitleChanged` "not a standard top-level notification"; 34 names total (`AX/…/NotificationTypes.swift:8-43`). No debounce exists. `axorc observe` stays alive only in DEBUG builds (`AX/Sources/axorc/AXORCMain.swift:116-135`).
- `Task.sleep` deadlines got starved by uncooperative work on Swift 6.2.1 → GCD timer (`AX/…/AXTimeoutPolicy.swift:242-243`).
---
## 8. The policy layer (AgentAccess) — what a production agent adds on top
- **No coordinates, no raw input**: `InputDriver` and every coordinate path removed; drags "just don't work via AX" → Shortcut/AppleScript (`AA/…/Actions.swift:68-80`; `AA/…/Interaction.swift:159-171`).
- **Fuzzy rescue** (in Agent!, over AgentAccess): on not-found, collect `AXTitle`+`AXDescription` for the role to depth 12, score exact=1000 / contains / Jaccard, threshold **≥50** ("we'd rather keep the original error than click the wrong button"), retry, and report `auto_retry{requested_title, matched_title}` so the model sees the substitution (`Agent/…/NativeToolHandler.swift:68-174`).
- **Failure payloads teach vocabulary**: "Dead-end errors waste an LLM turn. List the titles that actually exist for the requested role" — up to 25 names, text inputs present, menu titles (`AA/…/Interaction.swift:294-303,393-406,451-479`).
- `open_app` returns the interactive element tree in one call (first 50, `width>0 && height>0`, depth 5) so the model rarely needs `find_element` (`AA/…/Elements.swift:69-129`).
- Element JSON keys are the real `AX*` names "that LLMs recognize from training data" (`AA/…/AccessibilityService.swift:196-221`).
- **`verify:` parameters on `clickElement`/`typeTextIntoElement` are never read**; nothing reads the value back (`AA/…/Interaction.swift:238,384-421`). Verification = re-query with `find_element`, opt-in screenshots off by default (`Agent/…/ToolBatch.swift:198-199`).
- Read-only calls never launch apps; write calls do (`Agent/…/NativeToolHandler.swift:176-202`).
- Every search runs with **no messaging timeout armed** and `findElement` polls with `Thread.sleep` on `@MainActor` up to `automationFinishTimeout = 18000 s` (`AA/…/AccessibilityConstants.swift:4-6`).
- `highlightElement` draws an `NSWindow` from the AX frame with **no coordinate flip** — wrong on the primary display (`AA/…/Window.swift:48,61-68`).
---
## 9. Where the repos disagree (pick deliberately)
| Topic | AXorcist | SDK / MCP | AgentAccess |
|---|---|---|---|
| Y-flip | per display via `CGDisplayBounds` | `NSScreen.main` height (primary only) | none |
| Double-click | two pairs, clickState 1 then 2 | one pair, clickState 2 | — |
| Scroll sign | + = up | − = up | — |
| Hit-test | `CopyElementAtPosition` + pid check | tree walk, smallest frame | system-wide point (any app) |
| Traversal | DFS, depth 10, prune containers, 30 s | BFS, 2000 el / 5 s / depth 100, sorted | AXorcist exact-match, depth 100, no timeout |
| Typing | physical keycodes (UCKeyTranslate), Unicode fallback | Unicode per scalar, US-QWERTY table | AXValue first, then keystrokes |
| Permission prompt | explicit only; suppressed in tests | every traversal | cached forever + relaunch |
| Click activation | never activates | activate if needed / always + 200 ms | launch+unminimize+activate 1.0 s+0.3 s |
| Apple Events | forbidden by CI | `osascript` in tests | — |
---
## 10. api.ts — real primitives on this Mac (axorc + macos-use, built on the Mini)
```bash
A=~/.claude/skills/snappy-ax/api.ts
npx tsx $A permissions # {"accessibility":true}
npx tsx $A tree Finder --depth 2 [--role AXButton] # axorc tree (container-pruned)
npx tsx $A find Finder --role AXButton --title "Back" [--contains] [--depth 10]
npx tsx $A focused [app] # AXFocusedUIElement (absence is success)
npx tsx $A at-point <app> <x> <y> # top-left global points; pid-verified
npx tsx $A press <app> --role AXButton --title "Save" # AXPress via locator (AX* names, case-sensitive)
npx tsx $A set-value <app> --role AXTextField --title "Search" --value "hello" # AXValue write
npx tsx $A text <app> [--role R --title T] [--depth 3] # extractText
npx tsx $A raw '<json envelope>' # the full protocol (ping, query, batch, observe…)
npx tsx $A macos-use <tool> '<json args>' # mcp-server-macos-use over stdio (BSL 1.1!)
```
Live-verified 2026-09-02: `permissions`, `focused`, `raw ping`, `text`, Dock `tree`/`find`, and a `macos-use` tools/call over stdio. `tree Finder --role AXButton` returns 0 by design (toolbar buttons are pruned) — add `--scan-all`. `macos-use` needs `com.apple.finder`, not `Finder`.\n\nBinary resolution: `$SNAPPY_AXORC` → `~/projects/cloned-repos/bin/axorc` → `axorc` on PATH; fails visibly with the build line otherwise. Every call has a hard timeout.
---
## 11. Top 25 things AI gets wrong (each contradicted by source)
1. AX/CGEvent are top-left global; AppKit is bottom-left; flip per display (`AX/…/AppLocator.swift:139-149`).
2. Never click from screenshot pixels (`MCP/…/main.swift:1503`).
3. First click after an app switch only activates; activate + 200 ms first (`SDK/…/ActionCoordinator.swift:207-214`).
4. `click()` is a CGEvent; `AXPress` is separate and sometimes the only thing that works (`MCP/…/main.swift:1459`).
5. Rows may have `AXSelected` and no `AXPress` (`SDK/…/AccessibilityActions.swift:171-178`).
6. `AXSetValue` is not an action; write `AXValue` (`AX/…/AccessibilityConstants.swift:23-24`).
7. `cannotComplete` can arrive after the action fired — never retry (`AX/…/AXorcist+ActionHandlers.swift:206`).
8. Hit-test doesn't reach Catalyst rows (`SDK/…/AccessibilityActions.swift:47-49`).
9. `AXChildren` hides Electron background windows and Chromium's focus (`AX/…/Element+Hierarchy.swift:43-54`).
10. Default search skips tables/rows/cells/toolbars/menus (`AX/…/ElementSearch.swift:599-614`).
11. `attribute:"title"` never matches; use `AXTitle` (`AX/…/SingleCriterionMatching.swift:83-106`).
12. `AXTitle` is case-sensitive; `contains ""` fails on missing (`AX/…/AttributeMatchingFunctions.swift:134-151`).
13. Depth N visits but doesn't expand; timeouts are silent (`AX/…/AXTreeTraversal.swift:147-149`; `AX/…/ElementSearch.swift:414`).
14. Without a messaging timeout a wedged app blocks forever; reset it to 0 after (`AX/…/AXTimeoutPolicy.swift:126-133`).
15. `AXValue` raw type 4 = Boolean AND CFRange (`AX/…/ValueUnwrapper.swift:71-72`).
16. No `Parameterized` suffix; actions come from `CopyActionNames` (`AX/CHANGELOG.md:40`).
17. Unicode-only key events drop in VMs; resolve physical keycodes (`AX/…/Element+UIAutomation.swift:229-230`).
18. Double-click needs `clickState`; build hotkeys fully before posting; ~15 ms between events (`:117-121,437`; `SDK/…/InputController.swift:61-62`).
19. Scroll sign differs per library (`AX/…/InputDriver.swift:166-180`; `SDK/…/InputController.swift:155`).
20. The host process needs TCC; new signature = new identity; poll for changes (`AX/…/AccessibilityPermissions.swift:43-51`; `AX/docs/releasing.md:3`).
21. No system-wide observer; per-PID via KVO on runningApplications; PIDs get reused (`AX/README.md:514-517`; `AX/…/ObserverNativeWork.swift:19-32`).
22. A late `AddNotification` success must be rolled back (`AX/…/ObserverNativeWork.swift:93-107`).
23. `CGWindowListCreateImage` → ReplayKit → 19% CPU forever; subprocess it (`MCP/…/main.swift:382-385`).
24. SDK text is a 5-attribute join, numbers vanish, order is spatial not tree (`SDK/…/AccessibilityTraversal.swift:226-229,272-283,302-305`).
25. "No focused element" is success; `activate()` false can still work; activate right after unminimize races the AX queue (`AX/…/AXorcist+FocusedElementHandler.swift:34-43`; `AA/…/AccessibilityService.swift:377-386`).
## References
`references/extract-ax-layer.md` — 474 lines, 548 cites, §1–11 (incl. AgentAccess) + the exact public APIs, JSON protocol, and build lines.
## Related skills
`snappy-voice-control` · `snappy-agent-host` · `snappy-cleanshot` (its `ax.py` is the zero-dep ctypes road) · `snappy-desktop` (vision/pixels — the opposite approach) · `desktop-automation` (AppleScript) · `macos-patterns` · `swift-concurrency`.
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
## Near neighbours
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
| `snappy-agent-host` | Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable per-folder se... |
| `snappy-artifact-loop` | Build published Artifacts as I/O devices where the AGENT is the backend, not as static output documents |
| `snappy-browse` | THE DEFAULT for actually driving a browser on this machine -- Snappy stack and client sites via agent-brows... |
| `snappy-cleanshot` | CleanShot X local capture primitive |
| `snappy-content` | Interview-driven content production methodology, the writing engine for every Snappy channel: the 4-questio... |
| `snappy-corpus` | The Krisp transcript corpus + nugget mining pipeline: import calls into the corpus, find quotes, stories, o... |
| `snappy-desktop` | macOS desktop automation primitive for the Snappy stack via Midscene vision AI (`npx @midscene/computer@1`) |
| `snappy-docs` | THE DEFAULT for writing to Notion -- the Snappy stack's Notion primitive over the REST API (api.notion.com/v1) |
| `snappy-dom-cartographer` | Master DOM mapping agent for the Snappy swarm |
| `snappy-gmail` | Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gmail's REST API... |
| `snappy-hands` | THE HANDS OF AN AGENT ON THIS MAC -- how an agent in a Snappy room uses the kernel skills installed on the... |
| `snappy-image` | Centralized image generation, editing, and capture for Snappy: Nano Banana / Gemini, Grok / xAI edits, gpt... |
| `snappy-imessage` | iMessage on THIS Mac -- the one holding Messages.app -- through the hand's own verbs (`api.ts send/recent/c... |
| `snappy-infra` | Snappy infrastructure foundation -- Xano API surface (Slack, email, LinkedIn, FreshBooks, WhatsApp, calenda... |
| `snappy-jcode` | Dispatch GPT 5.6 (Luna/Sol) agents as sandboxed lane workers via the local jcode CLI, on this Mac or the Ma... |
| `snappy-nightshift` | The overnight orchestration operating system: one orchestrator drives a repo toward 100% all night with bui... |
| `snappy-os-operator` | Operate SnappyOS like a pro through product doors only: governed connector reads, staged writes with approv... |
| `snappy-pipeline` | Read-only QA agent for Orbiter enrichment pipeline data quality auditing |
| `snappy-resident` | The non-stop user seat: drive the Snappy OS app as a real user through a real browser (agent-browser), with... |
| `snappy-session-close` | Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md... |
| `snappy-swarm` | Orchestrate swarms of parallel AI agents for multi-wave quality passes across a project |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
| `snappy-testimonials` | Snappy Testimonials -- scans client meeting transcripts (Krisp) and the knowledge graph for positive client... |
| `snappy-video` | Video and audio processing pipeline for Snappy, run on the Mac Mini via SSH (caption-video.sh wrapper aroun... |
| `snappy-voice-control` | Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Agent! by Agenti... |
| `snappy-watchtower` | Standing error monitors and the probes that lie: arm live typecheck, build, and test monitors at session st... |
| `snappy-xano-mcp` | THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
#!/usr/bin/env npx tsx
/**
* snappy-ax/api.ts — macOS Accessibility (AXUIElement) primitives for THIS Mac.
*
* No credentials. Binds to `axorc` (AXorcist 0.1.9 CLI, MIT) for reads and
* actions, and to `mcp-server-macos-use` (mediar-ai, BSL 1.1) over stdio as a
* second road. Both binaries were built on the Mac mini (Xcode 26.4.1) and live
* in ~/projects/cloned-repos/bin/. No npm packages; child_process only.
*
* Every locator uses AX-prefixed attribute names (AXRole, AXTitle, AXValue,
* AXIdentifier) — `{"attribute":"title"}` never matches in AXorcist
* (SingleCriterionMatching.swift:83-106). AXTitle is case-sensitive unless
* `contains`. Every call has a hard timeout because a wedged app blocks AX
* calls forever (AXTimeoutPolicy.swift:126-133).
*
* Usage:
* npx tsx api.ts permissions
* npx tsx api.ts tree <app> [--depth 3] [--role AXButton] [--scan-all]
* npx tsx api.ts find <app> [--role R] [--title T] [--identifier I] [--value V] [--contains] [--depth 10] [--attribute A]...
* npx tsx api.ts focused [app]
* npx tsx api.ts at-point <app> <x> <y>
* npx tsx api.ts press <app> [--role R] [--title T] [--identifier I] [--value V] [--contains]
* npx tsx api.ts set-value <app> --value <text> [--role R] [--title T] [--identifier I] [--contains]
* npx tsx api.ts text <app> [--role R] [--title T] [--depth 3]
* npx tsx api.ts raw '<json envelope>' [--scan-all] [--no-stop-first] [--timeout s]
* npx tsx api.ts macos-use <tool> '<json args>'
*
* Or import as module:
* import { axDumpTree, axFind, axPerformPress, axSetValue, locator, macosUse } from "../snappy-ax/api.ts";
*/
import { env } from "../snappy-settings/load.ts";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// ------------------------------------------------------------ binaries
const BIN_DIR = join(homedir(), "projects", "cloned-repos", "bin");
function resolveBinary(envName: string, name: string, buildHint: string): string {
const fromEnv = process.env[envName] || env(envName, false); // shell env, then snappy-settings/.env.cache
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const local = join(BIN_DIR, name);
if (existsSync(local)) return local;
const which = spawnSync("which", [name], { encoding: "utf8" });
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
throw new Error(`${name} not found (set ${envName}, or put it in ${BIN_DIR}). Build: ${buildHint}`);
}
const axorcBin = () => resolveBinary("SNAPPY_AXORC", "axorc",
"cd ~/projects/cloned-repos/AXorcist && swift build -c release --product axorc (needs Swift 6.2+; the Mac mini has it)");
const macosUseBin = () => resolveBinary("SNAPPY_MACOS_USE", "mcp-server-macos-use",
"cd ~/projects/cloned-repos/mcp-server-macos-use && swift build -c release -Xswiftc -swift-version -Xswiftc 5");
// ------------------------------------------------------------ axorc core
export interface AxRunOpts { input?: string; timeoutMs?: number }
function runAxorc(args: string[], opts: AxRunOpts = {}): string {
const r = spawnSync(axorcBin(), args, { encoding: "utf8", input: opts.input, timeout: opts.timeoutMs ?? 20_000, maxBuffer: 64 * 1024 * 1024 });
if (r.error && (r.error as NodeJS.ErrnoException).code === "ETIMEDOUT") throw new Error(`axorc ${args[0]}: timed out after ${opts.timeoutMs ?? 20_000} ms (wedged app? no messaging timeout armed)`);
if (r.status === 10) throw new Error("axorc: Accessibility permission missing for the HOST process (Terminal/iTerm/app). Grant it: x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
if (r.status !== 0) throw new Error(`axorc ${args[0]} exit ${r.status}: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-4).join(" | ")}`);
return r.stdout;
}
function parseJson(s: string, ctx: string): unknown {
const t = s.trim(); const start = t.indexOf("{"); const arr = t.indexOf("[");
const i = start === -1 ? arr : arr === -1 ? start : Math.min(start, arr);
if (i < 0) throw new Error(`${ctx}: no JSON in output: ${t.slice(0, 200)}`);
return JSON.parse(t.slice(i));
}
// ------------------------------------------------------------ locators
export interface LocatorSpec { role?: string; title?: string; identifier?: string; value?: string; contains?: boolean }
export interface Criterion { attribute: string; value: string; match_type?: "exact" | "contains" | "regex" | "prefix" | "suffix" | "containsAny" }
export interface Locator { match_all: boolean; criteria: Criterion[] }
/** AX-prefixed criteria — the only names AXorcist's search reads. Role stays exact. */
export function locator(s: LocatorSpec): Locator {
const mt: Criterion["match_type"] = s.contains ? "contains" : "exact";
const c: Criterion[] = [];
if (s.role) c.push({ attribute: "AXRole", value: s.role, match_type: "exact" });
if (s.title) c.push({ attribute: "AXTitle", value: s.title, match_type: mt });
if (s.identifier) c.push({ attribute: "AXIdentifier", value: s.identifier, match_type: "exact" });
if (s.value) c.push({ attribute: "AXValue", value: s.value, match_type: mt });
if (!c.length) throw new Error("locator: need at least one of role/title/identifier/value (AXorcist: 'No criteria, no path hint')");
return { match_all: true, criteria: c };
}
// ------------------------------------------------------------ reads
export async function axPermissions(): Promise<{ accessibility: boolean }> {
const r = spawnSync(axorcBin(), ["permissions", "-j"], { encoding: "utf8", timeout: 10_000 });
return parseJson(r.stdout || r.stderr, "permissions") as { accessibility: boolean };
}
export interface TreeOpts { depth?: number; role?: string; scanAll?: boolean; timeoutMs?: number }
/** Accessibility tree. Default search prunes to container roles (tables/rows/cells/toolbars/menus invisible);
* `scanAll` uses the raw `collectAll` road with --scan-all ("May be extremely slow"). */
export async function axDumpTree(app: string, o: TreeOpts = {}): Promise<unknown> {
if (o.scanAll) return axRaw({ command: "collectAll", application: app, max_depth: o.depth ?? 10, ...(o.role ? { filter_criteria: { AXRole: o.role } } : {}) }, { scanAll: true, timeoutMs: o.timeoutMs ?? 120_000 });
const args = ["tree", "--app", app, "--depth", String(o.depth ?? 3), "-j"];
if (o.role) args.push("--role", o.role);
return parseJson(runAxorc(args, { timeoutMs: o.timeoutMs }), "tree");
}
export interface FindOpts extends LocatorSpec { depth?: number; attributes?: string[]; timeoutMs?: number }
export async function axFind(app: string, o: FindOpts): Promise<unknown> {
const args = ["find", "--app", app, "--depth", String(o.depth ?? 10), "-j"];
if (o.role) args.push("--role", o.role);
if (o.title) args.push("--title", o.title);
if (o.identifier) args.push("--identifier", o.identifier);
if (o.value) args.push("--value", o.value);
if (o.contains) args.push("--contains");
for (const a of o.attributes ?? []) args.push("--attribute", a);
return parseJson(runAxorc(args, { timeoutMs: o.timeoutMs }), "find");
}
// ------------------------------------------------------------ raw protocol
export interface RawOpts { scanAll?: boolean; noStopFirst?: boolean; timeoutS?: number; timeoutMs?: number }
export interface AxResponse { command_id?: string; command_type?: string; status: "success" | "error"; data?: unknown; error?: string; error_code?: string; debug_logs?: string[] }
/** The complete axorc JSON protocol. Throws on status:"error" with error_code. */
export async function axRaw(envelope: Record<string, unknown>, o: RawOpts = {}): Promise<AxResponse> {
const env = { command_id: `snappy-${Date.now()}`, ...envelope };
const args = ["raw", "--stdin"];
if (o.scanAll) args.push("--scan-all");
if (o.noStopFirst) args.push("--no-stop-first");
if (o.timeoutS) args.push("--timeout", String(o.timeoutS));
const out = runAxorc(args, { input: JSON.stringify(env), timeoutMs: o.timeoutMs ?? ((o.timeoutS ?? 25) * 1000 + 5000) });
const res = parseJson(out, `raw ${String(env.command)}`) as AxResponse;
if (res.status === "error") throw new Error(`axorc ${String(env.command)}: ${res.error_code ?? "error"} — ${res.error ?? ""}`);
return res;
}
export async function axFocused(app?: string): Promise<AxResponse> {
return axRaw({ command: "getFocusedElement", ...(app ? { application: app } : {}) });
}
/** Hit-test at top-left-origin global points. Pid-verified by axorc. Does not reach Catalyst rows. */
export async function axAtPoint(app: string, x: number, y: number): Promise<AxResponse> {
return axRaw({ command: "getElementAtPoint", application: app, point: [x, y] });
}
/** AXPress on the first element matching the locator. No retry after cannotComplete — it may have fired. */
export async function axPerformPress(app: string, loc: Locator | LocatorSpec): Promise<AxResponse> {
const l = "criteria" in loc ? loc : locator(loc);
return axRaw({ command: "performAction", application: app, locator: l, action_name: "AXPress", max_depth: 20 });
}
/** Write AXValue (the "fastest" typing road). "AXSetValue" is a compat command, not a native action. */
export async function axSetValue(app: string, loc: Locator | LocatorSpec, value: string): Promise<AxResponse> {
const l = "criteria" in loc ? loc : locator(loc);
return axRaw({ command: "performAction", application: app, locator: l, action_name: "AXSetValue", action_value: value, max_depth: 20 });
}
/** extractText REQUIRES a locator ("FTE: No criteria, no path hint" otherwise); default = the first AXWindow. */
export async function axExtractText(app: string, loc?: Locator | LocatorSpec, o: { depth?: number } = {}): Promise<AxResponse> {
const l = loc ? ("criteria" in loc ? loc : locator(loc)) : locator({ role: "AXWindow" });
return axRaw({ command: "extractText", application: app, locator: l, max_depth: o.depth ?? 3 });
}
// ------------------------------------------------------------ macos-use (MCP over stdio)
/** One tools/call against mcp-server-macos-use. Keeps stdin open until the response arrives.
* License: BSL 1.1 — production use non-commercial-only until 2028-04-09. */
export async function macosUse(tool: string, args: Record<string, unknown> = {}, o: { timeoutMs?: number } = {}): Promise<unknown> {
const bin = macosUseBin();
return new Promise((resolve, reject) => {
const p = spawn(bin, [], { stdio: ["pipe", "pipe", "pipe"] });
let buf = ""; let stderr = ""; let done = false;
const finish = (fn: () => void) => { if (done) return; done = true; clearTimeout(timer); try { p.kill("SIGTERM"); } catch { /* */ } fn(); };
const timer = setTimeout(() => finish(() => reject(new Error(`macos-use ${tool}: timed out after ${o.timeoutMs ?? 60_000} ms. stderr: ${stderr.slice(-300)}`))), o.timeoutMs ?? 60_000);
p.stderr.on("data", (d) => { stderr += String(d); });
p.on("error", (e) => finish(() => reject(e)));
p.stdout.on("data", (d) => {
buf += String(d);
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
if (!line.startsWith("{")) continue;
let msg: { id?: number; result?: unknown; error?: { message?: string } };
try { msg = JSON.parse(line); } catch { continue; }
if (msg.id === 1) {
p.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
p.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: tool, arguments: args } }) + "\n");
} else if (msg.id === 2) {
if (msg.error) finish(() => reject(new Error(`macos-use ${tool}: ${msg.error?.message ?? JSON.stringify(msg.error)}`)));
else finish(() => resolve(msg.result));
}
}
});
p.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "snappy-ax", version: "1" } } }) + "\n");
});
}
// ------------------------------------------------------------ CLI
function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
function flags(args: string[], name: string): string[] { const out: string[] = []; args.forEach((a, i) => { if (a === name && args[i + 1]) out.push(args[i + 1]); }); return out; }
const has = (args: string[], name: string) => args.includes(name);
const specFrom = (args: string[]): LocatorSpec => ({ role: flag(args, "--role"), title: flag(args, "--title"), identifier: flag(args, "--identifier"), value: flag(args, "--value"), contains: has(args, "--contains") });
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-ax",
description: "Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it — extracted with path:line citations from AXorcist (steipete), MacosUseSDK + mcp-server-macos-use (mediar-ai), and AgentAccess (the policy layer inside Agent!). Coordinates and their two origins, why AXChildren lies for Electron, container-role pruning that skips tables, AXPress vs CGEvent click vs AXSelected vs AXValue, messaging timeouts, observers, TCC identity, and the exact CLI/JSON protocol. Use when Robert says: /snappy-ax, 'click the button in <app>', 'read the UI of <app>', 'what's on screen in <app>', 'drive Finder / Mail / Messages / Slack', 'the click didn't land', 'it can't find the element', 'AXUIElement', 'accessibility tree', 'use axorc', 'use macos-use'. NOT vision/pixel automation (see snappy-desktop). NOT AppleScript (see desktop-automation). NOT voice input (see snappy-voice-control). NOT hosting Claude Code in the app (see snappy-agent-host). Triggers on: accessibility, AXUIElement, axorc, macos-use, AXorcist, click element, UI tree, AXPress.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "unsupported_platform"),
verbs: {
"at-point": {
args: ["app","x","y"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application name or bundle id to inspect" }, x: { type: "integer", description: "Screen x coordinate in points" }, y: { type: "integer", description: "Screen y coordinate in points" } } },
},
find: {
args: ["app"], effect: "read", flags: { role: "--role", title: "--title", identifier: "--identifier", value: "--value", contains: "--contains", depth: "--depth", attribute: "--attribute" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application name or bundle id to search" } } },
},
focused: {
args: ["app?"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application to read the focused element of; omit for the frontmost app" } } },
},
"macos-use": {
args: ["tool","json-args?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { tool: { type: "string", description: "macos-use tool name to invoke" }, "json-args": { type: "string", description: "JSON object of arguments for that tool" } } },
},
permissions: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
press: {
args: ["app"], effect: "write-reversible", flags: { role: "--role", title: "--title", identifier: "--identifier", value: "--value", contains: "--contains" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application owning the element to press" } } },
},
raw: {
args: ["json-envelope"], effect: "read", flags: { scanAll: "--scan-all", noStopFirst: "--no-stop-first", timeout: "--timeout" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "json-envelope": { type: "string", description: "JSON query envelope passed straight to the accessibility bridge" } } },
},
"set-value": {
args: ["app"], effect: "write-reversible", flags: { value: "--value", role: "--role", title: "--title", identifier: "--identifier", contains: "--contains" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application owning the element whose value is set" } } },
},
text: {
args: ["app"], effect: "read", flags: { role: "--role", title: "--title", depth: "--depth" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application whose visible text is read" } } },
},
tree: {
args: ["app"], effect: "read", flags: { depth: "--depth", role: "--role", scanAll: "--scan-all" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application whose accessibility tree is walked" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) { // realpath: skills are symlinked from ~/.claude/skills
(async () => {
const [cmd, ...args] = process.argv.slice(2);
const print = (v: unknown) => console.log(JSON.stringify(v, null, 2));
try {
switch (cmd) {
case "permissions": print(await axPermissions()); break;
case "tree": print(await axDumpTree(args[0], { depth: flag(args, "--depth") ? Number(flag(args, "--depth")) : undefined, role: flag(args, "--role"), scanAll: has(args, "--scan-all") })); break;
case "find": print(await axFind(args[0], { ...specFrom(args), depth: flag(args, "--depth") ? Number(flag(args, "--depth")) : undefined, attributes: flags(args, "--attribute") })); break;
case "focused": print(await axFocused(args[0] && !args[0].startsWith("--") ? args[0] : undefined)); break;
case "at-point": print(await axAtPoint(args[0], Number(args[1]), Number(args[2]))); break;
case "press": print(await axPerformPress(args[0], specFrom(args))); break;
case "set-value": { const v = flag(args, "--value"); if (v == null) throw new Error("set-value: --value required"); const s = specFrom(args); delete s.value; print(await axSetValue(args[0], s, v)); break; }
case "text": print(await axExtractText(args[0], (flag(args, "--role") || flag(args, "--title")) ? specFrom(args) : undefined, { depth: flag(args, "--depth") ? Number(flag(args, "--depth")) : undefined })); break;
case "raw": print(await axRaw(JSON.parse(args[0]), { scanAll: has(args, "--scan-all"), noStopFirst: has(args, "--no-stop-first"), timeoutS: flag(args, "--timeout") ? Number(flag(args, "--timeout")) : undefined })); break;
case "macos-use": print(await macosUse(args[0], args[1] ? JSON.parse(args[1]) : {})); break;
default: console.error("usage: api.ts permissions|tree <app>|find <app>|focused [app]|at-point <app> x y|press <app>|set-value <app> --value v|text <app>|raw '<json>'|macos-use <tool> '<json>'"); process.exit(2);
}
} catch (e) { console.error(String((e as Error).message ?? e)); process.exit(1); }
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-ax/api.ts — macOS Accessibility (AXUIElement) primitives for THIS Mac.
*
* No credentials. Binds to `axorc` (AXorcist 0.1.9 CLI, MIT) for reads and
* actions, and to `mcp-server-macos-use` (mediar-ai, BSL 1.1) over stdio as a
* second road. Both binaries were built on the Mac mini (Xcode 26.4.1) and live
* in ~/projects/cloned-repos/bin/. No npm packages; child_process only.
*
* Every locator uses AX-prefixed attribute names (AXRole, AXTitle, AXValue,
* AXIdentifier) — `{"attribute":"title"}` never matches in AXorcist
* (SingleCriterionMatching.swift:83-106). AXTitle is case-sensitive unless
* `contains`. Every call has a hard timeout because a wedged app blocks AX
* calls forever (AXTimeoutPolicy.swift:126-133).
*
* Usage:
* npx tsx api.ts permissions
* npx tsx api.ts tree <app> [--depth 3] [--role AXButton] [--scan-all]
* npx tsx api.ts find <app> [--role R] [--title T] [--identifier I] [--value V] [--contains] [--depth 10] [--attribute A]...
* npx tsx api.ts focused [app]
* npx tsx api.ts at-point <app> <x> <y>
* npx tsx api.ts press <app> [--role R] [--title T] [--identifier I] [--value V] [--contains]
* npx tsx api.ts set-value <app> --value <text> [--role R] [--title T] [--identifier I] [--contains]
* npx tsx api.ts text <app> [--role R] [--title T] [--depth 3]
* npx tsx api.ts raw '<json envelope>' [--scan-all] [--no-stop-first] [--timeout s]
* npx tsx api.ts macos-use <tool> '<json args>'
*
* Or import as module:
* import { axDumpTree, axFind, axPerformPress, axSetValue, locator, macosUse } from "../snappy-ax/api.ts";
*/
import { env } from "../snappy-settings/load.ts";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// ------------------------------------------------------------ binaries
const BIN_DIR = join(homedir(), "projects", "cloned-repos", "bin");
function resolveBinary(envName: string, name: string, buildHint: string): string {
const fromEnv = process.env[envName] || env(envName, false); // shell env, then snappy-settings/.env.cache
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const local = join(BIN_DIR, name);
if (existsSync(local)) return local;
const which = spawnSync("which", [name], { encoding: "utf8" });
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
throw new Error(`${name} not found (set ${envName}, or put it in ${BIN_DIR}). Build: ${buildHint}`);
}
const axorcBin = () => resolveBinary("SNAPPY_AXORC", "axorc",
"cd ~/projects/cloned-repos/AXorcist && swift build -c release --product axorc (needs Swift 6.2+; the Mac mini has it)");
const macosUseBin = () => resolveBinary("SNAPPY_MACOS_USE", "mcp-server-macos-use",
"cd ~/projects/cloned-repos/mcp-server-macos-use && swift build -c release -Xswiftc -swift-version -Xswiftc 5");
// ------------------------------------------------------------ axorc core
export interface AxRunOpts { input?: string; timeoutMs?: number }
function runAxorc(args: string[], opts: AxRunOpts = {}): string {
const r = spawnSync(axorcBin(), args, { encoding: "utf8", input: opts.input, timeout: opts.timeoutMs ?? 20_000, maxBuffer: 64 * 1024 * 1024 });
if (r.error && (r.error as NodeJS.ErrnoException).code === "ETIMEDOUT") throw new Error(`axorc ${args[0]}: timed out after ${opts.timeoutMs ?? 20_000} ms (wedged app? no messaging timeout armed)`);
if (r.status === 10) throw new Error("axorc: Accessibility permission missing for the HOST process (Terminal/iTerm/app). Grant it: x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
if (r.status !== 0) throw new Error(`axorc ${args[0]} exit ${r.status}: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-4).join(" | ")}`);
return r.stdout;
}
function parseJson(s: string, ctx: string): unknown {
const t = s.trim(); const start = t.indexOf("{"); const arr = t.indexOf("[");
const i = start === -1 ? arr : arr === -1 ? start : Math.min(start, arr);
if (i < 0) throw new Error(`${ctx}: no JSON in output: ${t.slice(0, 200)}`);
return JSON.parse(t.slice(i));
}
// ------------------------------------------------------------ locators
export interface LocatorSpec { role?: string; title?: string; identifier?: string; value?: string; contains?: boolean }
export interface Criterion { attribute: string; value: string; match_type?: "exact" | "contains" | "regex" | "prefix" | "suffix" | "containsAny" }
export interface Locator { match_all: boolean; criteria: Criterion[] }
/** AX-prefixed criteria — the only names AXorcist's search reads. Role stays exact. */
export function locator(s: LocatorSpec): Locator {
const mt: Criterion["match_type"] = s.contains ? "contains" : "exact";
const c: Criterion[] = [];
if (s.role) c.push({ attribute: "AXRole", value: s.role, match_type: "exact" });
if (s.title) c.push({ attribute: "AXTitle", value: s.title, match_type: mt });
if (s.identifier) c.push({ attribute: "AXIdentifier", value: s.identifier, match_type: "exact" });
if (s.value) c.push({ attribute: "AXValue", value: s.value, match_type: mt });
if (!c.length) throw new Error("locator: need at least one of role/title/identifier/value (AXorcist: 'No criteria, no path hint')");
return { match_all: true, criteria: c };
}
// ------------------------------------------------------------ reads
export async function axPermissions(): Promise<{ accessibility: boolean }> {
const r = spawnSync(axorcBin(), ["permissions", "-j"], { encoding: "utf8", timeout: 10_000 });
return parseJson(r.stdout || r.stderr, "permissions") as { accessibility: boolean };
}
export interface TreeOpts { depth?: number; role?: string; scanAll?: boolean; timeoutMs?: number }
/** Accessibility tree. Default search prunes to container roles (tables/rows/cells/toolbars/menus invisible);
* `scanAll` uses the raw `collectAll` road with --scan-all ("May be extremely slow"). */
export async function axDumpTree(app: string, o: TreeOpts = {}): Promise<unknown> {
if (o.scanAll) return axRaw({ command: "collectAll", application: app, max_depth: o.depth ?? 10, ...(o.role ? { filter_criteria: { AXRole: o.role } } : {}) }, { scanAll: true, timeoutMs: o.timeoutMs ?? 120_000 });
const args = ["tree", "--app", app, "--depth", String(o.depth ?? 3), "-j"];
if (o.role) args.push("--role", o.role);
return parseJson(runAxorc(args, { timeoutMs: o.timeoutMs }), "tree");
}
export interface FindOpts extends LocatorSpec { depth?: number; attributes?: string[]; timeoutMs?: number }
export async function axFind(app: string, o: FindOpts): Promise<unknown> {
const args = ["find", "--app", app, "--depth", String(o.depth ?? 10), "-j"];
if (o.role) args.push("--role", o.role);
if (o.title) args.push("--title", o.title);
if (o.identifier) args.push("--identifier", o.identifier);
if (o.value) args.push("--value", o.value);
if (o.contains) args.push("--contains");
for (const a of o.attributes ?? []) args.push("--attribute", a);
return parseJson(runAxorc(args, { timeoutMs: o.timeoutMs }), "find");
}
// ------------------------------------------------------------ raw protocol
export interface RawOpts { scanAll?: boolean; noStopFirst?: boolean; timeoutS?: number; timeoutMs?: number }
export interface AxResponse { command_id?: string; command_type?: string; status: "success" | "error"; data?: unknown; error?: string; error_code?: string; debug_logs?: string[] }
/** The complete axorc JSON protocol. Throws on status:"error" with error_code. */
export async function axRaw(envelope: Record<string, unknown>, o: RawOpts = {}): Promise<AxResponse> {
const env = { command_id: `snappy-${Date.now()}`, ...envelope };
const args = ["raw", "--stdin"];
if (o.scanAll) args.push("--scan-all");
if (o.noStopFirst) args.push("--no-stop-first");
if (o.timeoutS) args.push("--timeout", String(o.timeoutS));
const out = runAxorc(args, { input: JSON.stringify(env), timeoutMs: o.timeoutMs ?? ((o.timeoutS ?? 25) * 1000 + 5000) });
const res = parseJson(out, `raw ${String(env.command)}`) as AxResponse;
if (res.status === "error") throw new Error(`axorc ${String(env.command)}: ${res.error_code ?? "error"} — ${res.error ?? ""}`);
return res;
}
export async function axFocused(app?: string): Promise<AxResponse> {
return axRaw({ command: "getFocusedElement", ...(app ? { application: app } : {}) });
}
/** Hit-test at top-left-origin global points. Pid-verified by axorc. Does not reach Catalyst rows. */
export async function axAtPoint(app: string, x: number, y: number): Promise<AxResponse> {
return axRaw({ command: "getElementAtPoint", application: app, point: [x, y] });
}
/** AXPress on the first element matching the locator. No retry after cannotComplete — it may have fired. */
export async function axPerformPress(app: string, loc: Locator | LocatorSpec): Promise<AxResponse> {
const l = "criteria" in loc ? loc : locator(loc);
return axRaw({ command: "performAction", application: app, locator: l, action_name: "AXPress", max_depth: 20 });
}
/** Write AXValue (the "fastest" typing road). "AXSetValue" is a compat command, not a native action. */
export async function axSetValue(app: string, loc: Locator | LocatorSpec, value: string): Promise<AxResponse> {
const l = "criteria" in loc ? loc : locator(loc);
return axRaw({ command: "performAction", application: app, locator: l, action_name: "AXSetValue", action_value: value, max_depth: 20 });
}
/** extractText REQUIRES a locator ("FTE: No criteria, no path hint" otherwise); default = the first AXWindow. */
export async function axExtractText(app: string, loc?: Locator | LocatorSpec, o: { depth?: number } = {}): Promise<AxResponse> {
const l = loc ? ("criteria" in loc ? loc : locator(loc)) : locator({ role: "AXWindow" });
return axRaw({ command: "extractText", application: app, locator: l, max_depth: o.depth ?? 3 });
}
// ------------------------------------------------------------ macos-use (MCP over stdio)
/** One tools/call against mcp-server-macos-use. Keeps stdin open until the response arrives.
* License: BSL 1.1 — production use non-commercial-only until 2028-04-09. */
export async function macosUse(tool: string, args: Record<string, unknown> = {}, o: { timeoutMs?: number } = {}): Promise<unknown> {
const bin = macosUseBin();
return new Promise((resolve, reject) => {
const p = spawn(bin, [], { stdio: ["pipe", "pipe", "pipe"] });
let buf = ""; let stderr = ""; let done = false;
const finish = (fn: () => void) => { if (done) return; done = true; clearTimeout(timer); try { p.kill("SIGTERM"); } catch { /* */ } fn(); };
const timer = setTimeout(() => finish(() => reject(new Error(`macos-use ${tool}: timed out after ${o.timeoutMs ?? 60_000} ms. stderr: ${stderr.slice(-300)}`))), o.timeoutMs ?? 60_000);
p.stderr.on("data", (d) => { stderr += String(d); });
p.on("error", (e) => finish(() => reject(e)));
p.stdout.on("data", (d) => {
buf += String(d);
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
if (!line.startsWith("{")) continue;
let msg: { id?: number; result?: unknown; error?: { message?: string } };
try { msg = JSON.parse(line); } catch { continue; }
if (msg.id === 1) {
p.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
p.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: tool, arguments: args } }) + "\n");
} else if (msg.id === 2) {
if (msg.error) finish(() => reject(new Error(`macos-use ${tool}: ${msg.error?.message ?? JSON.stringify(msg.error)}`)));
else finish(() => resolve(msg.result));
}
}
});
p.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "snappy-ax", version: "1" } } }) + "\n");
});
}
// ------------------------------------------------------------ CLI
function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
function flags(args: string[], name: string): string[] { const out: string[] = []; args.forEach((a, i) => { if (a === name && args[i + 1]) out.push(args[i + 1]); }); return out; }
const has = (args: string[], name: string) => args.includes(name);
const specFrom = (args: string[]): LocatorSpec => ({ role: flag(args, "--role"), title: flag(args, "--title"), identifier: flag(args, "--identifier"), value: flag(args, "--value"), contains: has(args, "--contains") });
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-ax",
description: "Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it — extracted with path:line citations from AXorcist (steipete), MacosUseSDK + mcp-server-macos-use (mediar-ai), and AgentAccess (the policy layer inside Agent!). Coordinates and their two origins, why AXChildren lies for Electron, container-role pruning that skips tables, AXPress vs CGEvent click vs AXSelected vs AXValue, messaging timeouts, observers, TCC identity, and the exact CLI/JSON protocol. Use when Robert says: /snappy-ax, 'click the button in <app>', 'read the UI of <app>', 'what's on screen in <app>', 'drive Finder / Mail / Messages / Slack', 'the click didn't land', 'it can't find the element', 'AXUIElement', 'accessibility tree', 'use axorc', 'use macos-use'. NOT vision/pixel automation (see snappy-desktop). NOT AppleScript (see desktop-automation). NOT voice input (see snappy-voice-control). NOT hosting Claude Code in the app (see snappy-agent-host). Triggers on: accessibility, AXUIElement, axorc, macos-use, AXorcist, click element, UI tree, AXPress.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "unsupported_platform"),
verbs: {
"at-point": {
args: ["app","x","y"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application name or bundle id to inspect" }, x: { type: "integer", description: "Screen x coordinate in points" }, y: { type: "integer", description: "Screen y coordinate in points" } } },
},
find: {
args: ["app"], effect: "read", flags: { role: "--role", title: "--title", identifier: "--identifier", value: "--value", contains: "--contains", depth: "--depth", attribute: "--attribute" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application name or bundle id to search" } } },
},
focused: {
args: ["app?"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application to read the focused element of; omit for the frontmost app" } } },
},
"macos-use": {
args: ["tool","json-args?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { tool: { type: "string", description: "macos-use tool name to invoke" }, "json-args": { type: "string", description: "JSON object of arguments for that tool" } } },
},
permissions: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
press: {
args: ["app"], effect: "write-reversible", flags: { role: "--role", title: "--title", identifier: "--identifier", value: "--value", contains: "--contains" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application owning the element to press" } } },
},
raw: {
args: ["json-envelope"], effect: "read", flags: { scanAll: "--scan-all", noStopFirst: "--no-stop-first", timeout: "--timeout" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "json-envelope": { type: "string", description: "JSON query envelope passed straight to the accessibility bridge" } } },
},
"set-value": {
args: ["app"], effect: "write-reversible", flags: { value: "--value", role: "--role", title: "--title", identifier: "--identifier", contains: "--contains" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application owning the element whose value is set" } } },
},
text: {
args: ["app"], effect: "read", flags: { role: "--role", title: "--title", depth: "--depth" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application whose visible text is read" } } },
},
tree: {
args: ["app"], effect: "read", flags: { depth: "--depth", role: "--role", scanAll: "--scan-all" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { app: { type: "string", description: "Application whose accessibility tree is walked" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) { // realpath: skills are symlinked from ~/.claude/skills
(async () => {
const [cmd, ...args] = process.argv.slice(2);
const print = (v: unknown) => console.log(JSON.stringify(v, null, 2));
try {
switch (cmd) {
case "permissions": print(await axPermissions()); break;
case "tree": print(await axDumpTree(args[0], { depth: flag(args, "--depth") ? Number(flag(args, "--depth")) : undefined, role: flag(args, "--role"), scanAll: has(args, "--scan-all") })); break;
case "find": print(await axFind(args[0], { ...specFrom(args), depth: flag(args, "--depth") ? Number(flag(args, "--depth")) : undefined, attributes: flags(args, "--attribute") })); break;
case "focused": print(await axFocused(args[0] && !args[0].startsWith("--") ? args[0] : undefined)); break;
case "at-point": print(await axAtPoint(args[0], Number(args[1]), Number(args[2]))); break;
case "press": print(await axPerformPress(args[0], specFrom(args))); break;
case "set-value": { const v = flag(args, "--value"); if (v == null) throw new Error("set-value: --value required"); const s = specFrom(args); delete s.value; print(await axSetValue(args[0], s, v)); break; }
case "text": print(await axExtractText(args[0], (flag(args, "--role") || flag(args, "--title")) ? specFrom(args) : undefined, { depth: flag(args, "--depth") ? Number(flag(args, "--depth")) : undefined })); break;
case "raw": print(await axRaw(JSON.parse(args[0]), { scanAll: has(args, "--scan-all"), noStopFirst: has(args, "--no-stop-first"), timeoutS: flag(args, "--timeout") ? Number(flag(args, "--timeout")) : undefined })); break;
case "macos-use": print(await macosUse(args[0], args[1] ? JSON.parse(args[1]) : {})); break;
default: console.error("usage: api.ts permissions|tree <app>|find <app>|focused [app]|at-point <app> x y|press <app>|set-value <app> --value v|text <app>|raw '<json>'|macos-use <tool> '<json>'"); process.exit(2);
}
} catch (e) { console.error(String((e as Error).message ?? e)); process.exit(1); }
})();
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"not_found",
"unsupported_platform",
] as const satisfies readonly RefusalCode[];
test("snappy-ax: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-ax: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"not_found",
"unsupported_platform",
] as const satisfies readonly RefusalCode[];
test("snappy-ax: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-ax: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
Date: 2026-09-02. Read-only extraction; nothing was built, run, or modified.
| Prefix | Repo | HEAD read | License |
|---|---|---|---|
AX/ |
/Users/robertboulos/projects/cloned-repos/AXorcist (steipete, 162 files) |
37d7ae8 2026-09-01 |
MIT (AX/LICENSE:1-3) |
SDK/ |
/Users/robertboulos/projects/cloned-repos/MacosUseSDK (mediar-ai, 17 files) |
a2d7866 2026-04-25 |
MIT (SDK/LICENSE:1-3) |
MCP/ |
/Users/robertboulos/projects/cloned-repos/mcp-server-macos-use (mediar-ai, 7 files) |
b5b9b9d 2026-04-26 |
BSL 1.1 (MCP/LICENSE:1,33-37) — see §10 |
Every citation is PREFIX/path:line relative to that repo root. Quotes are verbatim. Anything not found in these three repos is marked not in source.
The element handle
AXUIElement is an opaque CF handle; two handles for the same on-screen object compare equal only via CFEqual, and hash via CFHash. AXorcist's Element wrapper defines == and hash(into:) exactly that way and excludes cached attributes/children from identity — AX/Sources/AXorcist/Core/Element.swift:108-115. Traversal visited-sets in both AXorcist (AX/Sources/AXorcist/Search/AXTreeTraversal.swift:44,86-106) and MacosUseSDK (Set<AXUIElement>, SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:99,323-324) rely on this.Element is Sendable only because of @preconcurrency import ApplicationServices — AX/Sources/AXorcist/Core/Element.swift:4,43.kAXErrorInvalidUIElement; AXorcist's message: "The specified UI element is invalid (possibly stale)." — AX/Sources/AXorcist/Core/AccessibilityError.swift:85.Scopes: system-wide, application, window, element
AXUIElementCreateApplication(pid) — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:145. AXorcist validates the app element by checking role() != nil before trusting it (Element.application(for:)), while applicationElement(forProcessID:) only checks pid > 0 — AX/Sources/AXorcist/Core/Element+Factory.swift:15-26, AX/Sources/AXorcist/Core/ElementFactories.swift:29-45.AXUIElementCreateSystemWide(); used for AXFocusedApplication (type-ID checked, then unsafeDowncast) — AX/Sources/AXorcist/Core/AXUIElement+Static.swift:28-45. The "frontmost" app is a different query (NSWorkspace.shared.frontmostApplication.processIdentifier) — :47-58; both focused-window variants exist — :81-92.0 and the system-wide AX element cannot receive notifications." — AX/README.md:514-515; subscribe(pid: nil) fails explicitly with "macOS AXObserver requires an application PID; use NotificationWatcher(globalNotification:) for native global fan-out" — AX/Sources/AXorcist/Core/AXObserverCenter.swift:171-186; PID 0 is refused before any native call — :223-226.element == nil → process scope; an element equal to AXUIElement.application(pid:) is also process scope; anything else is element scope — AX/Sources/AXorcist/Core/AXObserverCenter.swift:494-506."AXSystemWide" — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:268-342 (kAXSystemWideRole).Roles, subroles, attributes, actions, parameterized attributes
"AXButton"-style; AXorcist's constant tables list roles/subroles including AXSwitch, AXPopover, AXWebArea, and notes kAXSearchFieldRole is "Often a subrole of text field" and kAXDialogRole "Often a subrole of window" — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:268-342. Subroles used for dock items: applicationDockItem, folderDockItem, fileDockItem, urlDockItem, minimizedWindowDockItem — AX/Sources/AXorcist/Core/Element+TypeChecking.swift:15-67.AXUIElementCopyAttributeNames and AXUIElementCopyActionNames; settability via AXUIElementIsAttributeSettable — MCP/scripts/ax_inspect.swift:29-45. AXorcist: "Action names have a dedicated Accessibility API; they are not a standard attribute." (falls back to an AXActionNames attribute only second) — AX/Sources/AXorcist/Core/Element+Properties.swift:112-121."AXStringForRange", "AXRangeForLine", "AXBoundsForRange", "AXLineForIndex", "AXRangeForPosition", "AXRangeForIndex", "AXRTFForRange", "AXAttributedStringForRange", "AXStyleRangeForIndex", "AXCellForColumnAndRow" — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:179-180,196-204; changelog: "Use the native macOS names for parameterized accessibility attributes instead of non-existent Parameterized-suffixed raw values." — AX/CHANGELOG.md:40. AXActionDescription is itself parameterized by the action name — :148,217.CFRange → AXValueCreate(.cfRange, &range); Element → its AXUIElement; String → CFString; NSNumber passthrough; no CGPoint bridging exists, so AXRangeForPosition has no convenience path — AX/Sources/AXorcist/Core/Element+ParameterizedAttributes.swift:31-46. AXCellForColumnAndRow takes [NSNumber(col), NSNumber(row)] — :106.AXValue boxed types: AXValueGetType must be checked before AXValueGetValue; .illegal → nil — AX/Sources/AXorcist/Core/AXValue+Extensions.swift:16-80. Raw type 4 is ambiguous: "AXValueType.cfRange also uses raw value 4, so raw-value guesses can corrupt range-based attributes like selectedTextRange into booleans." — AX/Sources/AXorcist/Values/ValueUnwrapper.swift:71-72; the older formatter still treats rawValue == 4 as Boolean first — AX/Sources/AXorcist/Values/AXValueSpecificFormatter.swift:14-23; fix recorded in AX/CHANGELOG.md:74. Swift's AXValueType enum is not exhaustive ("Common missing ones include Boolean (4), Number (5), Array (6), Dictionary (7), String (8), URL (9)") — AX/Sources/AXorcist/Values/ValueHelpers.swift:62-66.AXValue can carry an AXError payload (AXValueType.axError) — AX/Sources/AXorcist/Core/AXValue+Extensions.swift:47-53."AXLabel", "AXPlaceholderValue", "AXLinkedUIElements", "AXServesAsTitleForUIElements", "AXTitledUIElements", "AXDescribesUIElements", "AXEditable", "AXInsertionPointLineNumber", "AXTitleUIElement", "AXMenuItemCmdChar", "AXMenuItemCmdVirtualKey", "AXMenuItemCmdModifiers", "AXMenuItemMarkChar", "AXKeyboardShortcut" ("non-standard but sometimes used") — AX/Sources/AXorcist/Core/Element+TextAttributes.swift:10-131,130. Also "AXPid", "AXDOMClassList", "AXDOMIdentifier", "AXAlternateUIVisible", "AXTopLevelUIElement", "AXPlaceholderText" // Non-standard, but sometimes seen, "AXLabelValue", "AXTabs", "AXURL", "AXDocument", "AXContents" — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:64,75-76,82,92,135-136,166-171,187. AXEnhancedUserInterface is commented out as // Bool (private) — :103; AXFrame commented out "Less common, usually derived" — :127. AXManualAccessibility: not in source."AXWebAreaChildren", "AXHTMLContent", "AXApplicationNavigation", "AXApplicationElements", "AXBodyArea", "AXDocumentContent", "AXWebPageContent", "AXSplitGroupContents", "AXLayoutAreaChildren", "AXGroupChildren" — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:221-232.What "children" means (this is where every naive walker goes wrong)
children(strict:) fetches kAXChildrenAttribute then 14 alternatives: kAXVisibleChildren, AXWebAreaChildren, AXApplicationNavigation, AXApplicationElements, AXBodyArea, AXSplitGroupContents, AXLayoutAreaChildren, AXGroupChildren, kAXContents, "AXChildrenInNavigationOrder", kAXSelectedChildren, kAXRows, kAXColumns, kAXTabs — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:112-120; "collectAlternativeChildren may be expensive, so respect strict flag there." — :38.AXWindows and AXFocusedUIElement: "Some Electron apps only expose the front-most window via kAXChildrenAttribute, while all other windows are available via kAXWindowsAttribute. Not including the latter caused our searches to remain inside the first window (depth ≈ 37) and never reach hidden/background chat panes." — :43-48; "This exposes the single element (often a remote renderer proxy) that currently has keyboard/accessibility focus – crucial for Electron/Chromium where the deep subtree is not reachable through normal children." — :50-54. Consequence: the focused element appears at depth 1 under the app regardless of its real nesting.AXWindows → AXMainWindow → ranged AXChildren — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:375-411.AX/Sources/AXorcist/Core/Element+Hierarchy.swift:184-206; nil (not []) is returned when nothing was collected — :208-216.Focus
AXFocusedUIElement on the app element — AX/Sources/AXorcist/Core/Element+Properties.swift:10-207 (focusedUIElement()); MCP reads it raw and falls back to the parent's text — MCP/scripts/coord_test.swift:41-52.AX/Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift:34-43.AXFocused == true or settable to true) — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:176-179,196-198; changelog rationale: "Refuse element-scoped typing when native focus cannot be established, preventing keyboard events from reaching an unrelated focused app." — AX/CHANGELOG.md:25.setFocusedValue: if AXFocused is settable set it; else try AXPress "to potentially gain focus"; then set AXValue regardless ("but proceeding to set value.") — AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:72-75,263-317.Element.activate() tries AXFrontmost = true if settable, else AXRaise — AX/Sources/AXorcist/Core/Element+ApplicationActions.swift:29-62; focusWindow() does NSRunningApplication.activate(options: [.activateAllWindows]), ignores a false return ("// Continue anyway - sometimes activation reports false but works"), sleeps 0.1 s, then AXRaise, then falls back to setting kAXMain = true — AX/Sources/AXorcist/Core/Element+WindowOperations.swift:198-259,227,230.Coordinates — three spaces, two origins
AXPosition/AXSize are global screen points with top-left origin: "Reads the screen-space frame (origin top-left) of an AX element." — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:26. CGEvent mouse positions are in the same space: "AX coordinates and CGEvent coordinates are in the same logical point space" — MCP/CLAUDE.md:47.NSScreen, NSWindow, NSEvent.mouseLocation) is bottom-left origin: "AppKit coordinates (bottom-left origin) are used by NSWindow positioning." — SDK/Sources/MacosUseSDK/DrawVisuals.swift:232-234; conversion originY = screenHeight - point.y - (effectiveSize.height / 2.0) // Convert Y from top-left to bottom-left — :278-286; highlight boxes convertedY = screenHeight - originalY - elementHeight — :426.primaryScreen.frame.height - nsPos.y using NSScreen.screens.first — MCP/Sources/MCPServer/main.swift:1805-1807. SDK uses NSScreen.main?.frame.height ?? 0 and warns "coordinates might be incorrect" if 0 — SDK/Sources/MacosUseSDK/DrawVisuals.swift:280-283,414-416.NSScreen containing the AppKit point, compute the local offset, map into CGDisplayBounds(displayID) with y = quartzFrame.minY + quartzFrame.height - localY; display ID from screen.deviceDescription["NSScreenNumber"] — AX/Sources/AXorcist/Core/AppLocator.swift:124-158; test: AppKit (100,200) on a 1080-high screen → Quartz (100, 880) — AX/Tests/AXorcistTests/AppLocatorTests.swift:139-157. Doc: "A point in Quartz global screen coordinates. When omitted, the current AppKit mouse location is translated into the matching display's Quartz coordinate space." — AX/Sources/AXorcist/Core/AppLocator.swift:37-38.isActionable()/isOnAnyScreen() compare the AX (top-left) frame directly against NSScreen.frame (bottom-left) with no flip — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:150-164, AX/Sources/AXorcist/Core/Element+WindowOperations.swift:304-310.MCP/CLAUDE.md:46 (negative x is normal).MCP/CLAUDE.md:47. The screenshot helper handles scale explicitly: scaleX = imageWidth / windowRect.width (image pixels ÷ window points) — MCP/Sources/ScreenshotHelper/main.swift:53-58; it also flips Y for CoreGraphics drawing (drawY = imageHeight - localY) — :66-68.MCP/Sources/MCPServer/main.swift:1503.AXPosition/AXSize appear only as attribute entries ({"x","y"}/{"width","height"}) — AX/Sources/AXorcist/Core/ResponseModels.swift:104-112, AX/Sources/AXorcist/Search/AttributeBuilders.swift:44-51. frame() is two separate AX calls — AX/Sources/AXorcist/Core/Element+ConvenienceAttributes.swift:50-57.size.width > 0 || size.height > 0; zero-width or zero-height dimensions are nulled — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:339-345.Hit-testing (AXUIElementCopyElementAtPosition)
Float x/y — AX/Sources/AXorcist/Core/AXUIElement+Static.swift:61-76; AXorcist passes points unmodified — AX/Sources/AXorcist/Core/Element+Factory.swift:55-56,62-63.pid != 0, system-wide when pid == 0 — AX/Sources/AXorcist/Core/Element+Factory.swift:46-71. The CLI handler always resolves an app first, requires pid > 0, then verifies the returned element's pid matches and otherwise errors: "The element at the requested point did not belong to …" — AX/Sources/AXorcist/Core/AXorcist+GetElementAtPointHandler.swift:20-44.AXUIElementCopyElementAtPosition does not reliably penetrate into table rows in Catalyst apps; the rows are reachable by walking the tree but not by hit-test." — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:47-49; "Catalyst hit-tests typically return an AXCell or AXStaticText inside a row, but the selectable element is the parent AXRow." — :91-92; "Catalyst hit-test is unreliable for table rows (returns the window-level AXGroup, not the row)." — :186-187. SDK therefore BFS-walks the app tree for the smallest frame containing the point, preferring roles, capped at 4000 nodes — :43-77; ancestor walk capped at 12 — :93-107.MCP/Sources/MCPServer/main.swift:1075-1104, depth cap 25 — :1078. But it is deliberately not used to refine in-viewport clicks: "the AX tree has overlapping full-width group elements (e.g. message rows spanning the entire window) that would shadow sidebar items and send clicks to the wrong location." — :1189-1195.Element.elementAt(_:role:) walks up the parent chain until the role matches — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:735-755.Windows ↔ CGWindowID
@_silgen_name("_AXUIElementGetWindow") to get a CGWindowID from an AX window — AX/Sources/AXorcist/Core/AXWindowResolver.swift:15-17; cross-app lookup fast path via CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) → kCGWindowOwnerPID, with "// Fallback: full AX enumeration (works without Screen Recording permission)." — :57-77. WindowInfoHelper matches AX bounds to kCGWindowBounds with tolerance 1.0 — AX/Sources/AXorcist/Utils/WindowInfoHelper.swift:68-112 (its doc claims to use the private API but the body matches bounds — :67,76-108).kCGWindowLayer == 0 — MCP/Sources/MCPServer/main.swift:393-425. Sheets are found as AXSheet-role children of AXWindow — :241-278.CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements]), first window in list order whose bounds contain the point and whose owner is activationPolicy == .regular && !isHidden && bundleIdentifier != nil — AX/Sources/AXorcist/Core/AppLocator.swift:63-101,103-122; exactApp(at:) never falls back to frontmost, app(at:) does ("Compatibility lookup for legacy pointer workflows.") — :31-51.AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": kCFBooleanTrue] as CFDictionary). MacosUseSDK does this on every traversal, using the literal string key — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:135-142. AXorcist obtains the key as kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String — AX/Sources/AXorcist/Core/CFConstants.swift:26, and only prompts in askForAccessibilityIfNeeded() / AXTrustUtil.checkAccessibilityPermissions(promptIfNeeded:) — AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:40-51, AX/Sources/AXorcist/Utils/AXTrustUtil.swift:14-19.AXIsProcessTrusted() — AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:59-62; isAccessibilityApiEnabled and isProcessTrustedForAccessibility are the same value — AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:69,78-80. The isAXFeatureEnabled JSON command calls AXIsProcessTrustedWithOptions(nil) — AX/Sources/axorc/CommandExecutor.swift:253-262.false if XCTestConfigurationFilePath is set, --test-mode is an argument, or NSClassFromString("XCTest") != nil — AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:42-48 ("// Skip permission dialog in test environment").Timer on RunLoop.main (default mode) calls AXIsProcessTrusted() every interval (default 1.0 s) and yields on change; initial state yielded immediately — :128-135,142-175,207-216. Cancellation must not block the main queue — :160-173,189-226; tests AX/Tests/AXorcistTests/PermissionChangeStreamTests.swift:8-39.getppid(): "Hint: Grant accessibility permissions to \(parentName!)." — AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:43-51,112-121, AX/Sources/AXorcist/Core/ProcessUtils.swift:208-219. MCP: "The host application (Claude Desktop, Terminal, iTerm, VS Code, etc.) must have Accessibility permission granted" — MCP/llms.txt:137.CGEvent.tapCreate returns nil → "error: InputGuard: failed to create CGEventTap (check Accessibility permissions)" — MCP/Sources/MCPServer/InputGuard.swift:140. MacosUseSDK throws .accessibilityDenied with the System Settings path — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:17-18,138-141. AXorcist maps apiDisabled/notAuthorized to exit code 10 — AX/Sources/AXorcist/Core/AccessibilityError.swift:164; kAXErrorAPIDisabled → permission_denied error code — AX/Sources/AXorcist/Core/AXError+Extensions.swift:94-107.axorc permissions prints Accessibility: granted|missing (exit 1 when missing), JSON {"accessibility":true|false} — AX/Sources/axorc/CLIFrontend.swift:131-144. Deep link: x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility — AX/Sources/AXorcist/Utils/AXTrustUtil.swift:27-35.--adhoc; a stable signature keeps the macOS Accessibility identity consistent across upgrades." — AX/docs/releasing.md:3; "The designated requirement must contain anchor apple generic; an ad-hoc requirement is a release blocker." — :44. Release signing: codesign --force --options runtime --timestamp --sign "$AXORC_CODESIGN_IDENTITY" vs ad-hoc --sign - — AX/scripts/build-universal-binary.sh:66-70; "Rewriting tools can narrow permissions under a restrictive caller umask." → chmod 0755 after codesign — :72-73. Homebrew formula test asserts codesign --verify --strict and anchor apple generic — AX/packaging/homebrew/axorc.rb.template:23-28. "spctl --assess --type execute is an app assessment and can reject valid standalone executables as not being apps." — AX/docs/releasing.md:28; "Zip archives cannot be stapled" — :17. (TCC reset on bundle-id/signature change is implied by these, but no explicit reset/tccutil logic exists — not in source.)ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil — AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:70-73; only a warning results — AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:72-74. MacosUseSDK: CGEvent Unicode typing "works in sandboxed processes, requires no Script Editor consent, and does not fork-exec per call" (vs. its prior AppleScript path) — SDK/Sources/MacosUseSDK/InputController.swift:176-178; AX-driven writes exist for "Sandboxed/secure-input contexts where the HID tap is filtered." — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:9-10.AX/Sources/AXorcist/Core/AXWindowResolver.swift:69. MCP screenshots use CGWindowListCreateImage(.null, .optionIncludingWindow, windowID, [.boundsIgnoreFraming, .bestResolution]) — MCP/Sources/ScreenshotHelper/main.swift:45 (permission requirements for it: not in source).automationStatus is always [:], canAutomate always nil — AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:16-33,92-104; CI forbids NSAppleScript|NSUserAppleScriptTask|OSAKit|…|AESend|osascript in sources and the linked binary — AX/scripts/test-native-ax-only.sh:8-19,46-56,63-68; changelog AX/CHANGELOG.md:41. MacosUseSDK still carries an osascriptExecutionFailed error case — SDK/Sources/MacosUseSDK/InputController.swift:16-18, and its test teardown drives TextEdit through osascript System Events — SDK/Tests/MacosUseSDKTests/CombinedActionsFocusVisualizationTests.swift:59-90.NSApplication.shared + setActivationPolicy(.accessory) "// Don't show in dock or Cmd+Tab" — MCP/Sources/MCPServer/InputGuard.swift:203-205..regular activation-policy apps in SDK traversal — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:150-158.SDK/Tests/MacosUseSDKTests/CombinedActionsDiffTests.swift:12; AXorcist gates automation tests on RUN_AUTOMATION_TESTS=true/RUN_LOCAL_TESTS=true — AX/Tests/AXorcistTests/CommonTestHelpers.swift:18-26; only PingIntegrationTests runs headless (2.39 % coverage) — AX/README.md:803-806.MacosUseSDK (traverseAccessibilityTree)
SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:302-305.maxDepth = 100, maxElements = 2000, maxTraversalSeconds = 5.0 — :103-105; maxChildrenPerElement = 200 — :309; on any cap statistics.truncated = true and the walk returns — :313-317,166-168.AXUIElementGetAttributeValueCount + AXUIElementCopyAttributeValues(element, kAXChildrenAttribute, 0, fetchCount, …) "to avoid blocking on huge containers" — :395-411. MCP's sheet finder does the same with 50 — MCP/Sources/MCPServer/main.swift:255-262.AXRole, AXRoleDescription, AXValue, AXTitle, AXDescription, "AXLabel", "AXHelp", AXPosition, AXSize — :265-300. Text is the concatenation of the five text attrs joined by " " — :272-283. Non-CFString values (e.g. numeric AXValue) are dropped: "AXValue conversion is complex, return nil for generic string conversion" — :226-229.nonInteractableRoles (AXGroup, AXStaticText, AXUnknown, AXSeparator, AXHeading, AXLayoutArea, AXHelpTag, AXGrowArea, AXOutline, AXScrollArea, AXSplitGroup, AXSplitter, AXToolbar, AXDisclosureTriangle) are kept only if they have text — :109-114,357-358. Role display becomes "AXButton (button)" when the role description differs — :352-355.Set<ElementData> hashed on role+text+x+y+w+h, so identical-looking elements collapse — :32-56,100,365; then sorted by y then x (nil last) — :173-180. The tree structure is gone; MCP's later comment "the traversal is depth-first, so children follow the parent" (MCP/Sources/MCPServer/main.swift:548) is therefore wrong on both counts.attributeUnsupported/noValue silently; others commented-out warning) — :208-217.AXUIElementSetMessagingTimeout call exists anywhere in SDK/Sources (grep); a wedged app is bounded only by the 5 s wall clock checked between nodes..regular app and not active, with the delay left commented out (// Thread.sleep(forTimeInterval: 0.2)) — :148-161.count, excluded_count, excluded_non_interactable, excluded_no_text, with_text_count, without_text_count, visible_elements_count, truncated, role_counts — :59-69.mcp-server-macos-use (on top of the SDK)
AXUIElementSetMessagingTimeout(…, 5.0) on every app/window/child element it creates itself — MCP/Sources/MCPServer/main.swift:245,253,265,310,329,341,350,1182.in_viewport means "the element's top-left point lies inside any of the app's AXWindow frames" (or the AXSheet frame when a sheet exists) — :513-544,631-637; multi-window rationale "(e.g. Sparkle update dialogs)" — :295-296./tmp/macos-use/<ms-timestamp>_<tool>.txt plus a .png — :1961-1983; line format [Role] "text" x:N y:N w:W h:H visible — :992-1010, MCP/CLAUDE.md:39. Diff prefixes + - ~ — :1034-1048. The compact summary inlines up to 30 interactive + 10 static-text visible elements — :954; interactive role prefixes AXButton, AXLink, AXTextField, AXTextArea, AXCheckBox, AXRadioButton, AXPopUpButton, AXComboBox, AXSlider, AXMenuItem, AXMenuButton, AXTab — :937-941.SDK/Sources/MacosUseSDK/ActionCoordinator.swift:264-302; double tolerance 0.01 — :459-469. MCP then drops scroll-bar noise (scrollbar|scroll bar|value indicator|page button|arrow button) and textless structural rows/cells/columns/menus, and discards coordinate-only modifications — MCP/Sources/MCPServer/main.swift:591-607,649-718.:546-589.Task { @MainActor in … } — :1613-1615,1842-1845.hasDiff = false) — :1849-1890.AXorcist
traverseAXTree(from:initialDepth:maxDepth:order:strictChildren:timeout:now:children:shouldDescend:onTimeout:visit:), @MainActor, internal — AX/Sources/AXorcist/Search/AXTreeTraversal.swift:119-133. DFS is a stack with children pushed reversed; BFS indexes into the same growing array — :60-84. Default order .depthFirst.depth == maxDepth are visited but not expanded — :147-149,167-169; test maxDepth: 1 → ["root","left","right"] — AX/Tests/AXorcistTests/TraversalKernelTests.swift:59-71.:135,142-146. The result (visitedCount, timedOut, stopped) is discarded by every public surface; a timed-out search is only visible as the log line "Traverse: search timeout (…s) reached. Aborting traversal." — AX/Sources/AXorcist/Search/ElementSearch.swift:387,414,433.AX/Sources/AXorcist/Search/AXTreeTraversal.swift:44-45,86-116; cycles terminate — AX/Tests/AXorcistTests/TraversalKernelTests.swift:10-24. Visited state is per call — AX/Tests/AXorcistTests/TraversalStateTests.swift:8-23; changelog "Keep accessibility-tree traversal state local to each search … so repeated lookups cannot skip elements seen by earlier commands." — AX/CHANGELOG.md:30.shouldDescend is scanAll || containerRoles.contains(role) — AX/Sources/AXorcist/Search/ElementSearch.swift:410-412; containerRoles = Application, Window, Group, ScrollArea, SplitGroup, LayoutArea, LayoutItem, WebArea, List, Outline, Unknown, AXGeneric, AXSection, AXArticle, AXSplitter, AXScrollBar, AXPane, MenuBar — :599-614. Not containers: AXTable, AXRow, AXCell, AXToolbar, AXTabGroup, AXMenu, AXMenuItem, AXPopUpButton, AXRadioGroup, AXButton, AXTextArea. An element with nil role is a leaf. CLI escape hatch --scan-all "Traverse every node (ignore container role pruning). May be extremely slow." — AX/Sources/axorc/AXORCMain.swift:70. Menu bars are traversed by default since 0.1.7 — AX/CHANGELOG.md:26.maxChildrenPerElement = 50000 — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:169-172. Defaults: AXTraversalOptions.standard = (timeout: 30, scanAll: false, stopAtFirstMatch: true) — AX/Sources/AXorcist/Search/AXTraversalOptions.swift:7-14; depth constants collectAll 5 / search 10 / describe 3 / hint step 3 / max elements 1000 / 2.0 s per-element collectAll — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:424-430 (the CLI collectAll actually defaults to max_depth ?? 10 — AX/Sources/axorc/CommandTypeExtensions.swift:93).collectAll silently drops hidden subtrees (isHidden() == true → .skipChildren) unless include_ignored_elements — AX/Sources/AXorcist/Search/ElementSearch.swift:564-567, AX/Sources/AXorcist/Core/Element+Properties.swift:60-62.rawAttributeValue logs and returns nil for every AXError (no distinction for cannotComplete) — AX/Sources/AXorcist/Core/Element.swift:137-156; children() returns nil on error and the kernel treats nil/empty as a leaf, so a subtree lost to kAXErrorCannotComplete disappears silently — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:101-107, AX/Sources/AXorcist/Search/AXTreeTraversal.swift:170-172.runTraversal computes briefDescription(.smart) for every visit just for logging (role, pid, title, identifier, domIdentifier), and SearchVisitor.visit does it again — AX/Sources/AXorcist/Search/ElementSearch.swift:417,493, AX/Sources/AXorcist/Core/Element+Description.swift:42-57; criteria matching computes briefDescription(.raw) per criterion per element — AX/Sources/AXorcist/Search/CriteriaMatchingHelpers.swift:20,53. Only prefetched attributes/prefetchedChildren/actions are cached — AX/Sources/AXorcist/Core/Element.swift:93-105,119-134.Element.searchElements/findElement walk with no timeout and no container pruning; maxDepth = 0 means unlimited — AX/Sources/AXorcist/Core/Element+Search.swift:16,50-52,73-75,97-99,193.Element.withMessagingTimeout(_:operation:) arms AXUIElementSetMessagingTimeout, runs the operation, then resets to 0; if arming fails the operation is never run (systemFailure); a failed reset is reported instead of the operation result — AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:16-49,121-133; tests AX/Tests/AXorcistTests/AXTimeoutHelperTests.swift:90-125,166-186. Timeout must be finite and > 0 — :117-119. Nesting on the same AXUIElement (by ObjectIdentifier) throws nestedScope; all system-wide references share one scope — :85-109. Global: AXTimeoutConfiguration.setGlobalTimeout on the system-wide element — :140-150. windowsWithTimeout(timeout: 2.0), menuBarWithTimeout(2.0) — :52-61. Changelog: "Refuse per-element Accessibility reads when macOS cannot arm their messaging deadline" — AX/CHANGELOG.md:33. Observer registration uses 0.5 s and resets to 0 — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:693-721.seconds elapse even if operation ignores cancellation. The leftover work is asked to cancel but is not joined." — AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:196-199; "// GCD timer, not Task.sleep: CI Swift 6.2.1 serialized the sleeper Task behind the uncooperative work Task." — :242-243; "A throwing TaskGroup would still join an uncooperative child after the timeout throw." — :208-209.children() recursively with no cycle detection, first non-empty of title → value → description → help, else children joined — AX/Sources/AXorcist/Utils/TextExtraction.swift:7-50; the non-recursive variant concatenates instead — :87-125. Path generation walks up ≤ 25 levels and stops at AXApplication or a window whose parent is the app — AX/Sources/AXorcist/Core/Element+PathGeneration.swift:13,41-64; generatePathArray also dumps every ancestor's attributes — :86-88.AXElementData): brief_description, role, attributes{name:{any_value}}, all_possible_attributes, textual_content, children_brief_descriptions, full_ax_description, path[] — AX/Sources/AXorcist/Core/ResponseModels.swift:79-113; textual_content uses extractTextFromElement(maxDepth: 3); path splits generatePathString() on " -> " — AX/Sources/AXorcist/Core/AXorcist+QueryHandlers.swift:186-215. Value sanitizer placeholders "<AXUIElement_RS>", "<max_depth_reached>" (depth 50), "<circular_reference>" — AX/Sources/AXorcist/Core/DataModels.swift:91-125.AXorcist criteria matching
exact, contains, regex, containsAny, prefix, suffix — AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:29-36. There is no "case-insensitive" type; case sensitivity is per attribute: role/subrole insensitive, identifier sensitive, every generic attribute including AXTitle sensitive — AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:26,45,63,134-151, AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:235. (README says contains is "Case-insensitive substring match" — AX/README.md:265 — that is true only for the attributes marked insensitive.)compareStrings: a nil/empty actual value matches only if expected is empty and type is .exact; .contains "" against a missing attribute is a mismatch — AX/Sources/AXorcist/Search/StringComparisonLogic.swift:48-56. .exact uses localizedCompare == .orderedSame — :65-67; .regex is unanchored — :79-80; .containsAny splits on "," — :90-97.criterionKey recognizes only axrole|role, axsubrole|subrole, axidentifier|identifier|id, pid, axdomclasslist|domclasslist|classlist|dom, isignored|ignored, computedname|name, computednamewithvalue|namewithvalue; anything else is a generic key fetched literally with the original casing — AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:83-106,200-211. So {"attribute":"title"} reads an attribute called title and never matches; write AXTitle. The lower-case aliases (title, value, help, description, placeholder, enabled, focused) in PathUtils.attributeKeyMappings apply only to the legacy PathHintComponent — AX/Sources/AXorcist/Core/PathUtils.swift:5-19, AX/Sources/AXorcist/Search/PathHintComponent.swift:30-37,71. README's "Searchable Attributes" list of aliases (AX/README.md:271-293) overstates what criteria accepts."button" ≠ "AXButton"); role compare is case-insensitive exact — AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:21-28.match_type is used as the fallback for the others — AX/Sources/AXorcist/Search/ElementSearch.swift:231; per-criterion override — AX/Sources/AXorcist/Search/CriteriaMatchingHelpers.swift:14.matchesAll → true, matchesAny → false — :13-30,39-44; a Locator with neither criteria nor path hint errors "FTE: No criteria, no path hint"; path hint only returns the path element — AX/Sources/AXorcist/Search/ElementSearch.swift:135-144.computedName() priority: AXTitle → AXValue (String only, prefix(50)) → AXIdentifier → AXDescription → AXHelp → AXPlaceholderValue → AXRole with every "AX" substring removed — AX/Sources/AXorcist/Core/Element+ComputedName.swift:17-50. A text field's computed name is therefore its current text, not its label.AXDOMClassList .exact means "array contains this token" (not whole-list equality); .contains is localizedCaseInsensitiveContains over the joined string; accepts [String] or a space-separated string — AX/Sources/AXorcist/Search/SpecificCriterionMatchers.swift:100-147; on miss falls back to AXDOMIdentifier then AXIdentifier — AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:81-118. Role matching logs the DOM class list at INFO for every AXTextArea — :14-21.pid criterion is string equality on element.pid() — AX/Sources/AXorcist/Search/SpecificCriterionMatchers.swift:8-51.stopAtFirstMatch == false (--no-stop-first), foundElement is the last preorder match and allFoundElements has all — AX/Sources/AXorcist/Search/ElementSearch.swift:521-522, test AX/Tests/AXorcistTests/TraversalKernelTests.swift:131-156; the field comment "Stores the first element that matches criteria" is wrong in that mode — :456."Max depth visited = N of M" and nodes visited — :258-261.Element.matches(query:) (the lightweight API) is a case-insensitive substring over 8 fields including roleDescription, so "button" matches every button — AX/Sources/AXorcist/Core/Element+Search.swift:141-162; ElementSearchOptions defaults maxDepth 0 (unlimited), caseInsensitive true, visibleOnly false, enabledOnly false — :14-34. findElements(label:) matches against descriptionText() ("// Check label (using description as label)") — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:800-802.SDK collapses identical ElementData in a Set (§3). No "nth match" selector exists in either.AXorcist path queries (two engines with different semantics)
Locator = criteria + optional path_from_root (rootElementPathHint: [JSONPathHintComponent]); the path is navigated first from the app root, then criteria are searched under the resolved element — AX/Sources/AXorcist/Search/ElementSearch.swift:41-43,119-157. Fields descendantCriteria, requireAction, computedNameContains are decoded but never read; debugPathSearch is passed then ignored — AX/Sources/AXorcist/Core/MatchingTypes.swift:106-166, AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:115.{"attribute":"ROLE|SUBROLE|TITLE|ID|IDENTIFIER|DOM|DOMCLASS|DOMID|VALUE|HELP|DESCRIPTION|PLACEHOLDER" (case-insensitive) or raw AX name, "value":…, "depth":N?, "match_type":…?} — AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:7-66.defaultDepthForSegment = 3 is declared but unused; both engines use depth ?? 1 — AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:90, AX/Sources/AXorcist/Search/PathNavigationJSON.swift:73, AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:191,199. README claims "default: 3" — AX/README.md:355.findTargetElement → findDescendantAtPath): for each component, each child of the current node gets its own SearchVisitor traversal with stopAtFirstMatch: true and the child at depth 0, so depth: N searches N+1 levels; each child traversal gets the full timeout (a step over K children can cost K × timeout) — AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:186-205. If the path fails, criteria are not tried — AX/Sources/AXorcist/Search/ElementSearch.swift:129-131,207-212.getElement(appIdentifier:pathHint:) → navigateToElementByJSONPathHint): an attribute name outside the uppercase map yields empty criteria, which match unconditionally — an unknown attribute resolves to the first child — AX/Sources/AXorcist/Search/PathNavigationJSON.swift:169-172,185-198,210-212; depth > 1 runs a BFS from the current node that may match the node itself — :73,78-84,231-263; depth == 1 tries children then the node itself — :133-156; aborts when the component index ≥ maxDepth — :33-39; a leading "application" component is skipped — :25-31. Numeric appIdentifier is treated as a PID — AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:67-71.key:value[, key:value], quotes stripped, keys not aliased (role:AXButton works, title:X does not — use AXTitle:X), match type fixed (AXDOMClassList → contains, else exact) — AX/Sources/AXorcist/Search/PathNavigationCore.swift:17-55, AX/Sources/AXorcist/Core/PathUtils.swift:33-58, AX/Sources/AXorcist/Search/PathNavigationMatching.swift:81-84.AXWindow[1]) exists in any parser; selection is always first match in children order.Criterion CodingKeys deliberately have no raw values because the CLI decoder uses .convertFromSnakeCase ("Using a custom raw value here would break that feature because the strategy is applied after the raw value is resolved") — AX/Sources/AXorcist/Core/MatchingTypes.swift:37-45; path_from_root is decoded under both path_from_root and pathFromRoot — :133-138,163-165; tests AX/Tests/AXorcistCommandConversionTests/LocatorWireTests.swift:8-45.MacosUseSDK / MCP finding
click_and_traverse element: search: lowercased contains over the SDK text field, optional role prefix filter, element must have w > 0 && h > 0, first match wins, click at center — MCP/Sources/MCPServer/main.swift:1620-1638. Because SDK text is the join of AXValue AXTitle AXDescription AXLabel AXHelp (SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:272-283), a search for "Open" can match an element whose help text contains "open".AXValue → AXTitle → recurse into children ("AXRow -> AXCell -> AXStaticText") — MCP/Sources/MCPServer/main.swift:1106-1131; findElementByText requires exact equality and a 15 pt vertical inset from the window — :1147-1158. The dev scripts use a different order (AXValue, AXTitle, AXDescription, AXLabel) — MCP/scripts/coord_test.swift:18-25.AXTextField/AXTextArea/AXComboBox/AXSearchField for set-value; AXButton/AXMenuItem/AXRadioButton/AXCheckBox/AXMenuButton/AXPopUpButton for press; AXRow/AXOutlineRow/AXListItem for select), BFS capped at 4000 nodes — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:50-77,122,151-154,190.excluded_no_text) and drops non-interactable ones — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:357-372; AXorcist falls to the role-derived computed name (§ above). Messages' conversation header is found by "the first AXButton whose text isn't a known UI chrome label" with a hard-coded exclusion set — MCP/scripts/coord_test.swift:54-82.Click
Element.click(button:clickCount:) is a CGEvent click at frame.midX/midY, not AXPress; gated on isEnabled() ?? true; throws missingFrame without a frame — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:49-64. Posts to .cghidEventTap; Thread.sleep(0.01) between down/up, 0.03 between clicks "(stay within the system double-click interval)" — :70-82. Multi-click sends separate down/up pairs with .mouseEventClickState 1 then 2: "the system expects a sequence of click states: (1) down/up with clickState=1, then (2) down/up with clickState=2." — :117-121; tests AX/Tests/AXorcistTests/ClickEventGenerationTests.swift:22-40. Middle = .center + .otherMouseDown/Up, button 2 — :35-40. No activation, no coordinate conversion in this path.clickMouse(at:): down+up via CGEventSource(stateID: .hidSystemState), usleep(15_000) after every post ("crucial for some applications") — SDK/Sources/MacosUseSDK/InputController.swift:48-63,93-103; "Does not move the cursor first." — :90. doubleClickMouse sends one down/up with clickState = 2 — :109-121 (contrast AXorcist above).InputDriver.pressHold sets .mouseEventPressure = 2.0 "(simulates force click fallback)" — AX/Sources/AXorcist/Core/InputDriver.swift:53,105; drags interpolate steps .leftMouseDragged events, all pre-built so partial allocation posts nothing — :109-143; timestamps refreshed at post time — :145-151; philosophy "no logging, no implicit delays beyond what the underlying AX/UI toolkits already impose." — :8-10.runningApp.activate() + 200 ms — SDK/Sources/MacosUseSDK/ActionCoordinator.swift:207-214. MCP always does activate(options: []) + 200 ms before click/scroll/set-value/press/select — MCP/Sources/MCPServer/main.swift:1655-1660,1717-1722,1738-1741,1757-1760,1777-1780; the dev script uses 300 ms — MCP/scripts/coord_test.swift:114-116.(x + w/2, y + h/2) when width/height are passed — MCP/Sources/MCPServer/main.swift:1647-1652; tool descriptions say x/y are "top-left of element" — :1331-1334.CGEvent(scrollWheelEvent2Source:…units: .line…) at the window's mid-Y, 1–3 lines per step ("Each scroll line ≈ 20-40px"), up to 30 steps, 100–150 ms sleeps, re-finding the element by text after each step — :1172-1305.AXPress: AXorcist performAction is a thin AXUIElementPerformAction + throwIfError, no retry, no activation — AX/Sources/AXorcist/Core/Element+Actions.swift:31-48. The action list is read only after an actionUnsupported failure — AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:250-258; "// The platform can return cannotComplete after dispatch, so classify once and never retry here." — :206; changelog "Discover element actions through the dedicated macOS Accessibility API so supported actions such as AXPress work in SwiftUI apps." — AX/CHANGELOG.md:28. SDK pressAccessibilityElement tree-finds a pressable role then AXUIElementPerformAction(kAXPressAction) — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:147-169; MCP: "Use when a synthetic mouse click is dropped (Catalyst right-pane buttons, sandboxed apps). Often the only path that actuates buttons in those apps." — MCP/Sources/MCPServer/main.swift:1459.AXPress: set kAXSelectedAttribute on the AXRow/AXOutlineRow/AXListItem; "In single-selection tables, setting this attribute typically deselects any prior selection automatically" — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-206; MCP tool: "where regular click is dropped and press_ax errors with kAXErrorActionUnsupported" — MCP/Sources/MCPServer/main.swift:1477.AXMinimizeButton else set AXMinimized; maximize → AXZoomButton → AXFullScreenButton → set AXFullScreen → setFrame(visibleFrame); close → AXCloseButton → action "AXClose"; show → unminimize → unhide() → AXRaise — AX/Sources/AXorcist/Core/Element+WindowOperations.swift:60-195.Typing
typeText requires focus (§1), clearFirst = cmd+a, sleep 0.05, delete — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:203-210; "\n" → Return (36), "\t" → Tab (48), per-character sleep delay > 0 ? delay : 0.001 (never zero) — :213-225.:229-230; TISCopyCurrentKeyboardLayoutInputSource + UCKeyTranslate, brute-forcing keycodes 0…127 with [], shift, option, shift+option; printable ASCII only; dead-key states rejected — :239-333; Unicode fallback virtualKey: 0 + keyboardSetUnicodeString on both down and up — :345-371. Tests: QWERTZ z → 16, @ → 37+option; é/emoji → Unicode — AX/Tests/AXorcistTests/InputDriverTests.swift:192-243. Changelog — AX/CHANGELOG.md:71.writeText is Unicode-only, one key down/up pair per Unicode scalar with virtualKey: 0: "Some text fields collapse multi-char unicode payloads into a single keystroke, which breaks IME/auto-complete behavior." — SDK/Sources/MacosUseSDK/InputController.swift:182-214,191-193. Its mapKeyNameToKeyCode table "Assuming US QWERTY. Might need adjustments for others." — :239; unknown names are parsed as raw keycode numbers — :307-310; pressKey applies modifier flags to the key-up too — :80-86.AXValue instead of typing: AXorcist setValue(String) bridges String→CFString, Bool→CFBoolean, NSNumber, Element→AXUIElement; anything else throws illegalArgument before the native call — AX/Sources/AXorcist/Core/Element+ValueSetting.swift:38-40,57-74. "AXSetValue" is not a native action: "Compatibility command and receipt token. This is not a native macOS accessibility action." — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:23-24; the handler routes it to the setter and requires a string — AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:163-178; README — AX/README.md:482-496. SDK setAccessibilityValue writes kAXValueAttribute on a tree-found text element — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:109-137; MCP: "Bypasses the input event tap entirely. Use when typing fails (Catalyst right-pane fields, sandboxed/secure-input contexts)." — MCP/Sources/MCPServer/main.swift:1442. String→CF for arbitrary attributes decides the target type by reading the current value first — AX/Sources/AXorcist/Values/ValueParser.swift:51-72; CFNumber tries Double before Int — :124-129.0x37, shift 0x38, option 0x3A, ctrl 0x3B, fn only as .maskSecondaryFn — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:405-414; sequence = modifier downs (flags accumulate) → main down → main up → modifier ups reversed — :464-489; "Build the complete sequence before posting anything. Event creation can fail; posting cannot." — :437; changelog "preventing modifiers from remaining stuck" — AX/CHANGELOG.md:72. MCP modifier parsing accepts capslock|caps, shift, control|ctrl, option|opt|alt, command|cmd, help, function|fn, numericpad|numpad — MCP/Sources/MCPServer/main.swift:143-166; SDK note "'fn' might need special handling or accessibility settings" — SDK/Sources/InputControllerTool/main.swift:96.Scrolling — opposite sign conventions
Element.scrollAt: units: .pixel, wheelCount: 2, then overrides .scrollWheelEventDeltaAxis1/2 with up = +amount, down = −amount — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:688-727; InputDriver.scroll uses .line units ÷ pixelsPerLine = 10 and documents "Positive deltaY scrolls up." — AX/Sources/AXorcist/Core/InputDriver.swift:166-180.SDK/Sources/MacosUseSDK/InputController.swift:155, MCP/Sources/MCPServer/main.swift:1417. SDK moves the cursor to the point first "so the scroll lands in the right view" and uses .line units — SDK/Sources/MacosUseSDK/InputController.swift:158-172.Menus and shortcuts
menuBarWithTimeout(timeout: 2.0) (AXMenuBar attribute) — AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:58-61; Attribute.mainMenu maps to kAXMenuBarAttribute — AX/Sources/AXorcist/Core/Attribute.swift:192-194. Menu item shortcuts are reconstructed from AXMenuItemCmdChar + AXMenuItemCmdModifiers cast to CGEventFlags as ⌃⌥⇧⌘X — AX/Sources/AXorcist/Core/Element+TextAttributes.swift:129-162; hasSubmenu() = first child's role is AXMenuItem — :117-124. AXShowMenu action listed — AX/README.md:476. Context menus in MCP are a CGEvent right-click — MCP/Sources/MCPServer/main.swift:1338,1665-1669.Waiting and restoration
delayAfterAction default 0.2 s between action and post-traversal — SDK/Sources/MacosUseSDK/ActionCoordinator.swift:52; CombinedActions use 100 ms — SDK/Sources/MacosUseSDK/CombinedActions.swift:167,404. MCP: 100 ms between chained actions — MCP/Sources/MCPServer/main.swift:1866; 200 ms "grace period" before checking Esc cancellation — :1893-1896.waitUntilActionable(timeout: 5.0, pollInterval: 0.1) polls isActionable() (enabled + nonzero frame + on a screen) — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:130-164.NSWorkspace.shared.frontmostApplication and the cursor before disruptive tools, then restores cursor via a mouseMoved CGEvent and re-activates the previous app if frontmost changed — MCP/Sources/MCPServer/main.swift:1802-1809,1905-1920; "Disruptive" = every tool except refresh_traversal — :1800.app_switch — :1925-1948.Blocking the human while automating (MCP InputGuard)
CGEventTap at kCGHeadInsertEventTap (raw value 0, "Swift overlay doesn't expose the enum case name") on .cghidEventTap swallows hardware input — MCP/Sources/MCPServer/InputGuard.swift:130-139; "CGEventTap must be on the main run loop to receive events" — :149-150.:326-332.tapDisabledByTimeout / tapDisabledByUserInput) and they must be re-enabled — :298-306,320-324. Plain Esc (keycode 53, no modifiers) cancels — :340-350. 30 s watchdog auto-releases — :24,172-181. engage() must build the tap on the main thread synchronously; Swift await yields the main run loop so callbacks still arrive — :78-89.level = .screenSaver, ignoresMouseEvents = true, collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary], orderFrontRegardless() — :216-221,274. SDK overlays use .floating + [.canJoinAllSpaces, .stationary, .ignoresCycle] — SDK/Sources/MacosUseSDK/DrawVisuals.swift:209-210.When each approach fails (as stated in source)
AXPress/AXValue — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:6-12, SDK/Sources/MacosUseSDK/ActionCoordinator.swift:17-19.AX/Sources/AXorcist/Core/Element+UIAutomation.swift:229-230.SDK/Sources/MacosUseSDK/InputController.swift:191-193.SDK/Sources/MacosUseSDK/ActionCoordinator.swift:208-209.AXSelected but no AXPress → set selected — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-178.NSRunningApplication.activate "sometimes … reports false but works" — AX/Sources/AXorcist/Core/Element+WindowOperations.swift:227.AXChildren → read AXFocusedUIElement/AXWindows — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54.AXChildren, depth ≈ 37 search dead end, focused element as "remote renderer proxy" — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54; web-ish child attributes probed — :112-120; AXWebArea is a container role — AX/Sources/AXorcist/Search/ElementSearch.swift:608; AXDOMClassList/AXDOMIdentifier are in the default attribute fetch list — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:413-414; AXLoadComplete "Often for web views" — :387.AXWindow → AXWebArea (depth 5) then AXDOMClassList contains "submit-button primary" — AX/README.md:645-662; class-list search example — :317-324.pid = 7301 in a script): hit-test returns cell/static-text/window-group instead of the row; rows are selectable via AXSelected, not AXPress; right-pane controls swallow synthetic clicks and typing — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:6-12,47-49,91-92,120,149,186-188; MCP/scripts/coord_test.swift:54-61,96; MCP/scripts/old_click_test.swift:1-2 ("isolate whether the NSEvent change introduced a Catalyst table-row regression" — note the current SDK clickMouse is CGEvent-based; the "NSEvent bridge" it refers to is not in source at HEAD).AXPress only works when actions are discovered via AXUIElementCopyActionNames — AX/CHANGELOG.md:28.MCP/Sources/MCPServer/main.swift:295-296.MCP/Sources/MCPServer/main.swift:1498.axorc tree --app com.apple.dock --role AXDockItem — AX/README.md:568-571; dock-item subroles — AX/Sources/AXorcist/Core/Element+TypeChecking.swift:15-67.AX/README.md:515-517; "accessible" apps = activationPolicy != .prohibited && pid > 0 && bundleIdentifier != nil — AX/Sources/AXorcist/Utils/RunningApplicationHelper.swift:117-123; but point-lookup eligibility is stricter (.regular && !isHidden) — AX/Sources/AXorcist/Core/AppLocator.swift:67-69./Applications, /System/Applications, /System/Applications/Utilities; use a bundle id or path for anything else — SDK/Sources/MacosUseSDK/AppOpener.swift:105-107; a pre-found running PID is returned even if activation throws — :215-225; NSWorkspace.openApplication result must be read inside a Task { @MainActor in } because "MainActor.run … caused issues in Swift 6.1 with async closures" — :168-179. AXorcist resolves focused → bundle id → localized name (case-insensitive) → path → numeric PID — AX/Sources/AXorcist/Core/ProcessUtils.swift:36-55,57-172.CGWindowListCreateImage runs in a subprocess "so that the ReplayKit framework — loaded as a side-effect by macOS — dies with the subprocess instead of spinning at ~19% CPU forever in the parent MCP server process." — MCP/Sources/MCPServer/main.swift:382-385, MCP/Sources/ScreenshotHelper/main.swift:1-3; 5 s timeout then terminate() — MCP/Sources/MCPServer/main.swift:475-489.exit(0) caused crashes." — SDK/Sources/ActionTool/main.swift:93-95; CLI tools must keep the run loop alive for overlays to appear — SDK/Sources/HighlightTraversalTool/main.swift:98-112, SDK/Sources/ActionTool/main.swift:58-98.xcrun --toolchain com.apple.dt.toolchain.XcodeDefault swift — MCP/CLAUDE.md:25-27; after a rebuild "Claude Code's MCP connection still points at the old server process" — :15-23.Lifecycle
AXObserverCreateWithInfoCallback, never AXObserverCreate — AX/Sources/AXorcist/Core/AXObserverCenter.swift:864; one observer per PID, reused for every notification/element — :112,697-704..defaultMode — :838-839; removal uses CFRunLoopGetCurrent() + CFRunLoopSourceInvalidate — :448-452,653-655,681-685. Teardown happens only when a PID has no subscriptions and no pending work — :640-657.refcon is the center itself, unretained — :565,915-916. Callback derives the PID from the element (AXUIElementGetPid), converts userInfo synchronously, then hops Task { @MainActor in … } — handlers run on a later main-actor turn, not inside the AX callback — :925-949. Handler type: @MainActor (pid_t, AXNotification, AXUIElement, [String: Any]?) -> Void — AX/Sources/AXorcist/Core/ObserverTypes.swift:12-16.kAXErrorNotificationAlreadyRegistered on add is treated as success; notificationNotRegistered on remove is treated as absent; cannotComplete/failure on remove keeps the removal tracked for retry — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:685-691,109-111. No handling of kAXErrorNotificationUnsupported exists; unsupported apps are skipped — AX/README.md:535.AX/Sources/AXorcist/Core/ObserverTypes.swift:277-283. A process-scoped and an element-scoped subscription for the same (pid, notification) share one native registration — AX/Tests/AXorcistTests/ObserverLifecycleTests.swift:367-396.AX/Sources/AXorcist/Core/AXObserverCenter.swift:929-936. userInfo conversion handles CFString/CFNumber(→NSNumber)/CFBoolean/CFArray/CFDictionary/AXUIElement, raw otherwise — AX/Sources/AXorcist/Core/ObserverHelpers.swift:9-31.AX/Sources/AXorcist/Core/ObserverTypes.swift:269-297.Threading and the "wedged app" problem
Thread.detachNewThread in an autoreleasepool, raced against a detached sleeper; first result wins — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:58-71,169-182; creation — AX/Sources/AXorcist/Core/AXObserverCenter.swift:859-877.:807; notificationWorkTimeout = .milliseconds(500) — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:6; identity lookup 100 ms — :39; async completion 750 ms — :492,618; sync creation join 750 ms pumping RunLoop.current.run(mode: .default, before: +0.01) — AX/Sources/AXorcist/Core/AXObserverCenter.swift:718-722; sync pending-add join 2 s — AX/Sources/AXorcist/Core/AXObserverCenter+PendingNative.swift:229-231; async subscribe deadline 2 s — AX/Sources/AXorcist/Core/AXObserverCenter.swift:292-305.AXUIElementSetMessagingTimeout(element, 0.5) then reset to 0; if arming fails the call returns .cannotComplete without touching AX — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:693-721.:216-248,365-376; test AX/Tests/AXorcistTests/ObserverNativeCleanupTests.swift:96-143.AXObserverAddNotification may still succeed later; it is committed only if still pending, generation matches, and (not late or a waiter still exists); otherwise it is rolled back with a native remove — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:93-107; "Timeout bounds the waiter only. The pending record stays until the native remove reports its own result." — :185-186. An observer created after the 500 ms deadline is simply dropped (never added to a run loop) — AX/Sources/AXorcist/Core/AXObserverCenter.swift:847-849,860.AX/Sources/AXorcist/Core/NotificationWatcher.swift:283-284.axGetLogEntries() returns [] "to avoid concurrency issues" — AX/Sources/AXorcist/Logging/GlobalAXLogger.swift:24-25,309-321.PID reuse
proc_pidinfo(pid, 17 /* PROC_PIDUNIQIDENTIFIERINFO */, …).uniqueIdentifier — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:19-32; a mismatch purges every subscription for that PID and invalidates tokens — AX/Sources/AXorcist/Core/AXObserverCenter.swift:661-691; "A missing identity is unknown, not a confirmed PID reuse." — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:113-117.Global ("all apps") watching
NSWorkspace.shared.runningApplications (options: [.initial]), not didLaunchApplicationNotification — AX/Sources/AXorcist/Core/AXGlobalApplicationMonitor.swift:17-23,29-34; "Indexed KVO changes can contain only the changed entries. Capture the full membership snapshot here, without reading any application metadata." — :31-32.processIdentifier/isFinishedLaunching are read on a serial background queue, never on main — :76-77; "Do not request .new: KVO would fetch readiness synchronously on the notifying thread." — :240; onLaunch fires twice for a PID (membership, then readiness) — :141-144, test AX/Tests/AXorcistTests/WorkspaceApplicationMonitorTests.swift:295-321.[0.5 s, 2 s, 8 s], then "exhausted retries" — AX/Sources/AXorcist/Core/NotificationWatcher.swift:6-8,404-410; termination cancels in-flight work so a PID-reusing replacement starts clean — :298-303.windowMinimized = "AXWindowMiniaturized" — AX/Sources/AXorcist/Core/NotificationTypes.swift:24; AXTitleChanged "Not a standard top-level notification, often via kAXValueChanged on title attribute" — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:370-371; AXLayoutChanged "Might be app-specific" — :375; full list of 34 names — AX/Sources/AXorcist/Core/NotificationTypes.swift:8-43.observe via the CLI only keeps the process alive in DEBUG builds; release prints a JSON error to stderr and exits after setup — AX/Sources/axorc/AXORCMain.swift:116-135.SDK/README.md:11 refers to before/after traversal diffs.AXorcist, CLI axorc)#swift-tools-version: 6.2, .macOS(.v14), swiftLanguageModes: [.v6], .defaultIsolation(MainActor.self) + StrictConcurrency + NonisolatedNonsendingByDefault for every target; deps Commander exact: "0.2.4", swift-log — AX/Package.swift:1-11,15-25,64. Build: swift build -c release --product axorc → .build/release/axorc; brew install openclaw/tap/axorc — AX/README.md:190-198; universal binary via swift build -c release --arch arm64 --arch x86_64 --product axorc + lipo — AX/scripts/build-universal-binary.sh:46-60. Version axorcVersion = "0.1.9" — AX/Sources/axorc/Models/AXORCModels.swift:11. CI: macos-15, Swift 6.2.1, Xcode 16.4, SwiftFormat 0.62.1, SwiftLint 0.65.1 — AX/.github/workflows/ci.yml:27-34, AX/scripts/install-validation-tools.sh:5-8.@MainActor public class AXorcist { static let shared; func runCommand(_ envelope: AXCommandEnvelope) -> AXResponse; getLogs() -> [String]; clearLogs() } — AX/README.md:39-44, AX/Sources/AXorcist/Core/AXorcist.swift:114-118. AXResponse.success(payload: AnyCodable?, logs:) | .error(message:, code: AXErrorCode, logs:); payload is nil for .error — AX/Sources/AXorcist/Core/ResponseModels.swift:27-73.public struct Element: Equatable, Hashable, Sendable { init(_ AXUIElement); init(_:attributes:children:actions:); let underlyingElement; var attributes: [String: AttributeValue]?; var prefetchedChildren: [Element]?; var actions: [String]? } — AX/Sources/AXorcist/Core/Element.swift:52,69-73,87-105; attribute<T>(_: Attribute<T>) -> T?, rawAttributeValue(named:) -> CFTypeRef?, isAttributeSettable(named:), parameterizedAttribute<T>(_:parameter:), press()/pick()/showMenu() -> Bool — :119-202. Properties: role() subrole() title() descriptionText() isEnabled() -> Bool? value() -> Any? roleDescription() help() identifier() isFocused() isHidden() isElementBusy() isIgnored() pid() parent() windows() sheets() mainWindow() focusedWindow() focusedUIElement() supportedActions() domIdentifier() … attributeNames() dump() — AX/Sources/AXorcist/Core/Element+Properties.swift:10-207; position() size() frame() setPosition(_) -> AXError setSize setFrame isMinimized() setMinimized isFullScreen() selectedText() selectedTextRange() -> CFRange? … url() -> URL? — AX/Sources/AXorcist/Core/Element+ConvenienceAttributes.swift; computedName() — AX/Sources/AXorcist/Core/Element+ComputedName.swift:9; briefDescription(option: .smart|.raw|.stringified) — AX/Sources/AXorcist/Core/Element+Description.swift:12; generatePathString(upTo:) — AX/Sources/AXorcist/Core/Element+PathGeneration.swift:9.findTargetElement(for appIdentifier: String, locator: Locator, maxDepthForSearch: Int[, traversalOptions:]) -> (element: Element?, error: String?) — AX/Sources/AXorcist/Search/ElementSearch.swift:58-75; collectAllElements(from:matching:maxDepth:includeIgnored:[traversalOptions:]) -> [Element] — :287-307; traverseAndSearch(element:visitor:currentDepth:maxDepth:) — :349-369; protocol ElementVisitor { visit(element:depth:) -> TreeVisitorResult }, enum TreeVisitorResult { continue, skipChildren, stop } — :335-346. Element-level: searchElements(matching:options:) -> [Element], findElement(matching:options:), searchElements(byRole:options:), matches(query:options:), findAllButtons()/findAllTextFields()/findAllLinks(), findElement(byIdentifier:) — AX/Sources/AXorcist/Core/Element+Search.swift:45-191; findElements(role:title:label:value:identifier:maxDepth: = 10) -> [Element] = exact equality on each supplied field over an unpruned, un-timed traverseAXTree — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:758-815.Criterion(attribute: String, value: String, matchType: JSONPathHintComponent.MatchType? = nil); Locator(matchAll: Bool? = true, criteria: [Criterion] = [], rootElementPathHint: [JSONPathHintComponent]? = nil, descendantCriteria:, requireAction:, computedNameContains:, debugPathSearch:); PathStep(criteria:matchType:matchAllCriteria:maxDepthForStep:) — AX/Sources/AXorcist/Core/MatchingTypes.swift:9,53-57,106-113; JSONPathHintComponent(attribute:value:depth:matchType:) — AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:10; AXTraversalOptions(timeout:scanAll:stopAtFirstMatch:) — AX/Sources/AXorcist/Search/AXTraversalOptions.swift:16-19; ElementSearchOptions { maxDepth=0, caseInsensitive=true, visibleOnly=false, enabledOnly=false, includeRoles=[], excludeRoles=[] } — AX/Sources/AXorcist/Core/Element+Search.swift:14-34.click(button: MouseButton = .left, clickCount: Int = 1) throws, static clickAt(_:button:clickCount:), typeText(_:delay: = 0.005, clearFirst: = false) throws, clearField(), static typeText/typeCharacter/typeKey(_: SpecialKey, modifiers:), static performHotkey(keys: [String], holdDuration: = 0.1), scroll(direction: ScrollDirection, amount: = 3, smooth: = false), static scrollAt(...), waitUntilActionable(timeout: = 5.0, pollInterval: = 0.1) async throws -> Element, isActionable() -> Bool, elementAt(_:role:), findElements(...) — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:49-83,130-164,171-237,374-451,669-728,735-815. performAction(_ actionName: String) throws -> Element, performAction(_: AXAction), isActionSupported(_:) — AX/Sources/AXorcist/Core/Element+Actions.swift:12-56. setValue(_ value: String) throws -> Element, setAttributeValue(_: Any, forAttribute:) throws, legacy setValue(_: Any, forAttribute:) -> Bool — AX/Sources/AXorcist/Core/Element+ValueSetting.swift:6-29. activate() -> Bool, hideApplication()/unhideApplication() — AX/Sources/AXorcist/Core/Element+ApplicationActions.swift:26-93. Window ops minimizeWindow/unminimizeWindow/maximizeWindow/closeWindow/showWindow/focusWindow/activateApplication/windowScreen() — AX/Sources/AXorcist/Core/Element+WindowOperations.swift. InputDriver.click(at:button:count:) / move(to:) / currentLocation() / pressHold(at:button:duration:) / drag(from:to:button:steps: = 20, interStepDelay: = 0.0) / scroll(deltaX: = 0, deltaY:, at:) / type(_:delayPerCharacter: = 0.0) / tapKey(_:modifiers:) / hotkey(keys:holdDuration: = 0.1) — AX/Sources/AXorcist/Core/InputDriver.swift:17-207.Element.systemWide(), Element.application(for pid:) -> Element?, Element.application(for: NSRunningApplication), Element.focusedApplication(), Element.elementAtPoint(_ point: CGPoint, pid: pid_t = 0) — AX/Sources/AXorcist/Core/Element+Factory.swift:10-71; AXApp(pid:), AXWindowHandle — AX/Sources/AXorcist/Core/AXApp.swift:5-69; AppLocator.exactApp(at:) / app(at:) — AX/Sources/AXorcist/Core/AppLocator.swift:31-51; RunningApplicationHelper.allApplications() / filteredApplications(options:) / applications(withBundleIdentifier:) / frontmostApplication / runningApplication(pid:) — AX/Sources/AXorcist/Utils/RunningApplicationHelper.swift:73-158; WindowInfoHelper.getWindows(for:) / getVisibleWindows() / getWindowBounds(windowID:) / getOwnerPID(windowID:) / getWindowName(windowID:) / getWindowID(from:) — AX/Sources/AXorcist/Utils/WindowInfoHelper.swift; CFConstants.cgWindowNumber/cgWindowName/cgWindowBounds/cgWindowOwnerPID — AX/Sources/AXorcist/Core/CFConstants.swift.AXPermissionHelpers.askForAccessibilityIfNeeded() / hasAccessibilityPermissions() / isSandboxed() / requestPermissions() async / permissionChanges(interval: = 1.0) -> AsyncStream<Bool> — AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:40-135; AXTrustUtil.checkAccessibilityPermissions(promptIfNeeded:) / openAccessibilitySettings() — AX/Sources/AXorcist/Utils/AXTrustUtil.swift:14-37; Element.setMessagingTimeout(_ Float), withMessagingTimeout(_:operation:), AXTimeoutConfiguration.setGlobalTimeout, AXTimeoutWrapper(maxRetries: = 3, retryDelay: = 0.5).execute, AXTimeoutHelper.withTimeout(seconds:operation:) — AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:16-206.AXObserverCenter.shared.subscribe(pid:element:notification:handler:) -> Result<SubscriptionToken, AccessibilityError>, unsubscribe(token:) throws, removeAllObservers(), removeAllObservers(for:), isKeyRegistered(pid:notification:) — AX/Sources/AXorcist/Core/AXObserverCenter.swift:96-468; NotificationWatcher(forElement:notification:handler:) / (forPID:…) / (globalNotification:…), start() throws, stop(), isActive — AX/Sources/AXorcist/Core/NotificationWatcher.swift:38-227; deprecated AXObserverManager — AX/Sources/AXorcist/Utils/AXObserverManager.swift:11-88.axorc): subcommands permissions [-j], find --app <app> (--role|--title|--identifier|--value|--attribute k=v)… [--depth 10] [--contains] [-j], tree --app <app> [--depth 3] [--role R] [-j], raw (--stdin|--file <p>|--json '<s>'|'<s>') [--debug] [--verbose] [--timeout <s>] [--scan-all] [--no-stop-first], help <topic>, --version — AX/Sources/axorc/CLIFrontend.swift:19-107,131-196, AX/Sources/axorc/AXORCMain.swift:51-80. Exit codes: 0 ok, 1 command failed / decode error, 2 usage/argument error; JSON error IDs argument_error, decode_error, input_error, no_input — AX/Sources/axorc/AXORCMain.swift:27-41,215-260, AX/Sources/axorc/CLIFrontend.swift:7-16. Env: AXORC_JSON_LOG_ENABLED=true (JSON logs on stderr) — AX/Sources/AXorcist/Logging/GlobalAXLogger.swift:93-101; AXORC_CODESIGN_IDENTITY — AX/scripts/build-universal-binary.sh:38-41.command ∈ ping, query, getAttributes, describeElement, getElementAtPoint, getFocusedElement, performAction, batch, observe, collectAll, stopObservation, isProcessTrusted, isAXFeatureEnabled, setFocusedValue, extractText (+3 reserved not-implemented) — AX/Sources/AXorcist/Core/CommandTypes.swift:13-32; envelope keys command_id, command, application, pid, attributes, locator{matchAll, criteria[{attribute,value,match_type}], path_from_root[{attribute,value,depth,match_type}]}, max_depth, action_name, action_value, sub_commands, point:[x,y], notifications, include_element_details, watch_children, filter_criteria, include_children_brief, include_children_in_text, include_ignored_elements, debug_logging — AX/Sources/AXorcist/Core/CommandEnvelope.swift:103-128; decoder .convertFromSnakeCase, encoder .convertToSnakeCase + .sortedKeys — AX/Sources/axorc/Core/InputHandler.swift:17-21, AX/Sources/axorc/CommandResponseHelpers.swift:61-64. Response {"command_id","command_type","status":"success|error","data","error","error_code","debug_logs"} — AX/Sources/axorc/CommandResponseHelpers.swift:8-16; error_code ∈ element_not_found, action_failed, attribute_not_found, invalid_command, unknown_command, internal_error, permission_denied, invalid_parameter, timeout, observation_failed, application_not_found, batch_operation_failed, action_not_supported — AX/Sources/AXorcist/Core/ResponseModels.swift:8-22. Target: application and pid are mutually exclusive; pid must be 1…pid_t.max; omitted → focused app — AX/Sources/AXorcist/Core/CommandTarget.swift:10-29. Batch: sequential, no early exit, per-sub results lost on any failure — AX/Sources/AXorcist/Core/AXorcist+BatchHandler.swift:28-63, AX/Sources/axorc/CommandHandlers.swift:186-203. Only commands.first of a JSON array is executed — AX/Sources/axorc/AXORCMain.swift:262-275..macOS(.v12), links AppKit + ApplicationServices; executables TraversalTool, HighlightTraversalTool, InputControllerTool, VisualInputTool, AppOpenerTool, ActionTool — SDK/Package.swift:1-34. Build swift build → .build/debug/<Tool> or swift run <Tool> — SDK/README.md:14-22.public func traverseAccessibilityTree(pid: Int32, onlyVisibleElements: Bool = false) throws -> ResponseData — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:87-90; ResponseData { app_name, elements: [ElementData], stats: Statistics, processing_time_seconds }, ElementData { role, text?, x?, y?, width?, height? } — :32-76. Errors MacosUseSDKError.accessibilityDenied | appNotFound(pid:) | jsonEncodingFailed | internalError(String) — :9-27.@MainActor public func openApplication(identifier: String) async throws -> AppOpenerResult { pid, appName, processingTimeSeconds } — SDK/Sources/MacosUseSDK/AppOpener.swift:36-40,246-256.pressKey(keyCode: CGKeyCode, flags: CGEventFlags = []) throws, clickMouse(at:), doubleClickMouse(at:), rightClickMouse(at:), moveMouse(to:), scrollWheel(at:deltaY: Int32, deltaX: Int32 = 0), writeText(_:), mapKeyNameToKeyCode(_:) -> CGKeyCode?; key constants KEY_RETURN = 36 … KEY_FORWARD_DELETE = 117 — SDK/Sources/MacosUseSDK/InputController.swift:24-37,72-221.setAccessibilityValue(pid:at:value:) throws, pressAccessibilityElement(pid:at:) throws, setAccessibilitySelected(pid:at:selected:) throws — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:118,147,184.@MainActor public func performAction(action: PrimaryAction, optionsInput: ActionOptions = ActionOptions()) async -> ActionResult; PrimaryAction.open(identifier:) | .input(action: InputAction) | .traverseOnly; InputAction.click/doubleClick/rightClick(point:) | .type(text:) | .press(keyName:flags:) | .move(to:) | .scroll(point:deltaY:deltaX:) | .axSetValue(point:value:pid:) | .axPress(point:pid:) | .axSetSelected(point:selected:pid:); ActionOptions { traverseBefore=false, traverseAfter=false, showDiff=false, onlyVisibleElements=false, showAnimation=true, animationDuration=0.8, pidForTraversal=nil, delayAfterAction=0.2 } (showDiff forces both traversals via validated()); ActionResult { openResult, traversalPid, traversalBefore, traversalAfter, traversalDiff, primaryActionError, traversalBeforeError, traversalAfterError } — SDK/Sources/MacosUseSDK/ActionCoordinator.swift:8-107,120-124. TraversalDiff { added, removed, modified: [ModifiedElement{before, after, changes: [AttributeChangeDetail{attributeName, addedText, removedText, oldValue, newValue}]}] } — SDK/Sources/MacosUseSDK/CombinedActions.swift:5-18,101-106.CombinedActions.openAndTraverseApp / clickAndTraverseApp / pressKeyAndTraverseApp / writeTextAndTraverseApp / clickWithDiff / pressKeyWithDiff / writeTextWithDiff / *WithActionAndTraversalHighlight (all @MainActor async) — SDK/Sources/MacosUseSDK/CombinedActions.swift:129-687.@MainActor showVisualFeedback(at:type: FeedbackType, size:, duration: = 0.5), @MainActor drawHighlightBoxes(for: [ElementData], duration: = 3.0) (returns immediately; needs a live run loop), getMainScreenCenter(), clickMouseAndVisualize / doubleClickMouseAndVisualize / rightClickMouseAndVisualize / moveMouseAndVisualize / scrollWheelAndVisualize / pressKeyAndVisualize / writeTextAndVisualize — SDK/Sources/MacosUseSDK/DrawVisuals.swift:225-249,388-389, SDK/Sources/MacosUseSDK/HighlightInput.swift:12-140.AppOpenerTool <name|bundleId|path> (prints PID), TraversalTool [--visible-only] <PID> (JSON), HighlightTraversalTool <PID> [--duration s], InputControllerTool keypress <combo>|click x y|doubleclick x y|rightclick x y|mousemove x y|writetext "<t>", VisualInputTool … [--duration s], ActionTool (demo) — SDK/README.md:28-111, SDK/Sources/InputControllerTool/main.swift:66-145..macOS(.v13), deps modelcontextprotocol/swift-sdk from 0.11.0 and MacosUseSDK branch: "main"; executables mcp-server-macos-use (-parse-as-library) and screenshot-helper — MCP/Package.swift:1-30. Build: swift build -c release (npm postinstall does xcrun swift build -c release) — MCP/package.json:16-19; bin/mcp-server-macos-use wrapper builds on first run then execs .build/release/mcp-server-macos-use — MCP/bin/mcp-server-macos-use:14-21. Author's build line: xcrun --toolchain com.apple.dt.toolchain.XcodeDefault swift build — MCP/CLAUDE.md:8. Test harness python3 scripts/test_mcp.py [--test tools|cap|click --app X --search Y] spawns .build/debug/mcp-server-macos-use — MCP/scripts/test_mcp.py:8-26.{"mcpServers":{"macos-use":{"command":"/path/.build/release/mcp-server-macos-use"}}} — MCP/llms.txt:145-165, MCP/README.md:80-88. Server name SwiftMacOSServerDirect, version "1.6.0" — MCP/Sources/MCPServer/main.swift:1485-1487 (package.json says 0.1.18, llms.txt says 0.1.17 — MCP/package.json:3, MCP/llms.txt:12).MCP/llms.txt:21): macos-use_open_application_and_traverse {identifier}; macos-use_click_and_traverse {pid, x?, y?, width?, height?, element?, role?, doubleClick?, rightClick?, text?, pressKey?, pressKeyModifiers?}; macos-use_type_and_traverse {pid, text, pressKey?, pressKeyModifiers?}; macos-use_press_key_and_traverse {pid, keyName, modifierFlags?}; macos-use_scroll_and_traverse {pid, x, y, deltaY, deltaX?}; macos-use_set_value_and_traverse {pid, x, y, width?, height?, value}; macos-use_press_ax_and_traverse {pid, x, y, width?, height?}; macos-use_set_selected_and_traverse {pid, x, y, width?, height?, selected?}; macos-use_refresh_traversal {pid} — MCP/Sources/MCPServer/main.swift:1314-1482. Common overrides traverseBefore, traverseAfter, showDiff, onlyVisibleElements, showAnimation, animationDuration, delayAfterAction — :1580-1586. Numbers are accepted as int, double, or numeric string — :31-76.status / pid / app / dialog? / file / file_size / hint / screenshot / error? / summary / text_changes / visible_elements / app_switch — :731-906; full tree in /tmp/macos-use/<ts>_<tool>.txt and PNG — :1961-1983; MCP isError set when any action or traversal error occurred — :1952-1984. Server instructions to the model — :1488-1507.Coordinates
NSScreen.main.frame.height - y is only right on the primary display — SDK/Sources/MacosUseSDK/DrawVisuals.swift:278-286 vs AX/Sources/AXorcist/Core/AppLocator.swift:139-149.MCP/Sources/MCPServer/main.swift:1503, MCP/Sources/ScreenshotHelper/main.swift:53-58.(x+w/2, y+h/2) — MCP/Sources/MCPServer/main.swift:1331-1334,1647-1652.in_viewport/"visible" means the top-left point is inside a window frame, not that the element is actually unobscured — :513-544.Activation / focus
SDK/Sources/MacosUseSDK/ActionCoordinator.swift:207-214.NSRunningApplication.activate may return false and still work — AX/Sources/AXorcist/Core/Element+WindowOperations.swift:227.AXFocusedUIElement being absent is a valid state, not an error — AX/Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift:34-43.AX/CHANGELOG.md:25.Actions vs events
Element.click() in AXorcist is a CGEvent, not AXPress; AgentAccess/AXorcist fall back to AXPress only after the CGEvent path throws — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:49-64.AXPress is often the only thing that works for Catalyst right-pane buttons and sandboxed apps — MCP/Sources/MCPServer/main.swift:1459.AXSelected but no AXPress → set the attribute — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-178.AXSetValue is not an action; setting AXValue is an attribute write — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:23-24.kAXErrorCannotComplete (-25204) can arrive after the action already dispatched; retrying double-fires — AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:206, AX/Tests/AXorcistTests/ActionExecutionTests.swift:115.AX/Sources/AXorcist/Core/Element+UIAutomation.swift:117-121, SDK/Sources/MacosUseSDK/InputController.swift:109-121.AX/Sources/AXorcist/Core/InputDriver.swift:166-180, SDK/Sources/MacosUseSDK/InputController.swift:155.AX/Sources/AXorcist/Core/Element+UIAutomation.swift:437.AX/Sources/AXorcist/Core/Element+UIAutomation.swift:229-230, SDK/Sources/MacosUseSDK/InputController.swift:191-193.SDK/Sources/MacosUseSDK/InputController.swift:239.SDK/Sources/MacosUseSDK/InputController.swift:61-62.Traversal / search
AX/Sources/AXorcist/Search/ElementSearch.swift:410-412,599-614.AXChildren alone misses Electron background windows and Chromium's focused subtree — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54.N visits nodes at depth N but does not expand them — AX/Sources/AXorcist/Search/AXTreeTraversal.swift:147-149,167-169.AX/Sources/AXorcist/Search/ElementSearch.swift:387,414,433.cannotComplete for AXChildren silently vanishes — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:101-107.{"attribute":"title"} is a literal attribute named title → never matches; use AXTitle — AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:83-106,200-211.contains "" does not match a missing attribute — AX/Sources/AXorcist/Search/StringComparisonLogic.swift:48-56.AXTitle matching is case-sensitive; only role/subrole are insensitive — AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:26,45,63,134-151.computedName prefers the current AXValue over identifier/description — AX/Sources/AXorcist/Core/Element+ComputedName.swift:17-50.AX/Sources/AXorcist/Search/PathNavigationJSON.swift:73 vs AX/README.md:355.text is a concatenation of five attributes, so substring search hits help text — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:272-283.AXValues (sliders, steppers) never appear in text — :226-229.:302-305,173-180 (MCP's own comment gets this wrong — MCP/Sources/MCPServer/main.swift:548).SDK/Sources/MacosUseSDK/AccessibilityActions.swift:47-49, MCP/Sources/MCPServer/main.swift:1189-1195.Values / attributes
AXValue raw type 4 is both Boolean and CFRange — AX/Sources/AXorcist/Values/ValueUnwrapper.swift:71-72.AXValueType in Swift is not exhaustive — AX/Sources/AXorcist/Values/ValueHelpers.swift:62-66.Parameterized suffix — AX/CHANGELOG.md:40.AXUIElementCopyActionNames, not an attribute — AX/Sources/AXorcist/Core/Element+Properties.swift:112-121.AXWindowMiniaturized, not AXWindowMinimized — AX/Sources/AXorcist/Core/NotificationTypes.swift:24.AXFrame; compute from AXPosition + AXSize — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:127.Permissions / process
AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:43-51.AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:142-175.AX/docs/releasing.md:3.AXIsProcessTrustedWithOptions(prompt) on every traversal spams the dialog (SDK does this) — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:135-142.AX/Sources/AXorcist/Core/AXWindowResolver.swift:69.MCP/CLAUDE.md:15-23.Timeouts / threading
AXUIElementSetMessagingTimeout a wedged app blocks the calling thread indefinitely; AXorcist runs observer calls on detached threads with 500 ms deadlines — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:6,58-71.AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:126-133.AXObserverAddNotification success after timeout must be rolled back or it leaks — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:93-107.Task.sleep deadlines can be starved by uncooperative work on Swift 6.2.1; use a GCD timer — AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:242-243.runningApplications must not request .new, must not read metadata on the notifying thread, and delivers indexed partial changes — AX/Sources/AXorcist/Core/AXGlobalApplicationMonitor.swift:31-32,240.onLaunch fires twice per PID (membership then readiness) — :141-144.proc_pidinfo unique identifiers — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:19-32.exit(0) — SDK/Sources/ActionTool/main.swift:58-98.CGWindowListCreateImage loads ReplayKit which then burns CPU forever in a long-lived process — MCP/Sources/MCPServer/main.swift:382-385.CGEventTap must live on the main run loop and gets auto-disabled by macOS — MCP/Sources/MCPServer/InputGuard.swift:149-150,298-306.screencapture blocks ~50–200 ms; do it off the main actor — AA/Sources/AgentAccess/AccessibilityService+Screenshot.swift:9-18.| Repo | License | Notes |
|---|---|---|
| AXorcist | MIT, © 2025 Peter Steinberger — AX/LICENSE:1-3 |
README says MIT — AX/README.md:793; Homebrew formula license "MIT" — AX/packaging/homebrew/axorc.rb.template. Pinned dep Commander (steipete) and swift-log (Apache, not vendored) — AX/Package.swift:23-24. |
| MacosUseSDK | MIT, © 2025 mediar — SDK/LICENSE:1-3 |
README confirms — SDK/README.md:181-183. |
| mcp-server-macos-use | Business Source License 1.1 — MCP/LICENSE:1; "Licensed Work: screenpipe Computer Agent / Licensor: Mediar, Inc. / Additional Use Grant: Production use is permitted for non-commercial, educational purposes only / Change Date: April 9, 2028 / Change License: MIT License" — MCP/LICENSE:33-37. |
Conflict: package.json declares "license": "MIT" — MCP/package.json:30; llms.txt says BSL 1.1 — MCP/llms.txt:11. The LICENSE file governs; commercial production use requires a commercial license until 2028-04-09. Depends on MacosUseSDK branch: "main" (MIT) and the MCP Swift SDK — MCP/Package.swift:12-13. |
| AgentAccess | No LICENSE file (no LICENSE*/COPYING* in the repo root; README has no license section — AA/README.md:1-215). Treat as all-rights-reserved unless the owner states otherwise. |
Depends on AXorcist 0.1.9 (MIT) and AgentiLoop/AgentAudit 1.3.2 (license not in source) — AA/Package.resolved:5-21. |
Repo: AA/ = /Users/robertboulos/projects/cloned-repos/AgentAccess (AgentiLoop/AgentAccess, HEAD ce64d06 2026-09-02 "Update AXorcist dependency to 0.1.9"; 10 Swift files, 2,682 lines incl. README/manifests). Package: swift-tools-version: 6.2, platforms: [.macOS(.v26)], deps AgentiLoop/AgentAudit from 1.3.1 and steipete/AXorcist from 0.1.9 — AA/Package.swift:1-16; resolved to AXorcist rev 37d7ae8… = the exact AXorcist commit read above — AA/Package.resolved:13-21. README install URL points at macOS26/AgentAccess, not AgentiLoop — AA/README.md:9. All operations audit-log to os.log subsystem Agent.app.toddbruss.audit, category Accessibility — AA/README.md:209.
Which AXorcist APIs it calls, and how (the real-world usage pattern)
AXorcist.shared.runCommand(AXCommandEnvelope(commandID: UUID().uuidString, command: …)) for QueryCommand, PerformActionCommand, GetAttributesCommand, DescribeElementCommand, ExtractTextCommand, SetFocusedValueCommand, GetElementAtPointCommand(appIdentifier:x:y:attributesToReturn:), GetFocusedElementCommand, CollectAllCommand, AXBatchCommand(commands: [SubCommandEnvelope]), ObserveCommand(appIdentifier:locator:notifications:includeDetails:watchChildren:notificationName: AXNotification) — AA/Sources/AgentAccess/AccessibilityService+AXorcist.swift:16-24,30-50,56-81,87-106,112-132,138-157,163-181,187-201,207-220,226-241,247-258,264-295; and (b) the direct Element road for the LLM-facing "smart" methods (clickElement, typeTextIntoElement, findElement, getChildren, menus, windows).AXResponse is unwrapped as .success(payload, logs) / .error(message, code, logs); the payload (an AnyCodable) is re-encoded with JSONEncoder then JSONSerialization to build {"success":true,"data":…,"logs":…}; errors emit {"success":false,"error":…,"errorCode": code.rawValue} — AA/Sources/AgentAccess/AccessibilityService+AXorcist.swift:540-565. AXorcist.shared.getLogs()/clearLogs() are exposed — :446-456.buildLocator = AXRole exact, AXTitle/AXValue/AXDescription .contains, AXIdentifier exact, matchAll: true — AA/Sources/AgentAccess/AccessibilityService.swift:125-144 (correctly uses AX-prefixed names; see §4 on why title would not work). parseMatchType accepts exact|contains|regex|prefix|suffix|containsany — AA/Sources/AgentAccess/AccessibilityService+AXorcist.swift:527-538.performAction relies on a Locator field AXorcist ignores. It builds Locator(matchAll: true, criteria: [AXRole exact, AXValue contains], computedNameContains: title) with the comment "Use computedNameContains for title — searches AXTitle + AXDescription + AXHelp" and PerformActionCommand(... maxDepthForSearch: 100) — AA/Sources/AgentAccess/AccessibilityService+Actions.swift:46-58. In AXorcist 0.1.9 nothing in the library search path reads Locator.computedNameContains (grep of AX/Sources: it is only copied by the CLI converter AX/Sources/axorc/CommandTypeExtensions.swift:41 and consumed by the legacy dictionary matcher AX/Sources/AXorcist/Search/SpecificAttributeMatchers.swift:204-240 via AX/Sources/AXorcist/Search/AttributeMatcher.swift:26, which findTargetElement does not call — AX/Sources/AXorcist/Search/ElementSearch.swift:119-157). Consequences: with role + title, the first element of that role gets the action regardless of title; with title only, criteria are empty and AXorcist returns "FTE: No criteria, no path hint" — AX/Sources/AXorcist/Search/ElementSearch.swift:135-144.findAXElement → searchInElement): root.findElements(role:title:label:nil,value:identifier:nil,maxDepth: 100) — i.e. AXorcist's exact-equality matcher over an unpruned, un-timed traversal to depth 100 — then, if empty and a title was given, root.findElement(matching: title, options: ElementSearchOptions{maxDepth 100, caseInsensitive true, includeRoles [role]}) (substring over 8 fields incl. roleDescription) — AA/Sources/AgentAccess/AccessibilityService.swift:147-194; AXorcist semantics at AX/Sources/AXorcist/Core/Element+UIAutomation.swift:758-815, AX/Sources/AXorcist/Core/Element+Search.swift:141-162. With no app given it searches the frontmost app, then every .regular running app in turn — AA/Sources/AgentAccess/AccessibilityService.swift:155-167.resolveBundleId scans /Applications, /Applications/Utilities, /System/Applications, /System/Applications/Utilities, ~/Applications Info.plists once (lazy, cached) — :245-278; any input containing . is treated as a bundle id and auto-launched ("Before, this short-circuited without launching, which forced cloud LLMs to call open_app first") — :332-340; launchIfNeeded = NSWorkspace.openApplication + Thread.sleep(1.0), then per-window unminimizeWindow(), appElement.activate(), app.activate(), Thread.sleep(0.3) — :388-411. The comment is emphatic: "DO NOT REPLACE THIS WITH showWindow(). It looks equivalent. It is not." — "showWindow() adds a per-window performAction(.raise) on a window that was just unminimized, which races against the AX queue and fails silently" — :376-386. lookupBundleId is the side-effect-free variant for read-only queries — :280-316. openApp must call forceLaunchAndActivate because the dot early-return "would COMPLETELY SKIP the unminimize step. The dock-Genied window would never come back" — AA/Sources/AgentAccess/AccessibilityService+Elements.swift:58-67,84-87.Element.application(for: NSRunningApplication|pid), Element.focusedApplication(), Element.systemWide(), Element.elementAtPoint(point) (pid defaults to 0 → system-wide hit-test, so inspectElementAt returns whatever app owns the point — AA/Sources/AgentAccess/AccessibilityService+Elements.swift:16-18, AX/Sources/AXorcist/Core/Element+Factory.swift:46-71); windows(), title(), frame(), role(), mainMenu(), children(), focusedUIElement(), focusedApplicationElement(), isMinimized(), unminimizeWindow()/minimizeWindow()/maximizeWindow(), activate(), hideApplication()/unhideApplication(), setPosition()/setSize(), legacy setValue(_:forAttribute:) -> Bool, click(button:clickCount:), typeText(_:clearFirst:), scroll(direction:amount:), performAction(.press|.showMenu), isActionSupported(AXAction.showMenu.rawValue), isInteractive(), isActionable(), computedName(), searchElements(byRole: "AXWebArea"), free extractTextFromElement(_:maxDepth:), AppLocator.app(at:) (the frontmost-fallback variant), RunningApplicationHelper.{applications(withBundleIdentifier:), frontmostApplication, filteredApplications(options: .init(excludeProhibitedApps: true)), allApplications(), runningApplication(pid:)}, WindowInfoHelper.{getWindows(for:), getVisibleWindows(), getWindowBounds/getOwnerPID/getWindowName(windowID:)}, CFConstants.{cgWindowNumber, cgWindowName, cgWindowBounds, cgWindowOwnerPID} — throughout AA/Sources/AgentAccess/*.swift.InputDriver and every coordinate-based input ("They were unreliable (window positions shift, retina scaling, multi-display setups) and bypassed AXorcist entirely. Removed.") — AA/Sources/AgentAccess/AccessibilityService+Actions.swift:68-80; arbitrary drags ("file-system drag-and-drop between Finder and another app) just don't work via AX and should be done with a Shortcut or AppleScript instead") — AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:159-171; AXPermissionHelpers ("Uses AXIsProcessTrusted() directly since AXPermissionHelpers is @MainActor restricted") — AA/Sources/AgentAccess/AccessibilityService+Security.swift:18; AXTraversalOptions, withMessagingTimeout, AXTimeoutHelper, NotificationWatcher/AXObserverCenter (no references in any file) — so every search runs with AXorcist's process defaults (30 s, container pruning on the command road, stopAtFirstMatch) and no messaging timeout is ever armed.appBundleId in {com.apple.Safari, com.apple.SafariTechnologyPreview} (or a frontmost browser with no app given) returns "Error: Safari/browser detected. Do not use accessibility for web pages. Use the web tool…" from performAction, getElementProperties, setProperties, findElement, getChildren, waitForElement, showMenu, clickElement, typeTextIntoElement, waitForElementAdaptive — AA/Sources/AgentAccess/AccessibilityService.swift:13-31, AA/Sources/AgentAccess/AccessibilityService+Actions.swift:17-19, AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:11-13,27-29,64-66,121-123,177-179,206-208,239-241,339-341,385-387. scanWebContent (AXWebArea walk, roles AXLink AXButton AXTextField AXTextArea AXCheckBox AXRadioButton AXPopUpButton AXComboBox AXSlider AXImage AXHeading AXStaticText AXGroup, cap 200, strings truncated to 200/500) is the only web path — AA/Sources/AgentAccess/AccessibilityService+Elements.swift:133-210.The element JSON handed to the model
elementProperties(_:) keys: AXRole, AXTitle, AXDescription, AXRoleDescription, AXSubrole, AXIdentifier, AXHelp, AXEnabled, AXFocused, AXHidden, AXPosition{x,y}, AXSize{width,height}, AXValue (String | NSNumber | String(describing:)), AXURL, AXPlaceholderValue — "Uses standard AX* key names that LLMs recognize from training data." — AA/Sources/AgentAccess/AccessibilityService.swift:196-221. Wrapped as {"data":…,"success":true} via JSONSerialization .sortedKeys — :421-424. errorJSON is a hand-built string that escapes only " — :427-429.openApp → {"app","appName","elementCount","elements":[elementProperties + "computedName"]}; only isInteractive() elements with width > 0 && height > 0, first 50, recursion to maxDepth (default 5) — AA/Sources/AgentAccess/AccessibilityService+Elements.swift:69-129.getChildren → {"count","children":[props + nested "children"…],"truncated"?} with a 400-node cap; the recursion exists because "only direct children were returned, which made SwiftUI AXHostingView subtrees look empty" — AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:139-156.listWindows → {"windows":[{windowId, ownerName, ownerPID, windowName, bounds{x,y,width,height}, role}],"count","app"?}; AX windows are matched to CG windows by title, else frame within 2 pt, each CG id used once, layer 0 only — AA/Sources/AgentAccess/AccessibilityService.swift:43-85,87-119.AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:294-303,451-479; text inputs present (AXTextField/AXTextArea/AXSearchField/AXComboBox, 8 each) — :393-406; available menu titles (30) — AA/Sources/AgentAccess/AccessibilityService+Window.swift:131-137.Click / type flows and the "verify" logic
clickElement(role:title:value:appBundleId:timeout: = automationFinishTimeout, verify: = false): resolve+launch app → Element.application(for:) + activate() → retry loop with exponential backoff 0.1→0.2→0.4→0.8→1.0 s: findElements(… maxDepth: 20) preferring a match with width > 0, then fuzzy findElement(matching: title) → wait up to 5 s for isEnabled() → element.click() (CGEvent at center) → fallback performAction(.press) → error "Element is not clickable through accessibility (Element.click and AXPress both failed)". "No coordinate-based fallback" — AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:237-327. The verify parameter is never read in the body (:238 declares it; no use through :327).typeTextIntoElement(role:title:text:appBundleId:verify: = true): find element → setValue(text, forAttribute: "AXValue") first ("fastest") → fallback typeText(text, clearFirst: true) → success JSON with "method": "element_setValue" | "element_typeText" — :384-421. verify is likewise never read. Nothing reads the value back after either path.captureVerificationScreenshot(action:role:title:appBundleId:): takes captureAllWindows() and, if role/title given, re-runs findElement(timeout: 1.0) and sets element_status to verified_present when the returned JSON .contains("\"success\": true") (with a space), else not_found_after_action; not_verified when no role/title — :367-379. successJSON produces compact JSONSerialization output ({"data":…,"success":true}, .sortedKeys only — AA/Sources/AgentAccess/AccessibilityService.swift:421-424), so the spaced needle is unlikely to ever match (inference from the two call sites; not stated in source).findElement / waitForElement / waitForElementAdaptive poll with Thread.sleep on @MainActor until timeout, whose default automationFinishTimeout = 18000 s (5 h); automationStartTimeout = 9000, automationMaxDelay = 5 — AA/Sources/AgentAccess/AccessibilityConstants.swift:4-6, AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:63-80,176-196,332-363.scrollToElement: finds the first AXScrollArea (depth 10) and calls scroll(direction: .down, amount: 5) up to 20 times with 0.3 s sleeps, re-searching each time; no scroll area → "The app may use a custom non-accessible scroll view." — AA/Sources/AgentAccess/AccessibilityService+Window.swift:347-396.clickMenuItem: mainMenu() → for each path segment pick the best child by normalized title (trim, lowercase, strip trailing …/...; exact → prefix → contains) → intermediate items are pressed, 0.15 s sleep, then children.first is assumed to be the submenu; the last item errors if isEnabled() == false ("grayed out") before performAction(.press) — :104-195.showMenu requires isActionSupported("AXShowMenu"); no coordinate fallback — AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:200-233.highlightElement creates an NSWindow(contentRect: frame …) directly from the AX frame with no top-left→bottom-left conversion — AA/Sources/AgentAccess/AccessibilityService+Window.swift:48,61-68 (contrast the SDK's flip at SDK/Sources/MacosUseSDK/DrawVisuals.swift:278-286).setWindowFrame targets the first AXWindow of the app — AA/Sources/AgentAccess/AccessibilityService+Window.swift:216-227. manageApp promotes a dot-less "bundleId" to a name ("callers frequently pass a natural app name like "Photo Booth" in the bundleId slot") — :238-248; hide/unhide use lookupBundleId "no auto-launch: launching an app just to hide it would be absurd" — :296-300.Permission check implementation
hasAccessibilityPermission() = AXIsProcessTrusted() cached in a nonisolated(unsafe) static var _permissionGranted that is never cleared, so a revocation is not noticed until relaunch — AA/Sources/AgentAccess/AccessibilityService+Security.swift:12-24.requestAccessibilityPermission(): first call prompts via the literal ["AXTrustedCheckOptionPrompt": true]; later calls open x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility; either way starts a detached 2 s poll that, once trusted, relaunches the app (createsNewApplicationInstance = true, then NSApplication.shared.terminate) — :26-72.hasAccessibilityPermission() and returns {"success": false, "error": "Accessibility permission required."}; screenshots return "Accessibility/Screen Recording permission required." on the same check (Screen Recording is not actually checked) — AA/Sources/AgentAccess/AccessibilityService+Screenshot.swift:22-26,74-77.isRestricted(_ id:) ignores its argument and reads UserDefaults "AccessibilityGlobalEnabled" — AA/Sources/AgentAccess/AccessibilityService+Security.swift:74-77. The README's per-action gate (AccessibilityPermissions.shared.isRestricted("AXPress"), .toggle("AXDelete"), .enableAll()) and its "AX Actions (30)" table — AA/README.md:150-176 — describe an API that is not in source./usr/sbin/screencapture -x -t png [-l <windowID> | -R x,y,w,h] <path> under ~/Documents/AgentScript/screenshots/, on DispatchQueue.global because "process.waitUntilExit() … BLOCKS the calling thread. The previous implementation ran on MainActor and froze Agent's UI on every screenshot." — AA/Sources/AgentAccess/AccessibilityService+Screenshot.swift:9-18,22-70; frontmostWindowID() = first layer-0 CG window of the frontmost app — AA/Sources/AgentAccess/AccessibilityService+Window.swift:10-21.Gotchas recorded in AgentAccess comments (verbatim)
AA/Sources/AgentAccess/AccessibilityService+Actions.swift:10-12.:29-31.AA/Sources/AgentAccess/AccessibilityService.swift:377-380.:239-240.AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:101-102.AA/Sources/AgentAccess/AccessibilityService+Window.swift:179-180.:376-377.CGDisplayBounds, not NSScreen.main.frame.height - y — AX/Sources/AXorcist/Core/AppLocator.swift:139-149, SDK/Sources/MacosUseSDK/DrawVisuals.swift:278-286.MCP/Sources/MCPServer/main.swift:1503.SDK/Sources/MacosUseSDK/ActionCoordinator.swift:207-214.Element.click()/clickMouse are CGEvents, and AXPress is a separate path that is sometimes the only one that works (Catalyst, sandboxed apps) — MCP/Sources/MCPServer/main.swift:1459.AXSelected but no AXPress; selecting means setting an attribute — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-178.AXSetValue is not an accessibility action; typing-by-value is AXUIElementSetAttributeValue(kAXValueAttribute) — AX/Sources/AXorcist/Core/AccessibilityConstants.swift:23-24.kAXErrorCannotComplete can be returned after the action dispatched, so never retry it — AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:206.AXUIElementCopyElementAtPosition does not reach Catalyst table rows; walk the tree and pick the smallest containing frame — SDK/Sources/MacosUseSDK/AccessibilityActions.swift:47-49.AXChildren on an Electron app root only lists the frontmost window and hides Chromium's focused subtree; also read AXWindows and AXFocusedUIElement — AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54.AX/Sources/AXorcist/Search/ElementSearch.swift:599-614.Criterion(attribute: "title") reads a literal attribute named title; only role/subrole/identifier/pid/DOM/computedName have aliases — AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:83-106.AXTitle matching is case-sensitive and contains "" fails on a missing attribute — AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:134-151, AX/Sources/AXorcist/Search/StringComparisonLogic.swift:48-56.AX/Sources/AXorcist/Search/AXTreeTraversal.swift:147-149,167-169, AX/Sources/AXorcist/Search/ElementSearch.swift:414.AXUIElementSetMessagingTimeout, a wedged app blocks the caller forever, and the timeout must be reset to 0 afterwards — AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:126-133, AX/Sources/AXorcist/Core/ObserverNativeWork.swift:693-721.AXValue raw type 4 is both Boolean and CFRange, so a "raw value" switch corrupts AXSelectedTextRange — AX/Sources/AXorcist/Values/ValueUnwrapper.swift:71-72.Parameterized suffix, and action names come from AXUIElementCopyActionNames, not an attribute — AX/CHANGELOG.md:40, AX/Sources/AXorcist/Core/Element+Properties.swift:112-121.AX/Sources/AXorcist/Core/Element+UIAutomation.swift:229-230.mouseEventClickState, hotkeys must be fully built before posting or modifiers stick, and every event needs a ~15 ms gap — AX/Sources/AXorcist/Core/Element+UIAutomation.swift:117-121,437, SDK/Sources/MacosUseSDK/InputController.swift:61-62.AX/Sources/AXorcist/Core/InputDriver.swift:166-180, SDK/Sources/MacosUseSDK/InputController.swift:155.AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:43-51, AX/docs/releasing.md:3, AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:142-175.AXObserver per PID driven by KVO on NSWorkspace.runningApplications, with PID reuse detected via proc_pidinfo unique IDs — AX/README.md:514-517, AX/Sources/AXorcist/Core/ObserverNativeWork.swift:19-32.AXObserverAddNotification can succeed after your deadline; a late success must be rolled back with a native remove or it leaks — AX/Sources/AXorcist/Core/ObserverNativeWork.swift:93-107,185-186.CGWindowListCreateImage pulls ReplayKit into a long-lived process and it spins at ~19 % CPU forever; capture in a subprocess — MCP/Sources/MCPServer/main.swift:382-385.SDK traversal text is a five-attribute concatenation, numeric AXValues vanish, and the list is BFS + spatially sorted, so "children follow their parent" is false — SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:226-229,272-283,302-305,173-180.activate() returning false can still work, and an NSRunningApplication.activate immediately after unminimize races the AX queue — AX/Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift:34-43, AX/Sources/AXorcist/Core/Element+WindowOperations.swift:227, AA/Sources/AgentAccess/AccessibilityService.swift:377-386.# macOS Accessibility (AXUIElement) layer — extraction from AXorcist, MacosUseSDK, mcp-server-macos-use
Date: 2026-09-02. Read-only extraction; nothing was built, run, or modified.
## Sources and citation convention
| Prefix | Repo | HEAD read | License |
|---|---|---|---|
| `AX/` | `/Users/robertboulos/projects/cloned-repos/AXorcist` (steipete, 162 files) | `37d7ae8` 2026-09-01 | MIT (`AX/LICENSE:1-3`) |
| `SDK/` | `/Users/robertboulos/projects/cloned-repos/MacosUseSDK` (mediar-ai, 17 files) | `a2d7866` 2026-04-25 | MIT (`SDK/LICENSE:1-3`) |
| `MCP/` | `/Users/robertboulos/projects/cloned-repos/mcp-server-macos-use` (mediar-ai, 7 files) | `b5b9b9d` 2026-04-26 | **BSL 1.1** (`MCP/LICENSE:1,33-37`) — see §10 |
Every citation is `PREFIX/path:line` relative to that repo root. Quotes are verbatim. Anything not found in these three repos is marked **not in source**.
---
## 1. Mental model
**The element handle**
- An `AXUIElement` is an opaque CF handle; two handles for the same on-screen object compare equal only via `CFEqual`, and hash via `CFHash`. AXorcist's `Element` wrapper defines `==` and `hash(into:)` exactly that way and excludes cached attributes/children from identity — `AX/Sources/AXorcist/Core/Element.swift:108-115`. Traversal visited-sets in both AXorcist (`AX/Sources/AXorcist/Search/AXTreeTraversal.swift:44,86-106`) and MacosUseSDK (`Set<AXUIElement>`, `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:99,323-324`) rely on this.
- `Element` is `Sendable` only because of `@preconcurrency import ApplicationServices` — `AX/Sources/AXorcist/Core/Element.swift:4,43`.
- A stale handle surfaces as `kAXErrorInvalidUIElement`; AXorcist's message: `"The specified UI element is invalid (possibly stale)."` — `AX/Sources/AXorcist/Core/AccessibilityError.swift:85`.
**Scopes: system-wide, application, window, element**
- Application root: `AXUIElementCreateApplication(pid)` — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:145`. AXorcist validates the app element by checking `role() != nil` before trusting it (`Element.application(for:)`), while `applicationElement(forProcessID:)` only checks `pid > 0` — `AX/Sources/AXorcist/Core/Element+Factory.swift:15-26`, `AX/Sources/AXorcist/Core/ElementFactories.swift:29-45`.
- System-wide element: `AXUIElementCreateSystemWide()`; used for `AXFocusedApplication` (type-ID checked, then `unsafeDowncast`) — `AX/Sources/AXorcist/Core/AXUIElement+Static.swift:28-45`. The "frontmost" app is a *different* query (`NSWorkspace.shared.frontmostApplication.processIdentifier`) — `:47-58`; both focused-window variants exist — `:81-92`.
- **The system-wide element cannot receive notifications**: "Accessibility observers are application-scoped on macOS; PID `0` and the system-wide AX element cannot receive notifications." — `AX/README.md:514-515`; `subscribe(pid: nil)` fails explicitly with `"macOS AXObserver requires an application PID; use NotificationWatcher(globalNotification:) for native global fan-out"` — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:171-186`; PID 0 is refused before any native call — `:223-226`.
- Observer registration scope: `element == nil` → process scope; an element equal to `AXUIElement.application(pid:)` is also process scope; anything else is element scope — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:494-506`.
- Role of the system-wide element is `"AXSystemWide"` — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:268-342` (`kAXSystemWideRole`).
**Roles, subroles, attributes, actions, parameterized attributes**
- Role strings are `"AXButton"`-style; AXorcist's constant tables list roles/subroles including `AXSwitch`, `AXPopover`, `AXWebArea`, and notes `kAXSearchFieldRole` is "Often a subrole of text field" and `kAXDialogRole` "Often a subrole of window" — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:268-342`. Subroles used for dock items: `applicationDockItem, folderDockItem, fileDockItem, urlDockItem, minimizedWindowDockItem` — `AX/Sources/AXorcist/Core/Element+TypeChecking.swift:15-67`.
- Attribute names and action names come from two *different* C APIs: `AXUIElementCopyAttributeNames` and `AXUIElementCopyActionNames`; settability via `AXUIElementIsAttributeSettable` — `MCP/scripts/ax_inspect.swift:29-45`. AXorcist: "Action names have a dedicated Accessibility API; they are not a standard attribute." (falls back to an `AXActionNames` attribute only second) — `AX/Sources/AXorcist/Core/Element+Properties.swift:112-121`.
- Parameterized attributes use *unsuffixed* native names: `"AXStringForRange"`, `"AXRangeForLine"`, `"AXBoundsForRange"`, `"AXLineForIndex"`, `"AXRangeForPosition"`, `"AXRangeForIndex"`, `"AXRTFForRange"`, `"AXAttributedStringForRange"`, `"AXStyleRangeForIndex"`, `"AXCellForColumnAndRow"` — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:179-180,196-204`; changelog: "Use the native macOS names for parameterized accessibility attributes instead of non-existent `Parameterized`-suffixed raw values." — `AX/CHANGELOG.md:40`. `AXActionDescription` is itself parameterized by the action name — `:148,217`.
- Parameter bridging: `CFRange` → `AXValueCreate(.cfRange, &range)`; `Element` → its `AXUIElement`; `String` → `CFString`; `NSNumber` passthrough; no `CGPoint` bridging exists, so `AXRangeForPosition` has no convenience path — `AX/Sources/AXorcist/Core/Element+ParameterizedAttributes.swift:31-46`. `AXCellForColumnAndRow` takes `[NSNumber(col), NSNumber(row)]` — `:106`.
- `AXValue` boxed types: `AXValueGetType` must be checked before `AXValueGetValue`; `.illegal` → nil — `AX/Sources/AXorcist/Core/AXValue+Extensions.swift:16-80`. **Raw type 4 is ambiguous**: "AXValueType.cfRange also uses raw value 4, so raw-value guesses can corrupt range-based attributes like selectedTextRange into booleans." — `AX/Sources/AXorcist/Values/ValueUnwrapper.swift:71-72`; the older formatter still treats `rawValue == 4` as Boolean first — `AX/Sources/AXorcist/Values/AXValueSpecificFormatter.swift:14-23`; fix recorded in `AX/CHANGELOG.md:74`. Swift's `AXValueType` enum is not exhaustive ("Common missing ones include Boolean (4), Number (5), Array (6), Dictionary (7), String (8), URL (9)") — `AX/Sources/AXorcist/Values/ValueHelpers.swift:62-66`.
- `AXValue` can carry an `AXError` payload (`AXValueType.axError`) — `AX/Sources/AXorcist/Core/AXValue+Extensions.swift:47-53`.
- Non-standard/undocumented attribute strings AXorcist reads directly: `"AXLabel"`, `"AXPlaceholderValue"`, `"AXLinkedUIElements"`, `"AXServesAsTitleForUIElements"`, `"AXTitledUIElements"`, `"AXDescribesUIElements"`, `"AXEditable"`, `"AXInsertionPointLineNumber"`, `"AXTitleUIElement"`, `"AXMenuItemCmdChar"`, `"AXMenuItemCmdVirtualKey"`, `"AXMenuItemCmdModifiers"`, `"AXMenuItemMarkChar"`, `"AXKeyboardShortcut"` ("non-standard but sometimes used") — `AX/Sources/AXorcist/Core/Element+TextAttributes.swift:10-131,130`. Also `"AXPid"`, `"AXDOMClassList"`, `"AXDOMIdentifier"`, `"AXAlternateUIVisible"`, `"AXTopLevelUIElement"`, `"AXPlaceholderText" // Non-standard, but sometimes seen`, `"AXLabelValue"`, `"AXTabs"`, `"AXURL"`, `"AXDocument"`, `"AXContents"` — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:64,75-76,82,92,135-136,166-171,187`. `AXEnhancedUserInterface` is commented out as `// Bool (private)` — `:103`; `AXFrame` commented out "Less common, usually derived" — `:127`. `AXManualAccessibility`: **not in source**.
- Heuristic container attributes probed for children ("often non-standard"): `"AXWebAreaChildren"`, `"AXHTMLContent"`, `"AXApplicationNavigation"`, `"AXApplicationElements"`, `"AXBodyArea"`, `"AXDocumentContent"`, `"AXWebPageContent"`, `"AXSplitGroupContents"`, `"AXLayoutAreaChildren"`, `"AXGroupChildren"` — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:221-232`.
**What "children" means (this is where every naive walker goes wrong)**
- AXorcist's `children(strict:)` fetches `kAXChildrenAttribute` then 14 alternatives: `kAXVisibleChildren, AXWebAreaChildren, AXApplicationNavigation, AXApplicationElements, AXBodyArea, AXSplitGroupContents, AXLayoutAreaChildren, AXGroupChildren, kAXContents, "AXChildrenInNavigationOrder", kAXSelectedChildren, kAXRows, kAXColumns, kAXTabs` — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:112-120`; "collectAlternativeChildren may be expensive, so respect `strict` flag there." — `:38`.
- For the application root it **always** adds `AXWindows` and `AXFocusedUIElement`: "Some Electron apps only expose the *front-most* window via `kAXChildrenAttribute`, while all other windows are available via `kAXWindowsAttribute`. Not including the latter caused our searches to remain inside the first window (depth ≈ 37) and never reach hidden/background chat panes." — `:43-48`; "This exposes the single element (often a remote renderer proxy) that currently has keyboard/accessibility focus – crucial for Electron/Chromium where the deep subtree is not reachable through normal children." — `:50-54`. Consequence: the focused element appears at depth 1 under the app regardless of its real nesting.
- MacosUseSDK enqueues, per node, `AXWindows` → `AXMainWindow` → ranged `AXChildren` — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:375-411`.
- Children are deduped across all those attributes with first-seen order preserved — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:184-206`; `nil` (not `[]`) is returned when nothing was collected — `:208-216`.
**Focus**
- Focused element = `AXFocusedUIElement` on the app element — `AX/Sources/AXorcist/Core/Element+Properties.swift:10-207` (`focusedUIElement()`); MCP reads it raw and falls back to the parent's text — `MCP/scripts/coord_test.swift:41-52`.
- "No focused element" is a **success** response, not an error: "// This is not necessarily an error, could be a valid state." — `AX/Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift:34-43`.
- Typing in AXorcist refuses to proceed unless focus is established (`AXFocused == true` or settable to true) — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:176-179,196-198`; changelog rationale: "Refuse element-scoped typing when native focus cannot be established, preventing keyboard events from reaching an unrelated focused app." — `AX/CHANGELOG.md:25`.
- `setFocusedValue`: if `AXFocused` is settable set it; else try `AXPress` "to potentially gain focus"; then set `AXValue` regardless ("but proceeding to set value.") — `AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:72-75,263-317`.
- Activation vs focus: AXorcist `Element.activate()` tries `AXFrontmost = true` if settable, else `AXRaise` — `AX/Sources/AXorcist/Core/Element+ApplicationActions.swift:29-62`; `focusWindow()` does `NSRunningApplication.activate(options: [.activateAllWindows])`, ignores a `false` return ("// Continue anyway - sometimes activation reports false but works"), sleeps 0.1 s, then `AXRaise`, then falls back to setting `kAXMain = true` — `AX/Sources/AXorcist/Core/Element+WindowOperations.swift:198-259,227,230`.
**Coordinates — three spaces, two origins**
- AX `AXPosition`/`AXSize` are global screen points with **top-left origin**: "Reads the screen-space frame (origin top-left) of an AX element." — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:26`. CGEvent mouse positions are in the same space: "AX coordinates and CGEvent coordinates are in the same logical point space" — `MCP/CLAUDE.md:47`.
- AppKit (`NSScreen`, `NSWindow`, `NSEvent.mouseLocation`) is **bottom-left origin**: "AppKit coordinates (bottom-left origin) are used by NSWindow positioning." — `SDK/Sources/MacosUseSDK/DrawVisuals.swift:232-234`; conversion `originY = screenHeight - point.y - (effectiveSize.height / 2.0) // Convert Y from top-left to bottom-left` — `:278-286`; highlight boxes `convertedY = screenHeight - originalY - elementHeight` — `:426`.
- MCP converts the saved cursor with `primaryScreen.frame.height - nsPos.y` using `NSScreen.screens.first` — `MCP/Sources/MCPServer/main.swift:1805-1807`. SDK uses `NSScreen.main?.frame.height ?? 0` and warns "coordinates might be incorrect" if 0 — `SDK/Sources/MacosUseSDK/DrawVisuals.swift:280-283,414-416`.
- AXorcist does the conversion **per display**: find the `NSScreen` containing the AppKit point, compute the local offset, map into `CGDisplayBounds(displayID)` with `y = quartzFrame.minY + quartzFrame.height - localY`; display ID from `screen.deviceDescription["NSScreenNumber"]` — `AX/Sources/AXorcist/Core/AppLocator.swift:124-158`; test: AppKit `(100,200)` on a 1080-high screen → Quartz `(100, 880)` — `AX/Tests/AXorcistTests/AppLocatorTests.swift:139-157`. Doc: "A point in Quartz global screen coordinates. When omitted, the current AppKit mouse location is translated into the matching display's Quartz coordinate space." — `AX/Sources/AXorcist/Core/AppLocator.swift:37-38`.
- AXorcist's `isActionable()`/`isOnAnyScreen()` compare the AX (top-left) frame directly against `NSScreen.frame` (bottom-left) with **no flip** — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:150-164`, `AX/Sources/AXorcist/Core/Element+WindowOperations.swift:304-310`.
- Multi-monitor layout example from the MCP author's machine: "Screen 0 built-in (0,0 top-left), Screen 1 left external (x≈-3840), Screen 2 right external (x≈3456)" — `MCP/CLAUDE.md:46` (negative x is normal).
- Retina: "backingScaleFactor=1.0 on all screens — 1pt == 1px, no Retina scaling needed" is a statement about **that developer's setup**, not a rule — `MCP/CLAUDE.md:47`. The screenshot helper handles scale explicitly: `scaleX = imageWidth / windowRect.width` (image pixels ÷ window points) — `MCP/Sources/ScreenshotHelper/main.swift:53-58`; it also flips Y for CoreGraphics drawing (`drawY = imageHeight - localY`) — `:66-68`.
- "NEVER estimate coordinates visually from screenshots. Screenshot pixel positions do NOT match screen coordinates (they differ by the window origin offset)." — `MCP/Sources/MCPServer/main.swift:1503`.
- AXorcist exposes no frame fields on its element JSON; `AXPosition`/`AXSize` appear only as attribute entries (`{"x","y"}`/`{"width","height"}`) — `AX/Sources/AXorcist/Core/ResponseModels.swift:104-112`, `AX/Sources/AXorcist/Search/AttributeBuilders.swift:44-51`. `frame()` is two separate AX calls — `AX/Sources/AXorcist/Core/Element+ConvenienceAttributes.swift:50-57`.
- MacosUseSDK treats an element as "geometrically visible" only if it has a position and `size.width > 0 || size.height > 0`; zero-width or zero-height dimensions are nulled — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:339-345`.
**Hit-testing (`AXUIElementCopyElementAtPosition`)**
- Signature takes `Float` x/y — `AX/Sources/AXorcist/Core/AXUIElement+Static.swift:61-76`; AXorcist passes points unmodified — `AX/Sources/AXorcist/Core/Element+Factory.swift:55-56,62-63`.
- It can be called on an app element or the system-wide element; AXorcist uses the app element when `pid != 0`, system-wide when `pid == 0` — `AX/Sources/AXorcist/Core/Element+Factory.swift:46-71`. The CLI handler always resolves an app first, requires `pid > 0`, then **verifies the returned element's pid** matches and otherwise errors: `"The element at the requested point did not belong to …"` — `AX/Sources/AXorcist/Core/AXorcist+GetElementAtPointHandler.swift:20-44`.
- **It does not reliably penetrate Catalyst content**: "`AXUIElementCopyElementAtPosition` does not reliably penetrate into table rows in Catalyst apps; the rows are reachable by walking the tree but not by hit-test." — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:47-49`; "Catalyst hit-tests typically return an AXCell or AXStaticText inside a row, but the selectable element is the parent AXRow." — `:91-92`; "Catalyst hit-test is unreliable for table rows (returns the window-level AXGroup, not the row)." — `:186-187`. SDK therefore BFS-walks the app tree for the smallest frame containing the point, preferring roles, capped at 4000 nodes — `:43-77`; ancestor walk capped at 12 — `:93-107`.
- MCP's own tree hit-test returns the deepest match and "Always recurses into children since scroll area content may extend beyond the parent's visible frame." — `MCP/Sources/MCPServer/main.swift:1075-1104`, depth cap 25 — `:1078`. But it is deliberately **not** used to refine in-viewport clicks: "the AX tree has overlapping full-width group elements (e.g. message rows spanning the entire window) that would shadow sidebar items and send clicks to the wrong location." — `:1189-1195`.
- `Element.elementAt(_:role:)` walks **up** the parent chain until the role matches — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:735-755`.
**Windows ↔ CGWindowID**
- AXorcist uses the private symbol `@_silgen_name("_AXUIElementGetWindow")` to get a `CGWindowID` from an AX window — `AX/Sources/AXorcist/Core/AXWindowResolver.swift:15-17`; cross-app lookup fast path via `CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID)` → `kCGWindowOwnerPID`, with "// Fallback: full AX enumeration (works without Screen Recording permission)." — `:57-77`. `WindowInfoHelper` matches AX bounds to `kCGWindowBounds` with tolerance 1.0 — `AX/Sources/AXorcist/Utils/WindowInfoHelper.swift:68-112` (its doc claims to use the private API but the body matches bounds — `:67,76-108`).
- MCP picks the CGWindow for a PID by best overlap with the AX window frame, restricted to `kCGWindowLayer == 0` — `MCP/Sources/MCPServer/main.swift:393-425`. Sheets are found as `AXSheet`-role children of `AXWindow` — `:241-278`.
- App-under-point without AX: `CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements])`, first window in list order whose bounds contain the point and whose owner is `activationPolicy == .regular && !isHidden && bundleIdentifier != nil` — `AX/Sources/AXorcist/Core/AppLocator.swift:63-101,103-122`; `exactApp(at:)` never falls back to frontmost, `app(at:)` does ("Compatibility lookup for legacy pointer workflows.") — `:31-51`.
---
## 2. Permissions & TCC
- Prompting call: `AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": kCFBooleanTrue] as CFDictionary)`. MacosUseSDK does this **on every traversal**, using the literal string key — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:135-142`. AXorcist obtains the key as `kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String` — `AX/Sources/AXorcist/Core/CFConstants.swift:26`, and only prompts in `askForAccessibilityIfNeeded()` / `AXTrustUtil.checkAccessibilityPermissions(promptIfNeeded:)` — `AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:40-51`, `AX/Sources/AXorcist/Utils/AXTrustUtil.swift:14-19`.
- Non-prompting check is plain `AXIsProcessTrusted()` — `AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:59-62`; `isAccessibilityApiEnabled` and `isProcessTrustedForAccessibility` are the same value — `AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:69,78-80`. The `isAXFeatureEnabled` JSON command calls `AXIsProcessTrustedWithOptions(nil)` — `AX/Sources/axorc/CommandExecutor.swift:253-262`.
- **The prompt is silently suppressed under test**: returns `false` if `XCTestConfigurationFilePath` is set, `--test-mode` is an argument, or `NSClassFromString("XCTest") != nil` — `AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:42-48` ("// Skip permission dialog in test environment").
- **Permission changes are detected by polling**, not by notification: a repeating `Timer` on `RunLoop.main` (default mode) calls `AXIsProcessTrusted()` every `interval` (default 1.0 s) and yields on change; initial state yielded immediately — `:128-135,142-175,207-216`. Cancellation must not block the main queue — `:160-173,189-226`; tests `AX/Tests/AXorcistTests/PermissionChangeStreamTests.swift:8-39`.
- **Who must be granted**: the process whose TCC identity matters is usually the host, not the CLI. AXorcist's error hint names the *parent process* via `getppid()`: `"Hint: Grant accessibility permissions to \(parentName!)."` — `AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:43-51,112-121`, `AX/Sources/AXorcist/Core/ProcessUtils.swift:208-219`. MCP: "The host application (Claude Desktop, Terminal, iTerm, VS Code, etc.) must have Accessibility permission granted" — `MCP/llms.txt:137`.
- Symptom when missing in the MCP server: `CGEvent.tapCreate` returns nil → `"error: InputGuard: failed to create CGEventTap (check Accessibility permissions)"` — `MCP/Sources/MCPServer/InputGuard.swift:140`. MacosUseSDK throws `.accessibilityDenied` with the System Settings path — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:17-18,138-141`. AXorcist maps `apiDisabled`/`notAuthorized` to exit code 10 — `AX/Sources/AXorcist/Core/AccessibilityError.swift:164`; `kAXErrorAPIDisabled` → `permission_denied` error code — `AX/Sources/AXorcist/Core/AXError+Extensions.swift:94-107`.
- `axorc permissions` prints `Accessibility: granted|missing` (exit 1 when missing), JSON `{"accessibility":true|false}` — `AX/Sources/axorc/CLIFrontend.swift:131-144`. Deep link: `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility` — `AX/Sources/AXorcist/Utils/AXTrustUtil.swift:27-35`.
- **Code signature ↔ TCC identity**: "The Homebrew formula consumes a Developer ID-signed universal binary. Do not publish the ad-hoc artifact produced by `--adhoc`; a stable signature keeps the macOS Accessibility identity consistent across upgrades." — `AX/docs/releasing.md:3`; "The designated requirement must contain `anchor apple generic`; an ad-hoc requirement is a release blocker." — `:44`. Release signing: `codesign --force --options runtime --timestamp --sign "$AXORC_CODESIGN_IDENTITY"` vs ad-hoc `--sign -` — `AX/scripts/build-universal-binary.sh:66-70`; "Rewriting tools can narrow permissions under a restrictive caller umask." → `chmod 0755` after codesign — `:72-73`. Homebrew formula test asserts `codesign --verify --strict` and `anchor apple generic` — `AX/packaging/homebrew/axorc.rb.template:23-28`. "`spctl --assess --type execute` is an app assessment and can reject valid standalone executables as not being apps." — `AX/docs/releasing.md:28`; "Zip archives cannot be stapled" — `:17`. (TCC reset on bundle-id/signature change is *implied* by these, but no explicit reset/`tccutil` logic exists — **not in source**.)
- **Sandbox**: detected by `ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil` — `AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:70-73`; only a warning results — `AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:72-74`. MacosUseSDK: CGEvent Unicode typing "works in sandboxed processes, requires no Script Editor consent, and does not fork-exec per call" (vs. its prior AppleScript path) — `SDK/Sources/MacosUseSDK/InputController.swift:176-178`; AX-driven writes exist for "Sandboxed/secure-input contexts where the HID tap is filtered." — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:9-10`.
- **Screen Recording** is *not* needed for AX window enumeration: "// Fallback: full AX enumeration (works without Screen Recording permission)." — `AX/Sources/AXorcist/Core/AXWindowResolver.swift:69`. MCP screenshots use `CGWindowListCreateImage(.null, .optionIncludingWindow, windowID, [.boundsIgnoreFraming, .bestResolution])` — `MCP/Sources/ScreenshotHelper/main.swift:45` (permission requirements for it: **not in source**).
- **Apple Events / Automation permission is deliberately not used** by AXorcist: `automationStatus` is always `[:]`, `canAutomate` always nil — `AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:16-33,92-104`; CI forbids `NSAppleScript|NSUserAppleScriptTask|OSAKit|…|AESend|osascript` in sources and the linked binary — `AX/scripts/test-native-ax-only.sh:8-19,46-56,63-68`; changelog `AX/CHANGELOG.md:41`. MacosUseSDK still carries an `osascriptExecutionFailed` error case — `SDK/Sources/MacosUseSDK/InputController.swift:16-18`, and its test teardown drives TextEdit through `osascript` System Events — `SDK/Tests/MacosUseSDKTests/CombinedActionsFocusVisualizationTests.swift:59-90`.
- To show overlay windows from a CLI/MCP process you must bootstrap AppKit: `NSApplication.shared` + `setActivationPolicy(.accessory)` "// Don't show in dock or Cmd+Tab" — `MCP/Sources/MCPServer/InputGuard.swift:203-205`.
- Activation is only attempted for `.regular` activation-policy apps in SDK traversal — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:150-158`.
- Tests that need AX cannot self-authorize: "Ensure accessibility is granted (cannot check programmatically easily, user must pre-authorize)" — `SDK/Tests/MacosUseSDKTests/CombinedActionsDiffTests.swift:12`; AXorcist gates automation tests on `RUN_AUTOMATION_TESTS=true`/`RUN_LOCAL_TESTS=true` — `AX/Tests/AXorcistTests/CommonTestHelpers.swift:18-26`; only `PingIntegrationTests` runs headless (2.39 % coverage) — `AX/README.md:803-806`.
---
## 3. Traversal
**MacosUseSDK (`traverseAccessibilityTree`)**
- Order: BFS — "processes all siblings at each depth before going deeper. This ensures dialog buttons (siblings of file lists) are discovered before deep-diving into individual file list rows." — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:302-305`.
- Caps: `maxDepth = 100`, `maxElements = 2000`, `maxTraversalSeconds = 5.0` — `:103-105`; `maxChildrenPerElement = 200` — `:309`; on any cap `statistics.truncated = true` and the walk returns — `:313-317,166-168`.
- Children are fetched with **ranged** `AXUIElementGetAttributeValueCount` + `AXUIElementCopyAttributeValues(element, kAXChildrenAttribute, 0, fetchCount, …)` "to avoid blocking on huge containers" — `:395-411`. MCP's sheet finder does the same with 50 — `MCP/Sources/MCPServer/main.swift:255-262`.
- Attributes read per node (9 AX calls): `AXRole`, `AXRoleDescription`, `AXValue`, `AXTitle`, `AXDescription`, `"AXLabel"`, `"AXHelp"`, `AXPosition`, `AXSize` — `:265-300`. Text is the **concatenation** of the five text attrs joined by `" "` — `:272-283`. Non-`CFString` values (e.g. numeric `AXValue`) are dropped: "AXValue conversion is complex, return nil for generic string conversion" — `:226-229`.
- Filter: roles in `nonInteractableRoles` (`AXGroup, AXStaticText, AXUnknown, AXSeparator, AXHeading, AXLayoutArea, AXHelpTag, AXGrowArea, AXOutline, AXScrollArea, AXSplitGroup, AXSplitter, AXToolbar, AXDisclosureTriangle`) are kept only if they have text — `:109-114,357-358`. Role display becomes `"AXButton (button)"` when the role description differs — `:352-355`.
- Output is a `Set<ElementData>` hashed on role+text+x+y+w+h, so identical-looking elements collapse — `:32-56,100,365`; then **sorted by y then x** (nil last) — `:173-180`. The tree structure is gone; MCP's later comment "the traversal is depth-first, so children follow the parent" (`MCP/Sources/MCPServer/main.swift:548`) is therefore wrong on both counts.
- Every attribute error is swallowed (`attributeUnsupported`/`noValue` silently; others commented-out warning) — `:208-217`.
- **No `AXUIElementSetMessagingTimeout` call exists anywhere in `SDK/Sources`** (grep); a wedged app is bounded only by the 5 s wall clock checked between nodes.
- The SDK activates the target if it's a `.regular` app and not active, with the delay left commented out (`// Thread.sleep(forTimeInterval: 0.2)`) — `:148-161`.
- Stats returned: `count, excluded_count, excluded_non_interactable, excluded_no_text, with_text_count, without_text_count, visible_elements_count, truncated, role_counts` — `:59-69`.
**mcp-server-macos-use (on top of the SDK)**
- Sets `AXUIElementSetMessagingTimeout(…, 5.0)` on every app/window/child element it creates itself — `MCP/Sources/MCPServer/main.swift:245,253,265,310,329,341,350,1182`.
- `in_viewport` means "the element's top-left point lies inside any of the app's `AXWindow` frames" (or the `AXSheet` frame when a sheet exists) — `:513-544,631-637`; multi-window rationale "(e.g. Sparkle update dialogs)" — `:295-296`.
- Every tool writes a flat text file to `/tmp/macos-use/<ms-timestamp>_<tool>.txt` plus a `.png` — `:1961-1983`; line format `[Role] "text" x:N y:N w:W h:H visible` — `:992-1010`, `MCP/CLAUDE.md:39`. Diff prefixes `+ - ~` — `:1034-1048`. The compact summary inlines up to 30 interactive + 10 static-text visible elements — `:954`; interactive role prefixes `AXButton, AXLink, AXTextField, AXTextArea, AXCheckBox, AXRadioButton, AXPopUpButton, AXComboBox, AXSlider, AXMenuItem, AXMenuButton, AXTab` — `:937-941`.
- Diff (SDK side): elements match if same role and position within 5 pt, else text equality when both lack coordinates — `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:264-302`; double tolerance 0.01 — `:459-469`. MCP then drops scroll-bar noise (`scrollbar|scroll bar|value indicator|page button|arrow button`) and textless structural rows/cells/columns/menus, and discards coordinate-only modifications — `MCP/Sources/MCPServer/main.swift:591-607,649-718`.
- Container text (AXRow/AXCell) is recovered by coordinate containment, then by "list proximity" ±2 px within the next 5 entries — `:546-589`.
- Traversals run inside `Task { @MainActor in … }` — `:1613-1615,1842-1845`.
- The composed click→type→press path takes a *before* traversal with the click and a *final* traversal after all actions, and returns **no diff** (`hasDiff = false`) — `:1849-1890`.
**AXorcist**
- One kernel: `traverseAXTree(from:initialDepth:maxDepth:order:strictChildren:timeout:now:children:shouldDescend:onTimeout:visit:)`, `@MainActor`, internal — `AX/Sources/AXorcist/Search/AXTreeTraversal.swift:119-133`. DFS is a stack with children pushed reversed; BFS indexes into the same growing array — `:60-84`. Default order `.depthFirst`.
- Depth semantics: nodes at `depth == maxDepth` **are visited but not expanded** — `:147-149,167-169`; test `maxDepth: 1` → `["root","left","right"]` — `AX/Tests/AXorcistTests/TraversalKernelTests.swift:59-71`.
- Timeout: deadline computed once, checked after each pop, remaining nodes dropped — `:135,142-146`. The result (`visitedCount, timedOut, stopped`) is discarded by every public surface; a timed-out search is only visible as the log line `"Traverse: search timeout (…s) reached. Aborting traversal."` — `AX/Sources/AXorcist/Search/ElementSearch.swift:387,414,433`.
- Identity: re-encountered elements return the cached disposition (no second visit); a node is re-expanded only if reached again at a strictly **shallower** depth — `AX/Sources/AXorcist/Search/AXTreeTraversal.swift:44-45,86-116`; cycles terminate — `AX/Tests/AXorcistTests/TraversalKernelTests.swift:10-24`. Visited state is per call — `AX/Tests/AXorcistTests/TraversalStateTests.swift:8-23`; changelog "Keep accessibility-tree traversal state local to each search … so repeated lookups cannot skip elements seen by earlier commands." — `AX/CHANGELOG.md:30`.
- **Default search prunes to container roles**: `shouldDescend` is `scanAll || containerRoles.contains(role)` — `AX/Sources/AXorcist/Search/ElementSearch.swift:410-412`; `containerRoles` = Application, Window, Group, ScrollArea, SplitGroup, LayoutArea, LayoutItem, WebArea, List, Outline, Unknown, `AXGeneric, AXSection, AXArticle, AXSplitter, AXScrollBar, AXPane`, MenuBar — `:599-614`. **Not** containers: AXTable, AXRow, AXCell, AXToolbar, AXTabGroup, AXMenu, AXMenuItem, AXPopUpButton, AXRadioGroup, AXButton, AXTextArea. An element with nil role is a leaf. CLI escape hatch `--scan-all` "Traverse every node (ignore container role pruning). May be extremely slow." — `AX/Sources/axorc/AXORCMain.swift:70`. Menu bars are traversed by default since 0.1.7 — `AX/CHANGELOG.md:26`.
- No element-count budget; only `maxChildrenPerElement = 50000` — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:169-172`. Defaults: `AXTraversalOptions.standard = (timeout: 30, scanAll: false, stopAtFirstMatch: true)` — `AX/Sources/AXorcist/Search/AXTraversalOptions.swift:7-14`; depth constants collectAll 5 / search 10 / describe 3 / hint step 3 / max elements 1000 / 2.0 s per-element collectAll — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:424-430` (the CLI `collectAll` actually defaults to `max_depth ?? 10` — `AX/Sources/axorc/CommandTypeExtensions.swift:93`).
- `collectAll` silently drops **hidden** subtrees (`isHidden() == true` → `.skipChildren`) unless `include_ignored_elements` — `AX/Sources/AXorcist/Search/ElementSearch.swift:564-567`, `AX/Sources/AXorcist/Core/Element+Properties.swift:60-62`.
- Error mid-walk: `rawAttributeValue` logs and returns nil for every `AXError` (no distinction for `cannotComplete`) — `AX/Sources/AXorcist/Core/Element.swift:137-156`; `children()` returns nil on error and the kernel treats nil/empty as a leaf, so a subtree lost to `kAXErrorCannotComplete` disappears silently — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:101-107`, `AX/Sources/AXorcist/Search/AXTreeTraversal.swift:170-172`.
- Per-node cost: `runTraversal` computes `briefDescription(.smart)` for every visit just for logging (role, pid, title, identifier, domIdentifier), and `SearchVisitor.visit` does it again — `AX/Sources/AXorcist/Search/ElementSearch.swift:417,493`, `AX/Sources/AXorcist/Core/Element+Description.swift:42-57`; criteria matching computes `briefDescription(.raw)` per criterion per element — `AX/Sources/AXorcist/Search/CriteriaMatchingHelpers.swift:20,53`. Only prefetched `attributes`/`prefetchedChildren`/`actions` are cached — `AX/Sources/AXorcist/Core/Element.swift:93-105,119-134`.
- `Element.searchElements/findElement` walk with **no timeout and no container pruning**; `maxDepth = 0` means unlimited — `AX/Sources/AXorcist/Core/Element+Search.swift:16,50-52,73-75,97-99,193`.
- **Messaging timeouts**: `Element.withMessagingTimeout(_:operation:)` arms `AXUIElementSetMessagingTimeout`, runs the operation, then **resets to 0**; if arming fails the operation is never run (`systemFailure`); a failed reset is reported *instead of* the operation result — `AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:16-49,121-133`; tests `AX/Tests/AXorcistTests/AXTimeoutHelperTests.swift:90-125,166-186`. Timeout must be finite and > 0 — `:117-119`. Nesting on the same `AXUIElement` (by `ObjectIdentifier`) throws `nestedScope`; all system-wide references share one scope — `:85-109`. Global: `AXTimeoutConfiguration.setGlobalTimeout` on the system-wide element — `:140-150`. `windowsWithTimeout(timeout: 2.0)`, `menuBarWithTimeout(2.0)` — `:52-61`. Changelog: "Refuse per-element Accessibility reads when macOS cannot arm their messaging deadline" — `AX/CHANGELOG.md:33`. Observer registration uses 0.5 s and resets to 0 — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:693-721`.
- Async deadline helper: "Returns when `seconds` elapse even if `operation` ignores cancellation. The leftover work is asked to cancel but is not joined." — `AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:196-199`; "// GCD timer, not Task.sleep: CI Swift 6.2.1 serialized the sleeper Task behind the uncooperative work Task." — `:242-243`; "A throwing TaskGroup would still join an uncooperative child after the timeout throw." — `:208-209`.
- Text extraction walks `children()` recursively with **no cycle detection**, first non-empty of title → value → description → help, else children joined — `AX/Sources/AXorcist/Utils/TextExtraction.swift:7-50`; the non-recursive variant concatenates instead — `:87-125`. Path generation walks up ≤ 25 levels and stops at `AXApplication` or a window whose parent is the app — `AX/Sources/AXorcist/Core/Element+PathGeneration.swift:13,41-64`; `generatePathArray` also dumps every ancestor's attributes — `:86-88`.
- LLM-facing element shape (`AXElementData`): `brief_description, role, attributes{name:{any_value}}, all_possible_attributes, textual_content, children_brief_descriptions, full_ax_description, path[]` — `AX/Sources/AXorcist/Core/ResponseModels.swift:79-113`; `textual_content` uses `extractTextFromElement(maxDepth: 3)`; `path` splits `generatePathString()` on `" -> "` — `AX/Sources/AXorcist/Core/AXorcist+QueryHandlers.swift:186-215`. Value sanitizer placeholders `"<AXUIElement_RS>"`, `"<max_depth_reached>"` (depth 50), `"<circular_reference>"` — `AX/Sources/AXorcist/Core/DataModels.swift:91-125`.
---
## 4. Finding elements
**AXorcist criteria matching**
- Match types: `exact, contains, regex, containsAny, prefix, suffix` — `AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:29-36`. There is no "case-insensitive" type; case sensitivity is per attribute: role/subrole insensitive, identifier sensitive, **every generic attribute including `AXTitle` sensitive** — `AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:26,45,63,134-151`, `AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:235`. (README says `contains` is "Case-insensitive substring match" — `AX/README.md:265` — that is true only for the attributes marked insensitive.)
- `compareStrings`: a nil/empty actual value matches **only** if expected is empty *and* type is `.exact`; `.contains ""` against a missing attribute is a mismatch — `AX/Sources/AXorcist/Search/StringComparisonLogic.swift:48-56`. `.exact` uses `localizedCompare == .orderedSame` — `:65-67`; `.regex` is unanchored — `:79-80`; `.containsAny` splits on `","` — `:90-97`.
- **Criterion attribute names are not aliased.** `criterionKey` recognizes only `axrole|role`, `axsubrole|subrole`, `axidentifier|identifier|id`, `pid`, `axdomclasslist|domclasslist|classlist|dom`, `isignored|ignored`, `computedname|name`, `computednamewithvalue|namewithvalue`; anything else is a *generic* key fetched literally with the original casing — `AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:83-106,200-211`. So `{"attribute":"title"}` reads an attribute called `title` and never matches; write `AXTitle`. The lower-case aliases (`title`, `value`, `help`, `description`, `placeholder`, `enabled`, `focused`) in `PathUtils.attributeKeyMappings` apply only to the legacy `PathHintComponent` — `AX/Sources/AXorcist/Core/PathUtils.swift:5-19`, `AX/Sources/AXorcist/Search/PathHintComponent.swift:30-37,71`. README's "Searchable Attributes" list of aliases (`AX/README.md:271-293`) overstates what `criteria` accepts.
- No role normalization (`"button"` ≠ `"AXButton"`); role compare is case-insensitive exact — `AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:21-28`.
- Only the **first** criterion's `match_type` is used as the fallback for the others — `AX/Sources/AXorcist/Search/ElementSearch.swift:231`; per-criterion override — `AX/Sources/AXorcist/Search/CriteriaMatchingHelpers.swift:14`.
- Empty criteria: `matchesAll` → true, `matchesAny` → false — `:13-30,39-44`; a `Locator` with neither criteria nor path hint errors `"FTE: No criteria, no path hint"`; path hint only returns the path element — `AX/Sources/AXorcist/Search/ElementSearch.swift:135-144`.
- **`computedName()` priority**: `AXTitle` → `AXValue` (String only, `prefix(50)`) → `AXIdentifier` → `AXDescription` → `AXHelp` → `AXPlaceholderValue` → `AXRole` with every `"AX"` substring removed — `AX/Sources/AXorcist/Core/Element+ComputedName.swift:17-50`. A text field's computed name is therefore its current text, not its label.
- `AXDOMClassList` `.exact` means "array contains this token" (not whole-list equality); `.contains` is `localizedCaseInsensitiveContains` over the joined string; accepts `[String]` or a space-separated string — `AX/Sources/AXorcist/Search/SpecificCriterionMatchers.swift:100-147`; on miss falls back to `AXDOMIdentifier` then `AXIdentifier` — `AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:81-118`. Role matching logs the DOM class list at INFO for every `AXTextArea` — `:14-21`.
- `pid` criterion is string equality on `element.pid()` — `AX/Sources/AXorcist/Search/SpecificCriterionMatchers.swift:8-51`.
- With `stopAtFirstMatch == false` (`--no-stop-first`), `foundElement` is the **last** preorder match and `allFoundElements` has all — `AX/Sources/AXorcist/Search/ElementSearch.swift:521-522`, test `AX/Tests/AXorcistTests/TraversalKernelTests.swift:131-156`; the field comment "Stores the first element that matches criteria" is wrong in that mode — `:456`.
- Failure message reports `"Max depth visited = N of M"` and nodes visited — `:258-261`.
- `Element.matches(query:)` (the lightweight API) is a case-insensitive substring over 8 fields **including `roleDescription`**, so `"button"` matches every button — `AX/Sources/AXorcist/Core/Element+Search.swift:141-162`; `ElementSearchOptions` defaults `maxDepth 0 (unlimited), caseInsensitive true, visibleOnly false, enabledOnly false` — `:14-34`. `findElements(label:)` matches against `descriptionText()` ("// Check label (using description as label)") — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:800-802`.
- Duplicates: the kernel visits each identity once; `SDK` collapses identical `ElementData` in a `Set` (§3). No "nth match" selector exists in either.
**AXorcist path queries (two engines with different semantics)**
- `Locator` = `criteria` + optional `path_from_root` (`rootElementPathHint: [JSONPathHintComponent]`); the path is navigated first from the app root, then criteria are searched under the resolved element — `AX/Sources/AXorcist/Search/ElementSearch.swift:41-43,119-157`. Fields `descendantCriteria`, `requireAction`, `computedNameContains` are decoded but **never read**; `debugPathSearch` is passed then ignored — `AX/Sources/AXorcist/Core/MatchingTypes.swift:106-166`, `AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:115`.
- Component shape: `{"attribute":"ROLE|SUBROLE|TITLE|ID|IDENTIFIER|DOM|DOMCLASS|DOMID|VALUE|HELP|DESCRIPTION|PLACEHOLDER" (case-insensitive) or raw AX name, "value":…, "depth":N?, "match_type":…?}` — `AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:7-66`.
- **Default step depth is 1, not 3**: `defaultDepthForSegment = 3` is declared but unused; both engines use `depth ?? 1` — `AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:90`, `AX/Sources/AXorcist/Search/PathNavigationJSON.swift:73`, `AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:191,199`. README claims "default: 3" — `AX/README.md:355`.
- Engine 1 (`findTargetElement` → `findDescendantAtPath`): for each component, each child of the current node gets its own `SearchVisitor` traversal with `stopAtFirstMatch: true` and the child at depth 0, so `depth: N` searches N+1 levels; each child traversal gets the full `timeout` (a step over K children can cost K × timeout) — `AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:186-205`. If the path fails, criteria are **not** tried — `AX/Sources/AXorcist/Search/ElementSearch.swift:129-131,207-212`.
- Engine 2 (`getElement(appIdentifier:pathHint:)` → `navigateToElementByJSONPathHint`): an attribute name outside the uppercase map yields empty criteria, which match unconditionally — **an unknown attribute resolves to the first child** — `AX/Sources/AXorcist/Search/PathNavigationJSON.swift:169-172,185-198,210-212`; `depth > 1` runs a BFS from the current node that may match the node itself — `:73,78-84,231-263`; `depth == 1` tries children then the node itself — `:133-156`; aborts when the component index ≥ `maxDepth` — `:33-39`; a leading `"application"` component is skipped — `:25-31`. Numeric `appIdentifier` is treated as a PID — `AX/Sources/AXorcist/Search/PathNavigationUtilities.swift:67-71`.
- String path hints: segments `key:value[, key:value]`, quotes stripped, keys **not** aliased (`role:AXButton` works, `title:X` does not — use `AXTitle:X`), match type fixed (`AXDOMClassList` → contains, else exact) — `AX/Sources/AXorcist/Search/PathNavigationCore.swift:17-55`, `AX/Sources/AXorcist/Core/PathUtils.swift:33-58`, `AX/Sources/AXorcist/Search/PathNavigationMatching.swift:81-84`.
- **No index syntax** (`AXWindow[1]`) exists in any parser; selection is always first match in children order.
- JSON wire: `Criterion` CodingKeys deliberately have no raw values because the CLI decoder uses `.convertFromSnakeCase` ("Using a custom raw value here would *break* that feature because the strategy is applied **after** the raw value is resolved") — `AX/Sources/AXorcist/Core/MatchingTypes.swift:37-45`; `path_from_root` is decoded under both `path_from_root` and `pathFromRoot` — `:133-138,163-165`; tests `AX/Tests/AXorcistCommandConversionTests/LocatorWireTests.swift:8-45`.
**MacosUseSDK / MCP finding**
- MCP `click_and_traverse` `element:` search: lowercased `contains` over the SDK `text` field, optional role **prefix** filter, element must have `w > 0 && h > 0`, **first match wins**, click at center — `MCP/Sources/MCPServer/main.swift:1620-1638`. Because SDK `text` is the join of `AXValue AXTitle AXDescription AXLabel AXHelp` (`SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:272-283`), a search for `"Open"` can match an element whose *help text* contains "open".
- MCP's own AX text getter: `AXValue` → `AXTitle` → recurse into children ("AXRow -> AXCell -> AXStaticText") — `MCP/Sources/MCPServer/main.swift:1106-1131`; `findElementByText` requires **exact** equality and a 15 pt vertical inset from the window — `:1147-1158`. The dev scripts use a different order (`AXValue, AXTitle, AXDescription, AXLabel`) — `MCP/scripts/coord_test.swift:18-25`.
- SDK's AX-side finder picks the **smallest-area** element containing the point among preferred roles (`AXTextField/AXTextArea/AXComboBox/AXSearchField` for set-value; `AXButton/AXMenuItem/AXRadioButton/AXCheckBox/AXMenuButton/AXPopUpButton` for press; `AXRow/AXOutlineRow/AXListItem` for select), BFS capped at 4000 nodes — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:50-77,122,151-154,190`.
- Unlabeled elements: SDK counts them (`excluded_no_text`) and drops non-interactable ones — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:357-372`; AXorcist falls to the role-derived computed name (§ above). Messages' conversation header is found by "the first AXButton whose text isn't a known UI chrome label" with a hard-coded exclusion set — `MCP/scripts/coord_test.swift:54-82`.
---
## 5. Acting
**Click**
- AXorcist `Element.click(button:clickCount:)` is a **CGEvent** click at `frame.midX/midY`, not `AXPress`; gated on `isEnabled() ?? true`; throws `missingFrame` without a frame — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:49-64`. Posts to `.cghidEventTap`; `Thread.sleep(0.01)` between down/up, `0.03` between clicks "(stay within the system double-click interval)" — `:70-82`. Multi-click sends **separate down/up pairs** with `.mouseEventClickState` 1 then 2: "the system expects a sequence of click states: (1) down/up with clickState=1, then (2) down/up with clickState=2." — `:117-121`; tests `AX/Tests/AXorcistTests/ClickEventGenerationTests.swift:22-40`. Middle = `.center` + `.otherMouseDown/Up`, button 2 — `:35-40`. **No activation, no coordinate conversion** in this path.
- MacosUseSDK `clickMouse(at:)`: down+up via `CGEventSource(stateID: .hidSystemState)`, `usleep(15_000)` after **every** post ("crucial for some applications") — `SDK/Sources/MacosUseSDK/InputController.swift:48-63,93-103`; "Does not move the cursor first." — `:90`. `doubleClickMouse` sends **one** down/up with `clickState = 2` — `:109-121` (contrast AXorcist above).
- `InputDriver.pressHold` sets `.mouseEventPressure = 2.0` "(simulates force click fallback)" — `AX/Sources/AXorcist/Core/InputDriver.swift:53,105`; drags interpolate `steps` `.leftMouseDragged` events, all pre-built so partial allocation posts nothing — `:109-143`; timestamps refreshed at post time — `:145-151`; philosophy "no logging, no implicit delays beyond what the underlying AX/UI toolkits already impose." — `:8-10`.
- **Activate first or the click is eaten**: "Ensure the target app is frontmost before sending input. Without this, macOS eats the first click just to activate the window." → `runningApp.activate()` + 200 ms — `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:207-214`. MCP always does `activate(options: [])` + 200 ms before click/scroll/set-value/press/select — `MCP/Sources/MCPServer/main.swift:1655-1660,1717-1722,1738-1741,1757-1760,1777-1780`; the dev script uses 300 ms — `MCP/scripts/coord_test.swift:114-116`.
- Click point from a frame: MCP centers `(x + w/2, y + h/2)` when width/height are passed — `MCP/Sources/MCPServer/main.swift:1647-1652`; tool descriptions say x/y are "top-left of element" — `:1331-1334`.
- Off-screen targets: MCP scrolls into view with `CGEvent(scrollWheelEvent2Source:…units: .line…)` at the window's mid-Y, 1–3 lines per step ("Each scroll line ≈ 20-40px"), up to 30 steps, 100–150 ms sleeps, re-finding the element by text after each step — `:1172-1305`.
- `AXPress`: AXorcist `performAction` is a thin `AXUIElementPerformAction` + `throwIfError`, no retry, no activation — `AX/Sources/AXorcist/Core/Element+Actions.swift:31-48`. The action list is read **only after** an `actionUnsupported` failure — `AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:250-258`; "// The platform can return cannotComplete after dispatch, so classify once and never retry here." — `:206`; changelog "Discover element actions through the dedicated macOS Accessibility API so supported actions such as `AXPress` work in SwiftUI apps." — `AX/CHANGELOG.md:28`. SDK `pressAccessibilityElement` tree-finds a pressable role then `AXUIElementPerformAction(kAXPressAction)` — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:147-169`; MCP: "Use when a synthetic mouse click is dropped (Catalyst right-pane buttons, sandboxed apps). Often the only path that actuates buttons in those apps." — `MCP/Sources/MCPServer/main.swift:1459`.
- Selection where there is no `AXPress`: set `kAXSelectedAttribute` on the `AXRow/AXOutlineRow/AXListItem`; "In single-selection tables, setting this attribute typically deselects any prior selection automatically" — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-206`; MCP tool: "where regular click is dropped and press_ax errors with kAXErrorActionUnsupported" — `MCP/Sources/MCPServer/main.swift:1477`.
- Window ops fallback chains: minimize → press `AXMinimizeButton` else set `AXMinimized`; maximize → `AXZoomButton` → `AXFullScreenButton` → set `AXFullScreen` → `setFrame(visibleFrame)`; close → `AXCloseButton` → action `"AXClose"`; show → unminimize → `unhide()` → `AXRaise` — `AX/Sources/AXorcist/Core/Element+WindowOperations.swift:60-195`.
**Typing**
- AXorcist `typeText` requires focus (§1), `clearFirst` = `cmd+a`, sleep 0.05, `delete` — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:203-210`; `"\n"` → Return (36), `"\t"` → Tab (48), per-character sleep `delay > 0 ? delay : 0.001` (never zero) — `:213-225`.
- **Physical keycodes resolved from the live keyboard layout, Unicode as fallback**: "Physical key events survive VM/headless launch paths that can silently drop Unicode-only events. Resolve them through the active layout so the resulting text remains layout-independent." — `:229-230`; `TISCopyCurrentKeyboardLayoutInputSource` + `UCKeyTranslate`, brute-forcing keycodes 0…127 with `[]`, shift, option, shift+option; printable ASCII only; dead-key states rejected — `:239-333`; Unicode fallback `virtualKey: 0` + `keyboardSetUnicodeString` on both down and up — `:345-371`. Tests: QWERTZ `z` → 16, `@` → 37+option; `é`/emoji → Unicode — `AX/Tests/AXorcistTests/InputDriverTests.swift:192-243`. Changelog — `AX/CHANGELOG.md:71`.
- MacosUseSDK `writeText` is Unicode-only, **one key down/up pair per Unicode scalar** with `virtualKey: 0`: "Some text fields collapse multi-char unicode payloads into a single keystroke, which breaks IME/auto-complete behavior." — `SDK/Sources/MacosUseSDK/InputController.swift:182-214,191-193`. Its `mapKeyNameToKeyCode` table "Assuming US QWERTY. Might need adjustments for others." — `:239`; unknown names are parsed as raw keycode numbers — `:307-310`; `pressKey` applies modifier flags to the key-up too — `:80-86`.
- Setting `AXValue` instead of typing: AXorcist `setValue(String)` bridges String→CFString, Bool→CFBoolean, NSNumber, Element→AXUIElement; anything else throws `illegalArgument` before the native call — `AX/Sources/AXorcist/Core/Element+ValueSetting.swift:38-40,57-74`. **`"AXSetValue"` is not a native action**: "Compatibility command and receipt token. This is not a native macOS accessibility action." — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:23-24`; the handler routes it to the setter and requires a string — `AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:163-178`; README — `AX/README.md:482-496`. SDK `setAccessibilityValue` writes `kAXValueAttribute` on a tree-found text element — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:109-137`; MCP: "Bypasses the input event tap entirely. Use when typing fails (Catalyst right-pane fields, sandboxed/secure-input contexts)." — `MCP/Sources/MCPServer/main.swift:1442`. String→CF for arbitrary attributes decides the target type by **reading the current value first** — `AX/Sources/AXorcist/Values/ValueParser.swift:51-72`; CFNumber tries Double before Int — `:124-129`.
- Hotkeys (AXorcist): modifier keycodes cmd `0x37`, shift `0x38`, option `0x3A`, ctrl `0x3B`, `fn` only as `.maskSecondaryFn` — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:405-414`; sequence = modifier downs (flags accumulate) → main down → main up → modifier ups reversed — `:464-489`; "Build the complete sequence before posting anything. Event creation can fail; posting cannot." — `:437`; changelog "preventing modifiers from remaining stuck" — `AX/CHANGELOG.md:72`. MCP modifier parsing accepts `capslock|caps, shift, control|ctrl, option|opt|alt, command|cmd, help, function|fn, numericpad|numpad` — `MCP/Sources/MCPServer/main.swift:143-166`; SDK note "'fn' might need special handling or accessibility settings" — `SDK/Sources/InputControllerTool/main.swift:96`.
**Scrolling — opposite sign conventions**
- AXorcist `Element.scrollAt`: `units: .pixel`, `wheelCount: 2`, then overrides `.scrollWheelEventDeltaAxis1/2` with **up = +amount, down = −amount** — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:688-727`; `InputDriver.scroll` uses `.line` units ÷ `pixelsPerLine = 10` and documents "Positive `deltaY` scrolls up." — `AX/Sources/AXorcist/Core/InputDriver.swift:166-180`.
- MacosUseSDK/MCP: "Negative = scroll up, positive = scroll down" — `SDK/Sources/MacosUseSDK/InputController.swift:155`, `MCP/Sources/MCPServer/main.swift:1417`. SDK moves the cursor to the point first "so the scroll lands in the right view" and uses `.line` units — `SDK/Sources/MacosUseSDK/InputController.swift:158-172`.
**Menus and shortcuts**
- Menu bar via `menuBarWithTimeout(timeout: 2.0)` (AXMenuBar attribute) — `AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:58-61`; `Attribute.mainMenu` maps to `kAXMenuBarAttribute` — `AX/Sources/AXorcist/Core/Attribute.swift:192-194`. Menu item shortcuts are reconstructed from `AXMenuItemCmdChar` + `AXMenuItemCmdModifiers` cast to `CGEventFlags` as `⌃⌥⇧⌘X` — `AX/Sources/AXorcist/Core/Element+TextAttributes.swift:129-162`; `hasSubmenu()` = first child's role is `AXMenuItem` — `:117-124`. `AXShowMenu` action listed — `AX/README.md:476`. Context menus in MCP are a CGEvent right-click — `MCP/Sources/MCPServer/main.swift:1338,1665-1669`.
**Waiting and restoration**
- SDK `delayAfterAction` default 0.2 s between action and post-traversal — `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:52`; `CombinedActions` use 100 ms — `SDK/Sources/MacosUseSDK/CombinedActions.swift:167,404`. MCP: 100 ms between chained actions — `MCP/Sources/MCPServer/main.swift:1866`; 200 ms "grace period" before checking Esc cancellation — `:1893-1896`.
- AXorcist `waitUntilActionable(timeout: 5.0, pollInterval: 0.1)` polls `isActionable()` (enabled + nonzero frame + on a screen) — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:130-164`.
- MCP saves `NSWorkspace.shared.frontmostApplication` and the cursor before disruptive tools, then restores cursor via a `mouseMoved` CGEvent and re-activates the previous app if frontmost changed — `MCP/Sources/MCPServer/main.swift:1802-1809,1905-1920`; "Disruptive" = every tool except `refresh_traversal` — `:1800`.
- Cross-app handoff: if a different PID is frontmost after a diff action, MCP traverses it and reports `app_switch` — `:1925-1948`.
**Blocking the human while automating (MCP InputGuard)**
- A `CGEventTap` at `kCGHeadInsertEventTap` (raw value 0, "Swift overlay doesn't expose the enum case name") on `.cghidEventTap` swallows hardware input — `MCP/Sources/MCPServer/InputGuard.swift:130-139`; "CGEventTap must be on the main run loop to receive events" — `:149-150`.
- Synthetic vs hardware discrimination: "Our CGEvent.post() calls use .hidSystemState source which has a non-zero stateID. Hardware events have stateID == 0." — `:326-332`.
- macOS disables taps (`tapDisabledByTimeout` / `tapDisabledByUserInput`) and they must be re-enabled — `:298-306,320-324`. Plain Esc (keycode 53, no modifiers) cancels — `:340-350`. 30 s watchdog auto-releases — `:24,172-181`. `engage()` must build the tap on the main thread synchronously; Swift `await` yields the main run loop so callbacks still arrive — `:78-89`.
- Overlay windows: `level = .screenSaver`, `ignoresMouseEvents = true`, `collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]`, `orderFrontRegardless()` — `:216-221,274`. SDK overlays use `.floating` + `[.canJoinAllSpaces, .stationary, .ignoresCycle]` — `SDK/Sources/MacosUseSDK/DrawVisuals.swift:209-210`.
**When each approach fails (as stated in source)**
- Synthetic mouse clicks are swallowed by "Catalyst right-pane controls" → use `AXPress`/`AXValue` — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:6-12`, `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:17-19`.
- The HID tap is filtered in "Sandboxed/secure-input contexts" → AX writes — same lines.
- Unicode-only keyboard events are "silently drop[ped]" on "VM/headless launch paths" → physical keycodes — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:229-230`.
- Multi-char Unicode payloads break IME/autocomplete in some fields → one scalar per event — `SDK/Sources/MacosUseSDK/InputController.swift:191-193`.
- First click after switching apps is eaten unless the app is activated first — `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:208-209`.
- Rows exposing `AXSelected` but no `AXPress` → set selected — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-178`.
- `NSRunningApplication.activate` "sometimes … reports false but works" — `AX/Sources/AXorcist/Core/Element+WindowOperations.swift:227`.
- Electron/Chromium deep subtrees unreachable via `AXChildren` → read `AXFocusedUIElement`/`AXWindows` — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54`.
- Full-screen, Spaces, Java, Qt, games: **not in source**.
---
## 6. App-specific and framework-specific quirks
- **Electron / Chromium**: front-most-window-only `AXChildren`, depth ≈ 37 search dead end, focused element as "remote renderer proxy" — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54`; web-ish child attributes probed — `:112-120`; `AXWebArea` is a container role — `AX/Sources/AXorcist/Search/ElementSearch.swift:608`; `AXDOMClassList`/`AXDOMIdentifier` are in the **default** attribute fetch list — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:413-414`; `AXLoadComplete` "Often for web views" — `:387`.
- **Safari / WebKit**: README path example `AXWindow → AXWebArea (depth 5)` then `AXDOMClassList contains "submit-button primary"` — `AX/README.md:645-662`; class-list search example — `:317-324`.
- **Mac Catalyst** (Messages is the test target, hard-coded `pid = 7301` in a script): hit-test returns cell/static-text/window-group instead of the row; rows are selectable via `AXSelected`, not `AXPress`; right-pane controls swallow synthetic clicks and typing — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:6-12,47-49,91-92,120,149,186-188`; `MCP/scripts/coord_test.swift:54-61,96`; `MCP/scripts/old_click_test.swift:1-2` ("isolate whether the NSEvent change introduced a Catalyst table-row regression" — note the current SDK `clickMouse` is CGEvent-based; the "NSEvent bridge" it refers to is **not in source** at HEAD).
- **SwiftUI**: `AXPress` only works when actions are discovered via `AXUIElementCopyActionNames` — `AX/CHANGELOG.md:28`.
- **Sparkle update dialogs**: reason MCP checks every window's bounds for viewport — `MCP/Sources/MCPServer/main.swift:295-296`.
- **Slack**: canonical example for chained click+type+Return — `MCP/Sources/MCPServer/main.swift:1498`.
- **Dock**: `axorc tree --app com.apple.dock --role AXDockItem` — `AX/README.md:568-571`; dock-item subroles — `AX/Sources/AXorcist/Core/Element+TypeChecking.swift:15-67`.
- **Menu-bar agents / background apps**: global watchers cover "menu-bar agents and background applications" — `AX/README.md:515-517`; "accessible" apps = `activationPolicy != .prohibited && pid > 0 && bundleIdentifier != nil` — `AX/Sources/AXorcist/Utils/RunningApplicationHelper.swift:117-123`; but point-lookup eligibility is stricter (`.regular && !isHidden`) — `AX/Sources/AXorcist/Core/AppLocator.swift:67-69`.
- **App lookup by name** in the SDK only checks `/Applications`, `/System/Applications`, `/System/Applications/Utilities`; use a bundle id or path for anything else — `SDK/Sources/MacosUseSDK/AppOpener.swift:105-107`; a pre-found running PID is returned even if activation throws — `:215-225`; `NSWorkspace.openApplication` result must be read inside a `Task { @MainActor in }` because "`MainActor.run` … caused issues in Swift 6.1 with async closures" — `:168-179`. AXorcist resolves `focused` → bundle id → localized name (case-insensitive) → path → numeric PID — `AX/Sources/AXorcist/Core/ProcessUtils.swift:36-55,57-172`.
- **Screenshots and ReplayKit**: `CGWindowListCreateImage` runs in a subprocess "so that the ReplayKit framework — loaded as a side-effect by macOS — dies with the subprocess instead of spinning at ~19% CPU forever in the parent MCP server process." — `MCP/Sources/MCPServer/main.swift:382-385`, `MCP/Sources/ScreenshotHelper/main.swift:1-3`; 5 s timeout then `terminate()` — `MCP/Sources/MCPServer/main.swift:475-489`.
- **Overlay windows and process exit**: "We are intentionally *not* closing the overlay windows explicitly in the SDK anymore, as doing so near `exit(0)` caused crashes." — `SDK/Sources/ActionTool/main.swift:93-95`; CLI tools must keep the run loop alive for overlays to appear — `SDK/Sources/HighlightTraversalTool/main.swift:98-112`, `SDK/Sources/ActionTool/main.swift:58-98`.
- **Toolchain**: "The system Swift is mismatched (SDK 6.2 vs compiler 6.1)" → always `xcrun --toolchain com.apple.dt.toolchain.XcodeDefault swift` — `MCP/CLAUDE.md:25-27`; after a rebuild "Claude Code's MCP connection still points at the old server process" — `:15-23`.
- Chrome, VS Code, Java, Qt, games, Notification Center, login items, Spaces, full-screen: **not in source** (no special-casing in any of the three repos).
---
## 7. Observers & events (AXorcist is the only repo with observers)
**Lifecycle**
- Created with `AXObserverCreateWithInfoCallback`, never `AXObserverCreate` — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:864`; one observer per PID, reused for every notification/element — `:112,697-704`.
- Run-loop source is added to the **main** run loop in `.defaultMode` — `:838-839`; removal uses `CFRunLoopGetCurrent()` + `CFRunLoopSourceInvalidate` — `:448-452,653-655,681-685`. Teardown happens only when a PID has no subscriptions and no pending work — `:640-657`.
- `refcon` is the center itself, unretained — `:565,915-916`. Callback derives the PID from the element (`AXUIElementGetPid`), converts userInfo synchronously, then hops `Task { @MainActor in … }` — handlers run on a **later** main-actor turn, not inside the AX callback — `:925-949`. Handler type: `@MainActor (pid_t, AXNotification, AXUIElement, [String: Any]?) -> Void` — `AX/Sources/AXorcist/Core/ObserverTypes.swift:12-16`.
- `kAXErrorNotificationAlreadyRegistered` on add is treated as success; `notificationNotRegistered` on remove is treated as absent; `cannotComplete`/`failure` on remove keeps the removal tracked for retry — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:685-691,109-111`. No handling of `kAXErrorNotificationUnsupported` exists; unsupported apps are skipped — `AX/README.md:535`.
- Element-scoped registrations fire only for their exact element: "AXObserver registrations are object-specific. Only application registrations use process scope; element registrations must not fan the callback out to sibling accessibility objects." — `AX/Sources/AXorcist/Core/ObserverTypes.swift:277-283`. A process-scoped and an element-scoped subscription for the same (pid, notification) share **one** native registration — `AX/Tests/AXorcistTests/ObserverLifecycleTests.swift:367-396`.
- Unknown notification strings are dropped with a warning — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:929-936`. userInfo conversion handles CFString/CFNumber(→NSNumber)/CFBoolean/CFArray/CFDictionary/AXUIElement, raw otherwise — `AX/Sources/AXorcist/Core/ObserverHelpers.swift:9-31`.
- No debounce/coalesce exists — dispatch is immediate per event — `AX/Sources/AXorcist/Core/ObserverTypes.swift:269-297`.
**Threading and the "wedged app" problem**
- Every native AX observer call runs on a fresh `Thread.detachNewThread` in an `autoreleasepool`, raced against a detached sleeper; first result wins — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:58-71,169-182`; creation — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:859-877`.
- Numbers: observer creation 500 ms — `:807`; `notificationWorkTimeout = .milliseconds(500)` — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:6`; identity lookup 100 ms — `:39`; async completion 750 ms — `:492,618`; sync creation join 750 ms pumping `RunLoop.current.run(mode: .default, before: +0.01)` — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:718-722`; sync pending-add join 2 s — `AX/Sources/AXorcist/Core/AXObserverCenter+PendingNative.swift:229-231`; async subscribe deadline 2 s — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:292-305`.
- Before each process-scoped add/remove, `AXUIElementSetMessagingTimeout(element, 0.5)` then reset to 0; if arming fails the call returns `.cannotComplete` without touching AX — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:693-721`.
- Worker admission: 8 concurrent, 7 regular, 1 reserved for cleanup; a timed-out call **keeps its slot until the late native result returns** — `:216-248,365-376`; test `AX/Tests/AXorcistTests/ObserverNativeCleanupTests.swift:96-143`.
- **Late-add rollback**: a timed-out `AXObserverAddNotification` may still succeed later; it is committed only if still pending, generation matches, and (not late or a waiter still exists); otherwise it is rolled back with a native remove — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:93-107`; "Timeout bounds the waiter only. The pending record stays until the native remove reports its own result." — `:185-186`. An observer created after the 500 ms deadline is simply dropped (never added to a run loop) — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:847-849,860`.
- "Process registration is intentionally asynchronous: a wedged AX endpoint must never keep the lifecycle monitor or its caller on the main actor." — `AX/Sources/AXorcist/Core/NotificationWatcher.swift:283-284`.
- Logger assumes main thread ("Callers must ensure main-thread execution for all logger interactions.") and `axGetLogEntries()` returns `[]` "to avoid concurrency issues" — `AX/Sources/AXorcist/Logging/GlobalAXLogger.swift:24-25,309-321`.
**PID reuse**
- Process identity via `proc_pidinfo(pid, 17 /* PROC_PIDUNIQIDENTIFIERINFO */, …)`.uniqueIdentifier — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:19-32`; a mismatch purges every subscription for that PID and invalidates tokens — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:661-691`; "A missing identity is unknown, not a confirmed PID reuse." — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:113-117`.
**Global ("all apps") watching**
- No global AX observer exists; "global" = one observer per running app driven by **KVO on `NSWorkspace.shared.runningApplications`** (`options: [.initial]`), not `didLaunchApplicationNotification` — `AX/Sources/AXorcist/Core/AXGlobalApplicationMonitor.swift:17-23,29-34`; "Indexed KVO changes can contain only the changed entries. Capture the full membership snapshot here, without reading any application metadata." — `:31-32`.
- `processIdentifier`/`isFinishedLaunching` are read on a serial background queue, never on main — `:76-77`; "Do not request .new: KVO would fetch readiness synchronously on the notifying thread." — `:240`; `onLaunch` fires **twice** for a PID (membership, then readiness) — `:141-144`, test `AX/Tests/AXorcistTests/WorkspaceApplicationMonitorTests.swift:295-321`.
- Retry after transient registration failure: delays `[0.5 s, 2 s, 8 s]`, then "exhausted retries" — `AX/Sources/AXorcist/Core/NotificationWatcher.swift:6-8,404-410`; termination cancels in-flight work so a PID-reusing replacement starts clean — `:298-303`.
- Notification name gotchas: `windowMinimized = "AXWindowMiniaturized"` — `AX/Sources/AXorcist/Core/NotificationTypes.swift:24`; `AXTitleChanged` "Not a standard top-level notification, often via kAXValueChanged on title attribute" — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:370-371`; `AXLayoutChanged` "Might be app-specific" — `:375`; full list of 34 names — `AX/Sources/AXorcist/Core/NotificationTypes.swift:8-43`.
- `observe` via the CLI only keeps the process alive in DEBUG builds; release prints a JSON error to stderr and exits after setup — `AX/Sources/axorc/AXORCMain.swift:116-135`.
- MacosUseSDK/MCP have **no** observer code; "Listen to changes in the UI" in `SDK/README.md:11` refers to before/after traversal diffs.
---
## 8. Exact public APIs, build, and run
### AXorcist (library `AXorcist`, CLI `axorc`)
- Package: `swift-tools-version: 6.2`, `.macOS(.v14)`, `swiftLanguageModes: [.v6]`, `.defaultIsolation(MainActor.self)` + `StrictConcurrency` + `NonisolatedNonsendingByDefault` for every target; deps Commander `exact: "0.2.4"`, swift-log — `AX/Package.swift:1-11,15-25,64`. Build: `swift build -c release --product axorc` → `.build/release/axorc`; `brew install openclaw/tap/axorc` — `AX/README.md:190-198`; universal binary via `swift build -c release --arch arm64 --arch x86_64 --product axorc` + `lipo` — `AX/scripts/build-universal-binary.sh:46-60`. Version `axorcVersion = "0.1.9"` — `AX/Sources/axorc/Models/AXORCModels.swift:11`. CI: `macos-15`, Swift 6.2.1, Xcode 16.4, SwiftFormat 0.62.1, SwiftLint 0.65.1 — `AX/.github/workflows/ci.yml:27-34`, `AX/scripts/install-validation-tools.sh:5-8`.
- Core types: `@MainActor public class AXorcist { static let shared; func runCommand(_ envelope: AXCommandEnvelope) -> AXResponse; getLogs() -> [String]; clearLogs() }` — `AX/README.md:39-44`, `AX/Sources/AXorcist/Core/AXorcist.swift:114-118`. `AXResponse.success(payload: AnyCodable?, logs:) | .error(message:, code: AXErrorCode, logs:)`; `payload` is nil for `.error` — `AX/Sources/AXorcist/Core/ResponseModels.swift:27-73`.
- `public struct Element: Equatable, Hashable, Sendable { init(_ AXUIElement); init(_:attributes:children:actions:); let underlyingElement; var attributes: [String: AttributeValue]?; var prefetchedChildren: [Element]?; var actions: [String]? }` — `AX/Sources/AXorcist/Core/Element.swift:52,69-73,87-105`; `attribute<T>(_: Attribute<T>) -> T?`, `rawAttributeValue(named:) -> CFTypeRef?`, `isAttributeSettable(named:)`, `parameterizedAttribute<T>(_:parameter:)`, `press()/pick()/showMenu() -> Bool` — `:119-202`. Properties: `role() subrole() title() descriptionText() isEnabled() -> Bool? value() -> Any? roleDescription() help() identifier() isFocused() isHidden() isElementBusy() isIgnored() pid() parent() windows() sheets() mainWindow() focusedWindow() focusedUIElement() supportedActions() domIdentifier() … attributeNames() dump()` — `AX/Sources/AXorcist/Core/Element+Properties.swift:10-207`; `position() size() frame() setPosition(_) -> AXError setSize setFrame isMinimized() setMinimized isFullScreen() selectedText() selectedTextRange() -> CFRange? … url() -> URL?` — `AX/Sources/AXorcist/Core/Element+ConvenienceAttributes.swift`; `computedName()` — `AX/Sources/AXorcist/Core/Element+ComputedName.swift:9`; `briefDescription(option: .smart|.raw|.stringified)` — `AX/Sources/AXorcist/Core/Element+Description.swift:12`; `generatePathString(upTo:)` — `AX/Sources/AXorcist/Core/Element+PathGeneration.swift:9`.
- Search: `findTargetElement(for appIdentifier: String, locator: Locator, maxDepthForSearch: Int[, traversalOptions:]) -> (element: Element?, error: String?)` — `AX/Sources/AXorcist/Search/ElementSearch.swift:58-75`; `collectAllElements(from:matching:maxDepth:includeIgnored:[traversalOptions:]) -> [Element]` — `:287-307`; `traverseAndSearch(element:visitor:currentDepth:maxDepth:)` — `:349-369`; `protocol ElementVisitor { visit(element:depth:) -> TreeVisitorResult }`, `enum TreeVisitorResult { continue, skipChildren, stop }` — `:335-346`. Element-level: `searchElements(matching:options:) -> [Element]`, `findElement(matching:options:)`, `searchElements(byRole:options:)`, `matches(query:options:)`, `findAllButtons()/findAllTextFields()/findAllLinks()`, `findElement(byIdentifier:)` — `AX/Sources/AXorcist/Core/Element+Search.swift:45-191`; `findElements(role:title:label:value:identifier:maxDepth: = 10) -> [Element]` = **exact equality** on each supplied field over an unpruned, un-timed `traverseAXTree` — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:758-815`.
- Matching types: `Criterion(attribute: String, value: String, matchType: JSONPathHintComponent.MatchType? = nil)`; `Locator(matchAll: Bool? = true, criteria: [Criterion] = [], rootElementPathHint: [JSONPathHintComponent]? = nil, descendantCriteria:, requireAction:, computedNameContains:, debugPathSearch:)`; `PathStep(criteria:matchType:matchAllCriteria:maxDepthForStep:)` — `AX/Sources/AXorcist/Core/MatchingTypes.swift:9,53-57,106-113`; `JSONPathHintComponent(attribute:value:depth:matchType:)` — `AX/Sources/AXorcist/Models/JSONPathHintComponent.swift:10`; `AXTraversalOptions(timeout:scanAll:stopAtFirstMatch:)` — `AX/Sources/AXorcist/Search/AXTraversalOptions.swift:16-19`; `ElementSearchOptions { maxDepth=0, caseInsensitive=true, visibleOnly=false, enabledOnly=false, includeRoles=[], excludeRoles=[] }` — `AX/Sources/AXorcist/Core/Element+Search.swift:14-34`.
- Acting: `click(button: MouseButton = .left, clickCount: Int = 1) throws`, `static clickAt(_:button:clickCount:)`, `typeText(_:delay: = 0.005, clearFirst: = false) throws`, `clearField()`, `static typeText/typeCharacter/typeKey(_: SpecialKey, modifiers:)`, `static performHotkey(keys: [String], holdDuration: = 0.1)`, `scroll(direction: ScrollDirection, amount: = 3, smooth: = false)`, `static scrollAt(...)`, `waitUntilActionable(timeout: = 5.0, pollInterval: = 0.1) async throws -> Element`, `isActionable() -> Bool`, `elementAt(_:role:)`, `findElements(...)` — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:49-83,130-164,171-237,374-451,669-728,735-815`. `performAction(_ actionName: String) throws -> Element`, `performAction(_: AXAction)`, `isActionSupported(_:)` — `AX/Sources/AXorcist/Core/Element+Actions.swift:12-56`. `setValue(_ value: String) throws -> Element`, `setAttributeValue(_: Any, forAttribute:) throws`, legacy `setValue(_: Any, forAttribute:) -> Bool` — `AX/Sources/AXorcist/Core/Element+ValueSetting.swift:6-29`. `activate() -> Bool`, `hideApplication()/unhideApplication()` — `AX/Sources/AXorcist/Core/Element+ApplicationActions.swift:26-93`. Window ops `minimizeWindow/unminimizeWindow/maximizeWindow/closeWindow/showWindow/focusWindow/activateApplication/windowScreen()` — `AX/Sources/AXorcist/Core/Element+WindowOperations.swift`. `InputDriver.click(at:button:count:) / move(to:) / currentLocation() / pressHold(at:button:duration:) / drag(from:to:button:steps: = 20, interStepDelay: = 0.0) / scroll(deltaX: = 0, deltaY:, at:) / type(_:delayPerCharacter: = 0.0) / tapKey(_:modifiers:) / hotkey(keys:holdDuration: = 0.1)` — `AX/Sources/AXorcist/Core/InputDriver.swift:17-207`.
- Factories: `Element.systemWide()`, `Element.application(for pid:) -> Element?`, `Element.application(for: NSRunningApplication)`, `Element.focusedApplication()`, `Element.elementAtPoint(_ point: CGPoint, pid: pid_t = 0)` — `AX/Sources/AXorcist/Core/Element+Factory.swift:10-71`; `AXApp(pid:)`, `AXWindowHandle` — `AX/Sources/AXorcist/Core/AXApp.swift:5-69`; `AppLocator.exactApp(at:) / app(at:)` — `AX/Sources/AXorcist/Core/AppLocator.swift:31-51`; `RunningApplicationHelper.allApplications() / filteredApplications(options:) / applications(withBundleIdentifier:) / frontmostApplication / runningApplication(pid:)` — `AX/Sources/AXorcist/Utils/RunningApplicationHelper.swift:73-158`; `WindowInfoHelper.getWindows(for:) / getVisibleWindows() / getWindowBounds(windowID:) / getOwnerPID(windowID:) / getWindowName(windowID:) / getWindowID(from:)` — `AX/Sources/AXorcist/Utils/WindowInfoHelper.swift`; `CFConstants.cgWindowNumber/cgWindowName/cgWindowBounds/cgWindowOwnerPID` — `AX/Sources/AXorcist/Core/CFConstants.swift`.
- Permissions/timeouts: `AXPermissionHelpers.askForAccessibilityIfNeeded() / hasAccessibilityPermissions() / isSandboxed() / requestPermissions() async / permissionChanges(interval: = 1.0) -> AsyncStream<Bool>` — `AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:40-135`; `AXTrustUtil.checkAccessibilityPermissions(promptIfNeeded:) / openAccessibilitySettings()` — `AX/Sources/AXorcist/Utils/AXTrustUtil.swift:14-37`; `Element.setMessagingTimeout(_ Float)`, `withMessagingTimeout(_:operation:)`, `AXTimeoutConfiguration.setGlobalTimeout`, `AXTimeoutWrapper(maxRetries: = 3, retryDelay: = 0.5).execute`, `AXTimeoutHelper.withTimeout(seconds:operation:)` — `AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:16-206`.
- Observers: `AXObserverCenter.shared.subscribe(pid:element:notification:handler:) -> Result<SubscriptionToken, AccessibilityError>`, `unsubscribe(token:) throws`, `removeAllObservers()`, `removeAllObservers(for:)`, `isKeyRegistered(pid:notification:)` — `AX/Sources/AXorcist/Core/AXObserverCenter.swift:96-468`; `NotificationWatcher(forElement:notification:handler:) / (forPID:…) / (globalNotification:…)`, `start() throws`, `stop()`, `isActive` — `AX/Sources/AXorcist/Core/NotificationWatcher.swift:38-227`; deprecated `AXObserverManager` — `AX/Sources/AXorcist/Utils/AXObserverManager.swift:11-88`.
- CLI (`axorc`): subcommands `permissions [-j]`, `find --app <app> (--role|--title|--identifier|--value|--attribute k=v)… [--depth 10] [--contains] [-j]`, `tree --app <app> [--depth 3] [--role R] [-j]`, `raw (--stdin|--file <p>|--json '<s>'|'<s>') [--debug] [--verbose] [--timeout <s>] [--scan-all] [--no-stop-first]`, `help <topic>`, `--version` — `AX/Sources/axorc/CLIFrontend.swift:19-107,131-196`, `AX/Sources/axorc/AXORCMain.swift:51-80`. Exit codes: 0 ok, 1 command failed / decode error, 2 usage/argument error; JSON error IDs `argument_error`, `decode_error`, `input_error`, `no_input` — `AX/Sources/axorc/AXORCMain.swift:27-41,215-260`, `AX/Sources/axorc/CLIFrontend.swift:7-16`. Env: `AXORC_JSON_LOG_ENABLED=true` (JSON logs on stderr) — `AX/Sources/AXorcist/Logging/GlobalAXLogger.swift:93-101`; `AXORC_CODESIGN_IDENTITY` — `AX/scripts/build-universal-binary.sh:38-41`.
- JSON protocol: `command` ∈ `ping, query, getAttributes, describeElement, getElementAtPoint, getFocusedElement, performAction, batch, observe, collectAll, stopObservation, isProcessTrusted, isAXFeatureEnabled, setFocusedValue, extractText` (+3 reserved not-implemented) — `AX/Sources/AXorcist/Core/CommandTypes.swift:13-32`; envelope keys `command_id, command, application, pid, attributes, locator{matchAll, criteria[{attribute,value,match_type}], path_from_root[{attribute,value,depth,match_type}]}, max_depth, action_name, action_value, sub_commands, point:[x,y], notifications, include_element_details, watch_children, filter_criteria, include_children_brief, include_children_in_text, include_ignored_elements, debug_logging` — `AX/Sources/AXorcist/Core/CommandEnvelope.swift:103-128`; decoder `.convertFromSnakeCase`, encoder `.convertToSnakeCase` + `.sortedKeys` — `AX/Sources/axorc/Core/InputHandler.swift:17-21`, `AX/Sources/axorc/CommandResponseHelpers.swift:61-64`. Response `{"command_id","command_type","status":"success|error","data","error","error_code","debug_logs"}` — `AX/Sources/axorc/CommandResponseHelpers.swift:8-16`; `error_code` ∈ `element_not_found, action_failed, attribute_not_found, invalid_command, unknown_command, internal_error, permission_denied, invalid_parameter, timeout, observation_failed, application_not_found, batch_operation_failed, action_not_supported` — `AX/Sources/AXorcist/Core/ResponseModels.swift:8-22`. Target: `application` and `pid` are mutually exclusive; `pid` must be 1…`pid_t.max`; omitted → focused app — `AX/Sources/AXorcist/Core/CommandTarget.swift:10-29`. Batch: sequential, no early exit, per-sub results lost on any failure — `AX/Sources/AXorcist/Core/AXorcist+BatchHandler.swift:28-63`, `AX/Sources/axorc/CommandHandlers.swift:186-203`. Only `commands.first` of a JSON array is executed — `AX/Sources/axorc/AXORCMain.swift:262-275`.
### MacosUseSDK (library + 6 CLI tools)
- Package: tools 6.0, `.macOS(.v12)`, links AppKit + ApplicationServices; executables `TraversalTool, HighlightTraversalTool, InputControllerTool, VisualInputTool, AppOpenerTool, ActionTool` — `SDK/Package.swift:1-34`. Build `swift build` → `.build/debug/<Tool>` or `swift run <Tool>` — `SDK/README.md:14-22`.
- `public func traverseAccessibilityTree(pid: Int32, onlyVisibleElements: Bool = false) throws -> ResponseData` — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:87-90`; `ResponseData { app_name, elements: [ElementData], stats: Statistics, processing_time_seconds }`, `ElementData { role, text?, x?, y?, width?, height? }` — `:32-76`. Errors `MacosUseSDKError.accessibilityDenied | appNotFound(pid:) | jsonEncodingFailed | internalError(String)` — `:9-27`.
- `@MainActor public func openApplication(identifier: String) async throws -> AppOpenerResult { pid, appName, processingTimeSeconds }` — `SDK/Sources/MacosUseSDK/AppOpener.swift:36-40,246-256`.
- Input: `pressKey(keyCode: CGKeyCode, flags: CGEventFlags = []) throws`, `clickMouse(at:)`, `doubleClickMouse(at:)`, `rightClickMouse(at:)`, `moveMouse(to:)`, `scrollWheel(at:deltaY: Int32, deltaX: Int32 = 0)`, `writeText(_:)`, `mapKeyNameToKeyCode(_:) -> CGKeyCode?`; key constants `KEY_RETURN = 36 … KEY_FORWARD_DELETE = 117` — `SDK/Sources/MacosUseSDK/InputController.swift:24-37,72-221`.
- AX-side actions: `setAccessibilityValue(pid:at:value:) throws`, `pressAccessibilityElement(pid:at:) throws`, `setAccessibilitySelected(pid:at:selected:) throws` — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:118,147,184`.
- Orchestration: `@MainActor public func performAction(action: PrimaryAction, optionsInput: ActionOptions = ActionOptions()) async -> ActionResult`; `PrimaryAction.open(identifier:) | .input(action: InputAction) | .traverseOnly`; `InputAction.click/doubleClick/rightClick(point:) | .type(text:) | .press(keyName:flags:) | .move(to:) | .scroll(point:deltaY:deltaX:) | .axSetValue(point:value:pid:) | .axPress(point:pid:) | .axSetSelected(point:selected:pid:)`; `ActionOptions { traverseBefore=false, traverseAfter=false, showDiff=false, onlyVisibleElements=false, showAnimation=true, animationDuration=0.8, pidForTraversal=nil, delayAfterAction=0.2 }` (`showDiff` forces both traversals via `validated()`); `ActionResult { openResult, traversalPid, traversalBefore, traversalAfter, traversalDiff, primaryActionError, traversalBeforeError, traversalAfterError }` — `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:8-107,120-124`. `TraversalDiff { added, removed, modified: [ModifiedElement{before, after, changes: [AttributeChangeDetail{attributeName, addedText, removedText, oldValue, newValue}]}] }` — `SDK/Sources/MacosUseSDK/CombinedActions.swift:5-18,101-106`.
- `CombinedActions.openAndTraverseApp / clickAndTraverseApp / pressKeyAndTraverseApp / writeTextAndTraverseApp / clickWithDiff / pressKeyWithDiff / writeTextWithDiff / *WithActionAndTraversalHighlight` (all `@MainActor` async) — `SDK/Sources/MacosUseSDK/CombinedActions.swift:129-687`.
- Visuals: `@MainActor showVisualFeedback(at:type: FeedbackType, size:, duration: = 0.5)`, `@MainActor drawHighlightBoxes(for: [ElementData], duration: = 3.0)` (returns immediately; needs a live run loop), `getMainScreenCenter()`, `clickMouseAndVisualize / doubleClickMouseAndVisualize / rightClickMouseAndVisualize / moveMouseAndVisualize / scrollWheelAndVisualize / pressKeyAndVisualize / writeTextAndVisualize` — `SDK/Sources/MacosUseSDK/DrawVisuals.swift:225-249,388-389`, `SDK/Sources/MacosUseSDK/HighlightInput.swift:12-140`.
- CLI: `AppOpenerTool <name|bundleId|path>` (prints PID), `TraversalTool [--visible-only] <PID>` (JSON), `HighlightTraversalTool <PID> [--duration s]`, `InputControllerTool keypress <combo>|click x y|doubleclick x y|rightclick x y|mousemove x y|writetext "<t>"`, `VisualInputTool … [--duration s]`, `ActionTool` (demo) — `SDK/README.md:28-111`, `SDK/Sources/InputControllerTool/main.swift:66-145`.
### mcp-server-macos-use (MCP server, stdio)
- Package: tools 5.9, `.macOS(.v13)`, deps `modelcontextprotocol/swift-sdk from 0.11.0` and `MacosUseSDK branch: "main"`; executables `mcp-server-macos-use` (`-parse-as-library`) and `screenshot-helper` — `MCP/Package.swift:1-30`. Build: `swift build -c release` (npm `postinstall` does `xcrun swift build -c release`) — `MCP/package.json:16-19`; `bin/mcp-server-macos-use` wrapper builds on first run then `exec`s `.build/release/mcp-server-macos-use` — `MCP/bin/mcp-server-macos-use:14-21`. Author's build line: `xcrun --toolchain com.apple.dt.toolchain.XcodeDefault swift build` — `MCP/CLAUDE.md:8`. Test harness `python3 scripts/test_mcp.py [--test tools|cap|click --app X --search Y]` spawns `.build/debug/mcp-server-macos-use` — `MCP/scripts/test_mcp.py:8-26`.
- Client config: `{"mcpServers":{"macos-use":{"command":"/path/.build/release/mcp-server-macos-use"}}}` — `MCP/llms.txt:145-165`, `MCP/README.md:80-88`. Server name `SwiftMacOSServerDirect`, version `"1.6.0"` — `MCP/Sources/MCPServer/main.swift:1485-1487` (package.json says `0.1.18`, llms.txt says `0.1.17` — `MCP/package.json:3`, `MCP/llms.txt:12`).
- **9 tools** (llms.txt still documents six — `MCP/llms.txt:21`): `macos-use_open_application_and_traverse {identifier}`; `macos-use_click_and_traverse {pid, x?, y?, width?, height?, element?, role?, doubleClick?, rightClick?, text?, pressKey?, pressKeyModifiers?}`; `macos-use_type_and_traverse {pid, text, pressKey?, pressKeyModifiers?}`; `macos-use_press_key_and_traverse {pid, keyName, modifierFlags?}`; `macos-use_scroll_and_traverse {pid, x, y, deltaY, deltaX?}`; `macos-use_set_value_and_traverse {pid, x, y, width?, height?, value}`; `macos-use_press_ax_and_traverse {pid, x, y, width?, height?}`; `macos-use_set_selected_and_traverse {pid, x, y, width?, height?, selected?}`; `macos-use_refresh_traversal {pid}` — `MCP/Sources/MCPServer/main.swift:1314-1482`. Common overrides `traverseBefore, traverseAfter, showDiff, onlyVisibleElements, showAnimation, animationDuration, delayAfterAction` — `:1580-1586`. Numbers are accepted as int, double, or numeric string — `:31-76`.
- Output: compact text summary `status / pid / app / dialog? / file / file_size / hint / screenshot / error? / summary / text_changes / visible_elements / app_switch` — `:731-906`; full tree in `/tmp/macos-use/<ts>_<tool>.txt` and PNG — `:1961-1983`; MCP `isError` set when any action or traversal error occurred — `:1952-1984`. Server instructions to the model — `:1488-1507`.
---
## 9. The "AI gets this wrong" list (grouped; one-line versions in the Top 25)
**Coordinates**
- AX/CGEvent points are top-left-origin global; AppKit is bottom-left; converting with `NSScreen.main.frame.height - y` is only right on the primary display — `SDK/Sources/MacosUseSDK/DrawVisuals.swift:278-286` vs `AX/Sources/AXorcist/Core/AppLocator.swift:139-149`.
- Screenshot pixel coordinates are window-relative and scaled; never click from them — `MCP/Sources/MCPServer/main.swift:1503`, `MCP/Sources/ScreenshotHelper/main.swift:53-58`.
- The x/y in a traversal line is the element's **top-left**, not its center; click at `(x+w/2, y+h/2)` — `MCP/Sources/MCPServer/main.swift:1331-1334,1647-1652`.
- `in_viewport`/"visible" means the top-left point is inside a window frame, not that the element is actually unobscured — `:513-544`.
**Activation / focus**
- The first synthetic click after an app switch is eaten by activation; activate and wait ~200 ms first — `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:207-214`.
- `NSRunningApplication.activate` may return `false` and still work — `AX/Sources/AXorcist/Core/Element+WindowOperations.swift:227`.
- `AXFocusedUIElement` being absent is a valid state, not an error — `AX/Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift:34-43`.
- Typing without first securing focus can land in a different app — `AX/CHANGELOG.md:25`.
**Actions vs events**
- `Element.click()` in AXorcist is a CGEvent, not `AXPress`; AgentAccess/AXorcist fall back to `AXPress` only after the CGEvent path throws — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:49-64`.
- `AXPress` is often the **only** thing that works for Catalyst right-pane buttons and sandboxed apps — `MCP/Sources/MCPServer/main.swift:1459`.
- Rows may expose `AXSelected` but no `AXPress` → set the attribute — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-178`.
- `AXSetValue` is not an action; setting `AXValue` is an attribute write — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:23-24`.
- `kAXErrorCannotComplete` (-25204) can arrive after the action already dispatched; retrying double-fires — `AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:206`, `AX/Tests/AXorcistTests/ActionExecutionTests.swift:115`.
- Double-click: AXorcist sends two down/up pairs with clickState 1 then 2; SDK sends one pair with clickState 2 — the repos disagree — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:117-121`, `SDK/Sources/MacosUseSDK/InputController.swift:109-121`.
- Scroll sign: AXorcist positive = up; SDK/MCP negative = up — `AX/Sources/AXorcist/Core/InputDriver.swift:166-180`, `SDK/Sources/MacosUseSDK/InputController.swift:155`.
- A synthesized hotkey that fails mid-sequence leaves modifiers stuck unless all events were built first — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:437`.
- Unicode-only key events can be dropped in VMs/headless; multi-char Unicode payloads break IME — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:229-230`, `SDK/Sources/MacosUseSDK/InputController.swift:191-193`.
- Keycode tables are layout-specific (US QWERTY assumption) — `SDK/Sources/MacosUseSDK/InputController.swift:239`.
- Post a ~15 ms sleep after each CGEvent or some apps drop it — `SDK/Sources/MacosUseSDK/InputController.swift:61-62`.
**Traversal / search**
- The default AXorcist search never descends into AXTable/AXRow/AXCell/AXToolbar/AXTabGroup/AXMenu (not "container roles") — `AX/Sources/AXorcist/Search/ElementSearch.swift:410-412,599-614`.
- `AXChildren` alone misses Electron background windows and Chromium's focused subtree — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54`.
- Depth `N` visits nodes at depth N but does not expand them — `AX/Sources/AXorcist/Search/AXTreeTraversal.swift:147-149,167-169`.
- A search timeout is silent (result discarded) — `AX/Sources/AXorcist/Search/ElementSearch.swift:387,414,433`.
- A subtree that returns `cannotComplete` for `AXChildren` silently vanishes — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:101-107`.
- Criterion `{"attribute":"title"}` is a literal attribute named `title` → never matches; use `AXTitle` — `AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:83-106,200-211`.
- `contains ""` does not match a missing attribute — `AX/Sources/AXorcist/Search/StringComparisonLogic.swift:48-56`.
- `AXTitle` matching is case-sensitive; only role/subrole are insensitive — `AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:26,45,63,134-151`.
- `computedName` prefers the current `AXValue` over identifier/description — `AX/Sources/AXorcist/Core/Element+ComputedName.swift:17-50`.
- Path-hint default depth is 1, not the README's 3 — `AX/Sources/AXorcist/Search/PathNavigationJSON.swift:73` vs `AX/README.md:355`.
- SDK `text` is a concatenation of five attributes, so substring search hits help text — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:272-283`.
- SDK numeric `AXValue`s (sliders, steppers) never appear in `text` — `:226-229`.
- SDK traversal is BFS + spatially sorted, so list order is not tree order — `:302-305,173-180` (MCP's own comment gets this wrong — `MCP/Sources/MCPServer/main.swift:548`).
- Hit-testing does not penetrate Catalyst rows; but tree-walking in-viewport can pick a full-width overlay group instead of the intended sidebar item — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:47-49`, `MCP/Sources/MCPServer/main.swift:1189-1195`.
**Values / attributes**
- `AXValue` raw type 4 is both Boolean and CFRange — `AX/Sources/AXorcist/Values/ValueUnwrapper.swift:71-72`.
- `AXValueType` in Swift is not exhaustive — `AX/Sources/AXorcist/Values/ValueHelpers.swift:62-66`.
- Parameterized attributes have no `Parameterized` suffix — `AX/CHANGELOG.md:40`.
- Action names come from `AXUIElementCopyActionNames`, not an attribute — `AX/Sources/AXorcist/Core/Element+Properties.swift:112-121`.
- `AXWindowMiniaturized`, not `AXWindowMinimized` — `AX/Sources/AXorcist/Core/NotificationTypes.swift:24`.
- No `AXFrame`; compute from `AXPosition` + `AXSize` — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:127`.
**Permissions / process**
- The process that needs TCC is usually the host (parent) — `AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:43-51`.
- Permission changes must be polled — `AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:142-175`.
- A changed code signature is a new TCC identity — `AX/docs/releasing.md:3`.
- `AXIsProcessTrustedWithOptions(prompt)` on every traversal spams the dialog (SDK does this) — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:135-142`.
- Screen Recording is not required for AX window enumeration — `AX/Sources/AXorcist/Core/AXWindowResolver.swift:69`.
- Rebuilding an MCP server binary does not update the running MCP connection — `MCP/CLAUDE.md:15-23`.
**Timeouts / threading**
- Without `AXUIElementSetMessagingTimeout` a wedged app blocks the calling thread indefinitely; AXorcist runs observer calls on detached threads with 500 ms deadlines — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:6,58-71`.
- A per-element messaging timeout must be reset to 0 afterwards — `AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:126-133`.
- A late `AXObserverAddNotification` success after timeout must be rolled back or it leaks — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:93-107`.
- `Task.sleep` deadlines can be starved by uncooperative work on Swift 6.2.1; use a GCD timer — `AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:242-243`.
- KVO on `runningApplications` must not request `.new`, must not read metadata on the notifying thread, and delivers indexed partial changes — `AX/Sources/AXorcist/Core/AXGlobalApplicationMonitor.swift:31-32,240`.
- `onLaunch` fires twice per PID (membership then readiness) — `:141-144`.
- PIDs are reused; check `proc_pidinfo` unique identifiers — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:19-32`.
- Overlay windows need a live run loop and must not be closed right before `exit(0)` — `SDK/Sources/ActionTool/main.swift:58-98`.
- `CGWindowListCreateImage` loads ReplayKit which then burns CPU forever in a long-lived process — `MCP/Sources/MCPServer/main.swift:382-385`.
- `CGEventTap` must live on the main run loop and gets auto-disabled by macOS — `MCP/Sources/MCPServer/InputGuard.swift:149-150,298-306`.
- `screencapture` blocks ~50–200 ms; do it off the main actor — `AA/Sources/AgentAccess/AccessibilityService+Screenshot.swift:9-18`.
---
## 10. Licensing
| Repo | License | Notes |
|---|---|---|
| AXorcist | MIT, © 2025 Peter Steinberger — `AX/LICENSE:1-3` | README says MIT — `AX/README.md:793`; Homebrew formula `license "MIT"` — `AX/packaging/homebrew/axorc.rb.template`. Pinned dep Commander (steipete) and swift-log (Apache, not vendored) — `AX/Package.swift:23-24`. |
| MacosUseSDK | MIT, © 2025 mediar — `SDK/LICENSE:1-3` | README confirms — `SDK/README.md:181-183`. |
| mcp-server-macos-use | **Business Source License 1.1** — `MCP/LICENSE:1`; "Licensed Work: screenpipe Computer Agent / Licensor: Mediar, Inc. / Additional Use Grant: Production use is permitted for non-commercial, educational purposes only / Change Date: April 9, 2028 / Change License: MIT License" — `MCP/LICENSE:33-37`. | **Conflict**: `package.json` declares `"license": "MIT"` — `MCP/package.json:30`; `llms.txt` says BSL 1.1 — `MCP/llms.txt:11`. The LICENSE file governs; commercial production use requires a commercial license until 2028-04-09. Depends on `MacosUseSDK` `branch: "main"` (MIT) and the MCP Swift SDK — `MCP/Package.swift:12-13`. |
| AgentAccess | **No LICENSE file** (no `LICENSE*`/`COPYING*` in the repo root; README has no license section — `AA/README.md:1-215`). Treat as all-rights-reserved unless the owner states otherwise. | Depends on AXorcist 0.1.9 (MIT) and `AgentiLoop/AgentAudit` 1.3.2 (license **not in source**) — `AA/Package.resolved:5-21`. |
---
## 11. AgentAccess: the policy layer over AXorcist
Repo: `AA/` = `/Users/robertboulos/projects/cloned-repos/AgentAccess` (AgentiLoop/AgentAccess, HEAD `ce64d06` 2026-09-02 "Update AXorcist dependency to 0.1.9"; 10 Swift files, 2,682 lines incl. README/manifests). Package: `swift-tools-version: 6.2`, `platforms: [.macOS(.v26)]`, deps `AgentiLoop/AgentAudit from 1.3.1` and `steipete/AXorcist from 0.1.9` — `AA/Package.swift:1-16`; resolved to AXorcist rev `37d7ae8…` = the exact AXorcist commit read above — `AA/Package.resolved:13-21`. README install URL points at `macOS26/AgentAccess`, not `AgentiLoop` — `AA/README.md:9`. All operations audit-log to `os.log` subsystem `Agent.app.toddbruss.audit`, category `Accessibility` — `AA/README.md:209`.
**Which AXorcist APIs it calls, and how (the real-world usage pattern)**
- Two roads into AXorcist: (a) the **command envelope** road `AXorcist.shared.runCommand(AXCommandEnvelope(commandID: UUID().uuidString, command: …))` for `QueryCommand`, `PerformActionCommand`, `GetAttributesCommand`, `DescribeElementCommand`, `ExtractTextCommand`, `SetFocusedValueCommand`, `GetElementAtPointCommand(appIdentifier:x:y:attributesToReturn:)`, `GetFocusedElementCommand`, `CollectAllCommand`, `AXBatchCommand(commands: [SubCommandEnvelope])`, `ObserveCommand(appIdentifier:locator:notifications:includeDetails:watchChildren:notificationName: AXNotification)` — `AA/Sources/AgentAccess/AccessibilityService+AXorcist.swift:16-24,30-50,56-81,87-106,112-132,138-157,163-181,187-201,207-220,226-241,247-258,264-295`; and (b) the **direct `Element` road** for the LLM-facing "smart" methods (`clickElement`, `typeTextIntoElement`, `findElement`, `getChildren`, menus, windows).
- `AXResponse` is unwrapped as `.success(payload, logs)` / `.error(message, code, logs)`; the payload (an `AnyCodable`) is re-encoded with `JSONEncoder` then `JSONSerialization` to build `{"success":true,"data":…,"logs":…}`; errors emit `{"success":false,"error":…,"errorCode": code.rawValue}` — `AA/Sources/AgentAccess/AccessibilityService+AXorcist.swift:540-565`. `AXorcist.shared.getLogs()/clearLogs()` are exposed — `:446-456`.
- Locator construction: `buildLocator` = `AXRole` exact, `AXTitle`/`AXValue`/`AXDescription` `.contains`, `AXIdentifier` exact, `matchAll: true` — `AA/Sources/AgentAccess/AccessibilityService.swift:125-144` (correctly uses `AX`-prefixed names; see §4 on why `title` would not work). `parseMatchType` accepts `exact|contains|regex|prefix|suffix|containsany` — `AA/Sources/AgentAccess/AccessibilityService+AXorcist.swift:527-538`.
- **`performAction` relies on a Locator field AXorcist ignores.** It builds `Locator(matchAll: true, criteria: [AXRole exact, AXValue contains], computedNameContains: title)` with the comment "Use computedNameContains for title — searches AXTitle + AXDescription + AXHelp" and `PerformActionCommand(... maxDepthForSearch: 100)` — `AA/Sources/AgentAccess/AccessibilityService+Actions.swift:46-58`. In AXorcist 0.1.9 nothing in the library search path reads `Locator.computedNameContains` (grep of `AX/Sources`: it is only copied by the CLI converter `AX/Sources/axorc/CommandTypeExtensions.swift:41` and consumed by the legacy dictionary matcher `AX/Sources/AXorcist/Search/SpecificAttributeMatchers.swift:204-240` via `AX/Sources/AXorcist/Search/AttributeMatcher.swift:26`, which `findTargetElement` does not call — `AX/Sources/AXorcist/Search/ElementSearch.swift:119-157`). Consequences: with role + title, the **first element of that role** gets the action regardless of title; with title only, criteria are empty and AXorcist returns `"FTE: No criteria, no path hint"` — `AX/Sources/AXorcist/Search/ElementSearch.swift:135-144`.
- Element search (`findAXElement` → `searchInElement`): `root.findElements(role:title:label:nil,value:identifier:nil,maxDepth: 100)` — i.e. AXorcist's **exact-equality** matcher over an **unpruned, un-timed** traversal to depth 100 — then, if empty and a title was given, `root.findElement(matching: title, options: ElementSearchOptions{maxDepth 100, caseInsensitive true, includeRoles [role]})` (substring over 8 fields incl. `roleDescription`) — `AA/Sources/AgentAccess/AccessibilityService.swift:147-194`; AXorcist semantics at `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:758-815`, `AX/Sources/AXorcist/Core/Element+Search.swift:141-162`. With no app given it searches the frontmost app, then **every `.regular` running app in turn** — `AA/Sources/AgentAccess/AccessibilityService.swift:155-167`.
- App resolution is a launcher: `resolveBundleId` scans `/Applications, /Applications/Utilities, /System/Applications, /System/Applications/Utilities, ~/Applications` `Info.plist`s once (lazy, cached) — `:245-278`; any input containing `.` is treated as a bundle id **and auto-launched** ("Before, this short-circuited without launching, which forced cloud LLMs to call open_app first") — `:332-340`; `launchIfNeeded` = `NSWorkspace.openApplication` + `Thread.sleep(1.0)`, then per-window `unminimizeWindow()`, `appElement.activate()`, `app.activate()`, `Thread.sleep(0.3)` — `:388-411`. The comment is emphatic: "DO NOT REPLACE THIS WITH showWindow(). It looks equivalent. It is not." — "`showWindow()` adds a per-window performAction(.raise) on a window that was just unminimized, which races against the AX queue and fails silently" — `:376-386`. `lookupBundleId` is the side-effect-free variant for read-only queries — `:280-316`. `openApp` must call `forceLaunchAndActivate` because the dot early-return "would COMPLETELY SKIP the unminimize step. The dock-Genied window would never come back" — `AA/Sources/AgentAccess/AccessibilityService+Elements.swift:58-67,84-87`.
- Other AXorcist calls: `Element.application(for: NSRunningApplication|pid)`, `Element.focusedApplication()`, `Element.systemWide()`, `Element.elementAtPoint(point)` (pid defaults to 0 → **system-wide** hit-test, so `inspectElementAt` returns whatever app owns the point — `AA/Sources/AgentAccess/AccessibilityService+Elements.swift:16-18`, `AX/Sources/AXorcist/Core/Element+Factory.swift:46-71`); `windows()`, `title()`, `frame()`, `role()`, `mainMenu()`, `children()`, `focusedUIElement()`, `focusedApplicationElement()`, `isMinimized()`, `unminimizeWindow()/minimizeWindow()/maximizeWindow()`, `activate()`, `hideApplication()/unhideApplication()`, `setPosition()/setSize()`, legacy `setValue(_:forAttribute:) -> Bool`, `click(button:clickCount:)`, `typeText(_:clearFirst:)`, `scroll(direction:amount:)`, `performAction(.press|.showMenu)`, `isActionSupported(AXAction.showMenu.rawValue)`, `isInteractive()`, `isActionable()`, `computedName()`, `searchElements(byRole: "AXWebArea")`, free `extractTextFromElement(_:maxDepth:)`, `AppLocator.app(at:)` (the frontmost-fallback variant), `RunningApplicationHelper.{applications(withBundleIdentifier:), frontmostApplication, filteredApplications(options: .init(excludeProhibitedApps: true)), allApplications(), runningApplication(pid:)}`, `WindowInfoHelper.{getWindows(for:), getVisibleWindows(), getWindowBounds/getOwnerPID/getWindowName(windowID:)}`, `CFConstants.{cgWindowNumber, cgWindowName, cgWindowBounds, cgWindowOwnerPID}` — throughout `AA/Sources/AgentAccess/*.swift`.
- **Deliberately not used**: `InputDriver` and every coordinate-based input ("They were unreliable (window positions shift, retina scaling, multi-display setups) and bypassed AXorcist entirely. Removed.") — `AA/Sources/AgentAccess/AccessibilityService+Actions.swift:68-80`; arbitrary drags ("file-system drag-and-drop between Finder and another app) just don't work via AX and should be done with a Shortcut or AppleScript instead") — `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:159-171`; `AXPermissionHelpers` ("Uses AXIsProcessTrusted() directly since AXPermissionHelpers is @MainActor restricted") — `AA/Sources/AgentAccess/AccessibilityService+Security.swift:18`; `AXTraversalOptions`, `withMessagingTimeout`, `AXTimeoutHelper`, `NotificationWatcher`/`AXObserverCenter` (no references in any file) — so every search runs with AXorcist's process defaults (30 s, container pruning on the command road, `stopAtFirstMatch`) and **no messaging timeout is ever armed**.
- Safari is refused outright: any `appBundleId` in `{com.apple.Safari, com.apple.SafariTechnologyPreview}` (or a frontmost browser with no app given) returns "Error: Safari/browser detected. Do not use accessibility for web pages. Use the web tool…" from `performAction`, `getElementProperties`, `setProperties`, `findElement`, `getChildren`, `waitForElement`, `showMenu`, `clickElement`, `typeTextIntoElement`, `waitForElementAdaptive` — `AA/Sources/AgentAccess/AccessibilityService.swift:13-31`, `AA/Sources/AgentAccess/AccessibilityService+Actions.swift:17-19`, `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:11-13,27-29,64-66,121-123,177-179,206-208,239-241,339-341,385-387`. `scanWebContent` (AXWebArea walk, roles `AXLink AXButton AXTextField AXTextArea AXCheckBox AXRadioButton AXPopUpButton AXComboBox AXSlider AXImage AXHeading AXStaticText AXGroup`, cap 200, strings truncated to 200/500) is the only web path — `AA/Sources/AgentAccess/AccessibilityService+Elements.swift:133-210`.
**The element JSON handed to the model**
- `elementProperties(_:)` keys: `AXRole, AXTitle, AXDescription, AXRoleDescription, AXSubrole, AXIdentifier, AXHelp, AXEnabled, AXFocused, AXHidden, AXPosition{x,y}, AXSize{width,height}, AXValue (String | NSNumber | String(describing:)), AXURL, AXPlaceholderValue` — "Uses standard AX* key names that LLMs recognize from training data." — `AA/Sources/AgentAccess/AccessibilityService.swift:196-221`. Wrapped as `{"data":…,"success":true}` via `JSONSerialization` `.sortedKeys` — `:421-424`. `errorJSON` is a hand-built string that escapes only `"` — `:427-429`.
- `openApp` → `{"app","appName","elementCount","elements":[elementProperties + "computedName"]}`; only `isInteractive()` elements with `width > 0 && height > 0`, first **50**, recursion to `maxDepth` (default 5) — `AA/Sources/AgentAccess/AccessibilityService+Elements.swift:69-129`.
- `getChildren` → `{"count","children":[props + nested "children"…],"truncated"?}` with a 400-node cap; the recursion exists because "only direct children were returned, which made SwiftUI AXHostingView subtrees look empty" — `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:139-156`.
- `listWindows` → `{"windows":[{windowId, ownerName, ownerPID, windowName, bounds{x,y,width,height}, role}],"count","app"?}`; AX windows are matched to CG windows by title, else frame within 2 pt, each CG id used once, layer 0 only — `AA/Sources/AgentAccess/AccessibilityService.swift:43-85,87-119`.
- Failure payloads teach vocabulary: "Dead-end errors waste an LLM turn. List the titles that actually exist for the requested role so the model can retry correctly." — up to 25 names (title → description → computedName), depth 15 — `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:294-303,451-479`; text inputs present (`AXTextField/AXTextArea/AXSearchField/AXComboBox`, 8 each) — `:393-406`; available menu titles (30) — `AA/Sources/AgentAccess/AccessibilityService+Window.swift:131-137`.
**Click / type flows and the "verify" logic**
- `clickElement(role:title:value:appBundleId:timeout: = automationFinishTimeout, verify: = false)`: resolve+launch app → `Element.application(for:)` + `activate()` → retry loop with exponential backoff 0.1→0.2→0.4→0.8→1.0 s: `findElements(… maxDepth: 20)` preferring a match with `width > 0`, then fuzzy `findElement(matching: title)` → wait up to 5 s for `isEnabled()` → `element.click()` (CGEvent at center) → fallback `performAction(.press)` → error "Element is not clickable through accessibility (Element.click and AXPress both failed)". "No coordinate-based fallback" — `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:237-327`. **The `verify` parameter is never read in the body** (`:238` declares it; no use through `:327`).
- `typeTextIntoElement(role:title:text:appBundleId:verify: = true)`: find element → `setValue(text, forAttribute: "AXValue")` first ("fastest") → fallback `typeText(text, clearFirst: true)` → success JSON with `"method": "element_setValue" | "element_typeText"` — `:384-421`. **`verify` is likewise never read.** Nothing reads the value back after either path.
- The only verification primitive is `captureVerificationScreenshot(action:role:title:appBundleId:)`: takes `captureAllWindows()` and, if role/title given, re-runs `findElement(timeout: 1.0)` and sets `element_status` to `verified_present` when the returned JSON `.contains("\"success\": true")` (with a space), else `not_found_after_action`; `not_verified` when no role/title — `:367-379`. `successJSON` produces compact `JSONSerialization` output (`{"data":…,"success":true}`, `.sortedKeys` only — `AA/Sources/AgentAccess/AccessibilityService.swift:421-424`), so the spaced needle is unlikely to ever match (inference from the two call sites; not stated in source).
- `findElement` / `waitForElement` / `waitForElementAdaptive` poll with `Thread.sleep` **on `@MainActor`** until `timeout`, whose default `automationFinishTimeout = 18000` s (5 h); `automationStartTimeout = 9000`, `automationMaxDelay = 5` — `AA/Sources/AgentAccess/AccessibilityConstants.swift:4-6`, `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:63-80,176-196,332-363`.
- `scrollToElement`: finds the first `AXScrollArea` (depth 10) and calls `scroll(direction: .down, amount: 5)` up to 20 times with 0.3 s sleeps, re-searching each time; no scroll area → "The app may use a custom non-accessible scroll view." — `AA/Sources/AgentAccess/AccessibilityService+Window.swift:347-396`.
- `clickMenuItem`: `mainMenu()` → for each path segment pick the best child by normalized title (trim, lowercase, strip trailing `…`/`...`; exact → prefix → contains) → intermediate items are pressed, 0.15 s sleep, then `children.first` is assumed to be the submenu; the last item errors if `isEnabled() == false` ("grayed out") before `performAction(.press)` — `:104-195`.
- `showMenu` requires `isActionSupported("AXShowMenu")`; no coordinate fallback — `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:200-233`.
- `highlightElement` creates an `NSWindow(contentRect: frame …)` directly from the AX frame with **no top-left→bottom-left conversion** — `AA/Sources/AgentAccess/AccessibilityService+Window.swift:48,61-68` (contrast the SDK's flip at `SDK/Sources/MacosUseSDK/DrawVisuals.swift:278-286`).
- `setWindowFrame` targets the first `AXWindow` of the app — `AA/Sources/AgentAccess/AccessibilityService+Window.swift:216-227`. `manageApp` promotes a dot-less "bundleId" to a name ("callers frequently pass a natural app name like "Photo Booth" in the `bundleId` slot") — `:238-248`; `hide/unhide` use `lookupBundleId` "no auto-launch: launching an app just to hide it would be absurd" — `:296-300`.
**Permission check implementation**
- `hasAccessibilityPermission()` = `AXIsProcessTrusted()` cached in a `nonisolated(unsafe) static var _permissionGranted` that is **never cleared**, so a revocation is not noticed until relaunch — `AA/Sources/AgentAccess/AccessibilityService+Security.swift:12-24`.
- `requestAccessibilityPermission()`: first call prompts via the literal `["AXTrustedCheckOptionPrompt": true]`; later calls open `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility`; either way starts a detached 2 s poll that, once trusted, **relaunches the app** (`createsNewApplicationInstance = true`, then `NSApplication.shared.terminate`) — `:26-72`.
- Every public method gates on `hasAccessibilityPermission()` and returns `{"success": false, "error": "Accessibility permission required."}`; screenshots return "Accessibility/Screen Recording permission required." on the same check (Screen Recording is not actually checked) — `AA/Sources/AgentAccess/AccessibilityService+Screenshot.swift:22-26,74-77`.
- `isRestricted(_ id:)` ignores its argument and reads `UserDefaults "AccessibilityGlobalEnabled"` — `AA/Sources/AgentAccess/AccessibilityService+Security.swift:74-77`. The README's per-action gate (`AccessibilityPermissions.shared.isRestricted("AXPress")`, `.toggle("AXDelete")`, `.enableAll()`) and its "AX Actions (30)" table — `AA/README.md:150-176` — describe an API that is **not in source**.
- Screenshots shell out to `/usr/sbin/screencapture -x -t png [-l <windowID> | -R x,y,w,h] <path>` under `~/Documents/AgentScript/screenshots/`, on `DispatchQueue.global` because "process.waitUntilExit() … BLOCKS the calling thread. The previous implementation ran on MainActor and froze Agent's UI on every screenshot." — `AA/Sources/AgentAccess/AccessibilityService+Screenshot.swift:9-18,22-70`; `frontmostWindowID()` = first layer-0 CG window of the frontmost app — `AA/Sources/AgentAccess/AccessibilityService+Window.swift:10-21`.
**Gotchas recorded in AgentAccess comments (verbatim)**
- "Coordinate-based dispatch is intentionally absent — every action must go through AXorcist's element-finding so it can be reliably retargeted when the UI shifts." — `AA/Sources/AgentAccess/AccessibilityService+Actions.swift:10-12`.
- "x/y are accepted in the signature for source compatibility but ignored — the LLM must identify elements by role/title/value, not by screen position." — `:29-31`.
- "This is the byte-for-byte 2.6.0 implementation that worked correctly for ~7 days. The 2.9.1 attempt to "improve" it by switching to Element.showWindow() broke docked Photo Booth" — `AA/Sources/AgentAccess/AccessibilityService.swift:377-380`.
- "Apps installed AFTER startup won't appear until the next launch" — `:239-240`.
- "kAXFocusedUIElement is the canonical focus attribute — works on both app elements and the system-wide element. Try it first." — `AA/Sources/AgentAccess/AccessibilityService+Interaction.swift:101-102`.
- "Match order: exact (normalized) → prefix → contains. Case and trailing ellipsis are ignored so LLM-supplied paths survive cosmetic differences." — `AA/Sources/AgentAccess/AccessibilityService+Window.swift:179-180`.
- "If the app has no scroll area we can't scroll via accessibility." — `:376-377`.
---
## Top 25 things AI gets wrong about macOS AX (one sentence each)
1. AX and CGEvent coordinates are top-left global points while every AppKit API is bottom-left, and the correct conversion is per-display via `CGDisplayBounds`, not `NSScreen.main.frame.height - y` — `AX/Sources/AXorcist/Core/AppLocator.swift:139-149`, `SDK/Sources/MacosUseSDK/DrawVisuals.swift:278-286`.
2. Screenshot pixels are window-relative and scaled, so "click where it looks like it is in the PNG" is always wrong — `MCP/Sources/MCPServer/main.swift:1503`.
3. The first synthetic click after switching apps only activates the window; activate and wait ~200 ms first — `SDK/Sources/MacosUseSDK/ActionCoordinator.swift:207-214`.
4. `Element.click()`/`clickMouse` are CGEvents, and `AXPress` is a separate path that is sometimes the only one that works (Catalyst, sandboxed apps) — `MCP/Sources/MCPServer/main.swift:1459`.
5. Some rows expose `AXSelected` but no `AXPress`; selecting means setting an attribute — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:171-178`.
6. `AXSetValue` is not an accessibility action; typing-by-value is `AXUIElementSetAttributeValue(kAXValueAttribute)` — `AX/Sources/AXorcist/Core/AccessibilityConstants.swift:23-24`.
7. `kAXErrorCannotComplete` can be returned *after* the action dispatched, so never retry it — `AX/Sources/AXorcist/Core/AXorcist+ActionHandlers.swift:206`.
8. `AXUIElementCopyElementAtPosition` does not reach Catalyst table rows; walk the tree and pick the smallest containing frame — `SDK/Sources/MacosUseSDK/AccessibilityActions.swift:47-49`.
9. `AXChildren` on an Electron app root only lists the frontmost window and hides Chromium's focused subtree; also read `AXWindows` and `AXFocusedUIElement` — `AX/Sources/AXorcist/Core/Element+Hierarchy.swift:43-54`.
10. A default AXorcist search never descends into AXTable/AXRow/AXCell/AXToolbar/AXTabGroup/AXMenu because they are not "container roles" — `AX/Sources/AXorcist/Search/ElementSearch.swift:599-614`.
11. `Criterion(attribute: "title")` reads a literal attribute named `title`; only role/subrole/identifier/pid/DOM/computedName have aliases — `AX/Sources/AXorcist/Search/SingleCriterionMatching.swift:83-106`.
12. `AXTitle` matching is case-sensitive and `contains ""` fails on a missing attribute — `AX/Sources/AXorcist/Search/AttributeMatchingFunctions.swift:134-151`, `AX/Sources/AXorcist/Search/StringComparisonLogic.swift:48-56`.
13. A traversal "depth N" visits but does not expand depth-N nodes, and a timeout mid-walk is silent — `AX/Sources/AXorcist/Search/AXTreeTraversal.swift:147-149,167-169`, `AX/Sources/AXorcist/Search/ElementSearch.swift:414`.
14. Without `AXUIElementSetMessagingTimeout`, a wedged app blocks the caller forever, and the timeout must be reset to 0 afterwards — `AX/Sources/AXorcist/Core/AXTimeoutPolicy.swift:126-133`, `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:693-721`.
15. `AXValue` raw type 4 is both Boolean and CFRange, so a "raw value" switch corrupts `AXSelectedTextRange` — `AX/Sources/AXorcist/Values/ValueUnwrapper.swift:71-72`.
16. Parameterized attribute names have no `Parameterized` suffix, and action names come from `AXUIElementCopyActionNames`, not an attribute — `AX/CHANGELOG.md:40`, `AX/Sources/AXorcist/Core/Element+Properties.swift:112-121`.
17. Unicode-only key events are silently dropped in VMs/headless sessions; resolve physical keycodes from the live layout and fall back to Unicode — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:229-230`.
18. Double-clicks need explicit `mouseEventClickState`, hotkeys must be fully built before posting or modifiers stick, and every event needs a ~15 ms gap — `AX/Sources/AXorcist/Core/Element+UIAutomation.swift:117-121,437`, `SDK/Sources/MacosUseSDK/InputController.swift:61-62`.
19. Scroll-wheel sign conventions differ between libraries (AXorcist positive = up, SDK negative = up) — `AX/Sources/AXorcist/Core/InputDriver.swift:166-180`, `SDK/Sources/MacosUseSDK/InputController.swift:155`.
20. The process that needs Accessibility permission is the host (parent), a changed code signature is a new TCC identity, and permission changes can only be polled — `AX/Sources/AXorcist/Core/AccessibilityPermissions.swift:43-51`, `AX/docs/releasing.md:3`, `AX/Sources/AXorcist/Core/AXPermissionHelpers.swift:142-175`.
21. There is no system-wide AX observer; "global" watching is one `AXObserver` per PID driven by KVO on `NSWorkspace.runningApplications`, with PID reuse detected via `proc_pidinfo` unique IDs — `AX/README.md:514-517`, `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:19-32`.
22. `AXObserverAddNotification` can succeed *after* your deadline; a late success must be rolled back with a native remove or it leaks — `AX/Sources/AXorcist/Core/ObserverNativeWork.swift:93-107,185-186`.
23. `CGWindowListCreateImage` pulls ReplayKit into a long-lived process and it spins at ~19 % CPU forever; capture in a subprocess — `MCP/Sources/MCPServer/main.swift:382-385`.
24. `SDK` traversal text is a five-attribute concatenation, numeric `AXValue`s vanish, and the list is BFS + spatially sorted, so "children follow their parent" is false — `SDK/Sources/MacosUseSDK/AccessibilityTraversal.swift:226-229,272-283,302-305,173-180`.
25. "No focused element" is a success, `activate()` returning `false` can still work, and an `NSRunningApplication.activate` immediately after unminimize races the AX queue — `AX/Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift:34-43`, `AX/Sources/AXorcist/Core/Element+WindowOperations.swift:227`, `AA/Sources/AgentAccess/AccessibilityService.swift:377-386`.