* feat: add strict ref reliability core
* feat: add session-scoped reliability diagnostics
* fix: skip finder pseudo windows for snapshots
* fix: report wait and trace failure context
* fix: harden ref action reliability
* fix: close reliability review findings
* fix: harden reliability edge cases
* fix: close final reliability edge cases
* fix: address reliability follow-ups
* docs: update reliability docs and skills
* fix: harden wait and ref action reliability
* refactor: centralize reliability helpers
* fix: stabilize macos ref resolution
* refactor: organize binary crate modules
* fix: harden ref action reliability
* fix: harden ref action reliability
* docs: compound reliability patterns
* fix: harden ref reliability edge cases
* fix: harden source-window ref resolution
* fix: preserve safe window title fallback
* fix: make explicit snapshots session-independent
* fix: harden ref fallback resolution
* fix: fail closed on uncertain ref fallback
* chore: strip inline comments and enforce docstrings
Replace inline // comments with /// docstrings where they carry non-obvious
contract, and add a pre-commit guard so inline comments cannot regress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(review): preserve action results, delegate timeout resolution, tighten core boundaries
Apply verified code-review fixes: ref-action release failures no longer
mask successful action results (prevents double-dispatch on retry);
resolve_element_strict_with_timeout defaults to delegating so strict-only
adapters support wait --element; wait --text reports count only when
--count is requested; latest-refmap refresh logs load failures instead of
silently serving stale refs; InteractionPolicy moved to its own module and
actionability/trace modules scoped pub(crate) per file rules; duplicate
wait test helper extracted to shared support module; timeout error
constructors deduplicated; redaction test covers description; policy
focus-denial path covered; skills document steps array, actionability
details, trace redaction, and batch trace inheritance.
* fix(review): eliminate per-element AX round trips and close remaining review findings
Fold AXPosition/AXSize and the scrollbar probe into the existing
AXUIElementCopyMultipleAttributeValues batch so tree traversal and the
actionability preflight pay one IPC per element instead of up to four;
A/B benchmark shows strictly-faster snapshots with identical ref counts
and scroll capabilities (Finder 4.5s -> 2.4s same-session, Docker
Desktop parity at 440 refs / 933 scroll-capable nodes).
Consolidate the CLI and FFI ref-action pipelines into one core
execute_resolved path (actionability, tracing, and dispatch semantics
live once; FFI passes a default context). Remove the no-context
execute() shims from is/right-click/snapshot/wait and the test-only
helper shims; every command now takes an explicit CommandContext.
Split the macOS resolver into resolve (orchestration), resolve_search
(candidate collection), and resolve_classify (strict classification),
clearing the 400-LOC ceiling with room to grow.
Notification waits now retry transient baseline failures inside the
timeout budget with the same retryable gate as window/text waits and
report last_error in timeout details instead of aborting on the first
flake; a baseline is never fabricated.
Refmap writes clean up their temp file on failure, stale *.tmp orphans
are swept under the store lock, and save_existing_snapshot re-verifies
snapshot ownership inside the owning store's write lock with bounded
re-discovery before deterministically recreating in the caller's store.
Coverage hardening: zero-budget wait timeout shape, wait --text
--count 0 absence detection, ref-action pipeline call-count guard
(1 resolve / 1 live read / 1 dispatch), duplicate snapshot-id collision
on load, pruned-everywhere recreation, tmp sweep and rename-failure
cleanup, FFI AMBIGUOUS_TARGET last-error code assertion.
* docs: document notification retry, error-code contract, and diagnostics sensitivity
Note transient-error retry and last_error timeout detail on wait
--notification; make explicit that agents branch on error.code (message
and suggestion text is informational); warn FFI consumers that
ad_last_error_details may carry on-screen element names, values, and
window titles and should stay out of shared log surfaces.
* fix: validate find roles against the canonical vocabulary
find --role with a role no adapter can emit (textarea, typos) silently
returned ok with zero matches, reading as 'element absent' when the
query could never match. Role queries now resolve through a canonical
vocabulary in core: common text-input aliases (textarea, textbox,
searchfield) normalize to textfield case-insensitively, and unknown
roles fail with INVALID_ARGS carrying details.valid_roles so agents can
self-correct.
The macOS role mapping becomes a sorted single-source table with
binary-search lookup, and a conformance test pins every emitted role
(plus the unknown fallback and the synthesized cell role) to core's
CANONICAL_ROLES — the cross-platform contract Windows/Linux adapters
must map their native vocabularies into, enforced by the same
table + test pattern rather than convention.
* refactor: derive find role hints from the live tree, drop hardcoded vocabulary
Replace the canonical-role allow-list (and its hard INVALID_ARGS
rejection) with a tree-derived approach. A role filter that matches
nothing now returns ok with roles_present — the distinct roles actually
in the searched tree — so the caller distinguishes 'none on screen' from
a wrong role name and self-corrects. This needs no central role list: a
role any adapter newly emits surfaces in roles_present automatically,
with nothing to keep in sync across core and the platform crates.
A tiny role-query normalizer keeps the ergonomic win (textarea, textbox,
searchfield fold to textfield, case-insensitive) but never gates or
rejects — it is a synonym shim, not a vocabulary. The macOS role table
returns to its plain match form; the cross-crate canonical-vocabulary
list and its conformance test are gone.
* refactor: move protected-process knowledge out of core into the adapter
close-app hardcoded macOS/Unix process names (loginwindow, windowserver,
dock, launchd, finder) inside core, baking platform-specific knowledge
into the platform-agnostic crate. Windows would need csrss.exe/
winlogon.exe, Linux gnome-shell/Xorg. Add PlatformAdapter::
is_protected_process (default denies nothing); the macOS adapter owns its
list with substring matching over display and bundle identifiers. core's
close-app just asks the adapter. Also genericize a macOS-flavored test
fixture string so core carries zero native vocabulary even in tests.
Verified: core has no platform-native references in non-test source, no
cfg(target_os) gates; the only remaining cfg(unix)/libc use is securing
core's own refmap/trace/lock files with non-unix fallbacks.
* fix: stop close-app claiming a graceful quit it cannot confirm
close-app returned closed:true the instant a graceful quit was *sent*,
while the app was still running behind an unsaved-changes dialog —
a false completion claim. Empirically (NSWorkspace.runningApplications):
a clean quit completes in ~0.2s, a dialog-blocked quit never completes
on its own, and macOS confirms only that the quit request was sent, not
that the app terminated. Verifying by polling would add seconds of
latency on the exact (blocked) case it is meant to catch, so we do not
poll.
Graceful close now reports { method: graceful, requested: true } —
truthful and instant, no closed claim. --force is a synchronous SIGKILL,
so it reports { method: force, requested: true, closed: true }. Callers
needing graceful confirmation observe via list-apps / wait --window and
can drive a save dialog with snapshot + find, which is the agent-native
path.
* feat: add drag --drop-delay for reliable macOS drop registration
macOS drop targets need the dragged item to dwell over them before they
register as the destination; too short and the gesture lands as a drag
with no drop. The dwell was a hardcoded 500ms dead sleep. Expose it as
--drop-delay <ms> (CLI), drop_delay_ms (DragParams/AdDragParams, 0 =
adapter default sentinel matching duration_ms), and replace the dead
sleep with an event-driven dwell that posts LeftMouseDragged over the
destination every 16ms so the target stays highlighted instead of
dropping the drag mid-pause.
DRY: the C-to-core drag conversion (duration/drop-delay zero-sentinel)
was copied across three FFI sites; collapse them into AdDragParams::
to_core(). FFI ABI: AdDragParams gains drop_delay_ms (header + repr +
header-compile test). Tests: core threads the value into params and
response and omits the field when unset; FFI maps both optionals.
* fix: make action-bearing elements ref-able so scroll/expand can target them
E2E testing against a diverse fixture app surfaced that disclosure
(Expand/Collapse/Click) and scrollarea (Scroll) advertise actions but
never received refs — they are not in INTERACTIVE_ROLES — so the scroll,
expand, and collapse commands required a <REF> their own target roles
could never have. The commands were uninvokable against their primary
targets.
Ref allocation now gates on addressability, not role alone: an element
is ref-able if its role is interactive OR it advertises a primary action
(any action other than a bare SetFocus, which would ref-allocate inert
focusable containers). scrollarea and disclosure become ref-able;
scroll now works against a real app. Ref-count impact is modest
(fixture 61->72, Finder ~262).
Tests assert action-bearing containers get refs, SetFocus-only and inert
elements do not, and interactive roles stay ref-able without actions.
Contract docs (CLAUDE.md, SKILL.md) updated.
* fix: eliminate vacuous AX successes and harden resolution
Dogfooding the binary against a real fixture app surfaced five cases where a
command reported success without producing the effect, or failed with the wrong
error. Each is verified by independent before/after observation in the E2E
harness.
- is_menu_open no longer treats a latent AXMenuBar as an open menu, so select
and wait --menu-closed stop seeing a permanently-open menu.
- set-value coerces the written AXValue to the element's existing CFNumber/
CFBoolean/CFString type and verifies numerically, fixing sliders; steppers
converge via AXIncrement/AXDecrement when AXValue writes are vacuous.
- double-click only claims success when the element advertises AXOpen; otherwise
it fails closed instead of reporting a non-existent double-click.
- a completed resolution pass that proves a ref absent downgrades a deadline
TIMEOUT to STALE_REF so removed elements fail with the correct code.
- expand/collapse verify the disclosure state and fall back to a press-toggle
for press-driven disclosures; press-toggled containers expose EXPAND/COLLAPSE.
roles.rs adds the disclosure expandable role and normalizes textarea/textbox/
searchfield role queries to textfield.
* feat: add Playwright-style headed/headless interaction mode
Ref actions now run in exactly two modes. Headless is the default: semantic
accessibility operations only, no cursor movement, and a fail-closed
POLICY_DENIED when only a physical gesture would work. The global --headed flag
upgrades every ref action to permit focus stealing and cursor movement, so the
chain's physical click/double-click/scroll/keypress fallbacks can complete. The
AX path is always tried first, so --headed never regresses headless-capable
elements; it only adds fallbacks for elements that need a real gesture.
- CommandContext::request(action, base) builds the per-command request: each
command declares its headless base (pure-AX headless; type uses focus_fallback
because typing requires focus but never moves the cursor) and --headed upgrades
any base to the headed policy.
- The internal/FFI "physical" policy is renamed "headed" throughout, including
the C ABI enum (AD_POLICY_KIND_HEADED keeps discriminant 2) and bindings.
- Raw-input commands (press, hover, drag, mouse-*, key-down/up) are unchanged:
always physical, mode-independent low-level escape hatch.
- Unit tests assert every ref command is headless by default and headed under
--headed; docs (CONCEPTS, CLAUDE, skills) describe the two-mode contract.
* test: add E2E fixture app and dual-mode harness
Drives the release binary against a real SwiftUI/AppKit fixture and verifies
every effect by independent before/after observation — never the command's own
ok:true — so a command that reports success without an effect is caught. This is
the layer mock-adapter unit tests cannot cover: it exercises the contract
against the real macOS Accessibility API.
- AgentDeskFixture.swift exposes a fixed, diverse AX surface (native AppKit
slider/stepper, gesture-only and ambiguous controls, a sheet, a press-toggled
disclosure, async-appearing elements, a drag canvas). It is never tuned to
make a command pass; a failure is a finding about the CLI or the harness.
- run.sh drives every ref-action command in BOTH headless and --headed mode with
mode-specific target values, plus the double-click discriminator (headless
fails closed with POLICY_DENIED, --headed completes) that proves the two modes
differ. It also covers strict resolution, wait predicates, skeleton drill-down,
sessions, trace redaction, surfaces, drag, expand, and force-close.
- The compiled fixture .app is a build artifact (gitignored; built on demand).
Run: cargo build --release && bash tests/e2e/run.sh (needs AX permission).
* fix: close review runtime, correctness, and security gaps
Addresses validated findings from the branch code review:
- chain: thread the chain deadline into increment_to_value so a non-converging
stepper cannot spin up to 1024 AX round trips and blow past the timeout.
- mouse: a RAII guard posts LeftMouseUp if any fallible step of a drag returns
early, so an error can never leave the mouse button held down system-wide.
- ffi: cap caller-supplied state/action/path counts before from_raw_parts,
mirroring the existing MAX_MODIFIERS_PER_COMBO guard, to reject out-of-bounds
reads from a garbage C count.
- wait: the actionable predicate now forwards the structured ActionabilityReport
from error.details instead of dropping it to a flat message, so agents can see
which check is blocking.
- scroll: gate the row-select fallback on policy.allow_focus_steal so a headless
scroll can no longer silently change the user's table selection.
- actionability: delete the unreachable stable->StaleRef branch (stability_check
never fails by design) and its now-dead failed_check helper.
- status: delete two pub wrappers with no production callers; the test now drives
the real execute_with_report_with_context entry point.
* refactor: extract disclosure chain steps under the 400-LOC limit
chain_steps.rs had grown to 402 lines, over the hard per-file limit. The six
press-toggle disclosure helpers form a cohesive group and move cleanly into a
sibling chain_disclosure_steps.rs (following the chain_web_steps/
chain_menu_steps pattern); chain_defs.rs references the new module. No behavior
change.
* feat: focus the target window before ref-addressed physical input
Ref-action physical fallbacks (click/scroll/type) already brought the target
app frontmost before synthesizing CGEvents. The raw-input commands (drag, hover,
mouse-*) resolving a point from a ref did not — the resolver had the pid but
discarded it, so the adapter saw only coordinates and synthetic events could
land on whatever window happened to be frontmost.
Add a best-effort focus_app(pid) to PlatformAdapter (macOS uses
ensure_app_focused; other adapters default to not_supported). The point resolver
now focuses the ref's app before returning, so every physical interaction that
targets a known element raises its window first. Coordinate (--xy) input is
unchanged: the caller owns the target there.
* fix: add AdDragParams size guard and document the ABI breaks
AdDragParams gained a drop_delay_ms field but, unlike AdRefEntry, had no size
guard — an old caller's smaller allocation would let Rust read past it and turn
stack garbage into a real drop delay. Add AD_DRAG_PARAMS_SIZE, ad_drag_params_size(),
a compile-time layout assertion, and a zero-init note, matching the ref-entry
pattern.
This branch makes several consumer-visible contract changes that release
tooling must cut as a major. They are gathered here because the release workflow
ships the C header as an artifact.
BREAKING CHANGE: the C ABI and CLI/JSON contract changed on this branch.
- AdPolicyKind: AD_POLICY_KIND_PHYSICAL is renamed AD_POLICY_KIND_HEADED
(discriminant 2 unchanged, so compiled binaries are safe; source-level C
consumers must rename). No back-compat alias is kept — "physical" is gone.
- AdRefEntry grew (caller-allocated input); validate layout with
AD_REF_ENTRY_SIZE / ad_ref_entry_size().
- AdDragParams grew; validate with AD_DRAG_PARAMS_SIZE / ad_drag_params_size()
and zero-initialize before use.
- close-app graceful response no longer includes closed:true; it returns
{ method: "graceful", requested: true } because a graceful quit cannot be
synchronously confirmed.
* test: harden and expand the E2E proof layer
Closes the honesty gaps the review found and covers the interactions that were
missing, all verified by independent before/after observation:
- twins: the fixture twins now record distinct effects (twin-a/twin-b) and the
assert requires the ADDRESSED twin to fire (or AMBIGUOUS_TARGET), instead of
passing on any ok:true.
- click: click-status is a counter, so the headed pass must observe a fresh
increment rather than inheriting the headless pass's value.
- adds triple-click + hover (headed gestures), tab selection (TabView tabs are
radiobuttons), context-menu open + item selection, and menu-bar enumeration
via --surface menubar.
- adds a performance section reporting per-command CLI wall-clock (snapshot,
find, get, click, set-value, type) with a soft <2s snapshot gate.
- documents the SwiftUI CommandMenu and cross-app drop limitations as tracked
notes, not silent skips.
* docs: trim CLAUDE.md to standards and document gesture headless-capability
- CLAUDE.md: remove ~130 lines of reference material that duplicated code,
Cargo.toml, or the skills (full PlatformAdapter trait dump, Key Types listing,
macOS API listings, dependency/build-config tables, the 54-command table).
Replaced the stale trait dump with a pointer to adapter.rs (which also fixes
the review's stale-trait-docs finding) and kept only the non-obvious gotchas.
CLAUDE.md is now standards, invariants, and conventions.
- Document, on macOS (Phase 1), which gestures have a headless path: most ref
actions do; double-click via AXOpen; triple-click/hover/drag are cursor
gestures with no AX equivalent (physical only). The command surface is
platform-agnostic — a future Windows/Linux adapter that exposes a headless
path lights it up with no command or core change. Added to README and the
interaction reference.
- FFI skill: AD_POLICY_KIND_PHYSICAL is now AD_POLICY_KIND_HEADED.
* docs: capture gesture headless-capability learning and refresh policy docs
- Add best-practices/macos-gesture-headless-capability: which desktop gestures
have a headless AX path on macOS (double-click via AXOpen; triple-click/hover/
drag are physical-only; SwiftUI controls vs native AppKit), and why the command
never decides — the platform adapter owns headless-vs-physical.
- Refresh two policy learnings for the physical->headed rename: ActionRequest::
physical -> headed and AD_POLICY_KIND_PHYSICAL -> AD_POLICY_KIND_HEADED, noting
the new global --headed upgrade path via CommandContext::request.
* fix: address review P2 correctness and reliability findings
- wait: cap each ref-resolution attempt (750ms) so a slow resolve cannot
consume the whole wait budget on the first poll; the predicate is re-checked
across the full timeout.
- wait: make LatestRefCache timing fields private (no external readers).
- wait: add a unit test for --menu-closed (asserts it waits for open=false).
- close-app: make the graceful and force responses symmetric — both carry
`closed` (force confirms true; graceful cannot confirm, so false) instead of
graceful silently omitting the field.
- close-app: test adapter-error propagation.
- adapter: the default resolve_element_strict_with_timeout now logs that it does
not enforce the deadline, so an adapter that forgets to override it is visible
in traces.
- type: a RAII guard restores the user's clipboard on every scope exit (success,
error, panic) during the paste-based non-ASCII path, shrinking the clobber
window to an unpreventable SIGKILL.
Note: the reviewer's "fail fast on AMBIGUOUS_TARGET with a pinned snapshot"
suggestion is not applied — existing tests prove transient ambiguity resolves on
retry even with a pinned ref (ambiguity is a property of the live tree, not the
refmap), so failing fast would regress intentional, tested behavior.
* perf: trim actionability preflight and resolve-search allocations
- action_list: gate the AXValue and AXExpanded `is_settable` probes on whether
the role could plausibly carry that capability (unknown roles always probe),
skipping up to two AX round trips per preflight on common click-only targets.
No capability is lost — value/expandable roles still probe.
- resolve_search: reuse one scratch FxHashSet across nodes instead of allocating
a fresh dedup set per node during path and recursive search.
* refactor: split actionability types into one file each
ActionabilityStatus, ActionabilityCheck, and ActionabilityReport move into their
own files under a new actionability/ module (following the tree/ and actions/
folder pattern); mod.rs keeps the check logic and re-exports the types. Honors
the one-domain-type-per-file rule without changing behavior.
* test: split fixture under the LOC limit and fix swift-ios issues
- Extract the reusable fixture components (status readout, native AppKit
slider/stepper, drag canvas, card) into FixtureComponents.swift so each file
is under the 400-LOC limit; build.sh now compiles every .swift in the dir.
- NativeSlider/NativeStepper implement updateNSView so a SwiftUI binding change
syncs back to the NSView.
- The fixture no longer steals focus unconditionally on launch
(activate ignoringOtherApps:false), so it cannot mask headless-policy focus
violations; the harness drives focus explicitly.
- build.sh pins the SDK and a macOS 13 deployment target for reproducible builds.
* docs: document the optional error.details field and roles_present shape
- SKILL.md: note that the error object may carry an optional `details` (the
actionability report, AMBIGUOUS_TARGET candidates, or a wait TIMEOUT's last
observed state) and that responses should be parsed leniently — `details` and
future fields are additive.
- commands-observation: show the no-match `find` response with the
`roles_present` hint so callers can tell a wrong role name from "none on
screen".
* fix: gate the ref-gesture focus raise on the interaction policy
Ref-addressed hover/drag raised the target app unconditionally, violating
the headless no-implicit-focus-steal contract. The point resolver now
returns the owning pid instead of focusing, and commands decide: headless
never raises, --headed raises once (drag focuses only the from-app, fixing
the cross-app double-focus). Responses report focused:true so multi-app
agents can detect the frontmost change.
* fix: abort failed drags at the origin and disarm the guard only on success
The mouse-up guard disarmed before the final fallible up-event, so a
failed final post left the button held. Worse, its corrective release
fired at the unreached destination, silently committing an aborted drag
as a completed drop (CGEvents resolve at their embedded coordinates).
The guard now owns the release: it disarms only after the up actually
posts, and an early return cancels by dragging back to the origin and
releasing there.
* fix: enforce the chain deadline inside increment steps
All dispatch sites construct ChainContext with deadline: None, so the
remediation parameter on increment_to_value never received a value and
the 1024-iteration loop ran unbounded by the chain timeout. The chain now
pins its resolved deadline into the context every step observes. Also
extracts the pure write-verification predicates and their tests to
chain_verify.rs, bringing chain.rs back under the 400-LOC limit.
* fix: pin the AdAction ABI layout and bound FFI string and array inputs
AdDragParams is embedded by value in AdAction, so its 8-byte growth grew
the struct C callers pass to ad_execute_action with no size guard —
old-layout callers under-allocate and stack garbage becomes a live
drop_delay_ms. Adds AD_ACTION_SIZE / ad_action_size() with a layout pin,
matching the AdRefEntry pattern.
Also hardens the input boundary: C strings are decoded with a bounded
NUL scan (AD_MAX_STRING_BYTES, sized for CLI argv parity) so a missing
terminator cannot walk arbitrary memory, and the single coarse 1024
array cap becomes published per-field caps (AD_MAX_REF_STATES/ACTIONS/
PATH_DEPTH) with tests just over each limit.
* perf: replace fixed input settle sleeps with state polls
ensure_app_focused slept 50ms per physical input even when the app was
already frontmost; it now polls AXFrontmost (1ms, 50ms deadline).
disclosure_settled slept an unconditional 40ms up to three times per
expand/collapse; it now polls the disclosed state (5ms, 200ms deadline),
converging immediately on fast UIs. The kAXFocusedAttribute settability
probe is gated by role_may_accept_focus, mirroring role_may_bear_value,
and key dispatch reuses ensure_app_focused instead of its inline
duplicate.
* feat: check a specific action in wait --predicate actionable
The actionable predicate hardcoded Click, so wait-then-type flows got a
false ready on fields that cannot accept text (the editability check only
runs for editing actions). --action selects click (default), type,
set-value, or clear, and the preflight mirrors each command's real base
policy (type uses its focus-fallback base).
* fix: redact title, url, help, and placeholder keys in traces
Window titles, URLs, tooltips, and placeholder text carry user content
just like names and values; the redaction list now covers them.
* test: harden the e2e harness and unify fixture AX labels
The harness now fails setup loudly when the fixture build or AX trust is
missing, rebuilds the fixture when sources are newer than the bundle,
asserts hover only against a freshly observed state, and force-collapses
the disclosure so the expand test proves a real flip. The slider/stepper
labels live solely on the NSViews (the AX-actionable elements), removing
the macOS-version-dependent race between two label sources, and the drag
canvas reports a zero frame when detached from a window.
* docs: record the perf commit type, pre-1.0 bump policy, and error details field
Adds perf: to the allowed commit types (release-please already maps it to
a Performance changelog section), records the pre-1.0 versioning policy so
a BREAKING footer is expected to cut a minor rather than a major, and
shows the optional error.details object in the error envelope docs.
The gitignored local AGENTS.md mirror got the same contract sync.
* fix: surface increment deadline expiry as timeout with the observed value
A chain deadline firing mid-increment returned Ok(false), so the step was
recorded as skipped, the chain exhausted into ACTION_FAILED, and the
control sat at a half-applied value the caller could not see — post-state
is only read on success, and ACTION_FAILED recovery guidance points away
from retrying. Expiry is now a TIMEOUT error carrying value_before,
value_at_timeout, target, and a mutated flag in details.
* perf: cap the disclosure settle poll to the chain deadline and widen its interval
The settle poll could spend 3 x 200ms x 5ms-interval reads (~360 IPCs)
per expand/collapse and overshoot the chain's own deadline. A new
CustomWithDeadline chain step threads the chain deadline into the
disclosure steps, the settle budget is min(200ms, remaining chain
budget), and the interval widens to 20ms (~30 IPCs worst case).
* fix: enforce the protected-process guard inside the adapter close path
The guard lived only in the CLI command layer, so ad_close_app could
force-kill session-critical processes (loginwindow, WindowServer, Dock)
that the CLI refuses. close_app_impl now refuses them before any side
effect with the exact CLI error contract, making CLI, FFI, and any future
consumer behave identically; the CLI preflight remains as an earlier
check against the same predicate.
* fix: make focused semantics honest and confirm window focus by polling
ensure_app_focused set AXFrontmost unconditionally and reported success
identically whether or not a raise happened; it now no-ops when the app
is already frontmost, so Ok (and the focused:true response field) means
"frontmost ensured" exactly as documented. focus_window_impl gains the
same confirmation poll after its raise, and the poll interval widens to
5ms (10 reads max in the 50ms window).
* fix: attach abort-state guidance to drag failures and document cancel limits
Drag synthesis errors surfaced as bare INTERNAL with no hint about the
gesture's end state. Failures now carry a suggestion stating the button
was released back at the origin (best-effort), no drop was committed,
and where the cursor ends; the guard doc spells out the two best-effort
limits (corrective posts can fail; a self-drop at the origin is a no-op
for most targets).
* refactor: split oversized files by responsibility under the 400-LOC limit
helpers_tests (426) splits into resolution/window/pipeline tests, a
ref-action+trace test file, and a shared entry-builder support module.
wait_element_tests (414) splits into predicate-behavior tests and
resolution/lifecycle tests over a widened wait_test_support. wait.rs
(395) loses the element-wait loop to wait_element.rs, and refs_store
(397) moves its tmp-cleanup/retention methods to a refs_store_prune
child module (declared via #[path] so the split keeps base_dir and
snapshots_dir private to the store).
* refactor: build the actionable preflight request per action name at parse
The policy mirror lived in a separate helper with a catch-all arm, so a
future action name could silently inherit the headless policy. Parse now
maps every --action name to the exact ActionRequest its real command
runs (type is the only focus-fallback), the catch-all is gone, and a
test pins each name's policy.
* refactor: move point resolution and the focus helper to point_resolve
PointResolveArgs, ResolvedPoint, the ref-or-xy resolver, and
focus_for_physical_input were accumulating in helpers.rs alongside
unrelated ref-action plumbing; they now live in a dedicated
point_resolve module consumed by hover and drag.
* test: keep the drag canvas AX label on the NSView only
The DragCanvas carried two label sources (the NSView and a SwiftUI
modifier on its representable), the same macOS-version-dependent race
the slider/stepper fix removed; the harness-facing label now lives
solely on the AX-actionable NSView.
* docs: sync skills and header with the focused, redaction, and wait contracts
Documents the ensured (best-effort, already-frontmost-aware) semantics of
focused:true and its absence-vs-false meaning, the four redaction keys
added in round 2 plus the substring-match behavior, the INTERNAL error
recovery row, the FFI wait-surface asymmetry, the cross-app drag
occlusion caveat, and the AdDragParams/AdAction layout history with the
adjudicated pre-1.0 breaks so fresh reviews stop re-finding them.
* fix: surface settle-wait deadline truncation as timeout with a schema discriminant
A chain deadline truncating the disclosure settle wait returned a plain
step failure, exhausting into ACTION_FAILED — the same masking class
fixed for increments — even though the triggering action may still land
after the truncated wait. Settle exits are now classified: full-budget
misses stay step failures, deadline-truncated waits raise TIMEOUT with
the wanted/observed state, and the poll sleeps are clamped so a tight
deadline still gets at least one read. All TIMEOUT details now carry a
kind discriminant (wait_timeout vs chain_deadline) so agents can branch
without sniffing field names.
* fix: match protected processes exactly, not by substring
'docker'.contains('dock') permanently blocked close-app for Docker,
FinderSync-class apps, and anything else embedding a protected name.
Matching is now an exact lowercase name or an exact dot-separated
bundle-id component, so Dock and com.apple.dock stay protected while
Docker, Docker Desktop, FinderSync, and PathFinder stay closable —
pinned by false-positive tests.
* fix: raise the element's window before the physical click fallback
CGEvents land on the topmost window at the click point, so an app being
frontmost is not enough when the target element lives in a background
window of that app — the physical fallback clicked whatever overlapped
it, and the skip-raise-when-frontmost optimization widened the window
for that. click_via_bounds now raises the element's own AXWindow (AXRaise,
AXMain fallback, brief confirmation poll) via a shared window_ops helper
that focus_window_impl reuses. Verified live: a headed click on a ref in
an occluded Finder window raises that window and lands the click in it.
* test: guard ref-action policy coverage against silent gaps
A new ref-action command could ship without a base-policy assertion. A
guard test now scans crates/core/src/commands/ for files calling
context.request( and fails unless each stem appears in the
POLICY_TESTED_COMMANDS list backing the policy assertions.
* fix: carry the protected-process suggestion on the CLI preflight
The CLI-layer guard returned bare INVALID_ARGS while the adapter layer
carried recovery guidance, so agents on the primary surface got 'check
command syntax' for a permanently-disallowed operation and looped on
argument fixes. Both layers now state the same suggestion.
* docs: document the TIMEOUT schemas, chain deadline knob, and roles_present scope
Names the two TIMEOUT details schemas by their kind discriminant with
the mutated-flag retry rule, points chain-deadline recovery at
AGENT_DESKTOP_CHAIN_TIMEOUT_MS instead of --timeout, extends the
roles_present hint to all non-count selection-mode misses, and aligns
the STALE_REF recovery row with the richer error.rs suggestion.
* test: extract the scroll card and document drag-canvas data flow
AgentDeskFixture.swift sat at exactly 400 lines; the scroll card is
fully self-contained (its offset state never leaves the card), so it
moves to FixtureCards.swift as a standalone view with zero bindings,
landing the main file at 374. Harness-facing labels are byte-identical.
DragCanvas gains two intent comments distinguishing the deliberately
empty updateNSView from a forgotten sync.
* chore: pin ABI sizes for C consumers and explain the prune module split
C11-gated _Static_asserts mirror the Rust-side layout pins so a C
consumer compiling against a drifted header fails at build time, and
the production #[path] prune module carries its privacy rationale.
* test: give racing wait tests a deterministic budget
Three wait tests used a 1ms timeout that can elapse before the loop's
first resolution attempt under load, flaking on machine pressure (the
ambiguous-resolution test needs at least one attempt to record its
observation). 50ms guarantees the first attempt without slowing the
suite.
* docs: capture three round-4 review learnings and grow the concept map
Documents the abort-state contract for multi-step physical input (guard
disarm ordering, origin release, end-state suggestions), the three-layer
repr(C) size-pinning discipline born from the AdAction silent-growth
incident, and the named-arms-plus-exhaustiveness-guard pattern for
policy/dispatch mirrors. CONCEPTS.md gains Action Chain and Protected
Process and refreshes Coordinate Fallback with the window-topmost rule.
* docs: refresh six learnings against the enhanced-reliability branch
Brings the learning corpus back in line with code that moved this
branch: the gesture-capability and policy docs now describe the
window-level raise in the physical path and link the new abort-state
doc, the reliability contract documents the TIMEOUT kind discriminant
and the wait --action per-name policy variant, the FFI review rule
covers structural repr(C) size drift alongside behavioral parity, the
allocator doc records how the config struct absorbed four more fields
in one place, and the fingerprint doc names the real tri-state decode
error type. Three docs verified accurate with no edits.
* docs: sync roadmap with current reliability contracts
* fix: harden desktop action reliability
* fix: harden reliability review regressions
* fix: close final reliability review gaps
* fix: close reliability review gaps
* test: avoid raw pointer mutation in ffi free tests
* refactor: trim reliability branch dead code
* fix: close reliability review gaps
BREAKING CHANGE: the C ABI AdActionResult layout now includes action steps; C consumers must rebuild against the updated agent_desktop.h header.
* fix: close reliability review gaps
* ci: scope cache hash inputs
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
152 KiB
agent-desktop — Phase Roadmap
Public source of truth for shipped and planned platform work.
Release Tracker
Most recent shipments against this roadmap:
| Version | Date | What shipped |
|---|---|---|
| Unreleased | 2026-06 | Reliability hardening on the Phase 1 contracts: session-scoped latest snapshot pointers, explicit snapshot IDs usable across sessions, actionability checks, JSONL --trace, stale-ref diagnostics, and refstore symlink hardening |
| v0.1.14 | 2026-05 | Phase 1 unified core: typed batch/CLI path, CommandPolicy, PermissionReport, snapshot-scoped RefStore, headless ActionRequest, macOS screenshot backend boundary |
| v0.1.13 | 2026-04-17 | FFI cdylib on 5 platforms (aarch64/x86_64 macOS + Linux, x86_64 Windows MSVC), Sigstore build-provenance attestations, FFI review fixes (#26 — 50 commits) |
| v0.1.12 | 2026-03–04 | Progressive skeleton traversal + ref-rooted drill-down (#20) |
| v0.1.11 | 2026-02–03 | Skill-install prompt fix on all success paths |
| v0.1.9 | 2026-01–02 | Scalable skill architecture + ClawHub auto-publish (#14) |
| v0.1.8 | 2026-01 | --compact flag to collapse single-child unnamed nodes |
| v0.1.7 | 2025-12 | Electron / web app accessibility-tree compatibility |
- Phase 1 completion: incremental across v0.1.0 – v0.1.14 (macOS MVP, 54 commands, unified core engine).
- Current unreleased hardening extends Phase 1 contracts; it does not change the planned Windows/Linux adapter sequence.
- Phase 1.5 completion: v0.1.13 (FFI cdylib on 5 platforms).
- Phase 2: planned. Public scope is summarized in the Phase 2 section below.
- Phase 3+: planned. See each phase section below for the additive platform work and trait defaults that later phases backfill.
Phase Overview
| Phase | Name | Status | Platforms |
|---|---|---|---|
| 1 | Foundation + macOS MVP | Completed (v0.1.0 – v0.1.14) | macOS |
| 1.5 | FFI Distribution (C-ABI cdylib) | Completed (v0.1.13) | macOS, Windows, Linux |
| 2 | Windows Adapter | Planned | macOS, Windows |
| 3 | Linux Adapter | Planned | macOS, Windows, Linux |
| 4 | MCP Server Mode | Planned | All |
| 5 | Production Readiness | Planned | All |
Future platform phases are additive against the Phase 1 contracts: typed command args, CommandPolicy, PermissionReport, snapshot-scoped refs, session-scoped latest snapshot pointers, ActionRequest, JSONL reliability tracing, and the PlatformAdapter boundary. Core can still gain explicitly planned additive trait methods, but Windows/Linux should not fork command semantics or duplicate transport dispatch.
Command Surface Architecture (DRY invariant)
Every command in agent-desktop has one shared semantic path. CLI and batch both parse into the same typed Commands enum, run the same CommandPolicy preflight, and enter the same dispatch() match. Platform crates implement primitives through PlatformAdapter; they do not own command semantics.
Current shipped code uses explicit match arms, not a runtime command registry. Later sections that discuss descriptor/codegen work are planned future transport-generation work; they do not describe the current CLI/batch dispatch path.
Current Layering
| Layer | Scope | Invariant |
|---|---|---|
crates/core/src/commands/<name>.rs |
Platform-agnostic command behavior and args passed to &dyn PlatformAdapter |
One command implementation |
src/cli/ / src/cli_args/ |
Clap command enum and transport args | CLI shape only, no platform behavior |
src/command_policy/ |
Permissions, ref usage, side-effect classification | One policy source of truth for CLI, batch, and tests |
src/batch/ |
JSON batch parser and executor | Deserializes into Commands; no separate command interpretation |
src/dispatch/ |
Direct command match plus parse helpers | Shared CLI/batch execution path |
crates/{macos,windows,linux}/ |
Adapter method implementations | Same trait signatures across platforms |
crates/ffi/ |
C ABI wrappers around adapter/core types | ABI marshaling only |
Add a Command
- Add
crates/core/src/commands/{name}.rs. - Register it in
crates/core/src/commands/mod.rs. - Add the CLI args/variant in
src/cli_args/andsrc/cli/mod.rs. - Add a single arm in
src/dispatch/mod.rs. - Add a
CommandPolicyarm. - If needed, add one
PlatformAdaptermethod with anot_supported()default, then implement it per adapter.
Batch receives the command automatically once src/batch/mod.rs maps the JSON command name to that same CLI enum variant. There is no separate batch-only behavior.
Headless Contract
Ref actions use ActionRequest { action, policy }. The default InteractionPolicy forbids focus stealing and cursor movement. macOS is the reference adapter:
- Semantic AX steps run first.
- Physical fallbacks are explicit and policy-gated.
- Raw cursor commands (
hover,drag,mouse-*) require--headed; other commands must not silently focus apps or move the cursor. - Expected OS denials return specific error codes such as
PERM_DENIED,SNAPSHOT_NOT_FOUND, orPOLICY_DENIED, not genericINTERNAL.
Windows and Linux should implement the same signatures rather than copying macOS-specific fallback decisions.
Phase 1 — Foundation + macOS MVP
Status: Completed — shipped incrementally across v0.1.0 – v0.1.14.
Phase 1 is the load-bearing phase. It establishes the shared command path, trait boundaries, output contract, error types, permission model, ref lifecycle, and full workspace structure. All subsequent platform phases build on top of this foundation without duplicating command semantics.
Objectives
| ID | Objective | Success Metric |
|---|---|---|
| P1-O1 | Working macOS snapshot CLI | snapshot --app Finder returns valid JSON with refs for all interactive elements |
| P1-O2 | Platform adapter trait | Trait compiles with mock adapter; macOS adapter satisfies all trait methods |
| P1-O3 | Ref-based interaction | click @e3 successfully invokes AXPress on the resolved element |
| P1-O4 | Context efficiency | Typical Finder snapshot < 500 tokens (measured via tiktoken) |
| P1-O5 | Typed JSON contract | Output envelope carries version: "2.0". Partial: dedicated standalone JSON-Schema files were never delivered — deferred to later quality gates. |
| P1-O6 | Permission detection | Permission report covers Accessibility, Screen Recording, and Automation with recovery suggestions |
| P1-O7 | Command extensibility | Adding a new command follows the current shared path: commands/{name}.rs + commands/mod.rs + src/cli_args/ + src/cli/mod.rs + src/dispatch/mod.rs + src/command_policy/mod.rs |
| P1-O8 | 54 working commands | All commands pass integration tests |
| P1-O9 | CI pipeline | GitHub Actions macOS runner executes full test suite on every PR |
| P1-O10 | Progressive skeleton traversal | Skeleton + drill-down workflow achieves 78%+ token savings on Electron apps |
Workspace Structure
agent-desktop/
├── Cargo.toml # workspace: members, shared deps
├── rust-toolchain.toml # pinned Rust version
├── clippy.toml # project-wide lint config
├── LICENSE # Apache-2.0 (shipped in every release tarball)
├── crates/
│ ├── core/ # agent-desktop-core (platform-agnostic)
│ │ └── src/
│ │ ├── lib.rs # public re-exports only
│ │ ├── node.rs # AccessibilityNode, Rect, WindowInfo
│ │ ├── adapter.rs # PlatformAdapter trait
│ │ ├── action.rs # Action enum
│ │ ├── action_request.rs / action_result.rs / action_step*.rs
│ │ ├── actionability/ # Live actionability checks and reports
│ │ ├── refs.rs # RefMap and RefEntry
│ │ ├── refs_store.rs # Snapshot/session-scoped ref persistence
│ │ ├── refs_lock.rs # RefStore write lock
│ │ ├── ref_alloc.rs # INTERACTIVE_ROLES, allocate_refs, is_collapsible, transform_tree
│ │ ├── snapshot_ref.rs # Ref-rooted drill-down (run_from_ref)
│ │ ├── snapshot.rs # SnapshotEngine (filter, allocate, serialize)
│ │ ├── trace.rs # JSONL reliability trace support
│ │ ├── error.rs # ErrorCode enum, AdapterError, AppError
│ │ ├── notification.rs # NotificationInfo, NotificationFilter, NotificationIdentity
│ │ └── commands/ # one file per command (direct match, no Command trait)
│ ├── macos/ # agent-desktop-macos (Phase 1, shipped)
│ ├── windows/ # agent-desktop-windows (stub → Phase 2)
│ ├── linux/ # agent-desktop-linux (stub → Phase 3)
│ └── ffi/ # agent-desktop-ffi (cdylib, shipped v0.1.13; see Phase 1.5)
├── src/ # agent-desktop binary (entry point)
│ ├── main.rs
│ ├── batch/ # JSON batch -> typed Commands
│ ├── cli/ # Clap enum, help text, contract tests
│ ├── cli_args/ # Command argument structs by domain
│ ├── command_policy/ # Permission/ref/side-effect policy
│ ├── dispatch/ # Command dispatcher and parse helpers
│ └── tests/ # Binary-level conformance tests
└── tests/
├── fixtures/
└── integration/
PlatformAdapter Trait
The single most important abstraction. Every platform-specific operation goes through this trait. Core never imports platform crates. The canonical definition is crates/core/src/adapter.rs; this roadmap lists representative method groups only, because the trait grows additively as reliability and platform parity work lands.
pub trait PlatformAdapter: Send + Sync {
// Core observation
fn list_windows(&self, filter: &WindowFilter) -> Result<Vec<WindowInfo>, AdapterError>;
fn list_apps(&self) -> Result<Vec<AppInfo>, AdapterError>;
fn focused_window(&self) -> Result<Option<WindowInfo>, AdapterError>;
fn get_tree(&self, win: &WindowInfo, opts: &TreeOptions) -> Result<AccessibilityNode, AdapterError>;
fn get_subtree(&self, handle: &NativeHandle, opts: &TreeOptions) -> Result<AccessibilityNode, AdapterError>;
fn list_surfaces(&self, pid: i32) -> Result<Vec<SurfaceInfo>, AdapterError>;
// Interaction
fn execute_action(&self, handle: &NativeHandle, request: ActionRequest) -> Result<ActionResult, AdapterError>;
fn resolve_element_strict(&self, entry: &RefEntry) -> Result<NativeHandle, AdapterError>;
fn resolve_element_strict_with_timeout(&self, entry: &RefEntry, timeout: Duration) -> Result<NativeHandle, AdapterError>;
fn release_handle(&self, handle: &NativeHandle) -> Result<(), AdapterError>;
fn mouse_event(&self, event: MouseEvent) -> Result<(), AdapterError>;
fn drag(&self, params: DragParams) -> Result<(), AdapterError>;
fn key_event(&self, combo: &KeyCombo, down: bool) -> Result<(), AdapterError>;
fn press_key_for_app(&self, app_name: &str, combo: &KeyCombo) -> Result<ActionResult, AdapterError>;
// Lifecycle + windowing
fn permission_report(&self) -> PermissionReport;
fn request_permissions(&self) -> PermissionReport;
fn focus_window(&self, win: &WindowInfo) -> Result<(), AdapterError>;
fn focus_app(&self, pid: i32) -> Result<(), AdapterError>;
fn launch_app(&self, id: &str, timeout_ms: u64) -> Result<WindowInfo, AdapterError>;
fn close_app(&self, id: &str, force: bool) -> Result<(), AdapterError>;
fn is_protected_process(&self, identifier: &str) -> bool;
fn window_op(&self, win: &WindowInfo, op: WindowOp) -> Result<(), AdapterError>;
// Capture + clipboard
fn screenshot(&self, target: ScreenshotTarget) -> Result<ImageBuffer, AdapterError>;
fn get_clipboard(&self) -> Result<String, AdapterError>;
fn set_clipboard(&self, text: &str) -> Result<(), AdapterError>;
fn clear_clipboard(&self) -> Result<(), AdapterError>;
// Notifications (macOS shipped; Windows/Linux planned)
fn list_notifications(&self, filter: &NotificationFilter) -> Result<Vec<NotificationInfo>, AdapterError>;
fn dismiss_notification(&self, index: usize, app_filter: Option<&str>) -> Result<NotificationInfo, AdapterError>;
fn dismiss_all_notifications(&self, app_filter: Option<&str>) -> Result<(Vec<NotificationInfo>, Vec<String>), AdapterError>;
fn notification_action(&self, index: usize, identity: Option<&NotificationIdentity>, action_name: &str) -> Result<ActionResult, AdapterError>;
// Live evidence and wait probes
fn get_live_value(&self, handle: &NativeHandle) -> Result<Option<String>, AdapterError>;
fn get_live_state(&self, handle: &NativeHandle) -> Result<Option<ElementState>, AdapterError>;
fn get_live_actions(&self, handle: &NativeHandle) -> Result<Option<Vec<String>>, AdapterError>;
fn get_live_element(&self, handle: &NativeHandle) -> Result<LiveElement, AdapterError>;
fn get_element_bounds(&self, handle: &NativeHandle) -> Result<Option<Rect>, AdapterError>;
fn wait_for_menu(&self, pid: i32, open: bool, timeout_ms: u64) -> Result<(), AdapterError>;
}
Key Supporting Types
Action— closed core enum whose platform dispatch arms must stay exhaustive. Current variants: Click, DoubleClick, TripleClick, RightClick, SetValue(String), SetFocus, Expand, Collapse, Select(String), Toggle, Check, Uncheck, Scroll(Direction, Amount), ScrollTo, PressKey(KeyCombo), KeyDown(KeyCombo), KeyUp(KeyCombo), TypeText(String), Clear, Hover, Drag(DragParams)ActionRequest—{ action, policy }; default policy forbids focus stealing and cursor movementPermissionReport—{ accessibility, screen_recording, automation }, each{ "state": "granted" },{ "state": "denied", "suggestion": "..." },{ "state": "not_required" }, or{ "state": "unknown" }MouseEvent,DragParams,KeyCombo— dedicated types (not unified under anInputEventenum)WindowOp— Resize{w,h}, Move{x,y}, Minimize, Maximize, Restore, CloseScreenshotTarget— Screen(usize), Window(pid), FullScreenNotificationInfo— index, app_name, title, body, actions: VecNotificationIdentity— expected_app, expected_title (used for NC-reorder-safenotification_action)SurfaceInfo— kind, label, bounds (forlist-surfacescommand)TreeOptions— max_depth, include_bounds, interactive_only, compact, surface, skeleton (root is CLI-only viaSnapshotArgs.root_ref, not plumbed intoTreeOptions)
macOS Adapter Implementation
Located in crates/macos/src/ following the platform crate folder structure:
crates/macos/src/
├── lib.rs # mod declarations + re-exports only
├── adapter.rs # MacOSAdapter: PlatformAdapter impl
├── tree/
│ ├── mod.rs # re-exports
│ ├── ax_element.rs # AXElement ownership wrapper
│ ├── attributes.rs # Batched AX attribute reads
│ ├── capabilities.rs # AX-supported actions and settable attributes
│ ├── builder.rs # build_subtree, tree traversal
│ ├── node_attrs.rs # Node metadata extraction
│ ├── roles.rs # AXRole string -> unified role enum mapping
│ ├── resolve*.rs # Element re-identification for ref resolution
│ └── surfaces.rs # Surface detection (menu, sheet, alert, popover)
├── actions/
│ ├── mod.rs # re-exports
│ ├── dispatch.rs # perform_action match arms
│ ├── chain*.rs # policy-aware AX-first activation chain
│ ├── discovery.rs # Live capability discovery
│ ├── extras.rs # select_value helpers
│ ├── post_state.rs # Post-action state reads
│ ├── scroll.rs # scroll semantics and explicit physical policy paths
│ └── type_text.rs # focus-fallback text insertion and physical typing
├── input/
│ ├── mod.rs # re-exports
│ ├── keyboard.rs # CGEventCreateKeyboardEvent, key synthesis, text typing
│ ├── keyboard_map.rs # Key name mapping
│ ├── mouse.rs # CGEventCreateMouseEvent, mouse events
│ └── clipboard.rs # NSPasteboard.generalPasteboard read/write
├── notifications/
│ ├── mod.rs # re-exports
│ ├── list.rs # List notifications via Notification Center AX tree
│ ├── dismiss.rs # Dismiss individual or all notifications via AXPress
│ └── actions.rs # Click notification action buttons (identity-verified)
└── system/
├── mod.rs # re-exports
├── app_ops.rs # launch, close, focus via NSWorkspace
├── app_list.rs # Running app inventory
├── window_list.rs # Window inventory
├── window_ops.rs # window resize, move, minimize, maximize, restore
├── key_dispatch.rs # app-targeted key press
├── permissions.rs # PermissionReport probe/request
├── screenshot.rs # ScreenshotBackend + secure screencapture path
└── wait.rs # wait utilities
Tree traversal:
- Entry:
AXUIElementCreateApplication(pid)for app root - Children:
kAXChildrenAttributerecursively with ancestor-path set (not global visited set — macOS reuses AXUIElementRef pointers across sibling branches) - Batch fetch:
AXUIElementCopyMultipleAttributeValuesfor 3-5x faster attribute reads - Role mapping: AXRole strings → unified role enum in
tree/roles.rs - Max depth default: 10, configurable via
--max-depth - Name:
kAXTitleAttribute/kAXDescriptionAttribute. Value:kAXValueAttribute - Bounds:
kAXPositionAttribute+kAXSizeAttributecombined to Rect
Action execution:
- Ref actions take
ActionRequest, not bareAction - Default policy forbids focus stealing and cursor movement
- Click/right-click/scroll chains run semantic AX steps first and return structured errors instead of silently using physical/headed paths
- Type uses the focus-fallback policy floor; SetValue/Clear are the pure headless AX value-mutation paths
- SetValue/Clear:
AXUIElementSetAttributeValue(kAXValueAttribute, value) - SetFocus/Press/Hover/Drag/Mouse: explicit focus/cursor/physical commands
- Keyboard/Mouse:
CGEventCreateKeyboardEvent/CGEventCreateMouseEventvia CoreGraphics - Clipboard:
NSPasteboard.generalPasteboardread/write via Cocoa FFI - Screenshot:
ScreenshotBackendboundary with secure temporary files; Screen Recording denial maps toPERM_DENIED
Permission detection:
- Probe once per CLI process into
PermissionReport - Accessibility:
AXIsProcessTrusted()/AXIsProcessTrustedWithOptions(prompt: true) - Screen Recording: platform screen-capture preflight/request path
- Automation: currently
{ "state": "not_required" }because the shipped command set does not use Apple Events; future Apple Event paths must report a real granted/denied probe status,permissions, preflight, andbatchshare the same report;permissions --requestinvokes the request path
Notification management:
- Open Notification Center via AX: target the
NotificationCenterprocess (bundleId:com.apple.notificationcenterui) - List notifications: traverse the Notification Center AX tree — each notification is an
AXGroupwith title, subtitle, and action buttons - Dismiss: perform
AXPresson the notification's close button, orAXRemoveFromParentif supported - Interact: resolve action buttons within a notification group and perform
AXPress - Dismiss all:
AXPressthe "Clear All" button at the group level - Do Not Disturb detection: read Focus/DND state via
NSDoNotDisturbEnableduser defaults orCoreFoundationpreferences
System tray / Menu bar extras:
- Menu bar extras (status items) live under the
SystemUIServerprocess AX tree - Current support is through surface discovery/snapshotting (
menubar/menu) where the AX tree exposes those items - Dedicated
list-tray-items,click-tray-item, andopen-tray-menucommands are not shipped - Control Center items: accessible via the
ControlCenterprocess (bundleId:com.apple.controlcenter)
AXElement safety:
- Inner field:
pub(crate)notpub(prevents double-free via raw pointer extraction) Cloneimpl must callCFRetainDropimpl must callCFRelease
Snapshot Engine and Ref Allocator
Platform-agnostic, lives in agent-desktop-core:
- Raw tree: Call
adapter.get_tree(window, opts) - Filter: Remove invisible/offscreen. Remove empty groups with no interactive descendants. Prune beyond max_depth
- Allocate refs: Depth-first. Interactive roles get
@e1,@e2, etc. Store in RefMap - Serialize: Omit null fields. Omit empty arrays. Omit bounds in compact mode
- Estimate tokens: Optionally warn if exceeding threshold
Snapshot refs persist through RefStore. The default namespace stores snapshots under ~/.agent-desktop/snapshots/{snapshot_id}/refmap.json; --session <id> stores the same shape under ~/.agent-desktop/sessions/{id}/snapshots/{snapshot_id}/refmap.json. Each namespace owns one latest_snapshot_id pointer for commands that omit --snapshot. Explicit --snapshot <id> is a direct snapshot handle and can be used without repeating --session; if the same snapshot ID appears in multiple sessions, callers pass the matching session to disambiguate. ~/.agent-desktop/last_refmap.json remains only as a latest-snapshot inspection artifact. Action commands resolve through RefStore and use ResolvedElement RAII so native handles are released after ref-consuming commands. Return STALE_REF on live re-identification mismatch and SNAPSHOT_NOT_FOUND when the requested snapshot does not exist.
Progressive Skeleton Traversal:
--skeletonflag clamps depth tomin(max_depth, 3), annotates truncated containers withchildren_countfor agent discovery--root <REF>flag starts traversal from a previously-discovered ref instead of window root;--snapshot <snapshot_id>selects the ref namespace- Named or described containers at skeleton boundary receive refs as drill-down targets (with empty
available_actions) - Scoped invalidation: re-drilling a ref replaces only that ref's subtree refs, preserving all others
- Core modules:
ref_alloc.rs(canonicalallocate_refs+RefAllocConfig),snapshot_ref.rs(drill-down flow that delegates allocation toref_alloc) - macOS:
count_children()uses rawCFArrayGetCountwithout materializingAXElementwrappers for performance - RefMap write-side size check prevents >1MB files
- Token savings: 78-96% reduction for dense Electron apps (Slack skeleton: ~3.6KB vs ~17.3KB full)
New Commands — Notification & System Tray (Post Phase 1)
Note: Notification management and system tray interaction were not part of the original Phase 1 delivery. These are new features to be implemented across all platforms as each platform adapter is built. The macOS implementations were added as a follow-up to Phase 1. Windows (Phase 2) and Linux (Phase 3) implementations follow the same pattern.
Notification Commands (macOS — Completed)
| Command | Description | Flags | Status |
|---|---|---|---|
list-notifications |
List current notifications with app, title, body, and available actions | --app (filter by app), --text (filter by text), --limit (max results) |
Completed |
dismiss-notification |
Dismiss a specific notification by 1-based index | <index>, --app (filter by app) |
Completed |
dismiss-all-notifications |
Clear all notifications, optionally filtered by app (single NC session, reports failures) | --app (filter by app) |
Completed |
notification-action |
Click an action button on a specific notification | <index> <action-name> |
Completed |
System Tray / Status Area Commands (New — Not Yet Implemented)
| Command | Description | Flags |
|---|---|---|
list-tray-items |
List all system tray / menu bar extra items with app name and tooltip | — |
click-tray-item |
Click a system tray item by ID or app name | <tray-item-id> |
open-tray-menu |
Click a tray item and snapshot its resulting menu for ref-based interaction | <tray-item-id> |
Wait Command Update (Notification — Completed, Menu — Completed)
The wait command has been extended with notification and menu support:
wait --notification— Wait for any new notification to appear (index-diff based detection)wait --notification --app Safari— Wait for a notification from a specific appwait --notification --text "Download complete"— Wait for a notification containing specific textwait --menu/wait --menu-closed— Wait for context menu open/close
Commands Shipped (54)
| Category | Commands | Count |
|---|---|---|
| App / Window | launch, close-app, list-windows, list-apps, focus-window, resize-window, move-window, minimize, maximize, restore |
10 |
| Observation | snapshot, screenshot, find, get (text, value, title, bounds, role, states, tree-stats), is (visible, enabled, checked, focused, expanded), list-surfaces |
6 |
| Interaction | click, double-click, triple-click, right-click, type, set-value, clear, focus, select, toggle, check, uncheck, expand, collapse |
14 |
| Scroll | scroll, scroll-to |
2 |
| Keyboard | press, key-down, key-up |
3 |
| Mouse | hover, drag, mouse-move, mouse-click, mouse-down, mouse-up |
6 |
| Clipboard | clipboard-get, clipboard-set, clipboard-clear |
3 |
| Notification (macOS) | list-notifications, dismiss-notification, dismiss-all-notifications, notification-action |
4 |
| Wait | wait (with --element, --window, --text, --menu, --notification flags) |
1 |
| System | status, permissions, version, skills |
4 |
| Batch | batch |
1 |
System Tray / Menu Bar Extras commands are listed under "Not Yet Implemented" above — they never shipped in Phase 1.
JSON Output Contract
All commands produce a response envelope with version: "2.0". Standalone schema files are still deferred; the current contract is enforced by Rust serde types, CLI conformance tests, and documented examples.
Success:
{
"version": "2.0",
"ok": true,
"command": "snapshot",
"data": {
"app": "Finder",
"window": { "id": "w-4521", "title": "Documents" },
"ref_count": 14,
"tree": { ... }
}
}
Error:
{
"version": "2.0",
"ok": false,
"command": "click",
"error": {
"code": "STALE_REF",
"message": "Element could not be resolved from the requested snapshot",
"suggestion": "Run 'snapshot' to refresh, then retry with updated ref"
}
}
Serialization rules: omit null/None fields (skip_serializing_if), omit empty arrays, omit bounds in compact mode, ref_count and tree inside data.
Error Taxonomy
The ErrorCode enum in crates/core/src/error.rs exposes these machine-readable variants:
| Code | Category | Example | Recovery Suggestion |
|---|---|---|---|
PERM_DENIED |
Permission | Accessibility not granted | Open System Settings > Privacy > Accessibility and add the app that launches agent-desktop |
ELEMENT_NOT_FOUND |
Ref | @e12 could not be resolved | Run 'snapshot' to refresh, then retry with updated ref |
APP_NOT_FOUND |
Application | --app 'Photoshop' not running | Launch the application first |
ACTION_FAILED |
Execution | AXPress returned error on disabled button | Element may be disabled. Check states before acting |
ACTION_NOT_SUPPORTED |
Execution | Expand on a button | This element does not support the requested action |
STALE_REF |
Ref | Element could not be re-identified from the requested snapshot | Run 'snapshot' (or snapshot --skeleton) to refresh |
AMBIGUOUS_TARGET |
Ref | Ref identity maps to more than one live candidate | Run 'snapshot' to refresh, then retry with a more specific ref |
WINDOW_NOT_FOUND |
Window | --window w-999 does not exist | Run 'list-windows' to see available windows |
PLATFORM_NOT_SUPPORTED |
Platform | Windows/Linux adapter not yet shipped | This platform ships in Phase 2/3 |
TIMEOUT |
Wait / Traversal | wait --element exceeded timeout | Increase --timeout or check app state |
INVALID_ARGS |
Input | Bad CLI argument or unknown ref format | Fix the argument per CLI help |
NOTIFICATION_NOT_FOUND |
Notification | Notification ID not found / NC reordered | Run 'list-notifications' to see current notifications |
SNAPSHOT_NOT_FOUND |
Ref | Requested snapshot ID is missing | Run 'snapshot' again and use the returned snapshot_id |
POLICY_DENIED |
Action policy | Physical input blocked by headless policy | Retry with --headed for explicit cursor movement, or use a semantic AX action when available |
INTERNAL |
Internal | Unexpected error or caught panic | Re-run with verbose logging |
Exit codes: 0 success, 1 structured error (JSON on stdout), 2 argument/parse error.
Codes the earlier draft listed but that do not exist in the codebase:
TREE_TIMEOUT(useTIMEOUT),CLIPBOARD_EMPTY(no special code; empty clipboard returns empty string),NOTIFICATION_UNSUPPORTED(usePLATFORM_NOT_SUPPORTED),TRAY_NOT_FOUND/TRAY_UNSUPPORTED(tray commands never shipped). Deferred-work additions (see Gap Analysis at bottom):PERMISSION_REVOKED,RESOURCE_EXHAUSTED,AX_MESSAGING_TIMEOUT,AUTOMATION_PERMISSION_DENIED.
Testing
Unit tests (core):
- AccessibilityNode ser/de roundtrips
- Ref allocator only assigns interactive roles
- SnapshotEngine filtering
- Error serialization
- JSON contract / output conformance coverage
- MockAdapter: in-memory PlatformAdapter returning hardcoded trees
Unit tests (macos):
- Role mapping coverage
- Permission check with mocks
- Tree traversal cycle detection
Integration tests (macOS CI):
- Snapshot Finder, TextEdit, System Settings — non-empty trees with refs
- Click button in test app — verify action succeeded
- Type text into TextEdit via ref — verify content changed
- Clipboard get/set roundtrip
- Wait for window
- Launch + close app lifecycle
- Permission denied scenario — correct error code and guidance
- Large tree (Xcode) snapshot in under 2 seconds
- List notifications — returns non-empty list when Notification Center has entries
- Dismiss notification — verify notification removed from Notification Center AX tree
- Menu/menu-bar surface snapshot and wait behavior where the host exposes AX menu surfaces
Golden fixtures (tests/fixtures/):
- Real snapshots from Finder, TextEdit, etc. checked into repo
- Regression-test serialization format changes
CI
Current .github/workflows/ci.yml on every PR:
fmtjob onubuntu-latest:cargo fmt --all -- --checktestjob onmacos-latest:cargo tree -p agent-desktop-coremust contain zero platform crate names (dependency isolation)cargo clippy --all-targets -- -D warningscargo test --lib --workspacecargo test -p agent-desktop-ffi --tests(c_abi_harness + c_header_compile + error_lifetime integration suites)cargo build --profile ci(fast CLI binary) + 15 MB size checkcargo build --profile release-ffi -p agent-desktop-ffi(the shipped cdylib profile)- FFI header contract check — compiles
crates/ffi/include/agent_desktop.hfrom C tests and keeps header regeneration out of the default build graph
Dependencies
| Crate | Version | Purpose |
|---|---|---|
clap |
4.6 | CLI parsing with derive macros |
serde + serde_json |
1.x | JSON serialization |
thiserror |
2.0 | Error derive macros |
tracing |
0.1+ | Structured logging |
tracing-subscriber |
0.3 | env-filter log formatter |
rustc-hash |
2.1 | Faster hashing for ref maps and visited sets |
smallvec |
1.13 | Small fixed-size vectors in hot paths |
base64 |
0.22+ | Screenshot encoding |
accessibility-sys |
0.2.0 | macOS AXUIElement FFI |
core-foundation |
0.10.1 | macOS CF types |
core-foundation-sys |
0.8.7 | macOS CF FFI |
core-graphics |
0.25.0 | macOS CG types |
Documentation Delivered
- README with installation (npm + source), core workflow, command reference, JSON output, ref system, platform support table
- Architecture diagram
- Agent skills:
skills/agent-desktop/(core + macOS references) andskills/agent-desktop-ffi/
Phase 1.5 — FFI Distribution (C-ABI cdylib)
Status: Completed — v0.1.13 (2026-04-17).
Phase 1.5 ships crates/ffi/ as a first-class distribution target. The CLI stays the primary surface; the cdylib lets Python (ctypes), Swift, Node (ffi-napi), Go (cgo), Ruby (fiddle), and C consumers call PlatformAdapter directly without spawning agent-desktop per call.
Objectives
| ID | Objective | Metric |
|---|---|---|
| P1.5-O1 | Stable C-ABI surface | crates/ffi/include/agent_desktop.h compiled in CI as the committed ABI contract |
| P1.5-O2 | 5-platform release | Tarballs for aarch64/x86_64 apple-darwin, aarch64/x86_64 unknown-linux-gnu, and x86_64 pc-windows-msvc on every tagged release |
| P1.5-O3 | Panic safety | Dedicated release-ffi profile overrides panic = "abort" → "unwind"; catch_unwind wraps every extern "C" boundary via trap_panic / trap_panic_ptr / trap_panic_const_ptr / trap_panic_void |
| P1.5-O4 | Main-thread safety (macOS) | require_main_thread() guard in every build profile; worker-thread call returns AD_RESULT_ERR_INTERNAL with a static 'static CStr message |
| P1.5-O5 | Enum UB immunity | Public ABI struct fields store raw i32; every entry validates discriminants at the boundary via try_from_c_enum! |
| P1.5-O6 | Out-param zeroing before any guard | Every fallible entry zeroes *out before pointer / UTF-8 / main-thread checks, so a worker-thread early return never leaves a stale caller buffer |
| P1.5-O7 | Sigstore build-provenance | actions/attest-build-provenance@v4.1.0 signs every release artifact; consumers verify with gh attestation verify <file> --repo <owner>/agent-desktop |
| P1.5-O8 | Skill documentation | skills/agent-desktop-ffi/SKILL.md + references: build-and-link.md, ownership.md, threading.md, error-handling.md |
| P1.5-O9 | README surface | "Language bindings (FFI)" section on the project README with platform→artifact table, Python dlopen snippet, and Sigstore verify one-liner |
Crate Layout
crates/ffi/
├── Cargo.toml # crate-type = ["cdylib", "rlib"]
├── cbindgen.toml # maintainer-only header regeneration config
├── build.rs # bakes install_name = @rpath/libagent_desktop_ffi.dylib on macOS
├── include/
│ └── agent_desktop.h # committed, drift-checked against the OUT_DIR output
├── src/ # ad_* extern "C" entrypoints, organized by domain
│ ├── types/ # 34 one-type-per-file modules (AdAction, AdRect, AdWindowList, ...)
│ ├── convert/ # string / rect / window / app / surface / notification helpers
│ ├── tree/ # BFS flat-tree layout (flatten.rs, get.rs, free.rs)
│ ├── actions/ # conversion, resolve, execute, result, native_handle
│ ├── apps/ windows/ input/ screenshot/ surfaces/ notifications/ observation/
│ ├── error.rs # AdResult, errno-style TLS last-error (message/suggestion/platform_detail)
│ ├── ffi_try.rs # panic boundary helpers (trap_panic_*)
│ ├── enum_validation.rs # try_from_c_enum! macro, fuzz tests
│ └── main_thread.rs # require_main_thread() guard
├── tests/
│ ├── c_abi_harness.rs # raw extern "C" decls, enum fuzzing, out-param zeroing, null tolerance
│ ├── c_header_compile.rs # shells out to `cc` to verify every AD_* constant is usable from C
│ └── error_lifetime.rs # last-error pointer stability across successful follow-up calls
└── examples/
└── panic_spike.rs # demonstrates panic boundary on the release-ffi profile
Release Artifacts
Shipped via .github/workflows/release.yml build-ffi matrix job:
| Target | Runner | Archive | Library |
|---|---|---|---|
| aarch64-apple-darwin | macos-latest | .tar.gz |
libagent_desktop_ffi.dylib |
| x86_64-apple-darwin | macos-latest | .tar.gz |
libagent_desktop_ffi.dylib |
| x86_64-unknown-linux-gnu | ubuntu-22.04 | .tar.gz |
libagent_desktop_ffi.so |
| aarch64-unknown-linux-gnu | ubuntu-22.04-arm | .tar.gz |
libagent_desktop_ffi.so |
| x86_64-pc-windows-msvc | windows-latest | .zip |
agent_desktop_ffi.dll |
Each archive contains lib/, include/agent_desktop.h, LICENSE, and a short README.md. macOS tarballs have their install_name verified @rpath/libagent_desktop_ffi.dylib via otool -D before upload. Linux binaries use ubuntu-22.04 (glibc 2.35) as the baseline for maximum distro coverage.
Build Profile
[profile.release-ffi]
inherits = "release"
panic = "unwind" # allow catch_unwind at the extern "C" boundary
Regular release profile keeps panic = "abort" for the CLI binary, so a panic there aborts the process rather than cascading through the FFI layer.
CI Hooks Added
cargo build --profile release-ffi -p agent-desktop-ffion every PRcargo test -p agent-desktop-ffi --testsruns the 3 integration suites- FFI header contract check compiles the committed header from C tests. Header regeneration is an explicit maintainer action via
scripts/update-ffi-header.sh, not part of ordinary builds.
New Dependencies
| Crate | Version | Scope | Purpose |
|---|---|---|---|
cbindgen |
maintainer-installed tool, denied in Cargo graph | scripts/update-ffi-header.sh only |
C header regeneration |
libc |
0.2+ | crates/ffi macOS target |
pthread_main_np for main-thread check |
Forward Compatibility
- Pre-1.0 the ABI is explicitly unstable; consumers pin the artifact version alongside the cdylib version.
- Any new
PlatformAdaptermethod that lands in Phase 2/3 must add a matchingad_*FFI wrapper in the same PR that adds the adapter method. - MCP server mode (Phase 4) is a parallel transport, not an FFI consumer — it calls
PlatformAdapterdirectly.
Known Gaps (surfaced by 2026-04-17 research)
ad_abi_version()export is still missing (consumers have no runtime compat check)- CLI-flagship primitives (
snapshotwith refs + refmap,batch,wait,version,status) are not wired through FFI — consumers today cannot replay theclick @e5idiom without shelling out to the CLI - No
tracing::log callback — in-process consumers lose debug output - No
pyo3/maturinwheel orcffiwrapper ships with the repo
These items are tracked under P2-O16 below: registry migration via build.rs filesystem enumeration, ad_set_log_callback with redaction, and ad_execute_by_ref + descriptor confirms.
Phase 2 — Windows Adapter + Cross-Platform Feature Parity
Status: Planned — this section is the public objective catalogue and implementation contract.
Core invariants (research-driven — from Phase 2 plan §Headless-First Invariant)
- Headless-first inside the active desktop session. Every command — existing and Phase 2 — must run without an agent-desktop GUI, foreground activation, focus steal, or physical cursor movement unless
--headedexplicitly opts into cursor input. Windows, macOS, and Linux still require the target app to exist in the current user's interactive desktop/display session for accessibility and capture APIs. Session 0, Server Core, secure desktops, locked desktops, and other-user sessions returnPLATFORM_NOT_SUPPORTED,PERM_DENIED, orWINDOW_NOT_FOUNDwithplatform_detail, not silent best effort. The invariant is enforced by integration tests: target window is NOT focused at test entry;list-windows --focused-onlyreturns the same window before/after; cursor position unchanged for headless commands. - Skeleton traversal is platform-agnostic. The novel progressive skeleton pattern (depth-3 clamp +
children_countannotation + drill-down via--root @ref+ scoped invalidation viaRefMap::remove_by_root_ref) lives entirely incrates/core/src/snapshot_ref.rs. Windows adapter contributes ~50 LOC glue:ControlViewWalker(NOTRawViewWalkerorContentViewWalker) +FindAll(TreeScope_Children, TrueCondition)forchildren_count+ freshUICacheRequestper drill-down. - Asymmetric event threading.
watch_elementuses main-threadAXObserveron macOS (research-confirmed: Apple DTS says all AX is main-thread-only; AXSwift / Hammerspoon / Phoenix all do this); worker-thread MTAIUIAutomationevent handler on Windows (Microsoft 2025 threading doc: UIA supports cross-thread event delivery). - No
inventory/linkmecommand registry. Research confirmed neither survives link-GC reliably across ld64, ld-prime, GNU ld, lld, MSVC for cdylib consumers. Phase 2 usesbuild.rsfilesystem enumeration ofcrates/core/src/commands/*.rs— deterministic, cdylib-safe, zero linker magic. The repository's "one command per file" rule becomes the codegen contract. - FFI compatibility gates. v0.1.14 adds explicit FFI result codes for snapshot-not-found and policy-denied paths. Phase 2 still owns
ad_abi_version(),ad_init(expected_major), and any broader ABI-version handshake before new cross-platform ABI surface ships. DeliverFilesreplacesFileDrop. Headless-first forbidsNSDraggingSessionon macOS; the new action uses a 4-tier fallback (URL scheme →NSWorkspace.openwithactivates: false→ pasteboard +Cmd-V→ AppleScript). Windows primary delivery is app/shell delivery (ShellExecuteEx, app URI handlers,IFileOperationfor filesystem destinations, andCF_HDROPclipboard paste where accepted).IDataObject + DoDragDropis an explicit policy-gated fallback/spike for targets that require drag semantics; it is never the default headless path.
Windows Engineering Invariants (from Phase 2 plan Unit 3)
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)at startup.CoInitializeEx(NULL, COINIT_MULTITHREADED)on main thread and on every dedicated UIA worker thread (UIA prefers MTA).- Never cache
IUIAutomationElementacross apartments. Event handlers are created, registered, removed, and drained on the same dedicated MTA thread; worker code re-resolves fromRefEntryinstead of moving elements across apartments. - UIA-first, SendInput-fallback (UIA patterns are focus-independent;
SendInputis focus-dependent + UIPI-blocked for elevated targets). PostMessage WM_KEYDOWNis DEAD for Chromium/UWP/games — not a viable alternative.- UIPI elevation detection via
GetTokenInformation(TokenIntegrityLevel). ShipuiAccess=trueas optional signed release, not default. RemoveAutomationEventHandlerwith post-remove-barrier pattern (Arc outlives final callback dispatch).- HRESULT format in
platform_detail:COM HRESULT 0x80070005 (E_ACCESSDENIED: Access is denied). PrintWindow(hwnd, hdc, PW_RENDERFULLCONTENT)for legacy screenshot (mitigates DWM black frames).windows-capture(modern) handles composition correctly.ElementFromHandle(hwnd)is headless-safe for same-user, same-session visible/minimized windows at an accessible integrity level — the foundation of observation headlessness.Windows.Graphics.Capturerequires DWM (Windows 10 1903+) in an active interactive session; returnsPlatformNotSupportedin Session 0, Server Core, secure desktop, or locked/remote sessions where capture is unavailable.- Session isolation: cannot drive windows in other user sessions.
SetForegroundWindow/SetWindowPos(HWND_TOP)is allowed only for explicit focus/window commands whoseInteractionPolicypermits focus steal. It is never a fallback for semantic ref actions.
Phase 2 brings agent-desktop to Windows. It is also the phase that closes the cross-platform feature-parity gaps surfaced after the v0.1.13 FFI ship — shipping Windows meaningfully requires new core abstractions (stable identifiers, event subscriptions, text-range primitives, shell surfaces, and Windows-specific tray/taskbar affordances) that Windows UIA exposes natively and the macOS adapter currently does not surface. Every new trait method added here is implemented on both platforms in the same PR pair when there is a real cross-platform analogue. True Windows shell concepts return PLATFORM_NOT_SUPPORTED on other adapters through the same core command path, never through side-channel code. Linux (Phase 3) mirrors the portable parts against AT-SPI2.
Core engine, CLI parser, JSON contract invariants, and command-registration pattern are preserved. What Phase 2 legitimately changes: AccessibilityNode field set, Action enum variants, ErrorCode variants, PlatformAdapter trait size. Every new Action variant must update core actionability, capability maps, platform dispatch, CLI/FFI conversion, and contract tests in the same change; exhaustive compiler checks are the guard against adapter drift. Every macOS backfill lands atomically with the Windows implementation so the two platforms never drift.
Per the Command Surface Architecture invariant, every new command added in Phase 2 (watch, text select-range, text get-selection, text insert-at-caret, etc.) lives in exactly one file under crates/core/src/commands/ and is wired through the shared typed command path. If Phase 2 adds codegen, it uses deterministic build.rs filesystem enumeration, not linker registries. The per-platform work is the three PlatformAdapter method implementations (one each in crates/macos/, crates/windows/, crates/linux/) — nothing repeats across transports.
P2-O16 (FFI parity expansion) also migrates the FFI wrappers from hand-written to codegen: a build.rs step in crates/ffi/ walks the registry and emits one ad_<name> extern "C" function per CommandDescriptor, using the per-type marshaling helpers in crates/ffi/src/convert/. After this migration, the FFI crate holds marshaling primitives, not command wrappers. The crates/mcp/ crate follows the same walk-the-registry pattern with rmcp's #[tool] shape — so Phase 4 can ship its MCP server without hand-maintaining the tool list.
Objectives
Core + Windows parity (original scope):
| ID | Objective | Metric |
|---|---|---|
| P2-O1 | Windows adapter | snapshot on Windows returns valid tree for Explorer, Notepad, Settings |
| P2-O2 | All existing commands cross-platform | Identical JSON contract output on macOS and Windows for every command |
| P2-O3 | Windows input synthesis | click, type, press, all mouse commands working via UIA + SendInput |
| P2-O4 | Windows screenshot | screenshot produces PNG via Windows.Graphics.Capture API |
| P2-O5 | Windows clipboard | clipboard-get / clipboard-set / clipboard-clear working via Win32 Clipboard API |
| P2-O6 | Windows CI | GitHub Actions Windows runner executes build, clippy, unit, contract, and non-interactive tests on every PR. UIA/shell integration tests that require Explorer, Start, Action Center, or an unlocked desktop run on a labeled interactive/self-hosted Windows job or are skipped with explicit PLATFORM_NOT_SUPPORTED assertions |
| P2-O7 | Windows binary release | Prebuilt .exe published via GitHub Releases and npm; Phase 1.5 FFI cdylib for Windows already ships |
Cross-platform core extensions (new, landed alongside Windows):
| ID | Objective | Metric |
|---|---|---|
| P2-O8 | AccessibilityNode stable-selector fields |
Nodes may carry identifier, subrole, role_description, placeholder, dom_id, dom_classes (all Option<String> / Vec<String> with skip_serializing_if). Populated where the platform/app exposes stable selectors: Windows UIA AutomationId / LocalizedControlType / HelpText; macOS kAXIdentifierAttribute / kAXSubroleAttribute / kAXRoleDescriptionAttribute / kAXPlaceholderValueAttribute / kAXDOMIdentifierAttribute / kAXDOMClassListAttribute. Resolver prefers stable selectors when present and falls back to the existing fingerprint; tests require known controls with explicit IDs to preserve them across re-drills, not every real-app node |
| P2-O9 | Action enum expansion for 2026 agent workloads |
New variants: LongPress { duration_ms }, ForceClick, ShowMenu, DeliverFiles(Vec<PathBuf>) (renamed from FileDrop — the original name implied NSDraggingSession which is not headless-compatible on macOS; see Phase 2 plan §Headless-First Invariant and Unit 12), WindowRaise, Cancel, SelectRange { start, len }, InsertAtCaret(String). watch_element is an adapter method, not an Action variant (plan §KD8 + origin brainstorm §D8). Each has a macOS AX API mapping (all AX calls on main thread per plan §KD9), a Windows UIA pattern mapping, a new CLI subcommand, FFI conversion coverage where applicable, and exhaustive platform-dispatch tests in the same change. |
| P2-O10 | ErrorCode expansion |
Add PermissionRevoked (distinct from PermDenied — TCC yanked mid-session), ResourceExhausted (refmap >1 MB, tree node-count cap), AxMessagingTimeout (AX-specific timeout separate from orchestration Timeout), AutomationPermissionDenied (macOS osascript grant). Tri-state permission probe at startup distinguishes "never granted" from "revoked" |
| P2-O11 | Event-subscription primitive (push, not poll) | New trait method watch_element(handle, events: &[EventKind], timeout_ms: u64) -> Result<Vec<ElementEvent>>. macOS: AXObserverCreate + AXObserverAddNotification + CFRunLoopSource (no more polling in system/wait.rs). Windows: IUIAutomation.AddAutomationEventHandler + AddFocusChangedEventHandler + AddPropertyChangedEventHandler. New wait --event value-changed --ref @e5 --timeout 3000 CLI flag. Linux mirrors in Phase 3 via AT-SPI2 D-Bus signals |
| P2-O12 | Text range primitives | Read caret, read selection, select a range by offsets, read text at range, insert at caret. macOS: kAXSelectedTextRangeAttribute (settable), AXStringForRangeParameterizedAttribute, AXBoundsForRangeParameterizedAttribute, AXRangeForLineParameterizedAttribute, AXValueCreate(kAXValueCFRangeType, …). Windows: TextPattern.GetSelection, TextPattern.DocumentRange, TextRange.Select, TextRange.Move, TextRange.GetText, TextRange.GetBoundingRectangles. Commands: text get-selection, text select-range <ref> <start> <len>, text insert-at-caret <ref> <string>, text at-offset <ref> <start> <len> |
| P2-O13 | Modern per-window screenshot APIs | macOS: replace /usr/sbin/screencapture subprocess with SCScreenshotManager.captureImage(contentFilter:config:) filtered to a specific CGWindowID from SCShareableContent.windows. Windows: Windows.Graphics.Capture via GraphicsCaptureItem.CreateFromWindowHandle(HWND) + Direct3D11CaptureFramePool when supported by the OS/session. No subprocess on the modern path, explicit fallback to legacy capture when unavailable, and permission/support failures map to structured PERM_DENIED / PLATFORM_NOT_SUPPORTED with platform_detail |
| P2-O14 | Toolbar and missing surfaces | Both platforms add SnapshotSurface::Toolbar. macOS additionally adds Spotlight (pid of /System/Library/CoreServices/Spotlight.app), Dock (pid of /System/Library/CoreServices/Dock.app), and MenuBarExtras (enumerates SystemUIServer, ControlCenter, and per-app AXExtrasMenuBar). Windows adds structured shell surfaces for Taskbar, SystemTray, SystemTrayOverflow, StartMenu, ActionCenter, and QuickSettings where the current Windows build/session exposes them |
| P2-O15 | Electron / WebView2 deep-tree toggles | macOS: build_subtree writes AXEnhancedUserInterface = YES on app root for known Electron bundle IDs (VS Code, Cursor, Slack post-Sept-2024, Teams, Discord, Figma Desktop, Notion). Windows: detect Edge WebView2 via UIA ClassName = "Chrome_WidgetWin_1" and the equivalent flag; apply same web-wrapper depth-skip. Both: new --force-electron-a11y CLI override |
| P2-O16 | FFI registry migration + parity expansion | Migrate crates/ffi/ from hand-written ad_* wrappers to a build.rs codegen step that walks the compile-time CommandDescriptor registry and emits one wrapper per command. After this, adding a CLI command automatically produces the FFI entry and the same descriptor metadata can feed JSON Schema / MCP generation in Phase 4. Marshaling helpers stay in crates/ffi/src/convert/ — these are per-type, not per-command. In the same migration: backfill ad_snapshot (full refmap pipeline), ad_execute_by_ref(adapter, "@e5", action, out), ad_wait(…), ad_version, ad_abi_version() -> u32 with AD_ABI_VERSION_MAJOR cbindgen [defines] export, ad_status, ad_set_log_callback(fn(level, msg)) installing a tracing_subscriber layer so dlopen consumers see debug output |
| P2-O17 | Screen Recording / Automation permission detection | macOS Phase 1 already exposes PermissionReport { accessibility, screen_recording, automation }. Phase 2 decides whether a distinct AutomationPermissionDenied code is still needed once Apple Event automation paths exist |
| P2-O18 | Windows shell surface coverage | Add explicit shell coverage for Start menu/search, taskbar, system tray/overflow, Action Center/notification center, Quick Settings, multi-monitor/DPI, virtual desktop detection, UAC/elevated targets, RDP/locked-session behavior, and Explorer-specific file destinations. New commands are added only where a ref-based snapshot --surface … loop cannot expose the surface first; Windows-only behavior still routes through core command files and adapter trait defaults |
Cross-Platform Trait Extensions
All methods land as #[non_exhaustive] additions in crates/core/src/adapter.rs with default implementations returning AdapterError::not_supported(method). Windows implements them natively. macOS backfills in the same PR pair. Linux (Phase 3) adds the AT-SPI2 implementations.
impl PlatformAdapter for … {
// P2-O11 — event subscription
fn watch_element(
&self,
handle: &NativeHandle,
events: &[EventKind],
timeout: Duration,
) -> Result<Vec<ElementEvent>, AdapterError> { /* default: not_supported */ }
// P2-O12 — text ranges
fn get_text_selection(&self, handle: &NativeHandle) -> Result<TextSelection, AdapterError>;
fn set_text_selection(&self, handle: &NativeHandle, range: TextRange) -> Result<(), AdapterError>;
fn get_text_at(&self, handle: &NativeHandle, range: TextRange) -> Result<String, AdapterError>;
fn insert_text_at_caret(&self, handle: &NativeHandle, text: &str) -> Result<(), AdapterError>;
// P2-O13 — modern screenshot
// (screenshot() gains a new `ScreenshotBackend::Modern` variant; platforms pick the
// native modern API; a `Legacy` fallback preserves the Phase 1 subprocess path.)
// P2-O14 — new surfaces
fn list_surfaces(&self, pid: i32) -> Result<Vec<SurfaceInfo>, AdapterError> // extended kinds
}
New supporting types (land in crates/core/src/):
EventKind—FocusChanged,ValueChanged,SelectionChanged,ChildrenChanged,WindowOpened,WindowClosed,MenuOpened,MenuClosed,NotificationPosted,ElementDestroyedElementEvent—{ kind, handle_ref_id: Option<String>, timestamp, attr_snapshot: Option<AccessibilityNode> }TextRange—{ start: u32, length: u32 }(UTF-16 code units to match both AX CFRange and UIA TextRange conventions)TextSelection—{ range: TextRange, caret_offset: u32, lines_in_view: Vec<TextRange> }ScreenshotBackend—Modern(ScreenCaptureKit / Windows.Graphics.Capture / PipeWire) orLegacy(preserves Phase 1 subprocess path as fallback for restricted environments)PermissionReportis{ accessibility, screen_recording, automation }where each field is{ "state": "granted" },{ "state": "denied", "suggestion": "..." },{ "state": "not_required" }, or{ "state": "unknown" }
Cross-platform capability map (P2-O8 through O17)
| Capability | macOS API | Windows API | Linux API (Phase 3) |
|---|---|---|---|
Stable identifier |
kAXIdentifierAttribute |
UIA AutomationId |
AT-SPI2 accessible-id + GTK gtk-id |
subrole |
kAXSubroleAttribute |
UIA LocalizedControlType + pattern-based heuristic |
AT-SPI2 role-name + state-set |
role_description |
kAXRoleDescriptionAttribute |
UIA LocalizedControlType |
AT-SPI2 role-description |
placeholder |
kAXPlaceholderValueAttribute |
UIA HelpText + IsTextEditPatternAvailable placeholder |
AT-SPI2 description + HTML placeholder via object-attributes |
dom_id / dom_classes |
kAXDOMIdentifierAttribute / kAXDOMClassListAttribute |
Edge WebView2 UIA HtmlId / HtmlClass properties |
AT-SPI2 object-attributes HTML keys |
| Event subscription | AXObserverCreate + AXObserverAddNotification on CFRunLoop |
IUIAutomation.AddAutomationEventHandler + AddFocusChangedEventHandler + AddPropertyChangedEventHandler |
AT-SPI2 D-Bus signals via zbus::StreamFactory |
| Text range read | AXStringForRangeParameterizedAttribute + AXSelectedTextRangeAttribute |
TextPattern.GetSelection, TextPattern.DocumentRange.GetText |
AT-SPI2 Text.GetText(start, end) + Text.GetCaretOffset |
| Text range write | AXSelectedTextRange = AXValueCreate(kAXValueCFRangeType, …) |
TextRange.Select + TextRange.Move |
AT-SPI2 EditableText.InsertText + Text.SetCaretOffset |
| Modern per-window screenshot | SCScreenshotManager.captureImage(contentFilter:config:) |
GraphicsCaptureItem.CreateFromWindowHandle + Direct3D11CaptureFramePool |
PipeWire org.freedesktop.portal.ScreenCast |
| Toolbar surface | AXRole == AXToolbar or AXUnifiedTitleAndToolbar |
UIA ControlType.ToolBar |
AT-SPI2 Role::ToolBar |
| Menu-bar extras surface | SystemUIServer + ControlCenter pid walk |
UIA Shell_TrayWnd + NotifyIconOverflowWindow |
AT-SPI2 StatusNotifierWatcher D-Bus |
| Dock / taskbar surface | Dock.app pid walk |
UIA Shell_TrayWnd TaskListButton children |
AT-SPI2 per-DE panel walk |
LongPress |
CGEventCreateMouseEvent(…Down…) + sleep + …Up |
SendInput hold + release |
Coordinate via ydotool/xdotool |
ForceClick |
CGEventSetIntegerValueField(kCGMouseEventPressure, …) + kCGEventMouseSubtypeTabletPoint |
Pen input SendInput with PEN_FLAGS_BARREL |
Not natively supported — return ActionNotSupported |
ShowMenu action |
AXPerformAction(kAXShowMenuAction) |
ExpandCollapsePattern.Expand + UIA right-click fallback |
AT-SPI2 Action.DoAction("popup") |
WindowRaise |
AXUIElementSetAttributeValue(kAXRaiseAction) |
SetForegroundWindow + SetWindowPos(HWND_TOP) only under explicit focus/window policy |
wmctrl -a / xdotool windowactivate only under explicit focus/window policy |
Cancel |
AXPerformAction(kAXCancelAction) |
UIA WindowPattern.Close on dialog or InvokePattern on cancel button |
AT-SPI2 Action.DoAction("cancel") or synthesize Escape |
DeliverFiles(Vec<PathBuf>) |
4-tier headless fallback: (1) app-native URL scheme, (2) NSWorkspace.open(urls:withApplicationAt:configuration:) with activates: false, (3) NSPasteboard.public.file-url + CGEventPostToPid(cmd+v), (4) osascript open. NEVER NSDraggingSession (not headless-compatible — Phase 2 plan Unit 12 research) |
App/shell delivery first: app URI handlers, ShellExecuteEx, IFileOperation for filesystem destinations, and CF_HDROP clipboard paste where accepted. IDataObject + DoDragDrop is policy-gated fallback/spike only |
Portal/native file-transfer path where available; XDND is Phase 3 research, not default |
| Screen Recording permission | CGPreflightScreenCaptureAccess / CGRequestScreenCaptureAccess |
No macOS-style TCC field. Use GraphicsCaptureSession::IsSupported / capture API failures to report not_required, unknown, PERM_DENIED, or PLATFORM_NOT_SUPPORTED with platform_detail |
PipeWire portal permission dialog |
| Automation permission | AEDeterminePermissionToAutomateTarget |
N/A (no equivalent restriction) | N/A |
Windows Adapter Implementation
Full WindowsAdapter in crates/windows/src/ following the identical platform crate folder structure:
crates/windows/src/
├── lib.rs # mod declarations + re-exports only
├── adapter.rs # WindowsAdapter: PlatformAdapter impl
├── tree/
│ ├── mod.rs # re-exports
│ ├── element.rs # UIA element wrapper + attribute readers
│ ├── builder.rs # IUIAutomationTreeWalker traversal with CacheRequest
│ ├── roles.rs # UIA ControlType → unified role enum mapping
│ ├── resolve.rs # Element re-identification for ref resolution
│ └── surfaces.rs # Surface detection (menus, dialogs, flyouts)
├── actions/
│ ├── mod.rs # re-exports
│ ├── dispatch.rs # perform_action match arms via UIA patterns
│ ├── activate.rs # Smart activation chain (InvokePattern → Toggle → coordinate)
│ └── extras.rs # SelectionPattern, ScrollPattern helpers
├── input/
│ ├── mod.rs # re-exports
│ ├── keyboard.rs # SendInput keyboard synthesis
│ ├── mouse.rs # SendInput mouse events
│ └── clipboard.rs # OpenClipboard / GetClipboardData / SetClipboardData Win32 APIs
├── notifications/
│ ├── mod.rs # re-exports
│ ├── list.rs # List toast/Action Center notifications via UIA
│ ├── dismiss.rs # Dismiss individual or all notifications
│ └── interact.rs # Click notification action buttons
├── tray/
│ ├── mod.rs # re-exports
│ ├── list.rs # List system tray items via Shell_TrayWnd UIA tree
│ └── interact.rs # Click tray items, open tray menus
└── system/
├── mod.rs # re-exports
├── app_ops.rs # Process launch via CreateProcess, close via TerminateProcess
├── window_ops.rs # SetWindowPos, ShowWindow for resize/move/minimize/maximize/restore
├── key_dispatch.rs # Explicit focus-policy key press via SetForegroundWindow + SendInput
├── permissions.rs # COM security check, UAC elevation detection
├── screenshot.rs # Windows.Graphics.Capture modern backend + PrintWindow legacy
├── shell_surfaces.rs # Start, taskbar, Action Center, Quick Settings
└── wait.rs # wait utilities (polling UIA element existence)
Windows API Mapping
| Capability | Technology | Details |
|---|---|---|
| Tree root | IUIAutomation.ElementFromHandle() |
Via uiautomation crate (v0.24+) wrapping UIA COM APIs via windows crate |
| Children | IUIAutomationTreeWalker.GetFirstChild / GetNextSibling |
With CacheRequest for batch attribute retrieval (3-5x faster) |
| Role mapping | UIA ControlType integers |
Map to unified role enum in tree/roles.rs — e.g. UIA_ButtonControlTypeId → button |
| Click | InvokePattern.Invoke() |
Pattern-based; coordinate click via SendInput only under explicit physical policy |
| Set text | ValuePattern.SetValue() |
Headless value write by default; SendInput only under explicit focus/physical policy |
| Expand/Collapse | ExpandCollapsePattern.Expand() / .Collapse() |
Native UIA pattern |
| Select | SelectionItemPattern.Select() |
For combobox, listbox, tab items |
| Toggle | TogglePattern.Toggle() |
For checkboxes, switches |
| Scroll | ScrollPattern.Scroll() / ScrollPattern.SetScrollPercent() |
Native UIA scroll; mouse wheel only under explicit physical policy |
| Keyboard | SendInput API |
INPUT_KEYBOARD structs with virtual key codes and scan codes |
| Mouse | SendInput API |
INPUT_MOUSE structs with MOUSEEVENTF_* flags |
| Clipboard | OpenClipboard / GetClipboardData / SetClipboardData |
Win32 APIs, handle CF_UNICODETEXT format |
| Screenshot | Windows.Graphics.Capture |
Modern per-window capture via GraphicsCaptureItem.CreateFromWindowHandle + Direct3D11CaptureFramePool when WGC is supported by the OS/session. No subprocess, respects DWM compositing. BitBlt / PrintWindow retained as ScreenshotBackend::Legacy fallback for pre-Windows-10 1903 or unavailable WGC environments |
| App launch | CreateProcess / ShellExecuteEx |
Launch by name or path, wait for main window |
| App close | WM_CLOSE / TerminateProcess |
Graceful close first, force kill with --force |
| Window ops | SetWindowPos / ShowWindow |
Resize, move, minimize (SW_MINIMIZE), maximize (SW_MAXIMIZE), restore (SW_RESTORE) |
| Permissions | COM security / UAC | Detect elevation requirements; return PERM_DENIED if UIA access blocked |
| Notifications | UserNotificationListener + UIA Action Center fallback | Prefer UserNotificationListener where app identity/capability and explicit user permission are available. Otherwise Action Center UIA traversal is best-effort fallback: list/dismiss/interact only when the shell exposes stable UIA elements. Do Not Disturb (Focus Assist) state via supported shell APIs or documented registry fallback |
| System tray | UIA + Shell_TrayWnd | System tray items accessible via UIA tree of Shell_TrayWnd class. Overflow items in NotifyIconOverflowWindow. List via IUIAutomationTreeWalker on tray area. Click via InvokePattern or coordinate-based SendInput. Expand overflow via click on chevron button |
| Start menu / search | UIA + explicit shell open command | open-system-surface --surface start-menu opens the Start surface under explicit shell-surface policy, then agents use snapshot --surface start-menu + refs. App launching remains launch; Start is for shell workflows and search results |
| Taskbar | UIA + Shell_TrayWnd task list | snapshot --surface taskbar exposes pinned/running app buttons as refs. Taskbar button invocation uses InvokePattern when available; focus-changing activation is allowed only for explicit focus-window / WindowRaise policy |
| Quick Settings | UIA shell flyout | open-system-surface --surface quick-settings exposes Wi-Fi, Bluetooth, audio, display, and accessibility toggles as refs when the shell exports them. Unsupported Windows builds return PLATFORM_NOT_SUPPORTED |
| Virtual desktops | IVirtualDesktopManager detection |
Use public COM detection for "current desktop" filtering and diagnostics. Moving windows between virtual desktops is deferred unless a stable public API path is validated |
| Multi-monitor / DPI | Per-monitor DPI + Win32 monitor APIs | All bounds are physical pixels normalized by the same DPI-aware process mode; tests cover mixed-DPI monitor layouts before any coordinate fallback ships |
Windows-specific command surface (P2-O18)
Windows-specific commands are allowed when the operating-system concept has no portable equivalent, but they still follow the repository rules: one core command file, typed CLI/batch dispatch, adapter trait default returning PLATFORM_NOT_SUPPORTED, skill docs, and tests. The preferred path remains generic: expose shell UI as a surface, then let agents interact with refs.
Planned Windows shell commands:
| Command | Purpose | Platform behavior |
|---|---|---|
open-system-surface --surface <kind> |
Opens an OS shell surface so agents can immediately call snapshot --surface <kind> and act by refs |
Windows kinds: start-menu, taskbar, system-tray, system-tray-overflow, action-center, quick-settings. macOS may support spotlight, dock, menu-bar-extras, notification-center. Unsupported kinds return PLATFORM_NOT_SUPPORTED |
list-tray-items / click-tray-item / open-tray-menu |
Structured tray workflows where the shell surface is not attached to a normal app window | Windows implementation uses Shell_TrayWnd / NotifyIconOverflowWindow; macOS maps to menu bar extras. Linux maps to StatusNotifier in Phase 3 |
No Windows-specific command bypasses refs for ordinary app controls. If a Windows workflow can be represented as snapshot --app, snapshot --surface, find, click, type, press, or wait, it uses the existing command surface.
Notification Management (New Feature — Windows Implementation)
Windows notification management must be implemented from scratch as part of Phase 2. The macOS notification implementation (completed as a follow-up to Phase 1) serves as the reference pattern — same PlatformAdapter trait methods (list_notifications, dismiss_notification, dismiss_all_notifications, notification_action), same JSON output contract, same 1-based indexing. Full notification parity is gated on a spike because Windows has two materially different surfaces: notification-listener APIs that require user permission/app identity, and shell UIA traversal that is best effort.
Implementation approach:
- Primary list path: Use
UserNotificationListenerwhen package identity/capability and explicit user permission are available. If permission is denied, returnPERM_DENIEDwith a permission-specific suggestion. - Fallback list path: Open Action Center with
open-system-surface --surface action-center; traverse exposed shell UIA elements only when they provide stable names/descriptions/action buttons. - Dismiss: Prefer notification-listener APIs where supported; otherwise invoke the notification's dismiss/close button through UIA. For "dismiss all", invoke the shell's "Clear all" control only when present.
- Interact with actions: Resolve action buttons within the notification tree and invoke via the primary API or
InvokePattern. - Focus Assist / Do Not Disturb: Query through supported shell APIs first. Registry/WNF probes are best-effort diagnostics, not the sole source of truth.
- Edge case: Some notifications may be transient (disappear after timeout). The
wait --notificationcommand should monitor for new toasts via event subscription where supported; otherwise it polls the notification-listener or Action Center fallback within the normal wait deadline.
System Tray (New Feature — Windows Implementation)
System tray interaction must be implemented from scratch as part of Phase 2.
Implementation approach:
- List items: Access the system tray via UIA tree of
Shell_TrayWndwindow class. Tray items are children of the notification area. Overflow items live inNotifyIconOverflowWindow - Click:
InvokePatternon tray items, falling back to coordinate-basedSendInputfor items that don't expose UIA patterns - Open menu: After clicking a tray item, detect the resulting popup menu via UIA focus-changed events and expose it for ref-based interaction
Web/Electron App Compatibility
Chromium-based apps (Electron, Chrome, Edge, VS Code) expose deep, noisy accessibility trees where every HTML <div> becomes a UIA Group element. The macOS adapter solved this with three patterns that must be replicated identically on Windows.
Chromium detection:
- Detect Chromium-based windows via UIA process name or
Chrome_WidgetWin_1window class matching - If tree is empty or minimal for a Chromium window, warn: "This appears to be a Chromium app. Run the app with
--force-renderer-accessibilityto expose the accessibility tree" - Include this guidance in the
platform_detailfield of the error response
Web-aware tree traversal (depth-skip):
- Non-semantic wrapper elements (
UIA_GroupControlTypeId/UIA_CustomControlTypeId) with emptyNameAND emptyValueproperties do NOT consume depth budget during tree traversal - This matches the macOS pattern where
AXGroup/AXGenericElementwrappers are skipped - Without this, default
--max-depth 10finds ~3 refs in Slack; with it, finds 100+ refs - Implement in
crates/windows/src/tree/builder.rswith the sameis_web_wrapperlogic
Resolver depth:
- Element re-identification must search up to
ABSOLUTE_MAX_DEPTH(50), not a lower hardcoded limit - Electron elements commonly sit at depth 25+ in the raw tree; a shallow resolver cap causes
STALE_REFerrors - Implement in
crates/windows/src/tree/resolve.rsmatching the macOS pattern
Surface detection for Electron:
- When an Electron app opens a modal (file picker, dialog), UIA may report the dialog as the focused window itself rather than a child of the parent window
- Surface detection (
list-surfaces,--surface sheet/alert) must check if the focused window IS the target surface, not only search its children - Check both
ControlTypeandLocalizedControlType/ UIA patterns (analogous to macOS checking both AXRole and AXSubrole) - Implement in
crates/windows/src/tree/surfaces.rs
Progressive skeleton traversal works identically on Windows — --skeleton and --root flags are platform-agnostic, handled entirely by core. The Windows adapter only needs to implement get_subtree() (which delegates to the same build_subtree() as get_tree()). Token savings for Electron apps (VS Code, Slack) apply equally.
Minimum OS Requirements
- Windows 10 1809+ for the baseline UIA adapter, app/window operations, clipboard, and legacy screenshot fallback
- Windows 10 1903+ for
Windows.Graphics.Captureper-window modern screenshot - Newer Windows 10/11 builds may expose richer Quick Settings / notification / shell UIA trees; commands report
PLATFORM_NOT_SUPPORTEDor degrade to the documented fallback when a shell surface is absent - UIA COM interfaces are available before Windows 10, but Phase 2 does not support pre-1809 as a release target
- Session 0, Server Core, secure desktop, locked desktop, and other-user sessions are explicitly unsupported for observation/action/capture
New Dependencies
| Crate | Version | Scope | Purpose |
|---|---|---|---|
uiautomation |
0.24+ | Windows | UIA client wrapper, tree walker, patterns |
windows |
0.62.2 | Windows | Raw Win32 / WinRT bindings for SendInput, clipboard, Windows.Graphics.Capture, D3D11 frame pool. Pinned to 0.62.2 to match windows-capture 1.5.x's own pin. |
windows-capture |
1.5.4 | Windows | Modern per-window screenshot via Windows.Graphics.Capture in supported interactive sessions. Replaces PrintWindow + PW_RENDERFULLCONTENT as default, keeps legacy fallback. |
screencapturekit |
1.5 (crates.io) | macOS | Published crates.io canonical crate — the doom-fish fork is the maintained successor, NOT a git-SHA pin. |
objc2 |
0.6 | macOS (new for P2-O13 / O17) | Safe bridging to SCScreenshotManager, CGPreflightScreenCaptureAccess, and AppKit/Foundation calls scoped to screenshot/permissions code |
Added as target-gated dependencies in the owning platform crates. The binary crate only depends on the platform crate for the current target.
# src/Cargo.toml
[target.'cfg(target_os = "windows")'.dependencies]
agent-desktop-windows = { path = "crates/windows" }
[target.'cfg(target_os = "macos")'.dependencies]
agent-desktop-macos = { path = "crates/macos" }
# crates/windows/Cargo.toml
[target.'cfg(target_os = "windows")'.dependencies]
uiautomation = "0.24"
windows = { version = "0.62.2", features = ["Win32_UI_Input", "Win32_UI_Input_KeyboardAndMouse", "Win32_System_Com", "Win32_System_DataExchange", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Graphics_Capture", "Win32_Graphics_Direct3D11"] }
windows-capture = "1.5.4"
# crates/macos/Cargo.toml
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", features = ["Foundation", "AppKit"] }
screencapturekit = "1.5"
Testing
Unit tests (windows):
- UIA ControlType → role mapping coverage for all control types
- Permission check with mocks (COM security state)
- CacheRequest attribute batching correctness
- Element resolution round-trip (pid, role, name, bounds_hash)
Integration tests (Windows CI):
- Snapshot Explorer — non-empty tree with refs, buttons, text fields
- Snapshot Notepad — text area with value, menu items
- Snapshot Settings — modern WinUI controls
- Snapshot Taskbar / Start / Quick Settings / Action Center surfaces where the runner exposes an interactive Explorer shell; otherwise assert
PLATFORM_NOT_SUPPORTEDwith a clearplatform_detail - Click button in test app — verify action succeeded
- Type text into Notepad via ref — verify content changed
- Set-value on a text field — verify value set via UIA
- Clipboard get/set/clear roundtrip
- Wait for window title pattern
- Launch + close app lifecycle (Notepad: launch, type, close)
- Resize, move, minimize, maximize, restore window operations
- Screenshot produces valid PNG
- Large tree snapshot performance validation
- Chromium detection — verify warning when tree is empty
- Electron app snapshot (VS Code) — default depth finds 50+ refs via web-aware depth-skip
- Electron surface detection — file picker dialog detected as sheet surface
- List notifications — primary listener path when permission/app identity is available; Action Center UIA fallback otherwise
- Dismiss notification — verify removal through listener or Action Center fallback; skip with
PLATFORM_NOT_SUPPORTEDon unsupported shell builds - Notification action — click action button on a test toast notification when the platform exposes one
- List tray items — returns known system tray entries (volume, network, clock)
- Click tray item — verify tray menu opens
Cross-platform validation:
- Same snapshot of a cross-platform app (e.g., VS Code) produces structurally identical JSON on macOS and Windows
- All error codes produce identical JSON envelope format
Cross-platform extension tests (P2-O8 through O17):
- Stable-selector fields: known interactive controls emit
identifieron both platforms when the app exposes one (UIAAutomationIdon Windows,AXIdentifieron macOS); controls without stable IDs omit the field and still resolve through the fingerprint fallback - Event subscription:
watch --event value-changed --ref @e3 --timeout 2000receives an event within 500 ms of a programmatic value change on both platforms - Text ranges:
text select-range @e1 5 10+text get-selection @e1round-trips to{start:5, length:10}on both platforms for a multi-line text editor (TextEdit / Notepad) - Text insert-at-caret:
text insert-at-caret @e1 "hello"produces matchingvalueon both platforms with the caret advanced correctly - Modern screenshot:
screenshot --window <id>PNG matches a reference capture within SSIM threshold on supported OS/session combinations; cold latency <50 ms on both platforms where modern capture is available (vs ~300 ms macOS subprocess baseline) - Toolbar surface:
snapshot --surface toolbaron Safari (macOS) and Edge (Windows) returns the toolbar's children with refs - Electron deep-tree: VS Code snapshot with
--force-electron-a11yexposes ≥100 refs at default depth on both platforms - Screen Recording permission: on a macOS runner without Screen Recording,
screenshot --windowreturnsPermDeniedwith the Screen Recording suggestion (distinct from AX denial) - Automation permission: on a macOS runner without Automation for a target app,
close-appreturnsAutomationPermissionDeniedrather than squeezing intoActionFailed
FFI parity tests (P2-O16):
ad_abi_version()returns a packedu32matching the Cargo version; consumer built against 0.2.0 refuses to load 0.3.0ad_snapshotwrites a refmap and the same@e5resolves viaad_execute_by_refwithout a prior CLI snapshot on diskad_execute_by_ref(adapter, "@e5", AD_ACTION_KIND_CLICK, &out)produces identicalAdActionResulttoad_resolve_element+ad_execute_actionad_set_log_callbackreceives at least onetracing::debug!event during aad_get_treecall- Every new
Actionvariant round-trips through theAdAction.kindi32 → Rust enum conversion without UB on arbitrary bit patterns (extends the existingfuzz_arbitrary_bit_patterns_never_panic_across_all_enumssuite)
CI
- Add GitHub Actions Windows runner alongside existing macOS runner
- Both runners execute:
cargo clippy --all-targets -- -D warnings,cargo test --workspace cargo tree -p agent-desktop-corecontinues to contain zero platform crate names- Binary size check: Windows
.exemust be under 15MB
Release
- Prebuilt Windows
.exebinary added to the existing.github/workflows/release.ymlbuildmatrix (alongside the macOS CLI targets). Uses the same tarball + sha256 + attestation pipeline shipped in Phase 1.5. - npm
postinstall.jsgains awin32-x64/win32-arm64branch sonpm install -g agent-desktopworks on Windows without changes to package shape. - The Phase 1.5 FFI cdylib for Windows (
x86_64-pc-windows-msvc) is already shipping; Phase 2 addsaarch64-pc-windows-msvcfor ARM64 parity. - Every new
ad_*FFI entrypoint (P2-O16) is included in therelease-ffibuild and CI header drift check. - GitHub Release notes document Windows support and installation.
Skill Update
Skill docs are part of the release surface and must stay in sync with command behavior.
- Create
skills/agent-desktop-windows/SKILL.md:- UIA permission model and UAC handling
- Windows-specific behaviors (UIA patterns, WinUI3 quirks, COM initialization, Start/taskbar/Action Center/Quick Settings shell surfaces, virtual desktop detection, mixed-DPI coordinates)
- Chromium/Electron compatibility: depth-skip, resolver depth, surface detection patterns
--force-renderer-accessibilityguidance for empty trees- Windows error codes and
platform_detailexamples (HRESULT codes) - Troubleshooting guide (empty trees, COM errors, elevation failures)
- Update core
SKILL.md:- Add Windows platform skill to skill graph table
- Update platform support section
- Update
workflows.md:- Add cross-platform patterns noting Windows-specific differences
- Add Windows-specific workflow examples (e.g., navigating UWP apps)
README Update
- Update Platform Support table: Windows column → Yes
- Add Windows installation instructions:
- npm (same command, auto-detects platform)
- Direct
.exedownload from GitHub Releases - From source:
cargo build --releaseon Windows (note: requires MSVC toolchain)
- Add Windows permissions section:
- UIA works without special permissions for most apps
- UAC elevation may be required for elevated processes
- Chromium apps need
--force-renderer-accessibility
- Update "From source" section with Windows build requirements (Rust + MSVC)
Phase 3 — Linux Adapter + Cross-Platform Parity Completion
Status: Planned
Phase 3 completes the three-platform story. The Linux adapter implements the original adapter surface plus every cross-platform extension landed in Phase 2 (event subscriptions, text ranges, modern screenshot, stable-selector fields, Toolbar surface, new Action variants, new ErrorCode variants). Each has a canonical AT-SPI2 / D-Bus / Wayland-portal implementation. Core engine, trait contract, command-registry, CLI dispatch, FFI wrappers, and MCP transport are all untouched — per the Command Surface Architecture invariant, Phase 3 is pure PlatformAdapter trait implementation code, nothing else. No new command files, no CLI dispatch changes, no FFI wrappers, no MCP tool registrations.
Objectives
Linux parity (original scope):
| ID | Objective | Metric |
|---|---|---|
| P3-O1 | Linux adapter | snapshot on Ubuntu GNOME returns valid tree for Files, Terminal, Settings |
| P3-O2 | All commands cross-platform | Identical JSON contract output on all 3 platforms for every command |
| P3-O3 | Linux input synthesis | click, type, press, all mouse commands via AT-SPI actions + xdotool/ydotool |
| P3-O4 | Linux screenshot | screenshot produces PNG via PipeWire ScreenCast portal (Wayland) / XGetImage (X11) |
| P3-O5 | Linux clipboard | clipboard-get / clipboard-set / clipboard-clear via wl-clipboard (Wayland) / xclip (X11) |
| P3-O6 | Cross-platform CI | GitHub Actions matrix: macOS + Windows + Ubuntu |
| P3-O7 | Linux binary release | Prebuilt CLI binary added to the release pipeline (Phase 1.5 already ships the Linux FFI cdylib) |
Cross-platform extensions (Linux implementations of Phase 2 primitives):
| ID | Objective | Metric |
|---|---|---|
| P3-O8 | Stable-selector fields on Linux | AccessibilityNode.identifier populated from AT-SPI2 accessible-id attribute (standard since AT-SPI 2.18) with GTK gtk-id / Qt objectName fallback; dom_id / dom_classes populated from AT-SPI2 object-attributes HTML keys (id, class) on WebKitGTK / Chromium-Content embeds |
| P3-O9 | AT-SPI2 event subscriptions (P2-O11 parity) | watch_element implemented via zbus::Proxy::receive_signal on AT-SPI2 signals: org.a11y.atspi.Event.Object.PropertyChange, ChildrenChanged, StateChanged:focused, Window:Create, Window:Destroy. Same wait --event CLI shape as macOS/Windows. Replaces polling in crates/linux/src/system/wait.rs before it's even written |
| P3-O10 | AT-SPI2 Text interface (P2-O12 parity) | Text range primitives via org.a11y.atspi.Text D-Bus methods: GetText(start, end), GetCaretOffset, SetCaretOffset, GetNSelections, GetSelection(n), AddSelection(start, end), RemoveSelection(n). InsertAtCaret uses org.a11y.atspi.EditableText.InsertText(position, text, length) |
| P3-O11 | PipeWire modern screenshot (P2-O13 parity) | screenshot --window <id> via org.freedesktop.portal.ScreenCast (Wayland) + org.freedesktop.portal.RemoteDesktop for capture permission flow. XDG desktop portal handles the user consent dialog exactly like SCScreenshotManager does on macOS. X11 fallback uses XGetImage for the lowest-permission path |
| P3-O12 | Toolbar + surfaces (P2-O14 parity) | SnapshotSurface::Toolbar via AT-SPI2 Role::ToolBar. Dock / taskbar surface via per-DE panel process walk (GNOME Shell process for gnome-shell extensions, Plasma plasmashell for KDE). StatusNotifierWatcher already scoped in the original Phase 3 tray spec |
| P3-O13 | Action variants on Linux (P2-O9 parity) | Action::LongPress via timed xdotool/ydotool button-hold; Action::ShowMenu via org.a11y.atspi.Action.DoAction("popup"); Action::Cancel via Action.DoAction("cancel") or Escape synthesis; Action::DeliverFiles via portal/native file-transfer where available with XDND as a researched fallback; Action::ForceClick returns ActionNotSupported on Linux (no pressure input primitive) |
| P3-O14 | FFI cdylib continues to ship | Phase 1.5 already publishes Linux FFI for x86_64 + aarch64; Phase 3 adds each new ad_* entrypoint's Linux implementation and extends the header drift check. No new FFI bindings to design — just implementations for the platform-specific methods under the existing trait |
| P3-O15 | Flatpak / Snap compatibility note | AT-SPI2 requires --talk-name=org.a11y.Bus permission inside sandboxed runtimes. Skill docs include the exact Flatpak override and Snap plug grants, so sandboxed consumers aren't silently empty-tree |
Linux Adapter Implementation
Full LinuxAdapter in crates/linux/src/ following the identical platform crate folder structure:
crates/linux/src/
├── lib.rs # mod declarations + re-exports only
├── adapter.rs # LinuxAdapter: PlatformAdapter impl
├── tree/
│ ├── mod.rs # re-exports
│ ├── element.rs # AT-SPI Accessible wrapper + attribute readers
│ ├── builder.rs # D-Bus tree traversal via GetChildren
│ ├── roles.rs # AT-SPI Role enum → unified role enum mapping
│ ├── resolve.rs # Element re-identification for ref resolution
│ └── surfaces.rs # Surface detection (menus, dialogs, popovers)
├── actions/
│ ├── mod.rs # re-exports
│ ├── dispatch.rs # perform_action via AT-SPI Action interface
│ ├── activate.rs # Smart activation chain (DoAction → coordinate fallback)
│ └── extras.rs # Text.InsertText, Selection helpers
├── input/
│ ├── mod.rs # re-exports
│ ├── keyboard.rs # xdotool (X11) / ydotool (Wayland) keyboard synthesis
│ ├── mouse.rs # xdotool (X11) / ydotool (Wayland) mouse events
│ └── clipboard.rs # wl-clipboard (Wayland) / xclip (X11) clipboard ops
├── notifications/
│ ├── mod.rs # re-exports
│ ├── list.rs # List notifications via D-Bus org.freedesktop.Notifications or daemon-specific API
│ ├── dismiss.rs # Dismiss/close notifications via CloseNotification D-Bus method
│ └── interact.rs # Invoke notification actions via ActionInvoked D-Bus signal
├── tray/
│ ├── mod.rs # re-exports
│ ├── list.rs # List tray items via StatusNotifierItem D-Bus interface or AT-SPI
│ └── interact.rs # Activate/context-menu tray items via D-Bus methods
└── system/
├── mod.rs # re-exports
├── app_ops.rs # App launch via xdg-open / process spawn, close via SIGTERM/SIGKILL
├── window_ops.rs # xdotool / wmctrl for resize/move/minimize/maximize/restore
├── key_dispatch.rs # App-targeted key press via window focus + input synthesis
├── permissions.rs # AT-SPI2 bus availability check, DBUS_SESSION_BUS_ADDRESS detection
├── screenshot.rs # PipeWire ScreenCast portal (Wayland) / XGetImage (X11) / xcap crate
└── wait.rs # wait utilities (polling AT-SPI element existence)
Linux API Mapping
| Capability | Technology | Details |
|---|---|---|
| Tree root | atspi Accessible on bus |
Via atspi crate (v0.28+) + zbus (5.x) — pure Rust, no libatspi/GLib dependency |
| Children | org.a11y.atspi.Accessible.GetChildren |
Async D-Bus calls to AT-SPI2 registry daemon |
| Role mapping | AT-SPI Role enum |
Map to unified role enum in tree/roles.rs — e.g. Role::PushButton → button |
| Click | org.a11y.atspi.Action.DoAction(0) |
AT-SPI actions preferred over coordinate-based input |
| Set text | org.a11y.atspi.Text.InsertText |
AT-SPI text interface; falls back to clipboard paste |
| Expand/Collapse | Action.DoAction("expand") / Action.DoAction("collapse") |
Action name-based dispatch |
| Select | org.a11y.atspi.Selection.SelectChild |
For combobox, listbox, tab items |
| Toggle | Action.DoAction("toggle") or Action.DoAction("click") |
For checkboxes, switches |
| Scroll | Coordinate-based scroll events via xdotool/ydotool | AT-SPI has no native scroll pattern |
| Keyboard | xdotool key (X11) / ydotool key (Wayland) |
Shelling out for input synthesis |
| Mouse | xdotool mousemove/click (X11) / ydotool mousemove/click (Wayland) |
Display server detected at runtime |
| Clipboard | wl-copy / wl-paste (Wayland) / xclip (X11) |
Shelling out; display server detected at runtime |
| Screenshot | PipeWire ScreenCast portal (Wayland) / XGetImage (X11) |
Or xcap crate for consistency |
| App launch | xdg-open / direct process spawn |
Launch by .desktop file or command name |
| App close | SIGTERM / SIGKILL |
Graceful close first, force with --force |
| Window ops | xdotool / wmctrl |
Window resize, move, minimize, maximize, restore |
| Permissions | AT-SPI2 bus availability | Check for org.a11y.Bus on D-Bus session bus. Return PLATFORM_UNSUPPORTED with enable instructions if missing |
| Notifications | D-Bus org.freedesktop.Notifications |
List via GetServerInformation + monitoring Notify signals. History varies by daemon: GNOME uses org.gnome.Shell.Notifications, KDE uses org.freedesktop.Notifications with GetNotifications. Dismiss via CloseNotification(id). Interact via ActionInvoked signal. Do Not Disturb: GNOME org.gnome.desktop.notifications.show-banners, KDE org.kde.notificationmanager |
| System tray | D-Bus org.kde.StatusNotifierWatcher |
SNI (StatusNotifierItem) protocol for modern tray items. Legacy XEmbed tray items via AT-SPI tree of the tray window. List via RegisteredStatusNotifierItems property. Activate via Activate(x, y) method. Context menu via ContextMenu(x, y) method. Fallback: coordinate-based click for XEmbed items |
Notification Management (New Feature — Linux Implementation)
Linux notification management must be implemented from scratch as part of Phase 3. The macOS implementation (completed) and Windows implementation (Phase 2) serve as reference patterns — same trait methods, same JSON output contract, same 1-based indexing.
Implementation approach:
- List notifications: The standard
org.freedesktop.NotificationsD-Bus interface does NOT provide a "list current notifications" method. Approach varies by desktop environment:- GNOME:
org.gnome.Shellexposesorg.gnome.Shell.Notificationsinterface withGetNotifications()method (returns array of notification dicts) - KDE Plasma:
org.freedesktop.NotificationswithGetNotifications()extension, ororg.kde.notificationmanagerD-Bus interface - Other DEs: Monitor
NotifyD-Bus signals to maintain an in-memory notification history within the daemon session
- GNOME:
- Dismiss:
org.freedesktop.Notifications.CloseNotification(id)D-Bus method call. Works across all notification daemons - Interact with actions: Listen for user-triggered actions or programmatically invoke via
ActionInvokedsignal. Note: the D-Bus spec does not define a method to programmatically trigger actions — coordinate-based click on the notification popup via AT-SPI may be needed as a fallback - Do Not Disturb:
- GNOME:
gsettings get org.gnome.desktop.notifications show-banners(boolean) - KDE:
org.kde.notificationmanagerD-Bus interface,inhibitedproperty
- GNOME:
- Edge case: Notification daemon varies by DE — detect via
GetServerInformation()D-Bus method. ReturnPLATFORM_UNSUPPORTEDwith daemon-specific guidance if the notification interface is unreachable
System Tray (New Feature — Linux Implementation)
System tray interaction must be implemented from scratch as part of Phase 3.
Implementation approach:
- Modern tray (SNI): Most modern Linux apps use the
StatusNotifierItem(SNI) D-Bus protocol. Discover items viaorg.kde.StatusNotifierWatcher.RegisteredStatusNotifierItemsproperty - Legacy tray (XEmbed): Older apps use XEmbed protocol. Access via AT-SPI tree of the tray window, or coordinate-based interaction
- List items: Query
StatusNotifierWatcherfor registered items. Each item exposesTitle,IconName,ToolTip,Menu(D-Bus menu path) properties - Activate: Call
Activate(x, y)method on theStatusNotifierItemD-Bus interface - Context menu: Call
ContextMenu(x, y)method, or read theMenuproperty to get thecom.canonical.dbusmenupath and traverse the menu tree - Edge case: GNOME does not natively support SNI (requires
AppIndicatorextension). Detect and report via error suggestion if no tray is available
Display Server Detection
Runtime detection required for input, clipboard, and screenshot since Linux runs either X11 or Wayland:
- Check
$WAYLAND_DISPLAYenvironment variable — if set, use Wayland path - Check
$DISPLAYenvironment variable — if set and no Wayland, use X11 path - If neither, return
PLATFORM_UNSUPPORTEDwith guidance to check display server configuration - Input tools: verify
xdotool(X11) orydotool(Wayland) is installed; error with install instructions if missing - Clipboard tools: verify
xclip(X11) orwl-clipboard(Wayland) is installed; error with install instructions if missing
Web/Electron App Compatibility
Same Chromium/Electron compatibility patterns as Phase 2 (Windows), adapted for AT-SPI2. These patterns ensure default --max-depth 10 works with Electron apps like Slack, VS Code, and Chrome.
Web-aware tree traversal (depth-skip):
- Non-semantic wrapper elements with AT-SPI roles
ROLE_PANEL,ROLE_SECTION, orROLE_FILLERthat have emptyNameAND emptyValuedo NOT consume depth budget during tree traversal - This is the AT-SPI equivalent of macOS
AXGroup/AXGenericElementand WindowsUIA_GroupControlTypeIdskipping - Implement in
crates/linux/src/tree/builder.rswith the sameis_web_wrapperlogic
Resolver depth:
- Element re-identification must search up to
ABSOLUTE_MAX_DEPTH(50), not a lower hardcoded limit - Electron elements commonly sit at depth 25+ in the raw AT-SPI tree
- Implement in
crates/linux/src/tree/resolve.rsmatching the macOS/Windows pattern
Surface detection for Electron:
- When an Electron app opens a modal (file picker, dialog), AT-SPI may report the dialog as the active window itself rather than a child of the parent window
- Surface detection must check if the focused window IS the target surface, not only search its children
- Check both
RoleandRelationSet/RELATION_EMBEDSfor dialog detection (analogous to macOS AXRole + AXSubrole) - Implement in
crates/linux/src/tree/surfaces.rs
Chromium detection:
- Detect Chromium-based apps via process name matching (electron, chrome, chromium, code)
- If AT-SPI tree is empty for a Chromium app, warn about
--force-renderer-accessibility - On Linux, Chromium respects
ACCESSIBILITY_ENABLED=1environment variable as an alternative
Progressive skeleton traversal works identically on Linux — --skeleton and --root flags are platform-agnostic, handled entirely by core. The Linux adapter only needs to implement get_subtree() (which delegates to the same async tree walker). Token savings for Electron apps apply equally.
AT-SPI2 Bus Detection
- Check for
org.a11y.Buspresence on the D-Bus session bus - If bus is not running, return
PLATFORM_UNSUPPORTEDwith instructions:- GNOME: "AT-SPI2 should be enabled by default. Check
gsettings get org.gnome.desktop.interface toolkit-accessibility" - Other DEs: "Install
at-spi2-coreand ensureat-spi-bus-launcheris running" - Flatpak/Snap: "Ensure the app has
--talk-name=org.a11y.Buspermission"
- GNOME: "AT-SPI2 should be enabled by default. Check
Minimum OS Requirements
- Ubuntu 22.04+ / Fedora 38+
- GNOME 42+ (primary target), KDE Plasma 5.24+ (secondary)
at-spi2-corepackage installed (default on GNOME)- X11:
xdotoolinstalled. Wayland:ydotoolinstalled
Key Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Wayland a11y gaps | Focus on GNOME (best AT-SPI2 support). Prefer AT-SPI actions over coordinate input. Document known gaps clearly in skill and README. |
| AT-SPI2 bus not running | Detect on first command. Return clear enable instructions specific to the detected distro/DE. |
| Display server fragmentation | Runtime detection (X11 vs Wayland). Separate code paths for input/clipboard/screenshot. Test both. |
| Rust a11y crate maintenance stalls | Pin atspi and zbus versions. atspi crate backed by Odilia accessibility project. Maintain patches if upstream stalls. |
| Input tool availability | Check for xdotool/ydotool on first use. Provide package manager install commands in error suggestion. |
New Dependencies
| Crate | Version | Purpose | License |
|---|---|---|---|
atspi |
0.28+ | Linux AT-SPI2 client | MIT/Apache-2.0 |
zbus |
5.x | D-Bus connection | MIT/Apache-2.0 |
tokio |
1.x | Async runtime (required by atspi/zbus for async D-Bus) | MIT |
Added to Cargo.toml as target-gated dependency:
[target.'cfg(target_os = "linux")'.dependencies]
agent-desktop-linux = { path = "crates/linux" }
Note: tokio is introduced here for the first time. Phases 1-2 are fully synchronous. The Linux adapter requires async D-Bus calls via zbus.
Testing
Unit tests (linux):
- AT-SPI Role → role mapping coverage for all role types
- Bus availability detection (mock D-Bus responses)
- Display server detection logic (Wayland vs X11 env vars)
- Element resolution round-trip (pid, role, name, bounds_hash)
Integration tests (Ubuntu CI):
- Snapshot GNOME Files — non-empty tree with refs, buttons, text fields
- Snapshot GNOME Terminal — text area, menu items
- Snapshot GNOME Settings — modern GTK4 controls
- Click button in test app — verify action succeeded
- Type text into GNOME Text Editor via ref — verify content changed
- Clipboard get/set/clear roundtrip (test both X11 and Wayland if CI supports)
- Wait for window title pattern
- Launch + close app lifecycle
- Resize, move, minimize, maximize, restore window operations
- Screenshot produces valid PNG
- AT-SPI2 bus not running — correct error code and guidance
- Electron app snapshot (VS Code) — default depth finds 50+ refs via web-aware depth-skip
- Electron surface detection — file picker dialog detected as sheet surface
- List notifications — returns non-empty list when notifications exist (GNOME)
- Dismiss notification — verify notification dismissed via D-Bus
CloseNotification - List tray items — returns known SNI items (if running under KDE or with AppIndicator extension)
- Click tray item — verify tray menu opens via
ActivateD-Bus method - Notification daemon detection — correct
GetServerInformationresult
Cross-platform validation:
- Same snapshot of a cross-platform app (e.g., VS Code) produces structurally identical JSON on all 3 platforms
- All error codes produce identical JSON envelope format on all 3 platforms
- Notification commands return identical JSON envelope structure across all 3 platforms (list, dismiss, action)
- Tray / StatusNotifierItem commands return identical JSON envelope structure across all 3 platforms
Extension tests for P3-O8 through O15 (Linux-specific parity):
- AT-SPI
accessible-idpopulated for every interactive node in GNOME Calculator, GNOME Files, Firefox (withACCESSIBILITY_ENABLED=1) watch --event value-changedviazbussignal subscription delivers an event within 500 ms for a programmatic value change in a test harness app (GTK4 + pygobject)text select-range/get-selection/insert-at-caretround-trips correctly in GNOME Text Editor viaorg.a11y.atspi.Text+EditableText- PipeWire portal screenshot flow: user approves via XDG portal dialog, subsequent calls bypass the dialog within the session grant window; screenshot matches reference
- Toolbar surface: Firefox toolbar + GNOME Files toolbar both enumerate via
Role::ToolBar - Flatpak compatibility: a Flatpak-packaged GNOME Text Editor snapshot is non-empty when
--talk-name=org.a11y.Busis granted; returns clear diagnostic otherwise
CI
- GitHub Actions matrix: macOS + Windows + Ubuntu (all three on every PR)
- All runners execute:
cargo clippy --all-targets -- -D warnings,cargo test --workspace cargo tree -p agent-desktop-corecontinues to contain zero platform crate names- Binary size check: all platform binaries must be under 15MB
Release
- Prebuilt Linux CLI binary added to
.github/workflows/release.ymlmatrix forx86_64-unknown-linux-gnuandaarch64-unknown-linux-gnu(Phase 1.5 already builds the FFI cdylib for both triples on the same runners — Phase 3 reuses those runners) - npm
postinstall.jsgainslinux-x64/linux-arm64branches - Every new
ad_*Linux implementation from P3-O9 / O10 / O11 is covered by the existing FFI drift check + Sigstore attestation pipeline - GitHub Release notes document Linux support, minimum glibc (2.35, Ubuntu 22.04 baseline), display-server requirements, and Flatpak/Snap compatibility
Skill Update
Skill docs are part of the release surface and must stay in sync with command behavior.
- Create
skills/agent-desktop-linux/SKILL.md:- AT-SPI2/D-Bus setup and bus detection
- Wayland vs X11 differences (input via xdotool/ydotool, clipboard via wl-clipboard/xclip, screenshot via PipeWire/XGetImage)
- Required system tools:
xdotoolorydotool,xcliporwl-clipboard - Linux error codes and
platform_detailexamples (D-Bus errors, bus not found) - Troubleshooting guide (bus not running, empty trees, missing tools, Flatpak/Snap permissions)
- Update core
SKILL.md:- Add Linux platform skill to skill graph table
- Update platform support section to show all 3 platforms
- Update
workflows.md:- Add cross-platform patterns noting Linux-specific differences
- Add Linux-specific workflow examples (e.g., GNOME app automation)
- Document display server detection behavior
README Update
- Update Platform Support table: Linux column → Yes
- Add Linux installation instructions:
- npm (same command, auto-detects platform)
- Direct binary download from GitHub Releases
- From source:
cargo build --releaseon Linux (note: requirespkg-config,libdbus-1-dev)
- Add Linux permissions section:
- AT-SPI2 bus must be running (default on GNOME, may need enabling on other DEs)
- Required tools:
xdotool(X11) orydotool(Wayland) for input synthesis - Required tools:
xclip(X11) orwl-clipboard(Wayland) for clipboard - How to check:
busctl --user list | grep a11y
- Update minimum OS versions: Ubuntu 22.04+ / Fedora 38+
- Update "From source" section with Linux build requirements
Phase 4 — MCP Server Mode
Status: Planned
Phase 4 adds a new I/O layer. Core engine and all three platform adapters are unchanged. The MCP server wraps existing command logic in JSON-RPC tool definitions, enabling agent-desktop to work as an MCP-native desktop automation server for Claude Desktop, Cursor, VS Code Copilot, Gemini CLI, Microsoft Agent Framework 1.0, and any other MCP-compatible host.
By Phase 4 the CLI already covers the shared command surface on three platforms, the FFI ships as a shared library for in-process consumers, and the cross-platform event / text-range / stable-selector primitives from Phase 2 / 3 are in place. MCP mode is a transport + discovery layer, nothing more. Per the Command Surface Architecture invariant at the top of this document, the MCP crate contains zero per-tool and zero per-platform code — it walks the same deterministic command descriptor registry the CLI and FFI use, and dispatches to the same execute(args, adapter) functions. New commands added in Phase 2 or Phase 5 (e.g. watch_element, text select-range, find --visual) become MCP tools automatically with no changes to crates/mcp/.
Objectives
| ID | Objective | Metric |
|---|---|---|
| P4-O1 | MCP server mode via --mcp |
Responds to MCP initialize handshake, reports capabilities, per-host hello-world passes |
| P4-O2 | All commands as MCP tools | tools/list returns 54+ tools with JSON Schemas generated from the CLI arg structs via schemars; tool names prefixed desktop_ |
| P4-O3 | Claude Desktop + Cursor + VS Code + Gemini CLI + MS Agent Framework validated | Each host invokes tools to control a desktop app end-to-end on all three platforms; repo ships mcp.json / claude_desktop_config.json / .cursor/mcp.json examples per host |
| P4-O4 | Tool annotations | readOnlyHint, destructiveHint, idempotentHint, openWorldHint on every tool; Claude Desktop surfaces destructive tools with a confirmation prompt |
| P4-O5 | Ref-based MCP tool shape (Playwright-MCP idiom) | Tools take {ref: "e5"} not raw element_handle, matching Playwright MCP so agents can swap between the two without relearning selectors. Tree snapshots return as MCP resources with refs inline |
| P4-O6 | MCP resource types | agent-desktop://refmap/current, agent-desktop://snapshot/latest, agent-desktop://audit/{trace_id} (audit log under Phase 5). resources/list + resources/read expose the current RefMap and last snapshot without re-running the command |
| P4-O7 | Tree-diff notifications | watch_element events (Phase 2 P2-O11) stream as MCP notifications/message during a long-running wait, so the host sees value-changed / focus-changed events as they happen rather than polling |
| P4-O8 | Progress notifications | notifications/progress for wait, snapshot --skeleton → --root drill-down chains, and large-tree traversals. Agents surface progress to users instead of hanging |
| P4-O9 | Tool-level permission tiers | Observation tools (desktop_snapshot, desktop_find, desktop_get, desktop_is, desktop_list_*) are freely callable. Interaction tools (desktop_click, desktop_type_text, desktop_set_value, desktop_drag) are gated behind an interactive capability negotiated at initialize. Destructive tools (desktop_close_app, desktop_dismiss_all_notifications) require the destructive capability plus the Phase 5 audit log |
| P4-O10 | Session-scoped RefMap | Each MCP session has its own in-memory RefMap keyed by session_id — no conflict with the on-disk CLI RefMap, no cross-session leakage when a host runs multiple agent-desktop-mcp instances |
| P4-O11 | MCP initialize returns tri-platform capability matrix |
The initialize response declares platform (macOS / Windows / Linux), permission status (AX + Screen Recording + Automation tri-state from Phase 2 P2-O17), display-server (Linux), and the set of actually-supported tools given current permissions. A host can decide whether to prompt for missing permissions before the first tool call |
| P4-O12 | SSE + Streamable HTTP transports | Stdio remains primary. SSE (pre-March-2025 spec) and Streamable HTTP (post-March-2025 replacement) are implemented for remote scenarios — MS Agent Framework and future MCP hosts prefer the HTTP transport |
Entry Point
The binary crate's main.rs detects mode:
- If invoked with
--mcpor stdin is a pipe: enter MCP server mode - Otherwise: parse CLI arguments, execute command, print JSON to stdout
This is the invariant: every MCP tool maps 1:1 to a CLI command. agent-desktop snapshot --app Finder is identical to invoking the MCP desktop_snapshot tool. Testing, debugging, and documentation are never fragmented.
New Crate: agent-desktop-mcp (platform-agnostic, no per-command code)
The MCP crate is small and generic by design. It contains zero per-tool files and zero per-platform code. Per the Command Surface Architecture invariant at the top of this document, every CLI command is described through deterministic command metadata; the MCP server iterates those descriptors at startup and exposes each entry as an MCP tool.
crates/mcp/src/
├── lib.rs # mod declarations + re-exports
├── server.rs # rmcp server bootstrap, initialize handler, walks the command registry
├── transport.rs # stdio (primary), Streamable HTTP (P4-O12), SSE (legacy)
├── capability.rs # P4-O9 tier gating (observation / interactive / destructive)
├── resources.rs # P4-O6 resource types (refmap / snapshot / permissions / events / audit)
├── notifications.rs # P4-O7 watch event forwarder, P4-O8 progress forwarder
└── schema.rs # Translates CommandDescriptor → rmcp tool definition
That's the whole crate. It doesn't know what desktop_click does — it reads generated command descriptors and forwards invocations through the same command execution function the CLI uses. Adding a command in Phase 2 (text select-range, watch_element) or Phase 5 (find --visual, audit tail) should mean zero lines of MCP-specific behavior — only shared command metadata and adapter methods change.
MCP tool registration — the one-time rewrite
// crates/mcp/src/server.rs (illustrative, ~80 lines total for the crate)
pub async fn serve(adapter: Box<dyn PlatformAdapter>) -> Result<()> {
let mut server = rmcp::ServerBuilder::new("agent-desktop", env!("CARGO_PKG_VERSION"));
// Walk generated descriptors. No hand-maintained tool list.
for cmd in command_descriptors() {
// Skip tools disallowed by current permission set (P4-O11).
if !cmd.available_under(&adapter.permission_report()) { continue; }
server.tool(rmcp::Tool {
name: cmd.mcp_name,
description: cmd.description,
input_schema: (cmd.args_schema)(), // schemars-derived
annotations: cmd.annotations.into(), // ReadOnlyHint etc.
}, {
let adapter = Arc::clone(&adapter);
move |args: Value| async move {
// Capability tier check (P4-O9).
capability::gate(cmd, &session)?;
// Invoke the same execute() the CLI uses.
let value = (cmd.invoke)(args, adapter.as_ref())?;
// Audit log entry (Phase 5 P5-O5).
audit::record(cmd.mcp_name, &args, &value, session.trace_id);
Ok(value)
}
});
}
server.run(stdio_transport()).await
}
Tool Surface (what the registry produces)
Each MCP tool maps 1:1 to a CLI command via CommandDescriptor. Tool names are prefixed desktop_ to avoid collision with other MCP servers. The tables below are a snapshot of what the registry emits, not hand-written entries. Adding a tool means adding a command file in crates/core/src/commands/; the tables refresh on regen.
Observation tools (always available):
| MCP Tool | CLI | Returns |
|---|---|---|
desktop_snapshot |
snapshot |
Tree + refmap in response; also published as agent-desktop://snapshot/latest resource |
desktop_find |
find <query> |
Matching refs (array) |
desktop_get |
get <prop> <ref> |
Property value |
desktop_is |
is <state> <ref> |
Boolean |
desktop_list_windows |
list-windows |
Array of windows |
desktop_list_apps |
list-apps |
Array of apps |
desktop_list_surfaces |
list-surfaces |
Array of surfaces (incl. Toolbar / Spotlight / Dock / MenuBarExtras and Windows shell surfaces from P2-O14/P2-O18) |
desktop_list_notifications |
list-notifications |
Array of notifications |
desktop_screenshot |
screenshot |
Base64 PNG (or MCP resource link) |
desktop_clipboard_get |
clipboard-get |
Clipboard text |
desktop_permissions |
permissions |
Tri-state permission report (AX + Screen Recording + Automation) |
desktop_status |
status |
Daemon + adapter status |
desktop_version |
version |
Version + ABI version |
Interaction tools (gated by interactive capability):
| MCP Tool | CLI | Shape |
|---|---|---|
desktop_click / desktop_double_click / desktop_triple_click / desktop_right_click |
click @e5 (and variants) |
{ref: "e5"} |
desktop_type_text |
type @e5 "hello" |
{ref: "e5", text: "hello"} |
desktop_set_value |
set-value @e5 "hello" |
{ref: "e5", value: "hello"} |
desktop_clear |
clear @e5 |
{ref: "e5"} |
desktop_focus |
focus @e5 |
{ref: "e5"} |
desktop_select / desktop_toggle / desktop_check / desktop_uncheck / desktop_expand / desktop_collapse |
— | {ref: "e5"} (+ value for select) |
desktop_scroll / desktop_scroll_to |
scroll <dir> |
{ref: "e5", direction, amount} |
desktop_press_key / desktop_key_down / desktop_key_up |
press <keys> |
{key, modifiers} |
desktop_hover / desktop_drag |
hover/drag |
{ref: "e5"} or {from, to} |
desktop_mouse_move / desktop_mouse_click / desktop_mouse_down / desktop_mouse_up |
— | {x, y, button} |
desktop_wait |
wait --element / --window / --text / --menu / --notification |
{condition, timeout_ms} |
desktop_watch_element (P2-O11) |
watch --event … |
{ref: "e5", events: [EventKind], timeout_ms} — streams via notifications/message |
desktop_launch_app / desktop_focus_window / desktop_resize_window / desktop_move_window / desktop_minimize / desktop_maximize / desktop_restore |
app / window ops | App / window args |
desktop_clipboard_set / desktop_clipboard_clear |
— | {text} / {} |
desktop_notification_action |
notification-action <idx> <action> |
{index, expected_app?, expected_title?, action} (NC-reorder safe) |
desktop_text_select_range / desktop_text_get_selection / desktop_text_insert_at_caret / desktop_text_at_offset (P2-O12) |
text … subcommands |
{ref, start, length, text?} |
Destructive tools (gated by both interactive and destructive capabilities; always write to the Phase 5 audit log):
| MCP Tool | CLI |
|---|---|
desktop_close_app |
close-app <app> [--force] |
desktop_dismiss_notification |
dismiss-notification <idx> |
desktop_dismiss_all_notifications |
dismiss-all-notifications |
desktop_batch |
batch — accepts destructive sub-commands, each evaluated against its own annotation |
MCP Resource Types
Resources let hosts pull structured state without re-issuing a tool call:
| URI | Content | Update model |
|---|---|---|
agent-desktop://refmap/current |
JSON RefMap for the current MCP session (not the on-disk CLI refmap) | Replaced on every desktop_snapshot invocation; subscribable via notifications/resources/updated |
agent-desktop://snapshot/latest |
Last desktop_snapshot response as JSON (tree + refmap + metadata) |
Same update model |
agent-desktop://permissions/current |
Tri-state permission report (AX, Screen Recording, Automation, display-server) | Refreshed on request; subscribable when Phase 2 P2-O17 permission observer is available |
agent-desktop://events/stream |
Merged watch_element event stream for the session |
Real-time, subscribable |
agent-desktop://audit/{trace_id} |
Phase 5 append-only audit log entries for a trace | Growable; new entries as notifications/resources/updated |
Framework Integration Targets
Every major 2026 MCP host gets a validated config example committed to examples/mcp-hosts/:
| Host | Config file | Transport | Notes |
|---|---|---|---|
| Claude Desktop | claude_desktop_config.json |
stdio | Already widespread; our reference host |
| Cursor | .cursor/mcp.json |
stdio | Per-workspace config |
| VS Code (Copilot) | .vscode/mcp.json + settings.json |
stdio | Copilot Chat 2026 adds MCP tool discovery |
| Gemini CLI | ~/.config/gemini-cli/mcp.json |
stdio | Google's first-party MCP integration |
| Microsoft Agent Framework 1.0 | agentframework.yaml MCP section |
Streamable HTTP | Cloud-first host, requires HTTP transport (P4-O12) |
| Zed editor | ~/.config/zed/settings.json |
stdio | Desktop IDE with MCP-native agents |
| Continue.dev | config.json MCP section |
stdio | OSS agent framework |
Each host gets a ~30-line config + a 60-second "hello agent" demo (launch Calculator → compute something → verify result) in the examples/ directory as a runnable acceptance test.
Transport
- Stdio (primary): MCP host spawns
agent-desktop --mcpas a child process. JSON-RPC over stdin/stdout. Required; validated against all hosts in the Framework Integration table. - Streamable HTTP (P4-O12, required for MS Agent Framework): Single HTTP endpoint at
POST /mcpwith chunked response streaming; replaces the pre-March-2025 SSE transport. Used when the host declarestransport: httpin its MCP config. Binds to127.0.0.1by default;--mcp-bind <addr:port>CLI flag overrides. - SSE (legacy): Retained for hosts that haven't migrated to Streamable HTTP. Gated on
--mcp-transport sse. - Session: On
initialize, detect platform, probe permissions (AX + Screen Recording + Automation tri-state), report tool capabilities given current permissions. The current CLI already supports--session <id>as an on-disk latest-snapshot namespace. MCP adds per-host in-memory session state keyed bysession_id; it must not use the legacy~/.agent-desktop/last_refmap.jsonartifact and should bridge to the same explicit snapshot semantics as the CLI.
Initialize Handler
On receiving MCP initialize:
- Detect platform (macOS / Windows / Linux)
- Check permissions (
permission_report()) - Report capabilities: list of available tools, platform, permission status
- If permissions not granted, include guidance in capabilities response
New Dependencies
| Crate | Version | Purpose | License |
|---|---|---|---|
rmcp |
0.15.0+ | Official MCP Rust SDK — #[tool] macro, JSON-RPC handling, transport |
MIT/Apache-2.0 |
schemars |
1.2+ | JSON Schema generation for tool parameter definitions | MIT/Apache-2.0 |
tokio |
1.x | Async runtime (required by rmcp for MCP server event loop) | MIT |
Note: If tokio was already introduced in Phase 3 (Linux), it is already available. Otherwise, it is introduced here.
Binary Crate Changes
src/main.rs/src/cli/— Add--mcpflag detection, route to MCP server modeCargo.toml— Addagent-desktop-mcpdependency (non-platform-gated, available on all platforms)- No changes to
src/dispatch/or command files — MCP tools call the sameexecute()functions
Testing
Unit tests (mcp):
- Tool definition schema validation — every tool's JSON Schema is valid
- Tool invocation round-trip — call tool, verify response matches CLI output
- Initialize handler — correct capabilities, platform detection, permission status
Integration tests:
- Full MCP protocol compliance — initialize, tools/list, tool invocation, error responses
- Claude Desktop end-to-end: launch app → snapshot → click button → verify action
- Cursor end-to-end: same workflow
- Session isolation: RefMap is session-scoped, not shared across sessions
- Protocol edge cases: malformed requests, unknown tools, invalid parameters
Cross-platform:
- MCP server works identically on macOS, Windows, and Linux
- Same tool invocations produce same JSON structure on all platforms
Framework host acceptance tests (one per row in the Framework Integration table):
- Claude Desktop: launch Calculator → snapshot → click buttons → verify result string via
desktop_get - Cursor: open a code file → snapshot editor →
desktop_text_insert_at_careta function → verify file content - VS Code Copilot: same as Cursor on the VS Code host
- Gemini CLI: text-only interaction — list open windows, focus one, dismiss a notification
- Microsoft Agent Framework 1.0 (Streamable HTTP): HTTP-based MCP client runs the same Calculator demo against
http://127.0.0.1:<port>/mcp - Zed: editor-focused scenario (open file → select range → replace)
- Continue.dev: Claude Opus 4.7 with our server runs a 3-step canvas test in TextEdit
Capability negotiation tests (P4-O9):
- Host that negotiates only
observationcannot invokedesktop_click— MCP error with clear-32601 Method not found within capability setmessage - Host that negotiates
interactivebut notdestructivecannot invokedesktop_close_app initializeresponse'ssupported_toolslist shrinks correctly when AX permission is denied (onlydesktop_permissions,desktop_version,desktop_statusremain)
Event streaming tests (P4-O7):
desktop_watch_elementsubscription receivesnotifications/messageevents for a programmatic value change within 500 ms of the change on all three platforms- Two concurrent watches on different refs get their events routed to the correct subscription ID
MCP Config Examples
Provide ready-to-use config snippets for:
Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"agent-desktop": {
"command": "agent-desktop",
"args": ["--mcp"]
}
}
}
Cursor (.cursor/mcp.json):
{
"mcpServers": {
"agent-desktop": {
"command": "agent-desktop",
"args": ["--mcp"]
}
}
}
Skill Update
Skill maintenance rules:
- Create
skills/agent-desktop-mcp/SKILL.md:- MCP tool surface documentation (all tools, parameters, annotations)
- Transport configuration (stdio setup, optional SSE)
- Session management (RefMap scoping, initialize flow)
- Tool-to-CLI mapping reference
- MCP-specific error handling
- Update core
SKILL.md:- Add MCP mode section
- Add MCP skill to skill graph table
- Update
workflows.md:- Add MCP workflow patterns (tool invocation from Claude Desktop, Cursor)
- Add session lifecycle patterns
README Update
- Add "MCP Server" section:
- How to start:
agent-desktop --mcp - What it does: wraps all CLI commands as MCP tools
- Session behavior: RefMap scoped per session
- How to start:
- Add Claude Desktop configuration snippet
- Add Cursor configuration snippet
- Document
--mcpflag in CLI reference - Add note: every MCP tool maps 1:1 to a CLI command
Phase 5 — Production Readiness
Status: Planned
Phase 5 transforms agent-desktop from functional to enterprise-grade. Persistent daemon process, in-memory session multiplexing for concurrent agents, the safety trio required for enterprise and regulated deployments (dry-run + confirm + audit log), an OCR/vision fallback for custom-rendered UIs where the accessibility tree is empty, OpenTelemetry-compatible trace export on top of the current JSONL reliability trace, and first-class distribution via native package managers.
Objectives
| ID | Objective | Metric |
|---|---|---|
| P5-O1 | Persistent daemon | Warm snapshot completes in <50ms (vs 200ms+ cold start) |
| P5-O2 | Daemon session multiplexing | Two agents hold independent in-memory RefMaps without interference; CLI --session remains the on-disk latest-snapshot namespace for non-daemon use |
| P5-O3 | Enterprise quality gates | All gates in quality gates table pass |
| P5-O4 | Package manager distribution | Available via brew (macOS), winget/scoop (Windows), snap/apt (Linux) with Sigstore attestation verification on install |
| P5-O5 | Safety trio: --dry-run / --confirm / append-only audit log |
Every destructive command supports --dry-run (resolves ref, computes the action, emits the would-be JSON response, does not execute), --confirm (stderr prompt with configurable timeout), and ~/.agent-desktop/audit.jsonl append-only log with trace_id, actor, tool, args, decision (allowed / dry-run / denied / confirmed), exit code, timestamp. Covers EU AI Act Article 14 and OWASP Agentic Top-10 (2026) requirements |
| P5-O6 | Policy allowlist / denylist | ~/.agent-desktop/policy.yaml defines per-tool rules — e.g. "never call desktop_close_app for com.apple.finder", "require confirm for any action on bundle ID com.apple.mail". Loaded at daemon start, reload-on-SIGHUP. Policy decisions land in the audit log |
| P5-O7 | OCR / vision fallback (find --visual) |
When the AX tree is empty or the target isn't exposed (Canvas apps, Flutter-desktop, games, remote desktop, Figma plugins), find --visual "label" falls back to a per-window screenshot + OCR to locate text. macOS: Vision framework VNRecognizeTextRequest. Windows: Windows.Media.Ocr.OcrEngine. Linux: Tesseract via tesseract crate. Returns a synthetic ref that routes to coordinate events; clearly marked source: "visual" in output to signal reduced reliability |
| P5-O8 | OpenTelemetry trace export | Current --trace <path> writes redacted JSONL reliability diagnostics. Phase 5 adds trace IDs, span structure, agent-desktop trace view <uuid>, and OTLP/HAR export without changing the existing stdout JSON contract |
| P5-O9 | Screencast / screenshot-per-action receipt | --record-trace <path.mp4> on long-running MCP sessions or CLI batches. Uses Phase 2 P2-O13 modern screenshot APIs at 2 Hz by default. Parity with Playwright 1.59 page.screencast. Mutually exclusive with --dry-run (nothing to record) |
| P5-O10 | Sigstore attestation verification at install time | brew install formula and winget manifest run cosign verify-blob / gh attestation verify against the downloaded tarball before installing. Prevents supply-chain tampering. apt/snap use distro-native signatures; the formula publishes both Sigstore bundle and the checksum |
Daemon Architecture
The daemon is a long-running process that maintains state between CLI/MCP invocations, dramatically reducing startup latency.
Auto-start:
- CLI detects if daemon is running by checking for socket file (
~/.agent-desktop/daemon.sockon Unix, named pipe on Windows) - If not running, spawns daemon as background process
- Daemon listens on the socket for incoming commands
Auto-stop:
- Daemon exits after configurable idle timeout (default 5 minutes)
- No active sessions = idle timer starts
- Any new connection resets the idle timer
Session multiplexing:
- The current CLI
--session <id>persists snapshots on disk and scopes only the latest-snapshot pointer. - The daemon upgrades that model to warm, in-memory per-session RefMaps while preserving explicit snapshot IDs as deterministic handles.
- Sessions are isolated: agent A's latest pointer never collides with agent B's latest pointer.
- Session destroyed on disconnect or explicit
session kill.
Health check:
agent-desktop statusreturns: daemon PID, uptime, active session count, platform, permission status
New Commands
| Command | Description |
|---|---|
session list |
List active daemon sessions with IDs, creation time, last activity |
session kill <id> |
Terminate a specific daemon session, release its RefMap |
trace view <uuid> |
Pretty-print a session trace from ~/.agent-desktop/traces/{uuid}.jsonl |
trace export <uuid> [--otlp | --har] |
Export a session trace as OpenTelemetry OTLP JSON or HAR for post-mortem inspection |
audit tail [--follow] |
Tail ~/.agent-desktop/audit.jsonl, optionally streaming new entries |
audit verify <path> |
Verify the append-only integrity of an audit log (hash-chain check) |
policy check <command> <args…> |
Evaluate the policy file against a would-be command without executing |
find --visual "<label>" |
OCR-based visual fallback when the AX tree has no match for label (P5-O7) |
Every command gains --dry-run |
Resolve ref, compute action, emit the would-be response, do not execute (P5-O5) |
Every destructive command gains --confirm [--confirm-timeout <ms>] |
Prompt on stderr before executing; defaults off for CLI, on for MCP destructive capability |
Every command gains --trace-id <uuid> |
Correlate the existing --trace JSONL events and future daemon/MCP spans; auto-generated when not provided (P5-O8) |
Every command gains --record-trace <path.mp4> |
Screencast while the command runs (P5-O9) |
CLI-to-Daemon Migration
When daemon is running:
- CLI command parses arguments as usual
- Instead of directly calling the adapter, CLI connects to daemon socket
- Sends serialized command to daemon
- Daemon executes command in the caller's session context
- Returns JSON response to CLI
- CLI prints response to stdout
When daemon is not running, CLI falls back to direct execution (same as Phases 1-4). Daemon is purely an optimization, never a requirement.
Safety Trio: --dry-run / --confirm / Audit Log (P5-O5)
Every destructive operation — close-app, dismiss-all-notifications, set-value (writes), clear, drag, deliver-files, notification-action, batch containing any of the above — supports three layered safety primitives that compose:
--dry-runresolves refs, validates all inputs, evaluates the policy, computes the would-bedata/errorfields, and emits the normal JSON envelope withdry_run: trueadded. No adapter call happens. The ref stays valid for a subsequent non-dry-run invocation within the same snapshot.--confirmprints a structured prompt to stderr:
Defaults: CLI = off (opt-in), MCPagent-desktop: destructive action requires confirmation command: close-app target: Finder (bundle com.apple.finder) trace: 9f3c2a… Proceed? [y/N] (30s timeout)destructivecapability = on (opt-out viaskipConfirm: trueat init).- Append-only audit log at
~/.agent-desktop/audit.jsonl:
Hash-chained (Merkle-style) so{"ts":"2026-05-…","trace_id":"9f3c…","actor":"cli|mcp:claude-desktop","tool":"close-app","args":{"app":"Finder"},"policy_decision":"allowed","user_decision":"confirmed","exit":0,"prev_hash":"sha256:…","entry_hash":"sha256:…"}agent-desktop audit verifydetects tampering. File mode0o600, directory0o700. Rotated at 100 MB viaaudit.jsonl.{N}.gz.
Maps to real regulatory anchors: EU AI Act Article 14 (human oversight + traceability), OWASP Agentic Top-10 2026 AA-02 (human-in-the-loop) / AA-06 (audit trail). Shipping without the trio closes off enterprise adoption; shipping with it opens it.
Policy Engine (P5-O6)
~/.agent-desktop/policy.yaml, loaded at daemon start, reloaded on SIGHUP:
version: 1
rules:
- match: { tool: close-app, bundle: com.apple.finder }
decision: deny
reason: "Finder is a system app — refusing."
- match: { tool: set-value, bundle: com.apple.mail }
decision: require-confirm
- match: { trace_mcp_host: claude-desktop }
decision: allow
- default: allow
Matchers: tool (glob), bundle (exact or glob), pid, trace_mcp_host (cli / mcp:<name>), ref_role, ref_name (regex). Decisions: allow / deny / require-confirm / dry-run-only. Every evaluation writes to the audit log with the matched rule ID for post-mortem.
OCR / Vision Fallback (P5-O7)
find --visual "<label>" closes the gap on apps that don't expose an accessibility tree (Figma plugins, Unity/Unreal games, Flutter-desktop apps, remote desktop clients, Canvas-based whiteboarding).
1. Capture the focused window via P2-O13 modern screenshot API.
2. Run OCR (platform-native, no extra runtime dep on macOS/Windows):
macOS: Vision.VNRecognizeTextRequest
Windows: Windows.Media.Ocr.OcrEngine
Linux: Tesseract via the `tesseract` crate (libtesseract bundled)
3. Fuzzy-match the label against recognized text spans (Levenshtein ≤ 2).
4. Pick the highest-confidence hit; return a synthetic ref (`@v1`, `@v2`)
that routes any subsequent action through coordinate-based input.
5. Tag the ref `source: "visual"` and downgrade confidence in the
response so the agent knows it's acting on OCR not AX.
STALE_REF semantics stay the same — a visual ref invalidates on the next snapshot. Visual refs never cache in the refmap persisted to disk.
Trace Export + OpenTelemetry (P5-O8)
Today, callers opt into a redacted reliability trace with --trace <path> and may add --trace-strict to fail on setup or pre-action trace write errors. Phase 5 layers trace IDs and span/export tooling on top of that existing JSONL event stream:
{"ts":"…","trace_id":"9f3c…","span_id":"…","parent_span_id":"…","name":"cli.snapshot","kind":"internal","attributes":{"app":"Finder","skeleton":true,"ref_count":14,"duration_ms":87}}
{"ts":"…","trace_id":"9f3c…","span_id":"…","parent_span_id":"<snapshot span>","name":"adapter.macos.get_tree","duration_ms":72,"attributes":{"surface":"window"}}
Phase 5 spans are OpenTelemetry-compliant so agent-desktop trace export <uuid> --otlp emits a valid OTLP JSON payload ingestable by Grafana Tempo / Jaeger / Honeycomb / Datadog. --har exports a HAR-like envelope for quick manual inspection. Screencasts from --record-trace attach as trace links.
Enterprise Quality Gates
| Gate | Requirement |
|---|---|
| Security | No arbitrary code execution. No privilege escalation. All actions allowlisted via Action enum. Daemon socket scoped to user. Policy engine denies by default when the policy file is syntactically invalid. |
| Safety | Every destructive command supports --dry-run; every MCP destructive tool requires the destructive capability + audit log; the audit log is hash-chained and tamper-detectable; policy engine evaluated on every invocation. |
| Performance | Cold start <200ms. Warm snapshot <50ms via daemon. Tree traversal timeout 5s default, configurable. watch --event latency <500ms (push, not poll) per P2-O11. |
| Reliability | Zero panics in non-test code. Graceful daemon recovery on crash. Stale socket cleanup on startup. FFI panic boundary in release-ffi profile (already shipping). |
| Observability | Current commands can opt into redacted JSONL via --trace. Phase 5 adds daemon metrics, trace IDs, and OpenTelemetry OTLP export via trace export --otlp. |
| Compatibility | Tested against target app matrix: Finder, TextEdit, Xcode, VS Code, Chrome, Slack (macOS); Explorer, Notepad, Settings, VS Code, Edge (Windows); Nautilus, Terminal, Firefox, VS Code (Linux). |
| Distribution | Single binary per platform. No runtime dependencies for the CLI. FFI cdylib tarballs signed via Sigstore (already shipping as of Phase 1.5). Formula / manifest verify Sigstore attestation before installing (P5-O10). |
| Documentation | README, CLI reference, MCP reference, per-platform setup guides, troubleshooting, audit-log format reference, policy-file reference, OpenTelemetry trace schema. |
| FFI stability | Header drift check green on every PR. ABI version exported via ad_abi_version(). Pre-1.0: minor version bump for any public struct field add; major version bump for any removed or changed signature. |
Performance Optimizations
| Optimization | Platform | Details |
|---|---|---|
| CacheRequest batching | Windows | Batch UIA attribute fetches via CacheRequest — reduces COM round-trips |
| Async tree walking | Linux | Parallel D-Bus calls for tree traversal — concurrent child fetching |
| Cached subtrees | All (daemon) | Reuse unchanged subtrees between snapshots in same session — skip re-traversal of stable UI regions |
| Warm adapter | All (daemon) | Adapter stays initialized between commands — skip COM init (Win), D-Bus connect (Linux), AX bootstrap (macOS) |
| Progressive skeleton drill | All | Skeleton overview + targeted drill-down reduces token consumption 78-96% for dense apps — fewer tokens per snapshot means more budget for actions |
Package Manager Distribution
| Platform | Package Manager | Format | Install Command | Signing |
|---|---|---|---|---|
| macOS | Homebrew | Formula in <owner>/homebrew-tap |
brew install <owner>/tap/agent-desktop |
Sigstore cosign verify-blob against release tarball |
| Windows | winget | Manifest in microsoft/winget-pkgs |
winget install agent-desktop |
Sigstore attestation check via gh attestation verify |
| Windows | scoop | Manifest in scoop-extras bucket |
scoop install agent-desktop |
Sigstore attestation check |
| Linux | snap | Snap package on snapcraft.io | snap install agent-desktop |
Snap-native signature (snapd-signed) |
| Linux | apt | .deb in custom PPA (ppa:<owner>/agent-desktop) |
apt install agent-desktop |
Debian-native Release.gpg signature |
| All | cargo install |
crates.io (the CLI binary crate, not the workspace) | cargo install agent-desktop |
Sigstore provenance on the crates.io release |
Each package manager distribution includes:
- Prebuilt binary for the target platform (matches
.github/workflows/release.ymlmatrix output) - Matching FFI cdylib tarball for consumers who want both the CLI and the library (Phase 1.5 artifacts)
- SHA256 checksum verification (unchanged from Phase 1)
- Sigstore build-provenance verification at install time (P5-O10) — formulas / manifests run
gh attestation verify/cosign verify-blobbefore extracting - Automatic PATH setup
- First-run Accessibility permission walkthrough (macOS) / UIA check (Windows) / AT-SPI bus check (Linux)
- Uninstall support
Testing
Daemon tests:
- Daemon starts on first CLI command when not running
- Daemon stops after idle timeout with no active sessions
- Multiple concurrent sessions have isolated RefMaps
- Session list returns correct session metadata
- Session kill terminates session and releases resources
- Stale socket cleaned up on daemon restart
- Daemon crash recovery — CLI falls back to direct execution
- Warm snapshot performance: <50ms after initial cold start
Quality gate tests:
- Security: verify Action enum is exhaustive, no shell injection vectors
- Performance: benchmark cold start (<200ms) and warm snapshot (<50ms)
- Reliability: stress test with concurrent sessions, verify zero panics
- Compatibility: snapshot + click workflow on each app in target matrix
Package tests:
- brew formula installs and runs on macOS;
brew reinstall --debug agent-desktopshows Sigstore verification log - winget/scoop manifest installs and runs on Windows; manifest's
InstallerSuccessExitCodesincludes 0; Sigstore check in install script - snap package installs and runs on Ubuntu;
--talk-name=org.a11y.Buspermission requested - apt
.debinstalls and runs on Ubuntu via PPA;debsignsignature verified cargo install agent-desktopsucceeds from crates.io with provenance attestation- All packages produce correct
versionoutput including the ABI version - All packages handle permissions correctly on their platform
Safety trio tests (P5-O5, P5-O6):
close-app Finder --dry-runemits{"data": {"would_close": "com.apple.finder"}, "dry_run": true}and does not actually closeclose-app Finder --confirm --confirm-timeout 2000times out withErrorCode::Timeout+ audit entryuser_decision: timeout- Policy
denyrule againstclose-apponcom.apple.finderreturnsPermDeniedwith the matched rule ID; audit entrypolicy_decision: deny audit verifyon a hand-editedaudit.jsonlreports the exact tampered lineaudit verifyon a legitimate append-only log passes cleanly- Concurrent audit writes serialize correctly under
flock-protected append
OCR fallback tests (P5-O7):
find --visual "Sign in"on a Figma-plugin-style Canvas app returns a@v1synthetic ref; subsequentclick @v1invokes coordinate-based input at the OCR hit centerfind --visualon an app with an accessibility tree falls back only when the AX search returns zero hits (does not shadow AX)- OCR confidence threshold: below 0.6, return
ElementNotFoundrather than a low-confidence synthetic ref - Visual refs never persist to disk refmap
- On Linux without Tesseract installed,
find --visualreturnsPlatformNotSupportedwith the install command
Trace export tests (P5-O8):
- Commands run with
--trace <path>write at least one redacted JSONL reliability event trace export <uuid> --otlpproduces a valid OpenTelemetry JSON payload that passesotel-cli validate- A multi-command batch under a single
--trace-idproduces a single-rooted span tree (batch command is the parent) - MCP sessions propagate the
trace_idfrom the host'sinitializeparams if provided; otherwise generate
Install-time Sigstore tests (P5-O10):
- Homebrew formula
installstep fails fast if the downloaded tarball's attestation fails verification - Winget manifest includes a pre-install script that runs
gh attestation verify - Tampered tarball (bit-flip) reliably fails verification
Skill Update
Skill maintenance rules:
- Update
commands-system.md:- Add
session listcommand documentation - Add
session kill <id>command documentation - Update
statuscommand to document daemon-specific fields (PID, uptime, sessions)
- Add
- Update
workflows.md:- Add daemon lifecycle patterns (auto-start, idle timeout, health checks)
- Add concurrent agent patterns (session isolation, multi-agent coordination)
- Add performance optimization patterns (warm snapshot, cached subtrees)
- Update platform skills:
- Document enterprise quality gates in each platform skill
- Add daemon-specific troubleshooting (stale socket, port conflicts)
README Update
- Add "Daemon Mode" section:
- How it works: auto-start, auto-stop, session isolation
- Configuration: idle timeout, socket location
- Health check:
agent-desktop status
- Add package manager installation methods:
brew install agent-desktop(macOS)winget install agent-desktop(Windows)snap install agent-desktop(Linux)
- Add "Performance" section:
- Cold start vs warm snapshot benchmarks
- Daemon mode benefits
- Update installation section with all distribution channels (npm, brew, winget, scoop, snap, apt, source)
- Final polish:
- Complete CLI reference for all commands including
session listandsession kill - Comprehensive troubleshooting guide covering all platforms
- Per-platform setup guides linked from main README
- Complete CLI reference for all commands including
Cross-Phase Requirements
README Update Schedule
The README is updated at the end of each phase to reflect the current state:
| Phase | README Changes |
|---|---|
| Phase 1 | Initial README: npm + source installation, core workflow, all 54 commands, JSON output, ref system, error codes, platform support table (macOS only) |
| Phase 1.5 | Add "Language bindings (FFI)" section: platform→artifact table, 5-line Python dlopen snippet, shasum -a 256 -c checksums.txt + gh attestation verify verification, link to skills/agent-desktop-ffi/ |
| Phase 2 | Add Windows: .exe installation, Windows permissions, update platform table, Windows build instructions |
| Phase 3 | Add Linux: binary installation, AT-SPI2 setup, update platform table, Linux build instructions, minimum OS versions |
| Phase 4 | Add MCP Server: --mcp usage, Claude Desktop config, Cursor config, tool-to-CLI mapping |
| Phase 5 | Add daemon mode, package managers (brew/winget/snap), performance benchmarks, final troubleshooting guide |
Skill Maintenance Rules
Skill maintenance rules:
- Every new command must be added to the appropriate
commands-*.mdfile - Every new platform gets its own skill directory under
skills/agent-desktop-{platform}/ - Every new mode (MCP, daemon) gets its own skill file
- Breaking changes to JSON output or CLI flags must update all affected skill files
- Skill files are reviewed as part of the PR checklist for any command-surface change
Command Surface DRYness (enforced across all phases)
See Command Surface Architecture for the full layering. Summary of the invariant enforced on every PR:
- A new command creates exactly one file under
crates/core/src/commands/. - CLI and batch must share the typed
Commandsenum,CommandPolicy, anddispatch()path. - Any future registry/codegen must be deterministic
build.rsfilesystem enumeration, notinventoryorlinkme. - Per-platform work is limited to the
PlatformAdaptertrait implementations incrates/{macos,windows,linux}/— never per-transport, never per-command. - PRs that add a command to a single transport without updating the shared registry fail review. If a task in this document sounds like it requires per-transport duplication, it's a wording bug — the actual implementation follows the registry pattern.
CI Matrix Evolution
| Phase | CI Runners |
|---|---|
| Phase 1 | macos-latest (tests + CLI build) + ubuntu-latest (fmt job) |
| Phase 1.5 | Same as Phase 1 on PRs; release workflow fans out to macos-latest × 2 darwin arches + ubuntu-22.04 + ubuntu-22.04-arm + windows-latest for the FFI matrix |
| Phase 2 | macOS + Windows (CLI tests on Windows) |
| Phase 3 | macOS + Windows + Ubuntu |
| Phase 4 | macOS + Windows + Ubuntu (+ MCP protocol tests) |
| Phase 5 | macOS + Windows + Ubuntu (+ daemon tests, package build verification) |
All runners enforce: cargo clippy --all-targets -- -D warnings, cargo test --workspace, cargo tree -p agent-desktop-core contains zero platform crate names, binary size <15MB.
Dependency Introduction Schedule
| Dependency | Introduced In | Purpose |
|---|---|---|
clap 4.x, serde 1.x, thiserror 2.x, tracing 0.1+, base64 0.22+ |
Phase 1 | Core: CLI, JSON, errors, logging, encoding |
tracing-subscriber 0.3, rustc-hash 2.1 |
Phase 1 | Log formatter + fast hashing |
accessibility-sys 0.1+, core-foundation 0.10+, core-graphics 0.24+ |
Phase 1 | macOS AX API FFI |
cbindgen maintainer tool, libc 0.2+ |
Phase 1.5 | explicit C header regeneration + macOS pthread_main_np for FFI main-thread guard |
uiautomation 0.24+ |
Phase 2 | Windows UIA wrapper |
windows 0.62.2 |
Phase 2 | Win32 / WinRT bindings (pinned to match windows-capture 1.5 pin) |
windows-capture 1.5.4 |
Phase 2 | Modern Windows.Graphics.Capture screenshot |
objc2 0.6 |
Phase 2 | macOS safe Objective-C bridging (scoped to system/screenshot.rs + system/permissions.rs; CI grep guard) |
screencapturekit 1.5 (crates.io) |
Phase 2 | ScreenCaptureKit wrapper — published canonical crate, not git fork |
atspi 0.28+ + zbus 5.x |
Phase 3 | Linux AT-SPI2 client via D-Bus |
tokio 1.x |
Phase 3 | Async runtime (required by atspi/zbus) |
rmcp 0.15.0+ |
Phase 4 | Official MCP Rust SDK |
schemars 1.2 |
Phase 4 | JSON Schema generation for MCP tool parameters (deferred from Phase 2 per plan §KD15 — no Phase 2 consumer) |
Explicitly NOT Added (research-rejected)
| Crate | Rejected at | Reason |
|---|---|---|
inventory 0.3 |
Phase 2 plan review | Link-GC unreliable across ld64, ld-prime, GNU ld, lld, MSVC for cdylib consumers. Research Topic B: inventory::submit! ctor sites are stripped when an rlib is linked into a binary that never references a symbol from that rlib. Replaced with build.rs filesystem enumeration. |
linkme |
Phase 2 plan review | Named linker sections have active Windows/lld-link edge cases (issues #70, #85, #114). Same reason as inventory rejection. |
xtask workspace crate |
Phase 2 plan review | Not needed once codegen is pure build.rs. Replaced with a tiny build-helpers/ workspace crate holding the shared filesystem-enumeration function. |
Platform API Quick Reference
| Capability | macOS | Windows | Linux |
|---|---|---|---|
| Tree root | AXUIElementCreateApp(pid) |
IUIAutomation.ElementFromHandle() |
atspi Accessible on bus |
| Children | kAXChildrenAttribute |
TreeWalker.GetFirstChild |
GetChildren D-Bus |
| Click | AXPress |
InvokePattern.Invoke() |
Action.DoAction(0) |
| Set text | AXValue = val |
ValuePattern.SetValue() |
Text.InsertText |
| Keyboard | CGEventCreateKeyboard |
SendInput |
xdotool / ydotool |
| Clipboard | NSPasteboard |
Win32 Clipboard API | wl-clipboard / xclip |
| Screenshot | ScreenshotBackend over secure screencapture path today; ScreenCaptureKit planned |
BitBlt / PrintWindow legacy, Windows.Graphics.Capture planned |
PipeWire / XGetImage |
| Permissions | AXIsProcessTrusted() |
COM security / UAC | Bus availability |
| Notifications | Notification Center AX tree (com.apple.notificationcenterui) |
UIA tree of Action Center / Toast Manager | D-Bus org.freedesktop.Notifications + daemon-specific history |
| System tray | SystemUIServer AX tree + ControlCenter AX tree |
UIA tree of Shell_TrayWnd + overflow window |
D-Bus StatusNotifierWatcher + XEmbed fallback |
Risk Register
| ID | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | macOS TCC friction deters adoption | High | High | Clear first-run guidance. Detect before any op. One-command setup: permissions --request. |
| R2 | Electron/Chrome no a11y tree by default | High | Medium | Detect Chromium windows. Print --force-renderer-accessibility guidance in error response. |
| R3 | Custom-rendered UIs invisible to a11y | Medium | High | Phase 5 stretch: vision fallback. Short-term: document limitation in README and skills. |
| R4 | Wayland a11y gaps | Medium | Medium | Focus on GNOME (best AT-SPI2 support). Prefer AT-SPI actions over coordinate input. Document gaps. |
| R5 | Rust a11y crate maintenance stalls | Low | High | Pin versions, maintain patches. atspi backed by Odilia project. Fork-ready. |
| R6 | MCP spec changes break compat | Low | Medium | Pin rmcp version. Monitor spec under Linux Foundation governance. |
| R7 | Tree traversal too slow (>5s) | Medium | Medium | Depth limiting via --max-depth. Focused-window-only. Cached subtrees in Phase 5 daemon. Progressive skeleton traversal (--skeleton + --root) reduces token consumption 78-96% for dense apps. |
| R8 | Ref instability confuses agents | Medium | High | Clear docs: refs are snapshot-scoped. STALE_REF error with recovery hint. Stable hashing in Phase 5. Progressive skeleton traversal with scoped invalidation provides a stable drill-down workflow for navigating complex UIs. Phase 2: stable-selector fields (identifier, subrole, role_description, placeholder, dom_id, dom_classes via StableSelectors flatten) + identifier-preferred resolver drop STALE_REF rate on Electron / localized apps. |
| R9 | Headless operation requirement | High | Critical | Phase 1 introduced ActionRequest/InteractionPolicy, default no focus steal/cursor movement, and explicit physical/headed policy paths. Phase 2 must preserve the same contract for Windows/Linux. |
| R10 | Command registry link-GC | Medium | High | Research Topic B confirmed inventory/linkme are unreliable across linkers for cdylib consumers. Resolved by pure build.rs filesystem enumeration — zero linker magic. |
| R11 | Skeleton traversal cross-platform | Low | High | Core is already platform-agnostic (crates/core/src/snapshot_ref.rs); Windows needs ~50 LOC glue (ControlViewWalker + FindAll(TreeScope_Children, TrueCondition) + fresh UICacheRequest per drill-down). Research Topic 4 confirmed ElementFromHandle(hwnd) is headless-safe. |