Tracks docs/plans and docs/brainstorms, adds .gitleaks.toml with privacy rules, and runs a history-mode gitleaks scan in CI. Docs and tooling only - no releasable change.
188 KiB
| title | type | status | date | origin | deepened |
|---|---|---|---|---|---|
| Phase 2 — Windows Adapter + Cross-Platform Feature Parity | feat | active | 2026-04-18 | docs/brainstorms/2026-04-18-phase2-windows-crossplatform-brainstorm.md | 2026-04-18 |
Phase 2 — Windows Adapter + Cross-Platform Feature Parity
Overview
Phase 2 brings agent-desktop to Windows and closes every cross-platform feature-parity gap surfaced after v0.1.13. A single release ships 18 objectives (P2-O1 … P2-O18) across ~15 implementation units. The core CLI, JSON envelope, and ref system are preserved; what grows is the adapter surface (new trait methods), the type surface (new Action/ErrorCode/PermissionReport variants, stable-selector fields), the FFI surface (deterministic codegen, ad_abi_version), the Windows shell-surface command surface, and the platform count (macOS + Windows, both x86_64 and ARM64). Version target: v0.2.0 (breaking ABI + JSON schema).
Headless-First Invariant (CORE PRINCIPLE)
Every command in agent-desktop — existing and Phase 2 additions — must work headlessly inside the current user's active desktop session. "Headless" means: (a) agent-desktop's own process has no GUI, no Dock icon, no visible window, no menu bar; (b) the target app does NOT need to be foregrounded or focused to be observed/driven; (c) no user-visible focus changes are side-effects of automation; (d) no physical cursor movement unless the caller explicitly invokes a mouse command. It does not mean bypassing the OS desktop model: Session 0, Server Core, secure desktop, locked desktop, and other-user sessions are unsupported for accessibility/capture and must return structured PlatformNotSupported, PermDenied, or WindowNotFound errors.
This is the core agent-automation contract. Any Phase 2 design choice that violates it is rejected.
Headless rules by category
| Operation | Headless path | Forbidden |
|---|---|---|
| macOS observation | AX API (AXUIElementCopy*) — works on any process with granted Accessibility TCC; no focus needed |
Reading AX from non-main thread (Apple DTS: all AX calls main-thread only) |
| macOS action | AXUIElementPerformAction (press, raise, show-menu) — headless on any visible or minimized window |
Mouse-cursor synthesis when AX action is available; NSApp.activate as an action side-effect |
macOS events (watch_element) |
AXObserver bound to the main-thread CFRunLoop (bootstrapped by the CLI) |
Worker-thread CFRunLoop with observer (unsupported by Apple per Topic-A research) |
| macOS file delivery | NSWorkspace.open(urls:withApplicationAt:configuration:) with activates: false, or pasteboard + CGEventPostToPid(cmd+v) to the target PID |
NSDraggingSession (requires NSApp event loop + focus steal — not headless) |
| macOS screenshot | ScreenCaptureKit SCScreenshotManager.captureImage — headless, but requires Screen Recording TCC |
/usr/sbin/screencapture subprocess (legacy only, behind --screenshot-backend legacy) |
| Windows observation | IUIAutomation.ElementFromHandle(hwnd) — works on same-user, same-session visible/minimized windows at an accessible integrity level without foregrounding |
Cross-session, secure desktop, locked desktop, Session 0, Server Core |
| Windows action | UIA pattern invocation (InvokePattern.Invoke, ValuePattern.SetValue, TogglePattern.Toggle, ExpandCollapsePattern.Expand, SelectionItemPattern.Select) — all focus-independent |
SendInput as a primary path — it IS focus-dependent; only fallback when no UIA pattern applies, and gated by AttachThreadInput + SetFocus worker-thread dance |
Windows events (watch_element) |
UIA event handler on dedicated MTA apartment thread — UIA explicitly supports cross-thread event delivery per Microsoft's 2025 threading doc | Caching IUIAutomationElement across apartment boundaries (apartment-affine handles invalidate) |
| Windows file delivery | App/shell delivery first: app URI handlers, ShellExecuteEx, IFileOperation for filesystem destinations, and CF_HDROP clipboard paste where accepted |
Cursor-synthesized drag as primary path; using IDataObject + DoDragDrop before a policy-gated spike proves target behavior |
| Windows screenshot | windows-capture (Windows.Graphics.Capture — active interactive DWM session, Windows 10 1903+) |
Session 0 / Server Core / locked or secure desktop capture; PrintWindow is fallback-only |
| Skeleton traversal | Core snapshot_ref.rs + adapter.get_subtree(handle, opts) is platform-agnostic — skeleton works on any window without focus, on both platforms |
— |
| Clipboard | NSPasteboard.general (macOS) / OpenClipboard (Windows, no HWND required when passing NULL) |
— |
Verification
Every Phase 2 integration test MUST assert headless-ness explicitly:
- Target window is NOT the focused window at test entry (send test driver to background first).
- agent-desktop CLI runs with stdin/stdout/stderr as pipes (no TTY) where possible.
- Before and after the command,
list-windows --focused-onlyreturns the SAME focused window — no focus steal. - Cursor position is unchanged for commands that aren't
hover/drag/mouse-*. Click uses semantic accessibility paths by default; coordinate clicking requires an explicit physical path.
Skeleton traversal invariant
The --skeleton / --root @ref progressive traversal pattern (P2-O-skeleton, shipped in v0.1.11 for macOS) is preserved and extended to Windows in Unit 3. The contract:
snapshot --skeletonclamps depth to 3 and annotates truncated containers withchildren_count.- Named / described containers at the depth boundary receive refs as drill-down targets.
snapshot --root @refwalks from a previous-snapshot ref with scoped invalidation (only that ref's subtree refs are replaced on re-drill).- Refmap write-side 1 MB guard prevents runaway ref counts.
- Works on unfocused windows on both platforms (Windows
ElementFromHandle(hwnd)+ macOSAXUIElementCreateApplication(pid)are both focus-independent).
Windows implementation notes (research-driven):
- Use
ControlViewWalker(NOTRawViewWalkerorContentViewWalker) —IsControlElementauto-filters layout noise and complements the Electron depth-skip in Unit 4. children_countviaFindAll(TreeScope_Children, TrueCondition)— single COM round-trip, no per-child property fetch.- Fresh
UICacheRequestper drill-down call — cached elements do not survive CLI process boundaries. - Expected token savings on VS Code / Slack Electron track macOS's 50-100× once U4's
--force-electron-a11y+ empty-UIA_Group/UIA_Customdepth-skip are in place.
Problem Frame
Three orthogonal problems share one release (see origin §What Phase 2 is solving):
- Three-platform reach. macOS is the only shipping platform. Phase 2 brings Windows online with an identical command surface and JSON contract, ahead of Linux in Phase 3.
- Identifier instability. Today every ref resolves via
(pid, role, name, bounds_hash). Electron trees, localized apps, and custom-rendered controls fray this and inflateSTALE_REFrates. Stable-selector fields (identifier,subrole,role_description,placeholder,dom_id,dom_classes) — free on macOS and native to UIA — collapse the churn. - Polling-shaped waiting.
wait --elementpolls every 100 ms.watch_elementreplaces it with sub-500 ms push notifications on both platforms.
Five smaller gaps are cheap individually but collectively move agent-desktop from "macOS-only observation tool" to "cross-platform agent automation runtime": modern screenshot APIs (ScreenCaptureKit / windows-capture), text-range primitives, new Action variants, new surfaces (Toolbar / Spotlight / Dock / MenuBarExtras / Windows shell surfaces), tri-state permission probing, and an FFI registry migration that makes the Phase 4 MCP crate trivial to ship.
Nothing is deferred to Phase 3 that was in the Phase 2 brainstorm scope — the earlier 2a/2b split was rejected (see origin §D1).
Requirements Trace
Each requirement maps 1:1 to a phases.md P2-O* objective (see origin §Acceptance criteria). GA ships when every requirement's metric is green.
- R1 (P2-O1) — Windows adapter:
snapshot --app Explorerreturns a valid tree with refs; same for Notepad, Settings, VS Code, Edge. - R2 (P2-O2 — review-refined) — Cross-platform parity on a structurally-identical app (Calculator on both platforms):
roleset jaccard ≥ 0.85;identifierequality where non-empty on both sides; ref count within ±15%;available_actionsset union is a superset of common-actions across the role map. "Structurally identical" replaced the earlier byte-identical aspiration, which UIA↔AX role-mapping cannot guarantee. - R3 (P2-O3) — Windows input:
click @e5,type @e2 "hello",press ctrl+c, every mouse command succeed against a test app. - R4 (P2-O4) — Windows screenshot:
screenshot --app Notepadproduces a valid PNG viawindows-capture, SSIM-matchesPrintWindowfallback. - R5 (P2-O5) — Windows clipboard: get/set/clear roundtrip for ASCII and Unicode.
- R6 (P2-O6) — Windows CI:
windows-latestruns build, clippy, unit, contract, and non-interactive tests on every PR. UIA/shell integration tests that require Explorer, Start, Action Center, Quick Settings, or an unlocked desktop run on a labeled interactive/self-hosted Windows job or assert structured unsupported behavior when the shell is absent. - R7 (P2-O7) — Windows release: x86_64 + aarch64
.exeand FFI archives ship with the Phase 2 tag;npm installworks on both. - R8 (P2-O8) — Stable-selector fields populated on both platforms; measurable
STALE_REFrate drop vs Phase 1 baseline. - R9 (P2-O9 — research-refined) — Action variants (
LongPress,ForceClick,ShowMenu,DeliverFiles(renamed fromFileDropbecauseNSDraggingSessionis not headless-compatible — see Unit 12),WindowRaise,Cancel,SelectRange,InsertAtCaret) exposed via CLI and each green in a platform-appropriate integration test. Semantic actions assert no focus steal. Explicit focus/window/physical actions assert the side effect is policy-authorized and documented. - R10 (P2-O10) — ErrorCode variants (
PermissionRevoked,ResourceExhausted,AxMessagingTimeout,AutomationPermissionDenied) each have a runtime producer. - R11 (P2-O11) —
watch --event value-changed --ref @e5 --timeout 3000receives an event within 500 ms of a programmatic value change on both platforms. - R12 (P2-O12) —
text select-range+text get-selectionroundtrip;text insert-at-caretadvances caret correctly on both platforms. - R13 (P2-O13) — Modern screenshot cold-latency <50 ms on both platforms vs ~300 ms macOS subprocess baseline; default is modern, legacy behind
--screenshot-backend legacy. - R14 (P2-O14) —
snapshot --surface toolbaron Safari (macOS) and Edge (Windows) works; macOS lists Spotlight / Dock / MenuBarExtras; Windows lists present shell surfaces (Taskbar,SystemTray,SystemTrayOverflow,StartMenu,ActionCenter,QuickSettings); tray commands function. - R15 (P2-O15) — Electron compat on Windows: VS Code snapshot with
--force-electron-a11yexposes ≥100 refs at default depth. - R16 (P2-O16) — FFI registry: adding a command requires only a new file under
crates/core/src/commands/; CLI, FFI wrappers, and (future) MCP tools auto-register;ad_abi_version()exported;ad_set_log_callbackreceives tracing output duringad_get_tree. - R17 (P2-O17) — Permission tri-state:
permissionsoutput shows AX, Screen Recording, Automation independently on macOS. Denied Screen Recording returnsPermDeniedwith Screen-Recording-specific suggestion; denied Automation for a target app returnsAutomationPermissionDenied. - R18 (P2-O18) — Windows shell coverage: 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 are explicitly covered by commands, surfaces, tests, or documented
PLATFORM_NOT_SUPPORTEDbehavior. Windows-only commands still live in core command files with adapter defaults.
Scope Boundaries
- Included: every P2-O* objective in
docs/phases.md §Phase 2; v0.2.0 breaking ABI + JSON schema bump; MSRV bump to 1.82; ARM64 Windows (build-only until GH runner arrives); tri-state permission model on macOS; registry-driven FFI codegen;ad_abi_version()export.
Deferred to Separate Tasks
- Linux adapter — Phase 3 (separate plan). Trait methods ship with default
not_supported()implementations in U1 so Linux mirrors later without re-opening core. - MCP server mode — Phase 4.
- Daemon, sessions, audit log, policy engine, OCR fallback — Phase 5.
- Streamable HTTP transport — Phase 4 (stdio confirmed sufficient for MS Agent Framework 1.0).
- Package-manager distribution (brew/winget/snap) — Phase 5.
- Async FFI — Phase 4 once MCP streaming arrives (see origin §D9).
ForceClickon Linux — permanent per-platform divergence (returnsActionNotSupported), see origin §D8, R9.
Context & Research
Relevant Code and Patterns
crates/core/src/node.rs—AccessibilityNode(10 fields today, L3–L33). Unit 1 nests new selectors via#[serde(flatten)].crates/core/src/error.rs—ErrorCodeenum (12 variants, L4–L19; not yet#[non_exhaustive]). Paired withAdResultvariant-countconst _: () = assert!(…)incrates/ffi/src/error.rs:57.crates/core/src/action.rs—Actionenum (already#[non_exhaustive], 21 variants). Unit 1 adds 8 new variants.crates/core/src/adapter.rs—PlatformAdaptertrait (~28 methods withnot_supported()defaults, L117–L278). New methods land as additive defaults.crates/core/src/refs.rs—RefEntry(9 fields, L13–L28). Unit 1 addsidentifier: Option<String>for selector-preferred resolution.crates/core/src/commands/— 54 files (53 commands +helpers.rs), one per command;click.rs(14 L),list_windows.rs(18 L),type_text.rs(24 L),snapshot.rs(226 L),wait.rs(229 L) are the representative patterns for Unit 6 / Unit 7 / Unit 8 new commands. The command count is used throughout this plan (document-review note).crates/macos/src/tree/element.rs(404 L) — where Unit 5 stable-selector reads land. File is at the 400-LOC cap; plan mandates a 2-way split intoelement.rs(core) +element_selectors.rs(new selector readers) before adding reads.crates/macos/src/tree/resolve.rs:20—resolve_depth = 50already matches theABSOLUTE_MAX_DEPTHtarget Unit 4 mirrors on Windows.crates/ffi/build.rs— existing cbindgen invocation + header-path stamp pattern; Unit 2 extends this, it does not replace it.crates/ffi/src/error.rs:5-60— the parityconstassertion pair; Unit 1 updates both arrays atomically with newErrorCodevariants.crates/ffi/include/agent_desktop.h— committed ABI contract; drift check at.github/workflows/ci.ymlstep "FFI header drift check"..github/workflows/release.yml— FFI matrix already includesx86_64-pc-windows-msvc; Unit 13 adds theaarch64-pc-windows-msvcFFI row, the Windows CLI binary row, and npm postinstall branches..github/workflows/ci.yml—testjob runs only onmacos-latesttoday; Unit 13 adds a siblingtest-windowsjob..githooks/pre-commit— runscargo fmt --all -- --check,cargo clippy --all-targets -- -D warnings,cargo test --lib --workspacewhen Rust/TOML files are staged; awareness only, no change required.skills/agent-desktop/SKILL.md(227 L) andskills/agent-desktop-ffi/SKILL.md(78 L) exist;skills/agent-desktop-macos/does not exist — macOS content lives atskills/agent-desktop/references/macos.md. Unit 14 createsskills/agent-desktop-windows/as a sibling and updates the core skill to three-platform.
Institutional Learnings
docs/solutions/best-practices/deduplicate-ref-allocator-via-config-struct-2026-04-14.md— DRY via config structs, threshold "4+ positional params + 1 new distinguishing value." Extraction bar: shared parameter types at trait boundaries and shared policy (not shared implementations). Phase 2 extracts 4 shared types incrates/core:ActionDispatchConfig— parameter type for action execution. Carries policy only:fallback_to_cursor: bool,timeout_ms,blocked_combos: &'static [KeyCombo]. Phantom DRY warning: the dispatch chain itself (AX action strings on macOS vs UIA pattern interfaces on Windows) is categorically different types — do NOT extract a shared dispatch chain. Share only the policy.WatchElementConfig—watch_elementtrait method parameter. Carriesevents: &[EventKind],timeout_ms,max_subscriptions,hard_join_multiplier(default 2.0).TextRangeConfig— text primitive trait method parameter. Carriesutf16_semantics: truemarker +check_password_field: bool(default true; see Unit 8 security gate) + per-platform text-pattern entrypoint hints.ScreenshotBackendConfig—get_screenshot_with_backendparameter. Carriesbackend: ScreenshotBackend,dimensions: Option<(u32, u32)>,pixel_format,encoding. Rejected (reviewed and dropped — YAGNI and duplication risk):TreeWalkConfig— duplicates the existingTreeOptionsstruct atcrates/core/src/adapter.rs:27. ExtendTreeOptionswithforce_electron_a11yinstead of creating a parallel struct.SurfaceDetectionConfig— per-platform surface policy (window-class lists, AX-role overrides) should not leak platform specifics into core types. Keep platform-local incrates/macos/src/tree/surfaces.rsandcrates/windows/src/tree/surfaces.rs.SelectorReadConfig— the 6 selector attribute names are categorically different types across platforms (macOS AX string constants vs Windows UIA property IDs). No shared shape exists to extract.EventWorkertrait — the "spawn → attach → mpsc → timeout → join" shell is ~15 LOC and the teardown primitives (CFRunLoopStopvsPostThreadMessage(WM_QUIT)) are categorically different. Inline in each platform'swatch_elementimplementation. Revisit at Phase 3 when a third platform proves the shape.NotificationSessiontrait — macOSnc_session.rsand Windowsaction_center.rshave superficially-similar "open/dismiss/close" lifecycles but radically different state (NSUserNotificationCenter observer vs UIA tree walk). No code is generic over both. Document shape alignment in code comments; revisit at Phase 3.
docs/solutions/logic-errors/progressive-snapshot-review-contract-2026-04-16.md— SeparateINVALID_ARGS(bad selector syntax),STALE_REF(valid syntax, gone element), andTIMEOUT. Apply towatch_elementin Unit 7 and text-range commands in Unit 8. Boundary-node pattern at subscription caps.docs/solutions/best-practices/deterministic-build-artifact-marker-2026-04-16.md—build.rsstamps absolute paths for generated artifacts; committed copy is the ABI contract; never self-heal. Apply to Unit 2's generatedad_*wrappers and JSON schemas, and to Unit 1'sad_abi_versionconstant. CI drift-checks each.docs/solutions/best-practices/identity-fingerprint-against-os-reorder-2026-04-16.md— Stable identifiers are optional fingerprints carried alongside index/handle; tri-state UTF-8 decode at FFI boundary. Directly applies to Unit 5 (identifierfield) and Unit 10 (tray/notification indices on Windows).- Private-memory rule: port
electron-compat.mdandmacos-ax-gotchas.mdfrom~/.claude/.../memory/intodocs/solutions/before Unit 4 opens. Windows contributors must inherit the Electron depth-skip rules and TCC traps without relying on private auto-memory. Tracked in Unit 14's doc cleanup.
External References
- UIA +
uiautomation 0.24crate docs (framework-docs-researcher cache):UITreeWalker,UICacheRequest, pattern interface set (InvokePattern,ValuePattern,ExpandCollapsePattern,SelectionItemPattern,TogglePattern,ScrollPattern,TextPattern,TextRange). windows 0.62.2crate:Windows.Graphics.Capture,Direct3D11CaptureFramePool,SendInput,OpenClipboard,IDataObject.windows-capture 1.5.4: pinned exactly because newer majors may move the capture API surface — re-evaluate post-Phase 2.Capture::start+ frame-callback API validated against 1.5.4.- ScreenCaptureKit via
screencapturekit 1.5(doom-fish fork) —SCShareableContent.windows,SCScreenshotManager.captureImage(contentFilter:config:). objc2 0.6replaces ad-hocobjcmessage sends, scoped tosystem/screenshot.rs+system/permissions.rs.inventoryandlinkmeboth rejected (research Topic B). Replaced withbuild.rsfilesystem enumeration ofcrates/core/src/commands/*.rs— deterministic, cdylib-safe across ld64, ld-prime, GNU ld, lld, MSVC link.exe, zero linker magic.schemars 1.2— required by P2-O16 for command-arg JSON Schema generation.- MS Agent Framework 1.0 MCP transport reference — stdio is sufficient for Phase 4; no streamable HTTP required.
Key Technical Decisions
Carried forward from origin §Decisions (D1–D17). Each is pinned here with its unit anchor so planning enforces them.
- KD1 (D1) Single release, no 2a/2b split. Scope managed via units, not sub-phases. Enforced by: every unit has a Phase 2 GA gate.
- KD2 (D2) ~15 implementation units, one PR each; dependencies flow left-to-right in the graph (§High-Level Technical Design).
- KD3 (D3)
WindowInfo.pidstaysi32; Windows adapter narrow-castsu32 → i32at the adapter boundary via a helperfn narrow_pid(dword: u32) -> Result<i32, AdapterError>that returnsErrorCode::ResourceExhausted(reusing the new variant added in Unit 1) withplatform_detail: "PID exceeds i32::MAX; Windows kernel should never produce this value"for values abovei32::MAX.Internalis wrong here because this is a boundary failure, not a bug in agent-desktop;ResourceExhaustedcosts zero additional ABI surface and is semantically correct (PID space exhausted relative toi32representation). Documented in Unit 1; implemented in Unit 3. - KD4 (D4)
ErrorCodegains#[non_exhaustive]as the first sub-PR in Unit 1, before any variant addition. The parityconst _: () = assert!(…)incrates/ffi/src/error.rsis updated atomically with each new variant — the assertion is the compile-time gate. - KD5 (D5) Registry migration (Unit 2) ships before the Windows adapter so every new Phase 2 command is born in the registry.
- KD6 (D6) Modern screenshot is the default on both platforms.
--screenshot-backend legacyreaches the Phase 1 subprocess (macOS) /PrintWindow(Windows) path for WDA-protected windows and restricted environments. - KD7 (D7) All 6 stable-selector fields ship, nested as a
StableSelectorssub-struct onAccessibilityNodewith#[serde(flatten)]+ per-fieldskip_serializing_if. JSON wire shape preserved. Rust field count onAccessibilityNodebecomes 11; grandfathered (KD15). - KD8 (D8) All 9 new
Actionvariants ship.Watch(WatchSpec)is not anAction—watch_elementis an adapter method (origin §D9).ForceClickreturnsActionNotSupportedon Linux (Phase 3) — legitimate platform divergence, not a deferral. - KD9 (D9 + review-refined)
watch_elementis synchronous at the public API. Per-call worker thread (macOS:CFRunLoop+AXObserver; Windows: MTA thread + UIA event handler) →std::sync::mpsc→ caller. Trait method takes&RefEntry(not&NativeHandle) — worker re-resolves the element fresh on its own thread, preserving theNativeHandle!Send/!Syncinvariant. The orchestration shell (~15 LOC: spawn → attach → mpsc → timeout → join) is inlined in each platform'swatch_elementimplementation — no sharedEventWorkertrait. Rationale: with N=2 platforms and categorically different teardown primitives (CFRunLoopStopvsPostThreadMessage(WM_QUIT)), a trait with one abstract method per side is a framework, not DRY. Revisit at Phase 3 when AT-SPI proves the three-way shape. AX threading invariant:AXObserverCreateand callback dispatch on the observer's CFRunLoop thread is Apple-blessed for non-main threads;AXUIElementCopy*calls from the worker are validated by Unit 7's opening spike against macOS 14/15. If the spike shows thread-hostility on specific calls, the worker hops to main via awith_main_thread(|| …)helper that usesdispatch_syncto the main queue. Same pattern on Windows: COM apartment hostility escapes viaSendMessageto a main-thread windowless receiver. Hard-join timeout =2 × user_timeout_ms; refuse-to-exit worker returnsErrorCode::Internal. Async surfaces are Phase 4's problem. Thread pool not shared with Unit 9. - KD10 (D10)
PermissionReportmigrates to a struct withaccessibility,screen_recording,automationtri-state fields. Breaking JSON schema on thepermissionscommand and breaking FFI ABI. Lands atomically withad_abi_version()bump in Unit 1. - KD11 (D11)
BLOCKED_COMBOSbecomes aPlatformAdaptertrait methodfn blocked_combos(&self) -> &'static [KeyCombo]. macOS blockscmd+q,cmd+shift+q. Windows blocksalt+f4,ctrl+alt+delete,win+l.key-down/key-upsafety check rejects any combo that matchesblocked_combos()as a whole — no persistent modifier-state file (keeps the tool stateless; rejecting solo modifiers is never needed because blocked combos always require at least one non-modifier key). - KD12 (D12) Electron/WebView2 compat on Windows mirrors macOS: depth-skip for non-semantic wrappers, resolver depth 50, surface detection that treats the focused window AS the target surface,
--force-electron-a11yCLI override. - KD13 (D13) ARM64 Windows ships in Phase 2 alongside x86_64.
aarch64-pc-windows-msvcis build-only until a GH runner arrives (test matrix adds it post-hoc in a follow-up PR).npm/scripts/postinstall.jsgainswin32-x64andwin32-arm64branches. - KD14 (D14) CI matrix = macOS + Windows for full tests; Ubuntu for fmt.
cargo tree -p agent-desktop-coreisolation check runs on both. - KD15 (D15 + research-refined) Dependencies:
- Windows:
uiautomation 0.24,windows 0.62.2(matcheswindows-capture 1.5's own pin),windows-capture = "1.5.4"(latest stable, published crates.io) - macOS:
objc2 0.6,screencapturekit = "1.5"(published crates.io — the doom-fish fork is the canonical maintained crate as of Q1 2026; NOT a git-SHA pin) - Cross-platform:
schemars 1.2(deferred to Phase 4 — see KD16 below) - NO
inventory/linkme(research Topic B — link-GC risk across ld64/ld-prime/GNU ld/lld/MSVC is real; both crates' ctor-based / linker-section patterns are unreliable for cdylib consumers) - Command registry uses
build.rsfilesystem enumeration ofcrates/core/src/commands/*.rs— deterministic, cdylib-safe, zero linker magic (research Topic B recommendation) - MSRV:
1.82(required bywindows 0.62.2).AccessibilityNode10-field grandfather preserved — only new selectors nest intoStableSelectors.
- Windows:
- KD16 (D16) Cross-compile-first workflow holds: macOS dev →
cargo check --target x86_64-pc-windows-msvc→ Windows CI integration. Pre-commit hook runs the cross-check best-effort (warn, never fail). - KD17 (D17 + review-refined) Pre-1.0 FFI policy, published at
crates/ffi/README.mdduring the v0.1.14 prep release (see §Phased Delivery). Policy matrix:#[non_exhaustive]enum variant addition (additive) → no major bump. C consumers MUST usedefault:/ wildcard fallthrough; library documents this as a hard contract. Defense-in-depth: a reservedAD_RESULT_UNKNOWN = -99sentinel is exported so consumers can map any unrecognized integer to a known value explicitly. Rust never produces this sentinel; it exists purely for consumer dispatch tables. Guarded by the compile-timeconst _: () = assert!(ErrorCode vs AdResult parity)gate.- FFI struct layout change (field add/remove/reorder) → major bump.
- FFI function signature change (parameter or return type) → major bump.
- New
extern "C" ad_*function (additive) → no bump; consumers must check symbol presence viadlsym. - Removal of any exported symbol → major bump.
Consumer version handshake:
ad_init(expected_major: u32) -> AdResult— the ONLY FFI function that must be called before any otherad_*. Fails closed withAdResult::ErrInvalidArgsifexpected_major != AD_ABI_VERSION_MAJOR. Without this call, subsequentad_*calls fail closed withErrInternal + "ad_init not called". Converts the "consumers SHOULD check" advisory into an enforced handshake.ad_initships in v0.1.14 returningMAJOR = 1. v0.1.14 prep release ships#[non_exhaustive]+ad_abi_version()+ad_init()+AD_RESULT_UNKNOWNsentinel +crates/ffi/README.mdpolicy doc, with no variant additions, no struct-layout changes. v0.2.0 shipsAD_ABI_VERSION_MAJOR = 2atomically with thePermissionReporttri-state layout change in sub-PR 1g. Phase 3 Linux adapter adds variants additively — no bump.
Open Questions
Resolved During Planning
- StableSelectors shape (origin open question): Use an inline
StableSelectorssub-struct with#[serde(flatten)]+ per-field#[serde(skip_serializing_if = "…")]. NoOption<StableSelectors>wrapper. Rationale: preserves exact JSON wire shape; each selector is individually skippable when empty; cbindgen emits cleaner output for a nested struct than 6 more optional top-level fields. Landed in Unit 1. - Modifier-state tracking for
key-down/key-upsafety check (origin open question): Do not persist modifier state. The check evaluates the combo passed tokey-down/key-updirectly and rejects if it matches any entry inblocked_combos()as a whole combo. Blocked combos always include a non-modifier key (e.g.,cmd+q,alt+f4), so solo modifier key-downs are never rejected. Keeps the tool stateless — matches the Phase 1 invariant. Landed in Unit 1 / Unit 3. - Thread-pool consolidation (origin open question): Keep per-call worker threads in Units 7 and 9. Different lifetimes (watch_element: seconds-to-tens-of-seconds observer loop; screenshot: sub-second single-shot capture). Re-evaluate when Phase 4 daemon arrives.
wait --eventCLI shape (origin open question):wait --event <kind> --ref @e5 --timeout 3000.--eventrepeats for multi-subscription (--event value-changed --event selection-changed).<kind>accepts the 10EventKindvariants named indocs/phases.md §Phase 2:focus-changed,value-changed,selection-changed,children-changed,window-opened,window-closed,menu-opened,menu-closed,notification-posted,element-destroyed. Event filter expressions beyond kind + ref are deferred.AccessibilityNode10-field grandfather (origin open question): Do not refactor existing flat fields. Only newStableSelectorsis nested. Smaller blast radius; pre-existing JSON consumers unaffected.inventoryvslinkme(origin §D15 ambiguity — research-resolved): Neither. Research Topic B found neither crate reliably survives link-GC across ld64, ld-prime, GNU ld, lld, and MSVC link.exe for cdylib consumers;deterministic registry metadatactor sites are stripped when anrlibis linked into a binary that never references a symbol from that rlib. Instead, the command registry is built at compile time viacrates/core/build.rsthat enumeratescrates/core/src/commands/*.rs(one-command-per-file is already a CLAUDE.md invariant) and codegens apub fn descriptors() -> &'static [CommandDescriptor]static. Deterministic, cdylib-safe, zero linker magic. FFI wrapper codegen incrates/ffi/uses the samebuild.rsapproach reading the same source listing. Simplification: no xtask crate needed.
Deferred to Implementation
- Exact
uiautomationcrate API surface forUICacheRequestbatching — requires a spike against real apps on Windows CI before Unit 3 freezes. - Exact
AXObserverteardown sequence onCFRunLoopstop — named as Unit 7's opening spike. Validates non-main-thread AX observer behavior against Finder, TextEdit, VS Code on macOS 14/15. windows-capture 1.5.4API againstwindows 0.62.2— verifyCapture::start+ frame-callback API compiles together before merging Unit 9. Future patch bumps require the same spike before changing the pin.SCShareableContentwindowing — exact API shape for filtering to aCGWindowIDviaSCContentFilteron macOS 14 vs 15. Unit 9's macOS spike.- Event handler lifetime on Windows —
IUIAutomation.AddAutomationEventHandlermust be removed before thread exit, confirmed by Unit 7 spike. DeliverFilesper-app URL scheme registry for Tier 1 — Unit 12 builds a smallcrates/macos/src/actions/deliver_files_registry.rsmapping known bundle IDs to their CLI/URL scheme. Initial entries (VS Code, Finder, Preview, TextEdit, Safari, Chrome) are defined at implementation time; the registry is extensible per-release.- Exact
AEDeterminePermissionToAutomateTargetbundle-id argument for Unit 11 — depends on which target app the user is automating; solved at call site, not trait signature.
Output Structure
New/rewritten directory layouts. Paths shown are repo-relative.
crates/
├── core/
│ └── src/
│ ├── node.rs # +StableSelectors sub-struct; flatten into AccessibilityNode
│ ├── error.rs # +4 ErrorCode variants; +#[non_exhaustive]
│ ├── action.rs # +8 Action variants
│ ├── adapter.rs # +blocked_combos, watch_element, text-range, get_screenshot_with_backend; PermissionReport → tri-state struct
│ ├── refs.rs # +identifier field on RefEntry
│ ├── event.rs # NEW — EventKind, ElementEvent, WatchSpec (supporting types for watch_element)
│ ├── text_range.rs # NEW — TextRange, TextSelection (supporting types for text primitives)
│ ├── screenshot_backend.rs # NEW — ScreenshotBackend enum (Modern / Legacy)
│ ├── permission.rs # NEW — Tri-state PermissionReport struct, extracted from adapter.rs
│ ├── commands/
│ │ ├── watch.rs # NEW — Unit 7 command
│ │ ├── text_get_selection.rs # NEW — Unit 8 command
│ │ ├── text_select_range.rs # NEW — Unit 8 command
│ │ ├── text_insert_at_caret.rs # NEW — Unit 8 command
│ │ ├── text_at_offset.rs # NEW — Unit 8 command
│ │ ├── list_tray_items.rs # NEW — Unit 3b / Unit 10 command
│ │ ├── click_tray_item.rs # NEW — Unit 3b / Unit 10 command
│ │ └── open_tray_menu.rs # NEW — Unit 3b / Unit 10 command
│ └── registry.rs # NEW — Unit 2: CommandDescriptor type + include!(registry.rs from $OUT_DIR)
├── windows/
│ └── src/
│ ├── lib.rs # mod + re-exports (rewritten)
│ ├── adapter.rs # WindowsAdapter: PlatformAdapter impl (Unit 3)
│ ├── tree/ # element, builder (UITreeWalker + UICacheRequest), roles, resolve, surfaces
│ ├── actions/ # dispatch, activate (smart chain), extras, file_drop (U12), force_click (U12)
│ ├── input/ # keyboard (SendInput), mouse (SendInput), clipboard (Win32)
│ ├── events/ # NEW — watch (U7): MTA thread, UIA event handlers
│ ├── text/ # NEW — U8: TextPattern helpers
│ ├── notifications/ # U3a: list, dismiss, interact
│ ├── tray/ # U3b: list, interact (Shell_TrayWnd UIA)
│ └── system/ # app_ops, window_ops, key_dispatch, permissions, screenshot (U9 modern + legacy), wait
├── macos/
│ └── src/
│ ├── tree/
│ │ ├── element.rs # SPLIT (was 404 L) — keep attribute reads
│ │ └── element_selectors.rs # NEW — Unit 5: AXIdentifier / Subrole / RoleDescription / PlaceholderValue / DOMIdentifier / DOMClassList readers
│ ├── events/ # NEW — Unit 7: AXObserver + CFRunLoop worker
│ ├── text/ # NEW — Unit 8: parameterized-attribute helpers
│ └── system/
│ ├── screenshot.rs # Unit 9: ScreenCaptureKit default, subprocess legacy (split into modern.rs + legacy.rs if LOC pressure)
│ └── permissions.rs # Unit 11: tri-state (AX + Screen Recording + Automation)
├── ffi/
│ ├── build.rs # Unit 2: extend — uses build-helpers::enumerate_commands to generate ad_* wrappers alongside cbindgen header
│ └── src/
│ ├── generated/ # NEW — include!() target for build.rs output
│ │ └── wrappers.rs # generated from registry; committed-and-drift-checked like include/agent_desktop.h
│ ├── abi_version.rs # NEW — Unit 1: ad_abi_version() export + AD_ABI_VERSION_MAJOR cbindgen define
│ ├── log_callback.rs # NEW — Unit 2: ad_set_log_callback installs a tracing_subscriber layer
│ └── ... # existing adapter.rs, error.rs, ffi_try.rs, etc. unchanged
skills/
├── agent-desktop/ # Unit 14: update core skill for three-platform
├── agent-desktop-ffi/ # Unit 14: update for ad_abi_version + ad_set_log_callback
└── agent-desktop-windows/ # NEW — Unit 14: SKILL.md + references/uia.md, references/windows-permissions.md, references/chromium.md
.github/workflows/
├── ci.yml # Unit 13: +test-windows job
└── release.yml # Unit 13: +aarch64-pc-windows-msvc + Windows CLI row
npm/
└── scripts/
└── postinstall.js # Unit 13: +win32-x64 + win32-arm64 branches
The implementer may adjust subfolder shape during implementation if it improves clarity; the per-unit **Files:** sections are authoritative for what each unit creates.
High-Level Technical Design
The diagrams below illustrate intended approach and are directional guidance for review, not implementation specification.
Dependency graph across units
flowchart LR
U1[U1: Core pre-work<br/>types + trait methods + MSRV]
U2[U2: Registry migration<br/>build.rs filesystem enumeration codegen]
U3[U3: Windows adapter<br/>UIA tree/actions/input/system]
U3a[U3a: Windows notifications]
U3b[U3b: Windows tray]
U4[U4: Windows Electron compat]
U5[U5: Stable-selector population]
U6[U6: Action variants<br/>LongPress/ShowMenu/WindowRaise/Cancel]
U7[U7: watch_element<br/>AXObserver + UIA events]
U8[U8: Text range primitives]
U9[U9: Modern screenshot]
U10[U10: New surfaces]
U11[U11: Permission tri-state macOS]
U12[U12: DeliverFiles + ForceClick]
U13[U13: Windows CI + release matrix]
U14[U14: Skills + README + phases.md sync]
U1 --> U2
U1 --> U3
U1 --> U4
U1 --> U5
U1 --> U6
U1 --> U7
U1 --> U8
U1 --> U9
U1 --> U10
U1 --> U11
U1 --> U12
U2 --> U3
U2 --> U5
U2 --> U6
U2 --> U7
U2 --> U8
U2 --> U9
U2 --> U10
U2 --> U11
U2 --> U12
U3 --> U3a
U3 --> U3b
U3 --> U4
U3 --> U7
U3 --> U8
U3 --> U9
U3 --> U10
U3 --> U12
U13 -.parallel to U3..U12.-> U3
U3 --> U13
U13 --> U14
U3a --> U14
U3b --> U14
U4 --> U14
U5 --> U14
U6 --> U14
U7 --> U14
U8 --> U14
U9 --> U14
U10 --> U14
U11 --> U14
U12 --> U14
watch_element lifecycle (directional)
CLI: wait --event value-changed --ref @e5 --timeout 3000
│
▼
commands/watch.rs
│ resolve ref → NativeHandle
▼
PlatformAdapter::watch_element(&handle, &[ValueChanged], 3000ms)
│
├─ macOS: spawn worker → CFRunLoopRun
│ AXObserverCreate(pid) → AddNotification(kAXValueChangedNotification)
│ callback pushes ElementEvent into mpsc::Sender
│ timeout: CFRunLoopStop + JoinHandle::join
│
└─ Windows: spawn worker → CoInitializeEx(MTA)
IUIAutomation.AddPropertyChangedEventHandler
handler pushes ElementEvent into mpsc::Sender
timeout: RemoveEventHandler + JoinHandle::join
│
▼
main thread: recv with Duration until deadline → Vec<ElementEvent>
│
▼
JSON envelope with events array
Registry → codegen flow (Unit 2 — research-refined: build.rs filesystem enumeration)
crates/core/src/commands/<cmd>.rs ← source of truth (file system)
│
│ top-of-file marker:
│ ///! command_meta { name = "click", summary = "Click an element by ref" }
│
│ body:
│ pub fn descriptor() -> CommandDescriptor { … }
▼
build-helpers::enumerate_commands(Path) ← pure file walk + regex, zero linker magic
│
├─ crates/core/build.rs → emit $OUT_DIR/registry.rs
│ pub static DESCRIPTORS: &[CommandDescriptor] = &[ click::descriptor(), … ];
│
├─ crates/ffi/build.rs → emit $OUT_DIR/wrappers.rs
│ #[no_mangle] pub extern "C" fn ad_click(…) -> AdResult { … }
│ #[no_mangle] pub extern "C" fn ad_type_text(…) -> AdResult { … }
│ …
│
├─ src/dispatch.rs (CLI) → DESCRIPTORS.iter().find(|d| d.name == name)
│
└─ crates/mcp/ (Phase 4) → same build-helpers::enumerate_commands →
emit rmcp #[tool] per descriptor
NO deterministic registry metadata, NO linkme, NO xtask, NO ctor sites,
NO link-GC mitigation needed — extern "C" symbols are directly exported
from the cdylib and visible via nm -g.
Implementation Units
Each unit is one reviewable PR unless explicitly flagged as multi-sub-PR (Unit 1 and Unit 2 are multi-sub-PR due to blast radius). Dependencies follow §High-Level Technical Design. The checkbox syntax drives progress tracking.
- [ ] Unit 1: Core pre-work — types, trait method stubs, MSRV bump, FFI ABI version
Goal: Land every additive type change, trait method stub, and MSRV bump before any adapter code opens. Every P2-O* objective that mutates core types resolves its type surface here.
Requirements: R8, R9, R10, R11, R12, R13, R14, R16, R17
Dependencies: None (first unit)
Files:
- Modify:
Cargo.toml(workspacerust-version = "1.82") - Modify:
rust-toolchain.toml(no target change yet; that lives in U13) - Modify:
crates/core/src/error.rs(add#[non_exhaustive]; addPermissionRevoked,ResourceExhausted,AxMessagingTimeout,AutomationPermissionDenied) - Modify:
crates/core/src/action.rs(addLongPress { duration_ms: u64 },ForceClick,ShowMenu,DeliverFiles(Vec<std::path::PathBuf>)(renamed fromFileDropper research),WindowRaise,Cancel,SelectRange { start: u32, length: u32 },InsertAtCaret(String)) - Modify:
crates/core/src/node.rs(introduceStableSelectorsstruct; add#[serde(flatten)] pub selectors: StableSelectorsfield onAccessibilityNode) - Modify:
crates/core/src/refs.rs(addidentifier: Option<String>toRefEntry; populate in allocator when available; prefer-identifier logic is Unit 5) - Modify:
crates/core/src/adapter.rs(extractPermissionReportintocrates/core/src/permission.rsas tri-state struct; add trait methodsblocked_combos,watch_element,get_text_selection,set_text_selection,get_text_at,insert_text_at_caret,get_screenshot_with_backend; all default tonot_supported()) - Create:
crates/core/src/event.rs(EventKindenum with 10 variants;ElementEventstruct;WatchSpec) - Create:
crates/core/src/text_range.rs(TextRange,TextSelection) - Create:
crates/core/src/screenshot_backend.rs(ScreenshotBackend { Modern, Legacy }) - Create:
crates/core/src/permission.rs(tri-statePermissionReport;TriState { Granted, Denied { suggestion: String }, Unknown }) - Modify:
crates/core/src/lib.rs(re-exports) - Modify:
crates/ffi/src/error.rs(extend bothconst fnvariant-count arrays with the 4 new variants; add matchingAdResult::Err*discriminants preserving existing ordering; updateerror_code_to_resultmatch) - Modify:
crates/ffi/src/adapter.rs(FFI-facingAdPermissionReportstruct mirroring tri-state; FFI conversion helper) - Create:
crates/ffi/src/abi_version.rs(pub const AD_ABI_VERSION_MAJOR: u32 = 1;at v0.1.14 anchor; bumped to2at sub-PR 1g when layout actually changes) +ad_abi_version()extern "C" returningu32+ad_init(expected_major: u32) -> AdResultenforced version-negotiation handshake +pub const AD_RESULT_UNKNOWN: i32 = -99;sentinel exported to cbindgen - Modify:
crates/ffi/cbindgen.toml(addAD_ABI_VERSION_MAJORto[defines]) - Modify:
crates/ffi/include/agent_desktop.h(regenerated, committed) - Modify:
src/cli_args.rs(new arg structs are stubbed/empty until U6, U7, U8 fill; cli.rs arm names reserved to prevent renumbering) - Modify:
src/dispatch.rs(map reserved arms to new command modules withunimplemented!()gated behind test-only until U6/U7/U8 land — except the Unit 2 dispatcher variant, which lands in U2 and never usesunimplemented!()in shipped binaries) - Test:
crates/core/src/node.rs(new tests: flatten shape, serde roundtrip, skip_serializing_if per selector field) - Test:
crates/core/src/error.rs(new variants serialize to SCREAMING_SNAKE_CASE) - Test:
crates/core/src/action.rs(new variants serde roundtrip includingPathBufinDeliverFiles) - Test:
crates/core/src/permission.rs(tri-state struct serde; JSON shape matches spec) - Test:
crates/core/src/event.rs,text_range.rs,screenshot_backend.rs(serde roundtrips) - Test:
crates/ffi/tests/abi_version.rs(ad_abi_version returnsAD_ABI_VERSION_MAJOR) - Test:
crates/ffi/src/error.rs(parity-count assertion passes;ErrorCode::PermissionRevoked→AdResult::ErrPermissionRevoked)
Approach:
Sub-PR ordering (refined by deepening pass — ad_abi_version first so every later ABI-affecting sub-PR can CI-assert it bumped):
- Sub-PR 1a:
ad_abi_version()export + FFI policy publication (document-review refinement). Addcrates/ffi/src/abi_version.rswithpub const AD_ABI_VERSION_MAJOR: u32 = 1;(anchor Phase 1 implicit value explicitly — not 2 yet, to avoid the mid-series lie where consumers seead_abi_version() = 2while the struct layout is still v1 shape) andad_abi_version()extern "C". Add theAD_ABI_VERSION_MAJORcbindgen[defines]entry. Createcrates/ffi/README.mddocumenting the policy matrix from KD17. Regenerate committed header. This lands first so every subsequent sub-PR in 1b–1j can assert via CI grep: "ifcrates/ffi/src/error.rsORcrates/ffi/src/generated/ORcrates/core/src/permission.rsis touched and the change is ABI-breaking,AD_ABI_VERSION_MAJORmust have bumped since the last main-branch commit." - Sub-PR 1b:
ErrorCodegets#[non_exhaustive]alone. No variant additions. Parity const assertions unchanged. Breaks nothing. Per KD17,#[non_exhaustive]addition does NOT bumpAD_ABI_VERSION_MAJOR. - Sub-PR 1c: Add 4 new
ErrorCodevariants atomically with matchingAdResult::Err*discriminants and theerror_code_to_resultarm. Parity assertion passes — this is the gate. Per KD17, additive variants under#[non_exhaustive]do NOT bumpAD_ABI_VERSION_MAJOR. - Sub-PR 1d: Add 8 new
Actionvariants. Platform adapters' existingexecute_actionarms fall through tonot_supported()via the#[non_exhaustive]default — no adapter changes yet. - Sub-PR 1e: Introduce
StableSelectorsonAccessibilityNodewith#[serde(flatten)]. ConstructStableSelectors::default()everywhere anAccessibilityNodeis built today (macOStree/builder.rs, test fixtures). Wire shape unchanged. - Sub-PR 1f:
RefEntry.identifier: Option<String>with serdeskip_serializing_if. Populated withNonefor now — U5 adds the reader logic. - Sub-PR 1g:
PermissionReportextracted tocrates/core/src/permission.rsas tri-state struct. Thepermissionscommand JSON output changes shape — breaking. This sub-PR atomically bumpsAD_ABI_VERSION_MAJORfrom 1 → 2 (document-review refinement — the version bumps only when the layout actually changes, avoiding mid-series lies). macOS adapter's existing single-state response maps toaccessibility: Granted | Denied;screen_recordingandautomationstartUnknownuntil U11. - Sub-PR 1h: Add trait methods with
not_supported()defaults. Signatures:fn blocked_combos(&self) -> &'static [KeyCombo]fn watch_element(&self, entry: &RefEntry, spec: &WatchSpec) -> Result<Vec<ElementEvent>, AdapterError>— takes&RefEntry, not&NativeHandle, so the worker thread can re-resolve on its own thread and theNativeHandle!Send/!Syncinvariant is preserved (deepening-pass refinement — KD9).fn get_text_selection(&self, handle: &NativeHandle, config: &TextRangeConfig) -> Result<TextSelection, AdapterError>fn set_text_selection(&self, handle: &NativeHandle, config: &TextRangeConfig, range: TextRange) -> Result<(), AdapterError>fn get_text_at(&self, handle: &NativeHandle, config: &TextRangeConfig, range: TextRange) -> Result<String, AdapterError>fn insert_text_at_caret(&self, handle: &NativeHandle, config: &TextRangeConfig, text: &str) -> Result<(), AdapterError>fn get_screenshot_with_backend(&self, target: ScreenshotTarget, config: &ScreenshotBackendConfig) -> Result<ImageBuffer, AdapterError>— takes&ScreenshotBackendConfig, not justScreenshotBackend, so per-backend options (dimensions, pixel format, encoding) travel through one argument (deepening-pass refinement).
- Sub-PR 1i: Create
event.rs,text_range.rs,screenshot_backend.rssupporting types +ActionDispatchConfig,WatchElementConfig,TextRangeConfig,ScreenshotBackendConfig(the 4 shared parameter types per §Context & Research). ExtendTreeOptionswithforce_electron_a11y: bool. Serde roundtrip tests. NoEventWorkerorNotificationSessiontraits — both dropped as premature abstraction per scope review. - Sub-PR 1j: Reserve CLI arms for U6/U7/U8 commands —
src/cli.rsgains variants marked#[clap(hide = true)](document-review refinement — so unimplemented commands do NOT appear inagent-desktop --helpoutput during the Phase 2 intermediate window). Theirdispatch.rsarms route toAppError::invalid_input("command not yet implemented")(NOTunimplemented!()) until the later units fill them. Each unit that implements a reserved command also flipshide = false. This reserves name-ordering so each subsequent unit's PR is smaller, without leaking broken commands into user-facing help.
Execution note: Land sub-PRs serially (1a → 1j). Each must keep cargo test --workspace green. The const _: () = assert!(…) parity gate in crates/ffi/src/error.rs is the atomic safeguard — breaking either side of the pair fails the build at sub-PR 1b.
Technical design:
// node.rs — directional guidance, not final
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StableSelectors {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subrole: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dom_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dom_classes: Vec<String>,
}
pub struct AccessibilityNode {
// existing 10 fields unchanged
#[serde(flatten, default)]
pub selectors: StableSelectors,
}
// permission.rs — tri-state
pub enum TriState {
Granted,
Denied { suggestion: String },
Unknown,
}
pub struct PermissionReport {
pub accessibility: TriState,
pub screen_recording: TriState,
pub automation: TriState,
}
Patterns to follow:
- Parity
const _: () = assert!(…)pair incrates/ffi/src/error.rs:5-60— extend both arrays symmetrically. - Serde
skip_serializing_ifidiom used throughoutcrates/core/src/node.rs. #[non_exhaustive]onAction(existing) — mirror forErrorCode.
Test scenarios:
- Happy path:
AccessibilityNodewith populatedStableSelectorsserializes withidentifier,subrole,dom_classesas flat top-level fields in JSON. - Edge case:
AccessibilityNodewithStableSelectors::default()emits no selector fields in JSON (roundtrip preservesNone/empty). - Happy path:
PermissionReport { accessibility: Granted, screen_recording: Denied { ... }, automation: Unknown }serializes to the expected tri-state object shape. - Happy path:
ErrorCode::PermissionRevoked.as_str() == "PERMISSION_REVOKED"; ditto the other three new variants. - Integration: parity
const _: () = assert!(…)incrates/ffi/src/error.rscompiles (gate). - Integration:
ad_abi_version()extern "C" returns2u32; cbindgen header exportsAD_ABI_VERSION_MAJOR 2as a preprocessor define. - Integration: every existing macOS unit test still passes after
PermissionReporttri-state migration (accessibility field carries the legacy boolean behavior; othersUnknown). - Error path:
Action::DeliverFiles(vec![PathBuf::from("relative/path")])serde roundtrips preservingPathBuf.
Verification:
cargo test --workspacegreen.cargo clippy --all-targets -- -D warningsclean.cargo build -p agent-desktop-ffigreen;scripts/update-ffi-header.shshows no drift beyond the intended additions.cargo tree -p agent-desktop-corestill free of platform crates.
- [ ] Unit 2: Registry migration — build.rs filesystem enumeration + codegen for FFI wrappers
Goal: Migrate the existing ad_* FFI wrappers from hand-written to codegen. Each command's commands/<name>.rs file is the source of truth; build.rs enumerates the filesystem at compile time and emits (a) a CommandDescriptor static array in crates/core/src/generated/registry.rs and (b) one extern "C" fn ad_<name> wrapper per command in crates/ffi/src/generated/wrappers.rs. This is P2-O16.
Research-driven architecture shift: the origin brainstorm's inventory 0.3 + xtask proposal is REPLACED with pure build.rs filesystem enumeration (research Topic B — inventory/linkme link-GC is unreliable across ld64, ld-prime, GNU ld, lld, MSVC for cdylib consumers; build.rs is deterministic and has zero linker dependencies). The "one command per file" CLAUDE.md invariant is now load-bearing — it's the codegen contract.
Scope (single-concern): ad_set_log_callback is Unit 2.5; Phase 1.5 FFI backfill is Unit 2.6.
Requirements: R16
Dependencies: Unit 1 (needs ad_abi_version, new trait method stubs, tri-state PermissionReport, and the 4 shared config structs).
Files:
- Create:
build-helpers/(new workspace member — tiny crate exposingfn enumerate_commands(dir: &Path) -> Vec<CommandMeta>. No runtime deps; pure file I/O + regex.) - Modify: workspace
Cargo.toml(addbuild-helpersto[workspace.members]; NOinventory, NOlinkme, NOschemars— deferred to Phase 4 MCP) - Create:
crates/core/build.rs(usesbuild-helpers::enumerate_commands; emits$OUT_DIR/registry.rswithpub static DESCRIPTORS: &[CommandDescriptor] = &[…];) - Create:
crates/core/src/registry.rs(pub struct CommandDescriptor { name, dispatch_fn, args_parse_fn }+include!(concat!(env!("OUT_DIR"), "/registry.rs"))) - Modify:
crates/core/src/commands/*.rs(each file gets a top-of-file///! command_meta { name = "...", summary = "..." }marker parsed bybuild-helpers+ apub fn descriptor() -> CommandDescriptor { … }function. ONE registration point per command file — no macros, no inventory, no ctor.) - Modify:
crates/core/src/lib.rs(re-exportregistry::DESCRIPTORS) - Modify:
src/dispatch.rs(replace the giantmatchwithDESCRIPTORS.iter().find(|d| d.name == cmd_name).map(|d| (d.dispatch_fn)(args, adapter)); retain clap-to-descriptor bridge insrc/cli.rs) - Modify:
crates/ffi/build.rs(extend: usesbuild-helpers::enumerate_commands— SAME enumeration as core's build.rs; emits$OUT_DIR/wrappers.rswith oneextern "C" fn ad_<name>(...)per command; stamp path intarget/ffi-wrappers-path.txt— same pattern as cbindgen header stamp) - Create:
crates/ffi/src/generated.rs(include!(concat!(env!("OUT_DIR"), "/wrappers.rs"));) - Modify:
crates/ffi/src/lib.rs(wiremod generated;after hand-written wrappers are removed) - Delete: hand-written
ad_<name>wrappers acrosscrates/ffi/src/{actions,apps,input,notifications,observation,screenshot,surfaces,tree,windows}/. Marshaling primitives incrates/ffi/src/convert/,adapter.rs,error.rs,ffi_try.rs,main_thread.rs,pointer_guard.rs,types/,enum_validation.rsstay. - Modify:
crates/ffi/include/agent_desktop.h(regenerated with generated wrappers in identical order) - Modify:
.github/workflows/ci.yml(add a "FFI header drift check" step — already present — plus the registry enumeration runs identically across all build profiles because it's pure file-walk, no linker involved) - Modify:
scripts/update-ffi-header.sh→ rename toscripts/update-ffi.sh(shim for old name kept for backward compat); regenerates the cbindgen header - Test:
crates/core/tests/registry_coverage.rs(assertsDESCRIPTORS.len()equals the count of.rsfiles incrates/core/src/commands/excludingmod.rs/helpers.rs; fails loudly if a command file exists without a descriptor or vice versa) - Test:
crates/core/tests/cli_registry_parity.rs(cross-checks that every CLI subcommand name enumerated from clap has a matchingCommandDescriptorAND vice versa; closes rename-detection gap) - Test:
tests/integration/per_command_fixture_diff.rs(for each migrated command, pre-migration JSON fixture vs post-migration byte-diff — empty diff or whitelisted with justification)
Approach:
- Migrate one command category at a time: observation → interaction → system → clipboard → notifications → batch. Each sub-PR keeps
cargo test --workspace+cargo test -p agent-desktop-ffi --testsgreen. CommandDescriptorcarries:name: &'static str,dispatch_fn,args_parse_fn(from&clap::ArgMatches). No schemars — deferred to Phase 4 MCP where schemas are actually consumed.- Codegen mechanism (research-refined): pure
build.rsfilesystem enumeration ofcrates/core/src/commands/*.rs, NOTinventory/linkme/ xtask. Research Topic B found neither inventory nor linkme survives link-GC across ld64, ld-prime, GNU ld, lld, and MSVC link.exe for cdylib consumers;deterministic registry metadatactor sites are stripped when anrlibis linked into a binary that never references a symbol from that rlib.build.rsreads source files at compile time, parses a///! command_meta { … }block, and emits a deterministic static array. Zero linker magic, zero ctor sites, cdylib-safe by construction. - The opening spike sub-PR validates the codegen on ONE command (
click): buildsbuild-helpers::enumerate_commands, emits generatedad_click, passes fixture byte-diff against hand-writtenad_click, ships alone. Only after the spike merges does the category migration open. build-helpersworkspace crate owns the enumeration logic (used by bothcrates/core/build.rsandcrates/ffi/build.rs). Single source of truth for "what is a command".- Per-command fixture diff gate: before migrating a category, the sub-PR captures pre-migration JSON fixtures for every command in that category. Post-migration,
per_command_fixture_diff.rsasserts byte-equivalent output (or whitelisted + justified diff). "Green tests" alone is not sufficient gating. - No link-GC mitigation needed: generated
extern "C" fn ad_<name>symbols are directly exported from the cdylib (nm -gshows them). Cargo's default visibility forpub extern "C"items in cdylib targets keeps them live. Zero#[used]annotations, zero--whole-archiveflags, zero CI matrix for link profiles.
Execution note: The spike PR opens first, on click only. Capture the codegen decision (build.rs filesystem vs alternatives) in the spike's commit message so reviewers see the chosen mechanism and its rationale (research Topic B).
Patterns to follow:
crates/ffi/build.rsstamp-path idiom (learning 3).crates/ffi/src/error.rsconstparity assertion — mirror for registry count:const _: () = assert!(ffi_wrapper_count() == command_count(), "…");.crates/ffi/src/convert/marshaling helpers stay per-type (strings, rects, windows, notifications). Generated wrappers call them.
Test scenarios:
- Happy path:
DESCRIPTORS.len() == 53after migration; asserted bycrates/core/tests/registry_coverage.rs. - Happy path: every existing CLI command dispatches via registry;
cargo test --lib --workspacepreserves all existing assertions. - Happy path:
ad_<name>extern "C" symbols exist for everyCommandDescriptor— checked bynm -g target/debug/libagent_desktop_ffi.*against the file enumeration count. - Integration:
ad_click @e5passes identical JSON to the generated wrapper as the hand-written wrapper did (pre/post-migration fixture byte-diff). - Integration:
ad_abi_version()still returnsAD_ABI_VERSION_MAJORunchanged through the migration. - Error path: malformed JSON input to any generated
ad_<name>wrapper returnsAdResult::ErrInvalidArgswith aset_last_errormessage — not a panic or crash. - Integration: CI header-drift check green after regenerating on migration PRs.
- Edge case: adding a new file
crates/core/src/commands/hello_world.rswith a descriptor auto-registers — no other file needs editing; CLI, FFI wrapper, and registry all pick it up on next build. - Edge case: removing a command file removes the descriptor and the
ad_<name>wrapper without a runtime check (compile-time enumeration).
Verification:
cargo test --workspace+cargo test -p agent-desktop-ffi --testsgreen.- Generated artifacts under
$OUT_DIR/regenerate deterministically (same input = same output, same byte ordering). crates/ffi/include/agent_desktop.hregenerated with identical symbol ordering as before (ordering test pins this).- No workspace-level
inventory/linkme/xtaskdependencies introduced.
- [ ] Unit 2.5: ad_set_log_callback with redaction layer
Goal: Ship the FFI log-forwarding callback with a mandatory redaction layer, as a small standalone unit. Split out from Unit 2 by review — three concerns in one unit was rejected.
Requirements: R16 (partial — log callback portion)
Dependencies: Unit 2 (registry migration lands first so the FFI crate is stable)
Files:
- Create:
crates/ffi/src/log_callback.rs(ad_set_log_callback(cb: extern "C" fn(level: i32, msg: *const c_char))installs a filteredtracing_subscriber::Layer) - Create:
crates/ffi/src/log_redaction.rs(security-critical: filters tracing events before emission to the callback) - Modify:
crates/ffi/src/lib.rs(exportad_set_log_callback) - Modify:
skills/agent-desktop-ffi/references/threading.md(callback lifetime invariants) - Modify:
crates/ffi/README.md(callback security contract — section "Log callback responsibilities") - Test:
crates/ffi/tests/log_callback.rs(invoking anyad_*emits a tracing event; callback receives a redacted message) - Test:
crates/ffi/tests/log_redaction.rs(tracing events containingvalue=<secret>,password=<secret>,clipboard=<secret>,token=<secret>arrive at the callback with values replaced by<redacted>) - Test:
crates/ffi/tests/log_callback_reentry.rs(setting the callback twice returnsErrorCode::InvalidArgswith the "callback already installed" suggestion)
Approach:
- Global
OnceCell<extern "C" fn(i32, *const c_char)>stores the callback; second registration fails closed withInvalidArgs. - The redaction layer sits between
tracing_subscriberand the callback. It filters:- Field-name allowlist/denylist: any event field with a name matching
value,text,content,clipboard,password,secret,token,credential,auth(case-insensitive) is replaced with<redacted>in the emitted string. - Level filter:
TRACE-level events are dropped by default; consumers opt in viaAGENT_DESKTOP_LOG_TRACE=1env var. - Size cap: any single event message longer than 4 KB is truncated with a
[truncated]marker to prevent memory amplification attacks.
- Field-name allowlist/denylist: any event field with a name matching
- Callback invocation is wrapped in
catch_unwind— a consumer-side panic does not propagate back through the FFI boundary. - Documented contract: "Callbacks must not persist received events to storage or network without explicit user consent. agent-desktop ships redaction as defense-in-depth; the primary trust boundary is the FFI consumer."
Patterns to follow:
- Existing
crates/ffi/src/ffi_try.rstrap_panicwrapper shape. crates/ffi/src/main_thread.rsfor global-state access discipline.
Test scenarios:
- Happy path:
ad_set_log_callback(cb)+ anyad_*call →cbreceives a C-string message. - Security:
type-text @e5 "hunter2"emits a tracing event; the callback receivestype-text ref=@e5 text=<redacted>(not the literal password). - Security: clipboard-set emits
clipboard=<redacted>regardless of content. - Edge case: message > 4 KB → truncated with marker.
- Edge case:
TRACEevents dropped unless env var set. - Edge case: callback that panics →
catch_unwindisolates; library continues. - Error path: second
ad_set_log_callbackcall returnsInvalidArgs.
Verification:
- All tests green.
crates/ffi/README.mdsection on callback responsibilities published and linked fromskills/agent-desktop-ffi/SKILL.md.
- [ ] Unit 2.6: Phase 1.5 FFI backfill — ad_snapshot / ad_execute_by_ref / ad_wait / ad_version / ad_status
Goal: Backfill the five FFI wrappers identified as missing in docs/plans/2026-04-16-001-fix-ffi-safety-abi-correctness-plan.md. Originally bundled into Unit 2; split out by review so Unit 2 stays single-concern.
Requirements: R16 (Phase 1.5 backfill portion)
Dependencies: Unit 2 (registry migration) — the 5 backfilled wrappers are new CommandDescriptor entries, not hand-written wrappers.
Files:
- Modify:
crates/core/src/commands/snapshot.rs(confirm the descriptor — added by U2 via build.rs filesystem enumeration — carries refmap-pipeline args correctly; includesskeletonandroot_refCLI bindings) - Modify:
crates/core/src/commands/status.rs,version.rs,wait.rs(same — verify descriptors are correct) - Create:
crates/core/src/commands/execute_by_ref.rs(new thin wrapper that takes(ref_id, action_json)and dispatches viaPlatformAdapter::execute_action) - Modify:
src/cli.rs+src/dispatch.rs+src/cli_args.rs(addExecuteByRefcommand arm) - Test:
crates/ffi/tests/backfill_gaps.rs(assertsad_snapshot,ad_execute_by_ref,ad_wait,ad_version,ad_statusare all exported and callable; closes the v0.1.13 known-gaps list) - Test:
crates/core/src/commands/execute_by_ref.rs(unit — resolves ref then dispatches action; propagates STALE_REF and INVALID_ARGS correctly)
Approach:
execute_by_refaccepts a serializedActionJSON + ref id. Resolves the ref viaRefMap::load, then callsadapter.execute_action. This is the CLI-shell-independent agent primitive that FFI consumers use for agent loops.- The other four (
snapshot,wait,version,status) already exist as CLI commands; this unit only confirms that theirCommandDescriptorentries carry the full arg surface (refmap pipeline for snapshot, multi-mode flags for wait, structured output for status).
Patterns to follow:
crates/core/src/commands/click.rsminimal shape.crates/core/src/commands/snapshot.rsfor the refmap pipeline.
Test scenarios:
- Happy path:
ad_execute_by_refwith valid ref +{"type":"Click"}action → callsPlatformAdapter::execute_action(Click)→ returns ActionResult JSON. - Error path: stale ref →
AdResult::ErrStaleRefpropagates cleanly. - Error path: malformed action JSON →
AdResult::ErrInvalidArgswith diagnostic. - Integration:
ad_snapshotexposes all flags from CLI (skeleton, root, surface, max-depth, include-bounds, interactive-only, compact).
Verification:
- Backfill test green;
crates/ffi/include/agent_desktop.hexports all 5ad_*functions.
- [ ] Unit 3: Windows adapter foundation
Goal: WindowsAdapter implements every existing 53-command's trait method via UIA (uiautomation 0.24) + windows 0.62.2. Base capability parity with macOS. Skeleton traversal (--skeleton, --root @ref) works identically on Windows. P2-O1, P2-O2, P2-O3, P2-O5, P2-O6, P2-O7 (partial).
Requirements: R1, R2, R3, R5, R6, R7
Dependencies: Unit 1, Unit 2
Windows Engineering Invariants (research-driven, MUST land in sub-PR 3.0 before any other U3 sub-PR)
All invariants validated against Microsoft docs (UIA Threading 2025-07-14, UIA Security 2026-02-18) and production UIA tooling. Source: /tmp/agent-desktop-research/windows-headless.md.
- DPI awareness at process startup:
main.rsWindows branch callsSetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)before any UIA call. Without this, UIA returns coordinates in virtualized pixels and click/hover synthesis drifts on mixed-DPI setups. - COM apartment on the main thread and UIA workers:
CoInitializeEx(NULL, COINIT_MULTITHREADED)is called once at startup and once per dedicated UIA worker thread. Research-backed: UIA explicitly prefers MTA for cross-thread event delivery;IUIAutomationElementis apartment-affine — caching elements across thread boundaries invalidates them. - Never cache
IUIAutomationElementacross apartments. NativeHandle stores an element obtained in the current apartment and is not safe to pass to a thread of different apartment affinity. Workers that need elements re-resolve fromRefEntryon their own thread. Event handlers are created, registered, removed, and drained on the same dedicated MTA thread. - UIA-first, SendInput-fallback. Every action attempts the UIA pattern first (
InvokePattern,ValuePattern,TogglePattern,ExpandCollapsePattern,SelectionItemPattern) — all focus-independent and headless-safe. Only when no pattern applies does the adapter fall back toSendInput, gated by anAttachThreadInput+SetFocusworker-thread dance. SendInput is documented as focus-dependent and silently blocked by UIPI against elevated targets. PostMessage WM_KEYDOWNis DEAD for modern apps. Chromium (Electron, Edge, Chrome, VS Code, Slack, Cursor), UWP apps, and games all ignore posted keyboard messages. It's NOT a viable SendInput alternative; do not implement.- UIPI elevation detection.
GetTokenInformation(TokenIntegrityLevel)compares the agent-desktop process's integrity level against the target window's (GetWindowThreadProcessId→ open process → query integrity). If mismatch, returnPermDeniedwithplatform_detail: "Target window runs at higher integrity level; SendInput and some UIA patterns are blocked by UIPI. Run agent-desktop with matching elevation."Do NOT ship theuiAccess=truemanifest flag by default — it requires code signing and a specific install location; document as an optional signed-release path. RemoveAutomationEventHandlerteardown race. Per Microsoft's 2025 UIA threading doc, the handler object must outlive the final dispatched callback. Implementation: store handlers in anArc<Handler>clone map keyed by(element_id, event_kind);RemoveAutomationEventHandleris called on the UIA client, but theArc<Handler>is dropped only AFTER a post-remove barrier call (a no-op UIA method call that drains any in-flight callback dispatch).- HRESULT encoding in
platform_detail. Standardized format:COM HRESULT 0x80070005 (E_ACCESSDENIED: Access is denied)— hex + FACILITY + symbolic name +FormatMessageWdescription. Parsed by a shared helpercrate::error::format_hresult(hr: HRESULT) -> String. - DWM composition caveat for legacy screenshot.
PrintWindow(hwnd, hdc, 0)returns black frames for composited windows on Windows 10+.PrintWindow(hwnd, hdc, PW_RENDERFULLCONTENT)mitigates — use the flag unconditionally in legacy backend.windows-capture(modern) handles composition correctly. ElementFromHandle(hwnd)is headless-safe within the current desktop session — works on same-user, same-session visible or minimized windows at an accessible integrity level regardless of focus. This is the foundation for observation headlessness. No focus side-effect.Windows.Graphics.Captureavailability: requires DWM and Windows 10 1903+ in an active interactive session. Fails in Session 0, Server Core, secure desktop, locked desktop, and some remote/virtualized sessions — document and returnPlatformNotSupportedin those environments. agent-desktop's own process does NOT need an HWND —windows-captureusesGraphicsCaptureItem.CreateFromWindowHandle(target_hwnd).- Session isolation: agent-desktop cannot drive windows in other user sessions, Session 0, secure desktop, or locked desktops. Document; return
WindowNotFound,PermDenied, orPlatformNotSupportedwith explanatoryplatform_detail. - Foreground APIs are explicit only:
SetForegroundWindow,SetFocus,AttachThreadInput, andSetWindowPos(HWND_TOP)are allowed only in commands/policies that explicitly request focus/window/physical side effects. They are never fallback paths for semantic ref actions. - Windows shell is part of the product surface: Start menu/search, taskbar, system tray/overflow, Action Center, Quick Settings, UAC/integrity, virtual desktop detection, and mixed-DPI monitor geometry are first-class Phase 2 cases. Each must be handled by a command/surface, an integration test, or a documented structured unsupported response.
Skeleton traversal on Windows
The skeleton primitive (snapshot --skeleton, snapshot --root @ref) is platform-agnostic at the core level (crates/core/src/snapshot_ref.rs). Windows adapter contribution is ~50 LOC:
get_subtree(handle, opts)delegates tobuild_subtree(element, opts)whereelementis theIUIAutomationElementheld byNativeHandle. Mirrors macOS'sadapter.rs:221-241shape.- Walker:
ControlViewWalker(NOTRawViewWalkerorContentViewWalker). Research Topic 4 confirmed:IsControlElementauto-filters layout noise and complements Unit 4's Electron depth-skip. children_countfor skeleton boundary annotation: singleFindAll(TreeScope_Children, TrueCondition)round-trip. Cheap enough — one COM call, no per-child property fetch.- Depth clamping at
opts.max_depth = 3in skeleton mode is enforced in core'sbuild_subtree; Windows adapter just respects the passed depth. - Scoped invalidation is entirely core-level (
RefMap::remove_by_root_ref). Windows inherits with zero adapter code. - Fresh
UICacheRequestper drill-down call — cached elements do not survive CLI process boundaries. Eachsnapshot --root @refallocates a new cache request; no attempt at cross-invocation caching. - Token savings target: 50-100× on VS Code / Slack Electron once Unit 4's
--force-electron-a11y+ empty-UIA_Group/UIA_Customdepth-skip are in place.
Files:
- Modify:
crates/windows/Cargo.toml(target-gated[target.'cfg(target_os = "windows")'.dependencies]gainsuiautomation = "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"] };windows-captureis deferred to U9; OLE/clipboard features for U12) - Modify:
crates/windows/Cargo.toml(mirror the deps; inheritagent-desktop-corefrom workspace) - Rewrite:
crates/windows/src/lib.rs(mod declarations + re-exports ofWindowsAdapter) - Create:
crates/windows/src/adapter.rs(WindowsAdapterstruct +impl PlatformAdapter) - Create:
crates/windows/src/tree/{mod,element,builder,roles,resolve,surfaces}.rs(UITreeWalker + UICacheRequest) - Create:
crates/windows/src/actions/{mod,dispatch,activate,extras}.rs(InvokePattern → TogglePattern → coord fallback) - Create:
crates/windows/src/input/{mod,keyboard,mouse,clipboard}.rs(SendInput, Win32 clipboard) - Create:
crates/windows/src/system/{mod,app_ops,window_ops,key_dispatch,permissions,screenshot,wait}.rs(screenshothere is the Phase 1PrintWindowequivalent — legacy backend; modern is U9) - Create:
crates/windows/src/helpers/pid.rs(narrow_pid: u32 → Result<i32, AdapterError>returningErrorCode::ResourceExhaustedper KD3; document-review refinement — this aligns with KD3's semantic choice) - Modify:
src/main.rs(build_adapter()#[cfg(target_os = "windows")]branch flips from stub to real adapter) - Test:
crates/windows/src/tree/roles.rs(#[cfg(test)]— unit: everyUIA_*ControlTypeIdmaps to a known role string) - Test:
crates/windows/tests/it_tree.rs(integration, Windows CI only: snapshot ofexplorer.exe,notepad.exe,calc.exe— non-empty tree with refs) - Test:
crates/windows/tests/it_actions.rs(click button in a test WinUI app; verify via UIA property read after the action) - Test:
crates/windows/tests/it_input.rs(SendInputtypes "hello" into Notepad; verifyValueproperty == "hello") - Test:
crates/windows/tests/it_clipboard.rs(get / set / clear roundtrip with ASCII + CJK viaCF_UNICODETEXT) - Test:
crates/windows/tests/it_window_ops.rs(resize, move, minimize, maximize, restore against a newly launched Notepad) - Test:
crates/windows/tests/it_app_lifecycle.rs(launch Notepad, type, close)
Approach:
- Tree walk:
IUIAutomation.ElementFromHandle(hwnd) → UITreeWalker.RawViewWalker.GetFirstChild/GetNextSiblingwithUICacheRequestbatchingName, ControlType, AutomationId, LocalizedControlType, HelpText, BoundingRectangle, IsEnabled, HasKeyboardFocus, ExpandCollapseState, ToggleState, ValueValuein one round-trip (origin §Windows API mapping). - Role map:
UIA_*ControlTypeId→ unified role strings (matches macOStree/roles.rsoutput). Table lives incrates/windows/src/tree/roles.rsand is exhaustive against the 50 UIA control types. - Action dispatch: pattern-first —
InvokePattern.Invoke→TogglePattern.Toggle→ExpandCollapsePattern.Expand→ coordinate-click fallback viaSendInput. Mirrors macOS smart-chain pattern; share dispatch config viaActionDispatchConfigincrates/coreper learning 1. blocked_combos()returns[alt+f4, ctrl+alt+del, win+l, ctrl+shift+esc]. Key-down / key-up safety check matches the whole combo (KD11).- PID narrowing:
fn narrow_pid(dword: u32) -> Result<i32, AdapterError>errors withErrorCode::ResourceExhausted(per KD3; document-review reconciliation — previously saidInternal) +platform_detail: "PID exceeds i32::MAX; Windows kernel should never produce this value"for values abovei32::MAX. Unlikely in practice; belt-and-braces. NativeHandleinner isAutomationElement(uiautomation::UIElement) held by pointer;release_handlecallsComPtr::Releaseequivalent.- Clipboard:
OpenClipboard(null)+CF_UNICODETEXTget/set with a UTF-16 ↔ String conversion. - Screenshot:
PrintWindow(hwnd, hdc, PW_RENDERFULLCONTENT)— this is the legacy path in this unit; U9 adds the modernwindows-capturebackend as the default. - App launch:
CreateProcessW+WaitForInputIdle+ resolve viaElementFromHandleon the main window.
Execution note: Keep each major surface (tree, actions, input, system) as its own sub-PR when possible to keep reviews manageable. The tree builder and UIA role map can land in one sub-PR (tree/ complete); actions + input in a second; clipboard + app lifecycle + window ops in a third; permissions + screenshot (legacy) in a fourth. Windows CI from U13 must be in place before this unit's integration tests can run — land U13 in parallel against a draft branch early.
Patterns to follow:
- Platform subfolder layout in
crates/macos/src/(tree/, actions/, input/, system/). AXElementsafety pattern (pub(crate)inner pointer, Clone/Drop via Retain/Release) — mirror forUIElementwithComPtr.- Smart activation chain in
crates/macos/src/actions/activate.rsandchain_steps.rs— define a cross-platformActionDispatchConfig(learning 1) so Windows doesn't copy the macOS structure wholesale.
Test scenarios:
- Happy path:
snapshot --app Explorerreturns a tree with ≥20 refs including the sidebar, breadcrumb, and file list (R1). - Happy path:
snapshot --app Notepadreturns a tree with atextfieldref for the document surface. - Happy path:
click @e<button_ref>on a known Settings button firesInvokePattern.Invokeand the UIA state changes. - Happy path:
type @e<textfield_ref> "hello"sets the Value viaSendInput; re-snapshot showsvalue="hello"(R3). - Edge case:
press cmd+qon Windows mapsCmd → Winand is blocked perblocked_combos(); returnsActionNotSupportedwith suggestion. - Edge case: UIA element present but offscreen — bounds reported,
BoundingRectanglereflects actual coordinates after scroll. - Edge case: stale
@e5(window closed) returnsErrorCode::StaleRefwith snapshot refresh suggestion. - Error path: calling
launch --app notexist.exereturnsErrorCode::AppNotFoundwith the lookup failure surfaced inplatform_detail. - Error path:
OpenClipboardcontention (another process holds the clipboard) retries then returnsActionFailedwith HRESULT inplatform_detail. - Integration:
clipboard-set "héllo ✓"+clipboard-getroundtrips Unicode exactly (R5). - Integration: cross-platform JSON identity — snapshot of Calculator on macOS and Windows, serialized, structurally identical on role set and ref ordering (R2). Fixture lives in
tests/fixtures/calculator.macos.jsonandtests/fixtures/calculator.windows.json; a test asserts the union of refs and the identifier map. - Integration:
window_op Maximize/Minimize/Restoreroundtrips viaShowWindow; state observable viaGetWindowPlacement. - Integration: PID narrowing test — mock
u32::MAX→narrow_pidreturnsErrorCode::ResourceExhausted(document-review refinement); any realistic PID (<2^31) roundtrips.
Verification:
- Windows CI (
test-windowsjob from U13) runs all integration tests green. cargo tree -p agent-desktop-corestill platform-free on Windows.- Binary size under 15 MB for
agent-desktop.exe(check in.github/workflows/release.yml).
- [ ] Unit 3a: Windows notifications
Goal: Windows Toast / Action Center parity with macOS 4-command surface (list-notifications, dismiss-notification, dismiss-all-notifications, notification-action) when the OS exposes a supported notification path. Primary path is UserNotificationListener with explicit user permission and app identity/capability; Action Center UIA is a best-effort fallback. Subset of P2-O14 and P2-O18.
Requirements: R14 (Windows subset)
Dependencies: Unit 3
Files:
- Create:
crates/core/src/notifications/session.rs(deepening-pass addition —NotificationSessiontrait; existing macOScrates/macos/src/notifications/nc_session.rsrefactors to implement it; Windowslistener.rsandaction_center.rsimplement it. Prevents "batch dismiss" future fixes from landing on only one platform.) - Modify:
crates/macos/src/notifications/nc_session.rs(refactor toimpl NotificationSession for NcSession) - Create:
crates/windows/src/notifications/{mod,list,dismiss,interact,listener.rs,action_center.rs}(listener.rswrapsUserNotificationListener;action_center.rsimplements UIA fallback) - Modify:
crates/windows/src/adapter.rs(implementlist_notifications,dismiss_notification,dismiss_all_notifications,notification_action) - Test:
crates/windows/tests/it_notifications.rs(CI integration — launch a known toast, list, dismiss, verify) - Test:
crates/core/src/notifications/session.rs(unit — trait contract tests that both platforms satisfy)
Approach:
- Primary listener access: use
UserNotificationListenerwhen package identity/capability and explicit permission are present. Denied permission maps toPERM_DENIEDwith a targeted suggestion. - Action Center fallback: open via
open-system-surface --surface action-center; traverse exposed shell UIA only when the toast list and action buttons have stable names/descriptions. - Each toast: listener metadata or UIA element with
Name= title,FullDescription= body, app info, and child action buttons withInvokePattern. dismiss_notification: prefer listener dismissal where supported; otherwise locate the toast's close button by stable identity (e.g.AutomationId == "DismissButton"when exposed) and invoke it.dismiss_all_notifications: listener bulk clear if supported; otherwise Action Center's "Clear all" button. Mirror macOSdismiss_allsemantics.notification_action(index, identity, action_name): enforce identity-fingerprint match (learning 4) — if identity fields areSome, compare against the toast's title/app before invoking. ReturnsNotificationNotFoundon mismatch.- Focus Assist state: use supported shell APIs first; registry/WNF probes are best-effort diagnostics and must never be the sole correctness signal.
Patterns to follow:
crates/macos/src/notifications/(list.rs, actions.rs, nc_session.rs) — same PlatformAdapter arm shape, same JSON contract.- Learning 4: identity fingerprint is optional, tri-state decoded at FFI.
Test scenarios:
- Happy path:
list-notificationsafter posting a test toast returns ≥1 entry withtitleandbodypopulated through the listener path when permission/app identity is available. - Happy path:
dismiss-notification --index 1removes the toast; re-list shows count decremented. - Happy path:
dismiss-all-notificationsclears Action Center; re-list returns empty. - Edge case: identity mismatch —
notification-action --index 1 --title "Wrong"returnsNotificationNotFoundwith suggestion to re-list. - Edge case: Action Center closed and listener unavailable → list returns empty or
PLATFORM_NOT_SUPPORTEDdepending on whether the shell exposes a supported fallback. - Error path: no notifications present →
dismiss-notification --index 1returnsNotificationNotFound. - Integration: cross-platform contract — same JSON keys, same 1-based indexing (compare with
crates/macos/tests/it_notifications.rs).
Verification:
- Windows listener tests green where package identity is available; Action Center fallback tests green on interactive desktop runners; hosted CI keeps unit/mocked contract tests green when shell UI is absent.
- [ ] Unit 3b: Windows shell surfaces + system tray
Goal: New command open-system-surface --surface <kind> plus list-tray-items, click-tray-item, open-tray-menu on Windows. Surfaces cover Start menu/search, taskbar, system tray/overflow, Action Center, Quick Settings, and shell flyouts. macOS gains the portable parts in Unit 10. Subset of P2-O14 and P2-O18.
Requirements: R14 (tray subset)
Dependencies: Unit 3
Files:
- Create:
crates/windows/src/tray/{mod,list,interact}.rs - Create:
crates/windows/src/system/shell_surfaces.rs - Create:
crates/core/src/commands/open_system_surface.rs - Create:
crates/core/src/commands/list_tray_items.rs,click_tray_item.rs,open_tray_menu.rs(the core dispatchers; delegate to adapter trait methods) - Modify:
crates/core/src/adapter.rs(trait methodsopen_system_surface,list_tray_items,click_tray_item,open_tray_menuwithnot_supported()defaults) - Modify:
crates/windows/src/adapter.rs(implement) - Modify:
src/cli.rs,src/cli_args.rs,src/dispatch.rs(wire new commands) - Modify:
crates/core/src/commands/mod.rs(module list) - Test:
crates/windows/tests/it_shell_surfaces.rs(Start/taskbar/Action Center/Quick Settings surface open + snapshot, interactive runner) - Test:
crates/windows/tests/it_tray.rs(CI integration or self-hosted interactive job)
Approach:
- Access
Shell_TrayWndwindow class viaFindWindow; walk its UIA tree; tray items areUIA_ButtonControlTypeIdleaves withAutomationId= ICON GUID when set. - Overflow:
NotifyIconOverflowWindowclass; expand by clicking the chevron button. open-system-surface --surface start-menu: use the documented shell keyboard command through explicit shell-surface policy, then snapshot Start via UIA. This is not used forlaunch, which remains directCreateProcess/ShellExecuteEx.open-system-surface --surface quick-settingsandaction-center: use supported shell invocation where available; returnPLATFORM_NOT_SUPPORTEDwithplatform_detailwhen the Windows build/session has no accessible shell surface.snapshot --surface taskbar: walkShell_TrayWndtask list and expose pinned/running app buttons as refs. Invoking a taskbar button is a focus/window action and must go through explicit policy.- Virtual desktop detection uses public
IVirtualDesktopManagerfor current-desktop filtering/diagnostics. Moving windows across virtual desktops is deferred until a stable public write path is validated. - Mixed-DPI geometry uses process-wide per-monitor awareness plus physical-pixel normalization before any coordinate fallback.
click_tray_item --name "Network": resolve byNameor byidentifier; fallback to coordinate click viaSendInputfor items without UIA patterns.open_tray_menu --name "Volume": click + wait for the resulting popup (UIA focus-changed event from U7, or poll fallback before U7 lands).
Patterns to follow:
- Commands in
crates/core/src/commands/stay slim — delegate to adapter (click.rs, list_windows.rs shape). - Learning 1 — define
SurfaceDetectionConfigso tray, menu, and notification surface walkers share shape.
Test scenarios:
- Happy path:
open-system-surface --surface start-menuopens Start;snapshot --surface start-menureturns search/results controls with refs. - Happy path:
snapshot --surface taskbarreturns pinned/running app buttons with stable names. - Happy path:
open-system-surface --surface quick-settingsopens Quick Settings on Windows builds that expose it; unsupported builds returnPLATFORM_NOT_SUPPORTED. - Happy path:
list-tray-itemsreturns volume, network, clock entries on a baseline interactive Windows VM. - Happy path:
click-tray-item --name "clock"opens the clock flyout; re-list-surfaces shows the popup surface. - Edge case: unknown tray item name →
ElementNotFoundwith suggestion to runlist-tray-items. - Edge case: overflow item — list includes items in
NotifyIconOverflowWindowtransparently. - Error path: tray or shell surface not accessible (Explorer shell missing, locked desktop, Server Core, unsupported build) →
WindowNotFoundorPlatformNotSupportedwith platform detail.
Verification:
- Windows interactive integration green; hosted CI keeps unit/mocked shell-surface contract tests green when Explorer shell is unavailable;
list-surfaceson Windows now includesSystemTray,Taskbar,StartMenu,ActionCenter, andQuickSettingswhen present.
- [ ] Unit 4: Windows Electron / WebView2 compatibility
Goal: Depth-skip for non-semantic UIA wrappers, resolver depth 50, surface detection treats focused window as the target surface, --force-electron-a11y CLI flag. Matches macOS Electron compat. P2-O15.
Requirements: R15
Dependencies: Unit 3 (needs the tree walker to patch)
Files:
- Modify:
crates/windows/src/tree/builder.rs(web-wrapper depth-skip:UIA_GroupControlTypeId/UIA_CustomControlTypeIdwith emptyNameAND emptyValuedo not consume depth budget) - Modify:
crates/windows/src/tree/resolve.rs(ABSOLUTE_MAX_DEPTH = 50mirror of macOS) - Modify:
crates/windows/src/tree/surfaces.rs(surface detection checks focused window itself against target surface shape) - Modify:
crates/core/src/adapter.rs(TreeOptions.force_electron_a11y: bool) - Modify:
src/cli_args.rs(snapshot args gain--force-electron-a11y) - Modify:
crates/windows/src/adapter.rs(if flag set, applyrenderer-accessibilityUIA override — checkClassName == "Chrome_WidgetWin_1"and setAutomationModeviaDirect UIhandshake where available; otherwise warn viaplatform_detail) - Modify:
crates/macos/src/tree/builder.rs(honor the newforce_electron_a11yflag; pair macOS change lands in the same PR per origin §Phase 2 "land atomically") - Note: the
docs/solutions/best-practices/electron-compat-cross-platform-2026-04-18.mdport from private memory moves to Unit 14 (document-review scope reconciliation — that port is an orthogonal documentation task, not Electron-compat implementation). - Test:
crates/windows/tests/it_electron.rs(CI: snapshot VS Code with and without--force-electron-a11y; assert ≥100 refs with the flag) - Test:
crates/macos/tests/it_electron.rs(existing; extend to validate the new flag is respected)
Approach:
- Depth-skip: mirror macOS
is_web_wrapperlogic —UIA_GroupControlTypeIdorUIA_CustomControlTypeIdwithName.is_empty() && Value.is_empty()returns true; wrapper is traversed but does not increment depth. - Chromium detection:
GetClassNameWon the HWND; ifChrome_WidgetWin_1and tree is empty, emit warning inplatform_detailwith--force-renderer-accessibilityhint. - Surface detection for Electron modals: when
surface = Sheet | Alert, check if the focused window IS the target surface (not only its children). This matches macOS's pattern — Electron wraps dialogs as independent windows. --force-electron-a11y: on macOS, setsAXEnhancedUserInterface = YESon app root (existing). On Windows, attemptSetPropertyValue(AutomationElementInformation.AutomationId, true)on the root element; if the app does not honor the flag, emit a warning rather than erroring.
Patterns to follow:
crates/macos/src/tree/builder.rsis_web_wrapper(exists today — check forAXGroup/AXGenericElementwith empty name/value).- Learning 4 —
Some("")vsNonebug: Chromium returnsSome(""); always useis_none_or(str::is_empty).
Test scenarios:
- Happy path: VS Code snapshot without
--force-electron-a11yreturns ~3 refs (matches current behavior); with the flag returns ≥100 refs (R15). - Happy path: Slack snapshot with depth-skip enabled returns ≥50 refs.
- Edge case: non-Chromium window with empty-name groups is NOT depth-skipped (ensures the heuristic doesn't over-trigger on, say, empty GroupBoxes in WinForms).
- Edge case: Chromium app in "accessibility disabled" mode → empty tree + warning in
platform_detailsuggesting--force-renderer-accessibility. - Integration: file-picker dialog in VS Code →
--surface sheetdetects it as the sheet surface (the dialog is a separate Electron window). - Integration:
--force-electron-a11yJSON output is identical between macOS and Windows against the same VS Code version (structural).
Verification:
- CI integration tests green on both platforms.
- Ported solutions doc committed under
docs/solutions/best-practices/.
- [ ] Unit 5: Stable-selector field population
Goal: Both platforms populate StableSelectors wherever the OS/app exposes them. resolve_element prefers identifier when non-empty; falls back to the existing (pid, role, name, bounds_hash) fingerprint. RefEntry.identifier carries the selector across snapshots. Tests require known controls with explicit IDs to preserve them; real apps may omit identifiers. P2-O8.
Requirements: R8
Dependencies: Unit 1 (types), Unit 3 (Windows adapter present), Unit 2 (FFI regen)
Files:
- Modify:
crates/macos/src/tree/element.rs→ 2-way split intoelement.rs(coreAXElementsafety +fetch_node_attrsbatch) +element_selectors.rs(new selector readers). File is at 404 L; splitting enforces the 400-LOC rule before adding reads. (Document-review refinement — Context & Research previously mentioned a 3-way split withelement_attrs.rs; the authoritative split is 2-way.) - Create:
crates/macos/src/tree/element_selectors.rs(readers forkAXIdentifierAttribute,kAXSubroleAttribute,kAXRoleDescriptionAttribute,kAXPlaceholderValueAttribute,kAXDOMIdentifierAttribute,kAXDOMClassListAttribute) - Modify:
crates/macos/src/tree/builder.rs(populateStableSelectorsduring node construction) - Modify:
crates/windows/src/tree/element.rs(readers forAutomationId,LocalizedControlType,HelpText, plus WebView2HtmlId/HtmlClassviaUIA_HtmlIdProperty/UIA_HtmlClassProperty) - Modify:
crates/windows/src/tree/builder.rs(populate) - Modify:
crates/core/src/ref_alloc.rs(populateRefEntry.identifierwhenStableSelectors.identifieris non-empty) - Modify:
crates/macos/src/tree/resolve.rs(prefer-identifier: ifentry.identifier.is_some(), search byAXIdentifiermatch first; fall back to(pid, role, name, bounds_hash)) - Modify:
crates/windows/src/tree/resolve.rs(same, viaAutomationId) - Test:
crates/macos/tests/it_selectors.rs(target: a test harness.appthat exportsaccessibilityIdentifieron known buttons; assert those controls carryidentifierand ordinary controls without IDs omit it) - Test:
crates/windows/tests/it_selectors.rs(target: Calculator; assert the=button carriesAutomationId = "equalButton") - Test:
crates/core/src/ref_alloc.rs(unit:RefEntry.identifierpreserved through allocator) - Test:
tests/integration/stale_ref_rate.rs(regression: against a fixture of 100 Electron/localized elements, resolver success rate with identifier preference ≥ baseline + 20 pp; captures the "measurably drops" metric in R8)
Approach:
- macOS:
AXUIElementCopyMultipleAttributeValuesrequest already batches reads; extend its attribute list with the 6 selector attributes. No extra round-trip. - Windows:
UICacheRequestalready batches; add the 6 selector properties to the cache request. No extra round-trip. - Resolver prefers: if
entry.identifier.is_some(), walk the tree and match byidentifier(O(n) per level but typically matches in first 3 levels). Only if identifier lookup fails, fall back to bounds-hash fingerprint. Matches learning 4 — identifier is an optional fingerprint, never mandatory. dom_classeson Windows requiresUIA_HtmlClassPropertywhich is WebView2-only; returns empty Vec on non-Chromium apps. This is expected.
Patterns to follow:
crates/macos/src/tree/element.rs:fetch_node_attrs— mirror batching shape for the new attributes.- Learning 1 — define
SelectorReadConfigincrates/coreso macOS / Windows / (future) Linux readers share shape. - Learning 4 — tri-state UTF-8 at FFI; invalid bytes →
INVALID_ARGS.
Test scenarios:
- Happy path (macOS): A test-harness "Save" button has
subrole = "AXConfirmButton"andidentifier = "save"when the app exposes it. - Happy path (Windows): Calculator's "=" button has
identifier = "equalButton"androle_description = "button". - Happy path:
snapshot --app CalculatorJSON includes theidentifierfield on the=button; roundtrip through refmap preserves it. - Edge case: element without an accessibility identifier emits no
identifierfield (skip_serializing_if). - Edge case: WebView2 element in Edge has
dom_idanddom_classespopulated; non-WebView2 element in Notepad has neither. - Edge case: localized app — same button carries the same
identifieron English and German system locales (verifies identifier is locale-stable). - Integration:
resolve_elementfor a button whosenamechanged (e.g. Chrome tab title) but whoseidentifieris stable → resolves successfully; before this unit, returnedSTALE_REF. - Integration: STALE_REF regression against a 100-element fixture: measured resolution success rate is at least +20 pp with identifier preference (the "measurably drops" metric in R8; baseline captured in a fixture generated once before this unit).
Verification:
- Both platform CI integration tests green.
tests/integration/stale_ref_rate.rsasserts the +20 pp metric.
- [ ] Unit 6: Action variant implementations — LongPress, ShowMenu, WindowRaise, Cancel
Goal: Implement the four cross-platform "simple" new Action variants on both platforms. SelectRange and InsertAtCaret wait for Unit 8 (require text infrastructure). DeliverFiles + ForceClick ship in Unit 12 (require heavier platform-specific scaffolding). Subset of P2-O9.
Requirements: R9 (subset)
Dependencies: Unit 1 (variants declared), Unit 3 (Windows adapter present)
Pre-work (first sub-PR before any arm additions — deepening-pass addition): crates/macos/src/actions/chain_steps.rs is already at 407 LOC (over the 400 cap today) and crates/macos/src/actions/dispatch.rs at 349 LOC will breach adding 4 arms. Before this unit's action-logic sub-PRs, land a refactor sub-PR that splits:
chain_steps.rs→chain_steps.rs(step definitions) +chain_steps_extra.rs(the overflow) OR along responsibility seams; target each ≤350 LOC.dispatch.rs→dispatch.rs(primary dispatch) +dispatch_variants.rs(the 4 new Phase 2 arms + future U12 arms); target each ≤350 LOC. This refactor is behavior-preserving (reorganizes internal module structure only, no public API change). U12 inherits the split, so its 2 additional arms (DeliverFiles,ForceClick) land indispatch_variants.rscleanly.
Files:
- Modify:
crates/macos/src/actions/chain_steps.rs(split pre-work — ≤350 LOC each side) - Create:
crates/macos/src/actions/chain_steps_extra.rs(split pre-work) - Modify:
crates/macos/src/actions/dispatch.rs(split pre-work; then arms forLongPress,ShowMenu,WindowRaise,Cancelland indispatch_variants.rs) - Create:
crates/macos/src/actions/dispatch_variants.rs(new arms here — usesActionDispatchConfigfrom core) - Modify:
crates/windows/src/actions/dispatch.rs+ optionaldispatch_variants.rssplit if needed - Create:
crates/core/src/commands/long_press.rs,show_menu.rs,window_raise.rs,cancel.rs - Modify:
crates/core/src/commands/mod.rs,src/cli.rs,src/cli_args.rs,src/dispatch.rs - Test:
crates/macos/tests/it_actions_new.rs - Test:
crates/windows/tests/it_actions_new.rs - Test:
crates/macos/src/actions/(smoke test after split — all existing behavior preserved)
Approach:
LongPress { duration_ms }: macOSCGEventCreateMouseEvent(MouseDown)+ sleep +MouseUpat element'sbounds.midpoint(). WindowsSendInputMOUSEDOWN + sleep + MOUSEUP. Duration clamped to[50, 10_000]ms withInvalidArgsoutside.ShowMenu: macOSAXPerformAction(kAXShowMenuAction)if supported by the element; fallback to right-click. WindowsExpandCollapsePattern.Expandif available; fallback toSendInputright-click.WindowRaise: macOSAXUIElementSetAttributeValue(kAXRaisedAttribute, true)+AXUIElementPerformAction(kAXRaiseAction). WindowsSetForegroundWindow(HWND)+SetWindowPos(HWND_TOP). This is an explicit focus/window command, not a fallback; its command policy must permit focus steal and tests assert the side effect is intentional.Cancel: macOSAXPerformAction(kAXCancelAction). WindowsWindowPattern.Closeon dialog, orInvokePattern.Invokeon the cancel button if detectable; fallback synthesizes Escape.
Patterns to follow:
- Existing
crates/macos/src/actions/dispatch.rsmatch arms forClick,Toggle,Expand. crates/core/src/commands/click.rsas command-file shape (14 L).
Test scenarios:
- Happy path:
long-press @e<ref> --duration 500on a macOS button fires MouseDown then MouseUp 500 ms apart; element'spressedstate observed transiently via probe. - Happy path:
show-menu @e<ref>on a Finder file opens the context menu; subsequentlist-surfacesshowsmenusurface. - Happy path:
window-raisebrings a background window to the front (verifylist-windows --focused-onlybefore/after). - Happy path:
cancelon a macOS Save dialog dismisses it; dialog disappears fromlist-surfaces. - Edge case:
long-presswithduration=0returnsInvalidArgs. - Edge case:
show-menuon an element that lacks context-menu support falls back to right-click; integration test verifies a menu appears or anActionNotSupportedis returned cleanly. - Error path:
cancelon a non-dialog element returnsActionNotSupported. - Integration: cross-platform JSON shape identical for all four commands.
Verification:
- Both platforms' CI green for
it_actions_new.rs.
- [ ] Unit 7: watch_element — event subscription with push notifications
Goal: watch --event <kind> --ref @e<id> --timeout <ms> returns events within 500 ms of a programmatic change. P2-O11. Replaces the polling in system/wait.rs for element existence when event-driven path is available.
Requirements: R11
Dependencies: Unit 1 (types: EventKind, ElementEvent, trait method stub), Unit 3 (Windows adapter present).
Ordering note (review-refined): Unit 7's opening spike PR (validation-only, no shipped code) must land before Unit 1 sub-PR 1h so that watch_element trait signature + threading model are informed by validated behavior, not guessed. Sequence: U1 sub-PRs 1a→1g land → U7 spike lands → U1 sub-PR 1h lands (trait method signatures informed by spike) → U7 implementation proceeds.
Files:
- Modify:
crates/core/src/adapter.rs(confirmwatch_element(handle, events, timeout) → Result<Vec<ElementEvent>, AdapterError>signature; stub landed in Unit 1) - Create:
crates/macos/src/events/{mod,observer,runloop_worker}.rs(AXObserver + CFRunLoop on a dedicated worker thread) - Create:
crates/windows/src/events/{mod,handler,mta_worker}.rs(UIA event handler on a dedicated MTA thread) - Create:
crates/core/src/commands/watch.rs - Modify:
src/cli.rs,src/cli_args.rs,src/dispatch.rs(wait --event <kind>flag, with--eventrepeatable) - Modify:
crates/core/src/commands/wait.rs(when--eventpresent, dispatch to new watch command rather than the polling path) - Test:
crates/macos/tests/it_watch.rs(value-changed, focus-changed, menu-opened scenarios against TextEdit / Finder) - Test:
crates/windows/tests/it_watch.rs(value-changed, focus-changed against Notepad / Settings) - Test:
crates/core/src/commands/wait.rs(unit: ref-format validation separate from event-kind validation)
Approach:
- Opening spike (research-directed): validate the asymmetric threading model on both platforms:
- macOS: confirm that a main-thread
CFRunLoop(the CLI's main thread runsCFRunLoopRunInModeduringwatch_element) supportsAXObserverattach + event delivery on the same thread. Research Topic A: every production AX consumer (AXSwift, Hammerspoon, Phoenix) bindsAXObserverGetRunLoopSourcetoCFRunLoopGetMain()— off-main attachment is a trap, not a supported configuration. Validate against Finder, TextEdit, VS Code on macOS 14/15. - Windows: confirm that a worker thread on
CoInitializeEx(MTA)hostsAddAutomationEventHandler/AddPropertyChangedEventHandlerwith cross-thread event delivery via mpsc to the main thread. Per Microsoft's 2025 UIA threading doc this is the supported pattern. Validate against Notepad, Settings, VS Code. - Spike also validates (a) callback-panic → stop path (
catch_unwindaround the AX/UIA callback emits a syntheticElementEvent::CallbackPanicand exits cleanly) and (b) hard-join timeout =2 × user_timeout_msreturningErrorCode::Internal. - Spike PR lands before Unit 1 sub-PR 1h so trait signatures reflect validated behavior.
- macOS: confirm that a main-thread
- Trait signature:
watch_element(&self, entry: &RefEntry, spec: &WatchSpec) -> Result<Vec<ElementEvent>, AdapterError>. Takes&RefEntry, not&NativeHandle. Each platform's implementation resolves the element on whichever thread owns its accessibility state. - Asymmetric threading model (research-refined — Apple and Microsoft prescribe different patterns):
- macOS — main-thread
AXObserver. Research Topic A confirmed all AX functions are main-thread-only. Implementation:- Main thread: resolve
RefEntry→AXUIElementRef,AXObserverCreate(pid, callback),AXObserverAddNotificationfor each subscribed kind,CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), kCFRunLoopDefaultMode). - Main thread: spawn a tiny signal thread whose only job is
thread::sleep(timeout_duration)+CFRunLoopStop(main_loop). - Main thread:
CFRunLoopRunInMode(kCFRunLoopDefaultMode, remaining_secs, false)— blocks until timeout ORCFRunLoopStop. - Callback (main thread): appends to a
RefCell<Vec<ElementEvent>>local towatch_element; no mpsc needed — callback and consumer are the same thread. - Teardown:
AXObserverRemoveNotification+CFRelease(observer); signal thread joins cleanly (either via its sleep expiring or a cancellation flag).
- Main thread: resolve
- Windows — worker-thread MTA handler. UIA supports cross-thread event delivery:
- Main thread: resolve
RefEntry→IUIAutomationElementon the main-thread MTA, extract(pid, automation_id, runtime_id)for the worker. - Main thread: spawn worker thread → worker calls
CoInitializeEx(NULL, COINIT_MULTITHREADED), creates its ownIUIAutomationclient, re-resolves the element by fingerprint on its thread (apartment-affine handles do not cross thread boundaries per Research Topic 4). - Worker: installs
AddAutomationEventHandler(and friends) with handlers stored asArc<Handler>so the handler object outlives the final dispatched callback (Research Topic 4 — post-remove barrier pattern). - Worker: handler pushes
ElementEventintompsc::Senderwrapped incatch_unwind. - Main thread:
recv_timeout(Duration::from_millis(timeout_ms))on the receiver. - Teardown: main calls
PostThreadMessage(worker_tid, WM_QUIT); worker callsRemoveAutomationEventHandler+ no-op UIA call (post-remove barrier drains in-flight callbacks) +CoUninitialize. Hard-join within2 × timeout_ms.
- Main thread: resolve
- macOS — main-thread
- No shared
EventWorkertrait. The two platforms are categorically different (main-thread CFRunLoop vs worker MTA). Inlining is simpler than abstracting over incompatible shapes. Revisit at Phase 3 when AT-SPI dbus joins; likely Linux is also asymmetric, and the three-way shape may or may not unify. - Public API is synchronous:
watch_elementreturnsVec<ElementEvent>orTIMEOUTerror. WaitStrategyenum incommands/wait.rs(learning 1 refinement): introduceenum WaitStrategy { EventDriven(WatchSpec), Polling(PollSpec) }thatexecute()matches over. The existingwait --element/--window/--text/--menu/--notificationroutes constructPolling(...); the newwait --eventroute constructsEventDriven(...). This prevents future flags (e.g.,--until-visible) from adding yet another branch.- Error taxonomy (learning 2): ref syntax error →
INVALID_ARGS; ref valid but element gone →STALE_REF; no events within timeout →TIMEOUT; observer couldn't attach →ACTION_FAILEDwithplatform_detailnaming the AX/COM error; > 32 subscriptions →RESOURCE_EXHAUSTED; worker hard-join timeout →INTERNALwith platform detail. - Secure-field redaction in
ElementEvent.attr_snapshot(review-added — security): before pushing anElementEventinto the mpsc channel, the worker inspects the source element. IfAXSubrole == AXSecureTextField(macOS) orUIA_IsPasswordProperty == true(Windows), thevaluefield on the carriedAccessibilityNodeis replaced with<redacted>. This preventswatch --event value-changedfrom streaming the literal keystrokes as a user types into a password field. - Concurrency cap (review-added): a process-wide
AtomicUsizecounts in-flightwatch_elementcalls. Cap default32; beyond that, returnRESOURCE_EXHAUSTEDwith suggestion "Close other watch sessions or raise AGENT_DESKTOP_MAX_WATCHES". Per-call MTA apartment creation on Windows costs ~5-20ms cold; unbounded fan-out from FFI consumers can hit COM rate limits. Cap configurable via env var.
Execution note: Open with the spike PR (no shipped code) before implementation units.
Technical design (research-refined — asymmetric by platform):
macOS — main-thread AXObserver (research Topic A):
============================================
main thread signal thread
----------- -------------
watch_element(entry, spec)
│
AXObserverCreate + AddNotification
│
CFRunLoopAddSource(main_loop, ...)
│
├── spawn ────────────────────────► sleep(timeout_ms)
│ CFRunLoopStop(main_loop)
│
CFRunLoopRunInMode(default, t, false)
│
(callback on same thread appends to local Vec<ElementEvent>)
│
return Vec<ElementEvent>
Windows — worker-thread MTA handler (research Topic 4):
============================================
main thread worker thread
----------- -------------
watch_element(entry, spec)
│
├── spawn ────────────────────────► CoInitializeEx(MTA)
│ IUIAutomation::new() on this thread
│ re-resolve element by fingerprint
│ AddAutomationEventHandler(Arc<Handler>)
│ (handler:)
│←────── mpsc::Sender ──────────── catch_unwind(|| tx.send(ElementEvent))
│
recv_timeout(Duration::from_ms(t))
│
PostThreadMessage(worker_tid, WM_QUIT) ─► RemoveAutomationEventHandler
│ post-remove barrier (no-op UIA call)
│ CoUninitialize
│ thread::exit
│
hard-join (2 * timeout_ms) or Internal
│
return Vec<ElementEvent>
Patterns to follow:
crates/macos/src/system/wait.rs— current polling path that this unit replaces for element-existence scenarios.- Learning 2 — separate error codes by failure mode; boundary-node pattern for capped subscription counts (max 32 active subscriptions per call; beyond that →
ResourceExhaustedwith suggestion).
Test scenarios:
- Happy path (macOS): Start
watch --event value-changed --ref @e<textfield> --timeout 2000; separately, set the field's value viaset-value; the watch command returns a singleValueChangedevent within 500 ms (R11 metric). - Happy path (Windows): Same against Notepad.
- Happy path: Multi-event subscription —
--event value-changed --event focus-changedreceives both kinds. - Edge case:
--event unknown-kind→InvalidArgswith the list of accepted kinds. - Edge case: ref has bad syntax (
not-a-ref) →InvalidArgs(notStaleRef) per learning 2. - Edge case: ref valid but element gone →
StaleRefwith snapshot-refresh suggestion. - Error path: timeout reached with no events →
Timeout(notOk(vec![])). The contract: the command returnsOkonly when events fired. - Edge case: > 32 subscriptions →
ResourceExhaustedwith "max 32 kinds per call" suggestion. - Integration: worker thread joins cleanly;
std::thread::available_parallelism()usage does not leak threads across 100 sequentialwatch_elementcalls (regression harness). - Integration: the legacy polling path in
system/wait.rsstill works for non-event scenarios (wait --elementwithout--event).
Verification:
- Spike PR lands first (separately reviewable — no shipped code); implementation PR cites it.
- Both platforms' CI green; 500 ms latency target met in the metric assertion.
- [ ] Unit 8: Text range primitives
Goal: text get-selection, text select-range, text insert-at-caret, text at-offset on both platforms. Enables Action::SelectRange and Action::InsertAtCaret dispatch arms added in Unit 1. P2-O12.
Requirements: R12
Dependencies: Unit 1 (types), Unit 3 (Windows adapter present)
Files:
- Create:
crates/macos/src/text/{mod,selection,range,param_attrs}.rs(parameterized attribute helpers:AXStringForRangeParameterizedAttribute,AXBoundsForRangeParameterizedAttribute,AXRangeForLineParameterizedAttribute;AXValueCreate(kAXValueCFRangeType)) - Create:
crates/windows/src/text/{mod,text_pattern,range}.rs(TextPattern.GetSelection,TextRange.Select,TextRange.Move,TextRange.GetText,TextRange.GetBoundingRectangles) - Create:
crates/core/src/commands/text_get_selection.rs,text_select_range.rs,text_insert_at_caret.rs,text_at_offset.rs - Modify:
src/cli.rs,src/cli_args.rs,src/dispatch.rs(top-leveltextsubcommand with four sub-subcommands) - Modify:
crates/macos/src/actions/dispatch.rs+crates/windows/src/actions/dispatch.rs(Action::SelectRange→set_text_selection;Action::InsertAtCaret→insert_text_at_caret) - Test:
crates/macos/tests/it_text.rs(TextEdit: select-range + get-selection roundtrip; insert-at-caret advances caret) - Test:
crates/windows/tests/it_text.rs(Notepad: same)
Approach:
- Use UTF-16 code units for
TextRange { start, length }consistently across both platforms (matches AXCFRangeand UIATextRangenative conventions). Document this at the public-contract boundary with a one-line doc comment onTextRange. - macOS
get_text_selection: readkAXSelectedTextRangeAttribute→CFRange; decode toTextRange. - macOS
set_text_selection:AXValueCreate(kAXValueCFRangeType, &CFRange)→AXUIElementSetAttributeValue(kAXSelectedTextRangeAttribute, value). - macOS
insert_text_at_caret: get selection →AXUIElementSetAttributeValue(kAXSelectedTextAttribute, string)replaces selection with string (and advances caret to end). - Password-field gate (review-added — security, both platforms): before any text-read operation (
get_text_selection,get_text_at), the adapter checks the target element for password-field status. macOS:AXSubrole == AXSecureTextField→ returnActionNotSupportedwith suggestion "Text reads are blocked on password fields; use set-value to write without reading." Windows:UIA_IsPasswordProperty == trueORControlType == PasswordEdit→ same error. Writes (set_text_selection,insert_text_at_caret) are allowed on password fields because typing a credential is a legitimate agent operation. TheTextRangeConfig.check_password_field: bool(defaulttrue) gates this; explicit--allow-password-readflag opts out with a stern CLI warning, reserved for security research scenarios. - Windows
get_text_selection:TextPattern.GetSelection→ firstTextRange.GetBoundingRectangles→ start/length relative toTextPattern.DocumentRange.GetText(-1)UTF-16 length. - Windows
set_text_selection:TextPattern.DocumentRange.Clone().Move(TextUnit_Character, start).MoveEndpointByRange(End, start+length)→Select. - Windows
insert_text_at_caret:set_text_selectionto caret-only range →SendInputtyping the text (UIA has no direct "insert at caret" primitive); alternativelyTextPattern.SupportedTextSelectionfallback.
Patterns to follow:
- Learning 2 —
INVALID_ARGSfor bad range bounds (negative, overflow);STALE_REFfor gone elements;ACTION_FAILEDfor elements that don't support text patterns.
Test scenarios:
- Happy path: TextEdit with "hello world" →
text select-range @e<field> 0 5+text get-selection @e<field>returns{ start: 0, length: 5, text: "hello" }. - Happy path:
text insert-at-caret @e<field> "bye "inserts at current caret; field value reflects it; caret advanced by 4. - Happy path:
text at-offset @e<field> 0 5returns"hello"without mutating selection. - Edge case:
start> document length →InvalidArgswith the document length in the suggestion. - Edge case:
length = 0selects nothing (caret-only); valid. - Edge case: element without
TextPatternon Windows orAXValuestring type on macOS →ActionNotSupported. - Edge case: Unicode surrogate pairs ("👋") — UTF-16
length = 2for one emoji; test verifies boundary alignment. - Error path: element gone between snapshot and call →
StaleRef. - Integration: cross-platform roundtrip — same test against a multi-line field on both platforms produces identical JSON with identical offsets.
Verification:
- Both platform CI integration tests green; roundtrip assertions equal on macOS and Windows.
- [ ] Unit 9: Modern screenshot backends
Goal: Modern default on both platforms where the OS/session supports it. macOS via ScreenCaptureKit (screencapturekit 1.5); Windows via windows-capture 1.5.4. Legacy fallback (--screenshot-backend legacy) keeps the Phase 1 subprocess on macOS and PrintWindow on Windows. Cold latency <50 ms on supported modern paths. P2-O13.
Requirements: R13
Dependencies: Unit 1 (ScreenshotBackend type, trait method get_screenshot_with_backend), Unit 3 (Windows adapter; legacy screenshot already landed there)
Files:
- Modify:
Cargo.toml(target-gated: macOS gainsscreencapturekit = "1.5"(research-refined — published crates.io canonical crate as of Q1 2026, doom-fish fork is the maintained successor and is published there; no git-SHA pin needed) +objc2 = "0.6"withFoundation/AppKitfeatures; Windows gainswindows-capture = "1.5.4"(latest stable, pinswindows = "0.62.2"matching our workspace)) - Create:
docs/security/screenshot-deps-audit-2026-04-18.md(review-added — documents (a) whyscreencapturekit 1.5(crates.io) is trusted, (b) the weekly CI audit that comparesCargo.lockhash against last known good, (c) the screen-capture code paths reviewed for exfiltration risk. Committed before Unit 9 merge.) - Modify:
.github/workflows/ci.yml(review-added — weekly cron job:cargo update --dry-run -p windows-capture -p screencapturekitreports transitive dep shifts; alerts on unexpected movement.) - Modify:
crates/macos/Cargo.toml,crates/windows/Cargo.toml(mirror deps) - Modify:
crates/macos/src/system/screenshot.rs(split intomodern.rs+legacy.rsif LOC pressure; wireScreenshotBackend::Modernto ScreenCaptureKitSCShareableContent.windows+SCScreenshotManager.captureImage) - Modify:
crates/windows/src/system/screenshot.rs(split; modern viawindows-capture+Direct3D11CaptureFramePool) - Modify:
crates/core/src/commands/screenshot.rs(wire--screenshot-backend <modern|legacy>flag; default Modern) - Modify:
src/cli_args.rs(add flag) - Modify:
crates/macos/src/adapter.rs+crates/windows/src/adapter.rs(implementget_screenshot_with_backend;screenshotlegacy trait method routes toget_screenshot_with_backend(ScreenshotBackend::Legacy)) - Create:
.github/workflows/ci.ymlgrep guard step — fails ifobjc2::import appears outsidecrates/macos/src/system/screenshot.rs/crates/macos/src/system/permissions.rs(R6 mitigation per origin risks) - Test:
crates/macos/tests/it_screenshot_modern.rs(SSIM compare modern vs legacy against Finder window; latency assertion <50 ms) - Test:
crates/windows/tests/it_screenshot_modern.rs(same against Notepad)
Approach:
- Spike first per origin risk R3: verify
windows-capture 1.5.4API +Capture::start+ frame-callback signature compiles withwindows 0.62.2before merging. Verifyscreencapturekit 1.5SCScreenshotManager.captureImage(contentFilter:config:)signature on macOS 14/15. - macOS modern:
SCShareableContent.current(excludingDesktopWindows: false, onScreenWindowsOnly: true)→ filter byCGWindowID→SCContentFilter(desktopIndependentWindow: window)→SCScreenshotManager.captureImage(contentFilter:config:). Config:SCStreamConfigurationwithwidth/height= window bounds;pixelFormat = BGRA. Result:CGImage→CGImageDestinationCreateWithData→ PNG buffer. - Windows modern: first check WGC/session support (
GraphicsCaptureSession::IsSupportedwhere available plus a realCreateFromWindowHandleprobe). ThenGraphicsCaptureItem.CreateFromWindowHandle(HWND)→Direct3D11CaptureFramePool::Create(device, BGRA8, 2, item.Size)→ attachFrameArrivedcallback; start session; receive one frame; copy to CPU memory via D3D11Map; encode PNG viaimagecrate orwindows::Win32::Graphics::Imaging::WIC. - Legacy fallback: macOS continues
/usr/sbin/screencapture -R <rect> -t png; Windows continuesPrintWindow(hwnd, hdc, PW_RENDERFULLCONTENT). --screenshot-backend legacyexists for WDA-protected windows (e.g. password managers) where modern APIs return a black frame.
Execution note: Open with a two-PR spike — one per platform — validating API compilation + single capture against a test app. Commit spike outputs (sample PNGs) in tests/fixtures/screenshot-*.png as ground truth for the SSIM regression.
Patterns to follow:
crates/macos/src/system/screenshot.rscurrent structure — keep its file shape, split into modern/legacy submodules.- Learning 4 — screenshot permission handling (Screen Recording TCC); failure →
PermDeniedwith Screen-Recording-specific suggestion (distinct from AX). Pairs with Unit 11.
Test scenarios:
- Happy path (macOS):
screenshot --app Finder(Modern) produces a valid PNG; dimensions match Finder window bounds; file opens in Preview. - Happy path (Windows):
screenshot --app Notepad(Modern) produces a valid PNG viawindows-capture. - Happy path:
screenshot --app Notepad --screenshot-backend legacyproduces a valid PNG viaPrintWindow. - Integration: cold latency —
screenshot --app FinderModern completes in <50 ms on macOS; <50 ms on Windows when WGC is supported by the runner/session (R13 metric). Measured across 10 runs; median reported. - Integration: SSIM — Modern vs Legacy outputs against the same window match with SSIM ≥ 0.95 (not pixel-identical because color spaces differ, but structurally identical).
- Edge case: Modern backend unavailable (pre-macOS 12.3, pre-Win10 1903, Session 0, Server Core, locked/secure desktop, or runner without WGC support) → automatic fall-through to Legacy when possible with a warning in
platform_detail; otherwisePLATFORM_NOT_SUPPORTED. - Edge case: WDA-protected window → Modern returns black frame; user re-runs with
--screenshot-backend legacy; legacy succeeds. - Error path: denied Screen Recording on macOS → Modern returns
PermDeniedwith Screen-Recording-specific suggestion (Unit 11's tri-state makes the distinction from AX possible). - Integration: grep guard CI step — adding
use objc2::…outside the two allowed files fails CI with a clear message.
Verification:
- Both platforms' CI green; latency regression asserted; SSIM assertion green; grep guard active.
- [ ] Unit 10: New surfaces — Toolbar, Spotlight, Dock, MenuBarExtras, Windows shell surfaces
Goal: macOS: add Toolbar, Spotlight, Dock, MenuBarExtras surfaces; ship tray commands (mirror of U3b). Windows: add structured shell surfaces for Toolbar, Taskbar, SystemTray, SystemTrayOverflow, StartMenu, ActionCenter, and QuickSettings where the current Windows build/session exposes them. P2-O14 and P2-O18.
Requirements: R14
Dependencies: Unit 3 (Windows adapter), Unit 3b (Windows tray commands already merged; this unit backfills macOS)
Files:
- Modify:
crates/core/src/adapter.rs(SnapshotSurfaceenum gainsToolbar,Spotlight,Dock,MenuBarExtras,Taskbar,SystemTray,SystemTrayOverflow,StartMenu,ActionCenter,QuickSettings) - Modify:
crates/macos/src/tree/surfaces.rs(detect Toolbar viaAXRole == AXToolbarorAXUnifiedTitleAndToolbar; Spotlight viaSpotlight.appPID; Dock viaDock.appPID; MenuBarExtras viaSystemUIServer+ControlCenter+ per-appAXExtrasMenuBar) - Modify:
crates/windows/src/tree/surfaces.rs(Taskbar/SystemTray viaShell_TrayWnd, overflow viaNotifyIconOverflowWindow, Start/ActionCenter/QuickSettings via shell-surface roots inlist_surfaces; the open-surface and tray command dispatchers already landed in U3b) - Modify:
crates/macos/src/adapter.rs(implementlist_tray_items,click_tray_item,open_tray_menuagainst MenuBarExtras items) - Modify:
crates/core/src/commands/list_surfaces.rs(no change — the newSnapshotSurfacevariants flow through) - Modify:
src/cli_args.rs(snapshot--surfaceaccepts the new kinds) - Test:
crates/macos/tests/it_surfaces.rs(snapshot --surface toolbaron Safari;list-surfacesincludes Spotlight / Dock / MenuBarExtras pids) - Test:
crates/windows/tests/it_surfaces.rs(snapshot --surface toolbaron Edge;list-surfacesincludes present Windows shell surfaces)
Approach:
- Spotlight surface: launch Spotlight via
Cmd+Spaceequivalent if needed (Spotlight process is ephemeral);NSRunningApplication.applications(withBundleIdentifier: "com.apple.Spotlight")→ pid → AX subtree. - Dock surface:
NSRunningApplication.applications(withBundleIdentifier: "com.apple.dock")→ pid → AX subtree; children areAXDockItementries. - MenuBarExtras surface: union of
SystemUIServer,ControlCenter, and each running app'sAXExtrasMenuBar. - Windows shell surfaces: walk
Shell_TrayWndUIA children +NotifyIconOverflowWindow; surface roots opened byopen-system-surfaceare snapshotted immediately through their UIA roots.
Patterns to follow:
crates/macos/src/tree/surfaces.rsMenu / Sheet / Popover detection.- Learning 1 —
SurfaceDetectionConfigshared shape so macOS and Windows surface detectors don't diverge by accident.
Test scenarios:
- Happy path:
snapshot --surface toolbar --app Safarireturns toolbar children (back, forward, URL field, bookmarks) with refs. - Happy path:
list-surfaceson macOS includes{type: "spotlight"},{type: "dock", item_count: N},{type: "menubar_extras", item_count: M}. - Happy path:
list-surfaceson Windows includes present shell surfaces such as{type: "system_tray", item_count: K},{type: "taskbar", item_count: M},{type: "start_menu"},{type: "action_center"}, and{type: "quick_settings"}. - Happy path:
snapshot --surface toolbar --app Edgeon Windows returns Edge's toolbar children. - Edge case: Spotlight not running → Spotlight surface missing from
list-surfaces(not an error). - Edge case: Dock hidden (auto-hide enabled) → Dock surface present but
item_countreflects visible items.
Verification:
- Both platforms' CI green for surfaces.
- [ ] Unit 11: Permission tri-state on macOS
Goal: check_permissions on macOS returns the tri-state struct introduced in Unit 1, fully populated for Accessibility, Screen Recording, and Automation. permissions command output shows all three. Errors distinguish PermDenied (AX), AutomationPermissionDenied, and Screen-Recording-denied screenshot failures. P2-O17.
Requirements: R17
Dependencies: Unit 1 (tri-state types, new error codes), Unit 9 (Modern screenshot uses Screen Recording)
Files:
- Modify:
crates/macos/src/system/permissions.rs(extend from the current ~55 L; addCGPreflightScreenCaptureAccess+CGRequestScreenCaptureAccessreads; addAEDeterminePermissionToAutomateTargetprobe) - Modify:
crates/macos/src/adapter.rs(check_permissionsreturns tri-state) - Modify:
crates/macos/src/system/screenshot.rs(Modern backend returnsPermDeniedwith Screen-Recording-specific suggestion whenCGPreflightScreenCaptureAccessis false) - Modify:
crates/macos/src/system/app_ops.rs(close_appthat requires AppleEvents returnsAutomationPermissionDeniedwhenAEDeterminePermissionToAutomateTargetsays denied) - Modify:
crates/core/src/commands/permissions.rs(output shape matches tri-state) - Test:
crates/macos/tests/it_permissions.rs(on a macOS runner without Screen Recording granted:screenshotreturnsPermDeniedwith the right suggestion) - Test:
crates/macos/tests/it_permissions_automation.rs(verifyAutomationPermissionDeniedfires onclose-appagainst an app lacking Automation grant)
Approach:
CGPreflightScreenCaptureAccessreturns bool;CGRequestScreenCaptureAccesstriggers the TCC prompt; expose the prompt behind an explicit--request-permissionflag — do not auto-prompt duringcheck_permissions.- TTY-gate on
--request-permission(review-added — security): the flag is permitted only whenatty::is(atty::Stream::Stdin)returns true. Automated agent pipelines (stdin piped / not a TTY) reject withErrorCode::InvalidArgsand suggestion "Permission requests require interactive stdin; run the tool manually once to grant." Prevents agent-consent-bypass where a malicious task payload instructs an automated agent to grant itself OS permissions. AEDeterminePermissionToAutomateTarget(const AEAddressDesc *target, AEEventClass theAEEventClass, AEEventID theAEEventID, Boolean askUserIfNeeded)— call withaskUserIfNeeded = falseduring probing; the returnedOSStatusmaps to the tri-state (noErr→Granted,errAEEventNotPermitted→Denied,procNotFound→Unknown).- Tri-state output JSON:
{ "accessibility": "Granted", "screen_recording": { "state": "denied", "suggestion": "Open System Settings > Privacy & Security > Screen & System Audio Recording" }, "automation": { "state": "not_determined" } } - Error mapping: screenshot failure with Screen Recording denied →
ErrorCode::PermDenied+ suggestion naming Screen Recording specifically (not the generic AX suggestion).close_appfailure with Automation denied →ErrorCode::AutomationPermissionDenied.
Patterns to follow:
- Existing
crates/macos/src/system/permissions.rs:check_permissionsshape. - Learning 4 — tri-state matches the optional-fingerprint pattern.
Test scenarios:
- Happy path:
permissionson a fully-granted macOS runner returns all three =Granted. - Happy path: on a Screen-Recording-denied runner,
permissionsreturnsscreen_recording.state = "denied"with the Screen Recording suggestion;accessibility.stateremainsGranted. - Happy path:
screenshot --app Finderwith Screen Recording denied returnsPermDenied+ Screen-Recording suggestion (not the generic AX one). - Happy path:
close-app --app TargetAppwith Automation denied returnsAutomationPermissionDenied. - Edge case:
Unknownstate → thepermissionsJSON still has the field (nosuggestionkey in that branch). - Edge case:
--request-permissionflag triggersCGRequestScreenCaptureAccess; subsequentpermissionsreflects the user's grant. - Integration: cross-platform parity — the
permissionscommand on Windows returnsaccessibility = "Granted"(always granted on Windows after UAC elevation) +Unknownfor the other two.
Verification:
- macOS CI green; Windows CI exercises the JSON shape (tri-state across both platforms).
- [ ] Unit 12: DeliverFiles + ForceClick per-platform (formerly "FileDrop")
Goal: DeliverFiles(Vec<PathBuf>) (renamed from FileDrop per research) and ForceClick on macOS and Windows. Linux returns ActionNotSupported on ForceClick (Phase 3 non-goal). Balance of P2-O9.
Rename rationale (research-driven): NSDraggingSession is NOT headless-compatible on macOS — it requires an NSApplication event loop, a window-server mouse-event stream, and foreground activation. Research Topic 1 confirmed this is not a gap to work around but a categorical incompatibility. The headless-first invariant (§Headless-First Invariant) forbids NSDraggingSession as a primary path. The action is renamed DeliverFiles to reflect the contract: "files appear at the destination" — the mechanism is platform-appropriate, not always a literal drag event.
Requirements: R9 (remaining)
Dependencies: Unit 1 (variants declared — FileDrop enum variant renamed to DeliverFiles in 1d), Unit 6 (simple variants landed)
macOS delivery strategy (research Topic 1 — four-tier fallback, headless-first):
- Tier 1 — app-native URL scheme (per-app registry). A small table in
crates/macos/src/actions/deliver_files.rsmaps known bundle IDs to their URL scheme / CLI protocols (e.g.,com.microsoft.VSCode→vscode://file/…,com.tinyspeck.slackmacgap→ slack slash-command). Only used when the target app advertises an official protocol; never guessed. - Tier 2 —
NSWorkspace.open(urls:withApplicationAt:configuration:completionHandler:)withactivates: false(PRIMARY path for most apps). Headless, non-focus-stealing, Apple-supported since macOS 10.15. Files open in the target app. Works for Finder, Preview, TextEdit, Safari, most native apps, and Electron apps that implement standard file-open protocol. - Tier 3 —
NSPasteboard.generalwrite ofpublic.file-url+CGEventPostToPid(cmd+v)to target PID. Simulates paste-into-focused-view. Requires the target app to have a focused paste-capable view. Used for Electron text editors, note apps. Preserves the system clipboard by saving/restoring. - Tier 4 —
osascript -e 'tell application "X" to open {POSIX file "/path"}'. AppleScript Apple Events. Requires Automation TCC grant (Unit 11 tri-state permission covers this). Fallback for apps with no URL scheme and no pasteboard response.
Selection logic: deliver_files.rs tries tiers in order, returns on first success. Tier choice logged via tracing::debug! for the user to understand which path fired. NSDraggingSession is NEVER invoked.
Windows delivery strategy:
- Tier 1 — app-native URI / command handoff. Use documented app URI handlers or shell verbs when the target app advertises them. Never guess private protocols.
- Tier 2 — filesystem destination. For Explorer windows and known filesystem destinations, use
IFileOperation::CopyItems/MoveItemsas the semantic path. This is the default for folders because it avoids fake drag state entirely. - Tier 3 —
CF_HDROPclipboard paste. Populate clipboard file-drop format, target the destination with an explicit paste action, then restore the prior clipboard. This is used only when the destination accepts paste semantics. - Tier 4 —
IDataObject + DoDragDropspike/fallback. Implement only after a spike proves it can be driven without violating focus/cursor policy for the target class. It is never the default headless path.
ForceClick strategy unchanged from origin plan — macOS via kCGMouseEventPressure on CGEventCreateMouseEvent; Windows via SendInput with pen-input flags. Linux → ActionNotSupported.
Opening spikes (first sub-PRs of Unit 12): validate macOS Tier 2 (NSWorkspace.open with activates: false) against 3 targets — Finder folder, VS Code workspace, Safari tab — confirming (a) files land correctly, (b) no focus steal, (c) agent-desktop process remains headless. Validate Windows Tiers 2/3 against Explorer, Notepad/wordpad-style file-open targets, and one Electron app. Only after that spike can Tier 4 OLE drag be attempted for targets that require drag semantics.
Path-validation rules (review-added — security, both platforms):
- All input paths pass through
std::fs::canonicalizebefore any pasteboard /IDataObjectwrite. - Default: reject paths outside
$HOMEand/tmp(macOS), outside%USERPROFILE%and%TEMP%(Windows). - Reject paths containing
..segments after canonicalization (defense in depth). --allow-system-pathsflag overrides the scope check with a CLI warning; never enabled by default, never settable via env var (explicit intent required).- Empty path list →
InvalidArgs. - Non-existent source path →
InvalidArgswith the missing path in the message. - Threat model documented in
docs/solutions/logic-errors/deliver-files-path-safety-2026-04-18.md(created in U14 docs pass).
Files:
- Create:
crates/macos/src/actions/deliver_files.rs(4-tier headless fallback from §Unit 12 strategy;NSWorkspace.openis Tier 2 primary path — noNSDraggingSession) - Create:
crates/macos/src/actions/deliver_files_registry.rs(per-app URL scheme table for Tier 1) - Create:
crates/macos/src/actions/force_click.rs(kCGMouseEventPressure = 1.0+kCGEventMouseSubtypeTabletPointonCGEventCreateMouseEvent) - Create:
crates/windows/src/actions/deliver_files.rs(URI/shell delivery,IFileOperation,CF_HDROPclipboard paste, and policy-gated OLE fallback after spike) - Create:
crates/windows/src/actions/force_click.rs(SendInputwithPEN_FLAGS_BARRELpen input flags) - Modify:
crates/macos/src/actions/dispatch.rs+crates/windows/src/actions/dispatch.rs(add arms) - Modify:
crates/linux/src/adapter.rs(ensureexecute_actionreturnsActionNotSupportedforForceClickwith documented-platform-divergence message — this is the stub; real implementation is Phase 3) - Modify:
crates/core/src/commands/(commands fordeliver-files,force-click) - Modify:
src/cli.rs,src/cli_args.rs,src/dispatch.rs - Test:
crates/macos/tests/it_deliver_files.rs(2 files delivered to a Finder folder via Tier 2; verify files appear; verify NO focus steal on agent-desktop process) - Test:
crates/macos/tests/it_force_click.rs(force-click a word in TextEdit; verify the definition popover appears vialist-surfaces) - Test:
crates/windows/tests/it_deliver_files.rs(files delivered to an Explorer folder viaIFileOperation; verify files appear; verify NO focus steal; clipboard paste path restores clipboard; OLE fallback tests are gated behind explicit policy/spike fixtures) - Test:
crates/windows/tests/it_force_click.rs(pen input into a test app; verify pressure was delivered)
Approach:
- macOS
ForceClick:CGEventCreateMouseEvent(..., kCGEventMouseDown, kCGMouseButtonLeft, ...)→CGEventSetIntegerValueField(event, kCGMouseEventPressure, 1)→CGEventSetIntegerValueField(event, kCGMouseEventSubtype, kCGEventMouseSubtypeTabletPoint)→ post; thenMouseUp. - macOS
DeliverFiles: see the 4-tier headless fallback strategy above (URL scheme →NSWorkspace.openwithactivates: false→ pasteboard +Cmd-V→ AppleScript).NSDraggingSession/NSFilePromiseProviderare explicitly rejected — they require foreground activation +NSApp.runevent loop, which violate the Headless-First Invariant. - Windows
ForceClick:SendInputwithINPUT_PENstructures; setPEN_FLAGS_BARRELandpressure = 1024(max). - Windows
DeliverFiles: implement the semantic tiers in order: URI/shell handoff,IFileOperationfor Explorer/filesystem destinations,CF_HDROPclipboard paste with save/restore, and only then policy-gatedIDataObject + DoDragDropfor target classes whose integration spike proves no unintended focus/cursor side effects.
Execution note: DeliverFiles macOS Tier-2 and Windows Tier-2/Tier-3 spikes are mandatory. OLE drag is treated as a fallback requiring proof, not assumed safe by architecture.
Patterns to follow:
crates/macos/src/actions/extras.rsas the template for action extras that bypass the standard AX dispatch.- Learning 1 —
ActionDispatchConfigso platform-specific quirks don't leak into the core trait.
Test scenarios:
- Happy path (macOS):
deliver-files @e<folder_ref> /tmp/a.txt /tmp/b.txtproduces a.txt and b.txt inside the folder (verified vials); no focus change observed on active frontmost app. - Happy path (Windows): same against Explorer.
- Happy path (macOS):
force-click @e<word_ref>in TextEdit → definition popover appears inlist-surfaces. - Edge case:
deliver-fileswith non-existent source path →InvalidArgswith the missing path in the message. - Edge case:
force-clickon an element that doesn't respond to pressure (e.g. a regular button) → action succeeds but no side-effect; document this as expected behavior. - Edge case: Linux
force-click→ActionNotSupportedwith platform-divergence note inplatform_detail. - Error path:
deliver-fileswith 0 paths →InvalidArgs. - Error path: destination element not a drop target →
ActionFailedwith suggestion.
Verification:
- Both platform CI integration tests green. Linux stub returns
ActionNotSupportedcleanly.
- [ ] Unit 13: Windows CI + release pipeline (x86_64 + aarch64)
Goal: windows-latest runs build, clippy, unit, contract, and non-interactive tests on every PR; interactive UIA/shell tests run on a labeled Windows desktop runner; Windows CLI binaries (x86_64, aarch64) attached to Phase 2 release; npm postinstall picks up Windows branches. P2-O6, P2-O7.
Requirements: R6, R7
Dependencies: None hard (runs parallel to U3…U12); must land before Unit 3's integration tests can validate on a Windows runner. In practice: a draft PR for this unit opens alongside Unit 3.
Files:
- Modify:
.github/workflows/ci.yml(addtest-windowsjob:runs-on: windows-latest; mirrors thetestjob — clippy,cargo test --lib --workspace,cargo build --profile ci,cargo build --profile release-ffi -p agent-desktop-ffi,cargo treeisolation check) - Modify:
.github/workflows/ci.yml(add optionaltest-windows-interactivejob guarded by label/runner availability:runs-on: [self-hosted, windows, desktop, interactive]; runs UIA/shell integration tests for Explorer, Start, taskbar, Action Center, Quick Settings, Notepad, and screenshot WGC. Hostedwindows-latestmust not be required to expose an unlocked Explorer desktop.) - Modify:
.github/workflows/ci.yml(the existingtestjob stays onmacos-latest; add a matrix if sharing logic becomes tidy) - Modify:
.github/workflows/release.yml(CLIbuildmatrix gainsx86_64-pc-windows-msvc+aarch64-pc-windows-msvconwindows-latest. aarch64 archive suffixed-experimental(review-refined — shipping a build-only artifact with no test coverage deserves explicit labeling):agent-desktop-v0.2.0-aarch64-pc-windows-msvc-experimental.zip. FFI matrix gainsaarch64-pc-windows-msvcrow (same suffix). Archive formatzipfor Windows matches existing FFI Windows row. MSVC ARM64 toolchain setup step added (review-refined — cross-compile from windows-latest x64 host requires the MSVC ARM64 build tools component, not present in the default Desktop C++ workload): step invokesInstall-Module VSSetup/vs_installer modifyto addMicrosoft.VisualStudio.Component.VC.Tools.ARM64beforecargo build --target aarch64-pc-windows-msvc, then sourcesvcvarsall.bat arm64viaVsDevCmd.bat.) - Modify:
rust-toolchain.toml(targetslist gainsx86_64-pc-windows-msvc;aarch64-pc-windows-msvcstays opt-in viarustup target addin the workflow step) - Modify:
npm/scripts/postinstall.js(addwin32-x64branch selectingagent-desktop-v<version>-x86_64-pc-windows-msvc.zip. aarch64 Windows falls back to x86_64 auto-selection (review-refined — arm64 archive is flagged experimental; npm does not auto-select it until a runner validates). Explicit opt-in viaAGENT_DESKTOP_PREFER_ARM64=1env var for users who want to test the experimental build. Decompress via native nodeyauzl(smaller footprint thanadm-zip, no optionalDeps).) - Modify:
npm/package.json(optional: declareos/cpumatrix includes win32) - Modify:
npm/bin/agent-desktop(wrapper picks.exeon Windows) - Test:
.github/workflows/ci.yml— Windows CI green on the reference branch before Unit 3 lands. - Test:
npm/scripts/postinstall.test.js(mock win32 + arm64 environments; assert correct archive name selected)
Approach:
- Clone the existing
testjob into a newtest-windowsjob targetingwindows-latestfor compile/unit/contract coverage. Add a separate interactive desktop job for UIA/shell tests; it is required before marking Windows adapter GA but is not assumed to be available on GitHub-hosted runners. Skip binary-size check on Windows initially (MSVC toolchain + dynamic CRT produces slightly larger binaries; either raise the cap to 20 MB on Windows or skip — recommend skip with a TODO for Phase 5 binary-size work). - The ARM64 Windows CLI binary is build-only for now. When GitHub Actions promotes the ARM runner to GA, a follow-up PR wires testing; the release job publishes the binary regardless.
npm/scripts/postinstall.jsmirrors existing macOS logic; addswin32branches.
Patterns to follow:
- Existing
release.ymlFFI matrix already includesx86_64-pc-windows-msvc— copy its structure for the CLI matrix row. - Existing
ci.ymltestjob shape.
Test scenarios:
- Happy path: PR with a trivial
crates/core/src/change triggerstest-windowsand it passes. - Happy path: PR with Windows shell changes triggers
test-windows-interactivewhen the label/runner is available; otherwise the PR must include mocked/unit coverage and a manual evidence artifact before merge. - Happy path: release-please creating a v0.2.0 tag publishes x86_64 + aarch64 Windows CLI
.ziparchives alongside existing macOS and Linux artifacts; checksum file includes them. - Happy path:
npm install @lahfir/agent-desktopon a Windows x64 machine downloads the right archive and the binary runs. - Integration:
cargo tree -p agent-desktop-coreisolation check runs on Windows and passes. - Edge case: Windows-specific test failure surfaces clearly in the PR check list.
- Error path: missing
windows-latestrunner minutes → workflow fails fast with a non-retriable error (document runbook in Unit 14 docs).
Verification:
- Windows CI job green on a reference PR.
- Release workflow dry-run against a test tag produces the expected artifact set.
- [ ] Unit 14: Documentation + skills + phases.md sync
Goal: Ship Phase 2 docs. skills/agent-desktop-windows/SKILL.md created; core skill updated to three-platform; skills/agent-desktop-ffi/ reflects ad_abi_version + ad_set_log_callback; README platform table + Windows install + permissions; phases.md stale-reference cleanup. Skill Maintenance Addendum compliance. Final unit; runs after U1..U13 are merged.
Requirements: Skill Maintenance Addendum; release-readiness
Dependencies: U1..U13 (all implementation complete)
Files:
- Create:
skills/agent-desktop-windows/SKILL.md(sibling to macOS content) - Create:
skills/agent-desktop-windows/references/uia.md(UIA control types, patterns, pattern-first dispatch order) - Create:
skills/agent-desktop-windows/references/windows-permissions.md(UAC, UIA access, integrity-level mismatches, WGC/session support, Focus Assist / notification listener permission, and unsupported locked/secure desktop behavior) - Create:
skills/agent-desktop-windows/references/windows-shell-surfaces.md(Start menu/search, taskbar, system tray/overflow, Action Center, Quick Settings, virtual desktop detection, mixed-DPI coordinate caveats, and when to useopen-system-surface) - Create:
skills/agent-desktop-windows/references/chromium.md(Windows-flavored version ofelectron-compat.md+--force-electron-a11y) - Modify:
skills/agent-desktop/SKILL.md(three-platform command surface; link to macOS + Windows sibling skills; note tray + notifications on Windows) - Modify:
skills/agent-desktop/references/commands-*.md(addwatch,text *,open-system-surface,list-tray-items,click-tray-item,open-tray-menu,long-press,show-menu,window-raise,cancel,force-click,deliver-files; note new flags--force-electron-a11y,--screenshot-backend,--event,--request-permission) - Modify:
skills/agent-desktop-ffi/SKILL.md(documentad_abi_version(),ad_set_log_callback, registry-driven wrapper surface,AD_ABI_VERSION_MAJOR) - Modify:
skills/agent-desktop-ffi/references/ownership.md+references/threading.md(note worker-thread use inwatch_element;ad_set_log_callbacklifetime) - Create:
crates/ffi/README.md(pre-1.0 FFI policy statement per KD17) - Modify:
README.md(platform support matrix; Windows install; permission pre-flight; link to Windows skill) - Modify:
docs/phases.md(strike "Gap Analysis — 2026-04-17 Research at the bottom" references; fix stale Windows dependency pins to0.62.2; removeWatch(WatchSpec)from P2-O9 enumeration; update shipped command count; add Phase 2 "Shipped" status block once v0.2.0 tags) - Modify:
CHANGELOG.md(release-please will generate, but add a human-curated summary of v0.2.0 breaking changes:ErrorCodenow#[non_exhaustive],PermissionReporttri-state,ad_abi_versionexported + policy) - Create:
docs/migrations/0.1-to-0.2.md(consumer migration guide: JSON schema changes, FFI ABI changes, new error codes) - Create:
docs/solutions/best-practices/electron-compat-cross-platform-2026-04-18.md(document-review refinement — moved here from Unit 4; ports private-memoryelectron-compat.mdso Windows + future Linux contributors see the depth-skip rules) - Keep backward-compat shim:
scripts/update-ffi-header.sh(thin wrapper) alongside the newscripts/update-ffi.shso existing references, skills, and contributor muscle memory don't break (document-review refinement) - Modify:
skills-lock.json(registeragent-desktop-windows)
Approach:
- Use
/skill-creatorfor every SKILL.md touched (per MEMORY.md Skill Maintenance rule). phases.mdsurgical edits — do not rewrite narrative, just fix the stale claims and add a "Shipped 2026-MM-DD" line to Phase 2 §Status.- Consumer migration guide covers:
ErrorCodeadditions (consumers parsing SCREAMING_SNAKE_CASE must accept unknown codes since#[non_exhaustive]),PermissionReportJSON shape change, any renames.
Patterns to follow:
- Existing
skills/agent-desktop/SKILL.mdstructure and references layout. - Existing release-please-generated CHANGELOG conventions.
Test scenarios:
- Test expectation: none — documentation-only unit; validated via manual review and by running
skills/publish dry-run (clawhub sync --root skills/ --dry-run). - Verification gates (not tests):
scripts/update-ffi-header.shshows no drift.skills-lock.jsonreferences the new Windows skill.docs/phases.mdgrep for"Gap Analysis — 2026-04-17 Research at the bottom"returns zero matches.docs/phases.mdgrep forwindows = "0.58"returns zero matches.README.mdplatform table includes Windows x64 + arm64.
Verification:
- Manual review of all skill files and README on the release PR.
- CI green; skills publish step dry-run succeeds.
System-Wide Impact
- Interaction graph:
watch_elementintroduces worker threads inside the adapter — breaks the single-threaded Phase 1 assumption. All shared state between callbacks and the main thread goes throughmpscchannels (no shared mutable state). Deepening-pass decision: thewatch_elementtrait signature takes&RefEntry(not&NativeHandle), so the worker re-resolves the element on its own thread and theNativeHandlePhantomData<*const ()>!Send/!Syncinvariant is preserved intact. The existingunsafe impl Send for NativeHandle/unsafe impl Sync for NativeHandleatcrates/core/src/adapter.rs:93-94(Phase 1 single-threaded justification) does NOT need reassessment in Phase 2. - Error propagation:
ErrorCodegains 4 new variants (#[non_exhaustive]makes this forward-compatible for consumers that match exhaustively and accept a default arm). Consumers of the FFIAdResultenum see 4 new discriminants; theconst _: () = assert!(…)parity gate keeps them in lockstep. New errors (AutomationPermissionDenied, Screen-Recording-specificPermDenied) flow through existingAdapterError::with_suggestion/with_platform_detailhelpers. - State lifecycle risks:
DeliverFilesmacOS usesNSWorkspace.open(activates: false)(no delegate lifetime to manage — async completion via closure,Retained<T>wrapper for any callback handles). On Windows,IFileOperation, clipboard save/restore, and optionalIDataObjectfallback each need explicit lifetime ownership;IDataObjectmust spanDoDragDroponly inside the policy-gated fallback.watch_elementon macOS uses main-threadCFRunLoopRunInMode(no worker thread lifecycle); on Windows the worker thread is hard-joined insidewatch_element(no leak).ad_set_log_callbackinstalls a globalOnceCell; removing the callback is not supported (second call errors). - API surface parity: Every new trait method ships with a
not_supported()default implementation so Linux (Phase 3) compiles without changes. The Linux adapter explicitly keepsForceClickreturningActionNotSupportedas permanent divergence. - Integration coverage: Cross-platform JSON identity fixture (
tests/fixtures/calculator.*.json,tests/fixtures/vscode.*.json) ensures structural parity is checked, not just individual unit behavior.watch_element+ad_set_log_callback+ad_abi_versioneach require their own FFI integration harness. - Unchanged invariants:
- Ref format
@e{n}and refmap file location at~/.agent-desktop/last_refmap.json(0o600). - JSON envelope shape (
{version, ok, command, data, error}). - Binary size limit 15 MB for the CLI (Windows CI may skip pending Phase 5 size work; documented explicitly).
- Core isolation —
cargo tree -p agent-desktop-corestill free of platform crates (CI checks on macOS AND Windows). - Single-command-per-file + 400-LOC rule (Unit 5 splits
element.rsbecause it's at the cap). - No
unwrap()in non-test code; zero-warning clippy.
- Ref format
Risks & Dependencies
| ID | Risk | Mitigation | Owner Unit |
|---|---|---|---|
| R1 | Calendar slip — 15 units is a large PR count | Parallelize where dependency graph allows; each unit is independently reviewable; honest calendar-months sizing up front | — |
| R2 | watch_element architecture unproven (AXObserver off main thread, UIA handler lifetime) |
Unit 7 opens with a named spike against three apps per platform; spike outcome gates the rest of U7 | U7 |
| R3 | windows-capture newer majors may move the API surface |
Pin windows-capture = "=1.5.4"; verify API compiles against 1.5.4 before merging U9 spike PR; re-evaluate newer majors post-Phase 2 |
U9 |
| R4 | Registry migration (U2) affects every existing command — high blast radius | Migrate one command category at a time (observation → interaction → system → clipboard → notifications → batch); each sub-PR keeps cargo test --workspace green |
U2 |
| R5 | MSRV bump to 1.82 may break downstream consumers on pinned toolchains | Release notes flag MSRV; pre-1.0 explicitly unstable; acceptable | U1 |
| R6 | objc2 introduction spreads beyond screenshot/permissions |
CI grep guard: rg "objc2::" crates/macos/src/ | grep -v "system/(screenshot|permissions).rs" && exit 1 (landed in U9) |
U9 |
| R7 | FFI ABI churn across 17 objectives needs one coordinated ad_abi_version() bump |
Unit 1 exports ad_abi_version() as sub-PR 1a with anchor MAJOR = 1; the first struct-layout-breaking sub-PR (1g — PermissionReport tri-state) atomically bumps to MAJOR = 2; every subsequent FFI-affecting change in U1–U12 asserts via CI grep that the version has incremented when its own changes touch crates/ffi/src/error.rs or crates/ffi/src/generated/ |
U1 |
| R8 | Windows runner flakiness against real apps | Split Windows CI: test-windows-unit (blocks merge) + test-windows-ui (nightly + pre-release); Unit 13 implements the split when GitHub Actions runner minutes warrant |
U13 |
| R9 | ForceClick on Linux has no native path (Phase 3 gap) |
Documented; Linux adapter returns ActionNotSupported — legitimate per-platform divergence, not a bug |
U12 |
| R10 | Cross-compile-from-macOS iteration cost for Windows-heavy phase | Windows CI runs on every PR touching crates/windows/ or cross-platform trait methods; if iteration cost exceeds 2000 CI minutes/PR-cycle, spin up a local Windows VM |
U3 / U13 |
| R11 | AccessibilityNode field explosion — nested StableSelectors preserves wire shape |
Planning picked #[serde(flatten)] on an inline StableSelectors with per-field skip_serializing_if (see §Open Questions §Resolved); JSON wire shape preserved |
U1 |
| R12 | phases.md references a "Gap Analysis — 2026-04-17 Research" section that doesn't exist |
Fixed in U14 alongside other phases.md syncs | U14 |
| R13 | Codegen mechanism for FFI wrappers | Resolved by research — Unit 2 uses build.rs filesystem enumeration of crates/core/src/commands/*.rs (deterministic, cdylib-safe, zero linker magic). inventory/linkme/xtask all rejected per Research Topic B. Spike PR still opens Unit 2 to validate codegen on click alone before migrating 52 more. |
U2 |
| R14 | Electron rules currently live in private memory (electron-compat.md) |
Unit 4 ports those rules to docs/solutions/best-practices/electron-compat-cross-platform-2026-04-18.md as the first task |
U4 |
| R15 | SendInput on Windows doesn't honor blocked_combos by itself — the safety check is ours |
Unit 3's input/keyboard.rs calls is_blocked(combo, adapter.blocked_combos()) before dispatching; test covers this |
U3 |
| R16 | UIA_HtmlIdProperty / UIA_HtmlClassProperty only work on WebView2 elements |
Unit 5 returns empty Vec for non-WebView2 nodes; documented in selectors reference | U5 |
| R17 | FFI wrapper rename gap — renaming a command without updating its filename or the descriptor() function could slip past wrapper drift check |
crates/core/tests/cli_registry_parity.rs cross-checks that every clap subcommand name has a matching CommandDescriptor and vice versa; build.rs filesystem enumeration makes drift impossible because the file listing IS the source of truth |
U2 |
| R18 | 400-LOC breaches compound in Phase 2 — actions/chain_steps.rs already at 407 L today; actions/dispatch.rs at 349 L will breach adding 4 arms in U6 |
Unit 6 opens with a behavior-preserving refactor sub-PR that pre-splits both files; documented in U6 pre-work | U6 |
| R19 | NativeHandle !Send/!Sync invariant vs Unit 7 worker thread |
Deepening pass resolves by passing &RefEntry (not &NativeHandle) to watch_element; worker re-resolves on its own thread, preserving the invariant |
U1 / U7 |
| R20 | Unit 7 callback panic → hung worker thread leak (critical for long-running FFI consumers) | U7 spike validates catch_unwind wrapper around AX/UIA callback; hard-join timeout in watch_element itself (2 × timeout_ms) with Internal error on refuse-to-exit |
U7 |
| R21 | AD_ABI_VERSION_MAJOR bump ordering race — sub-PR 1g is the only actual ABI-breaking sub-PR in Unit 1 (struct-layout change on PermissionReport); 1b adds #[non_exhaustive] without variant changes (non-breaking) and 1c adds variants under #[non_exhaustive] (breaking for C consumers per adversarial review — see P0 findings) |
Document-review refinement: ad_abi_version() ships at sub-PR 1a as anchor MAJOR = 1; sub-PR 1g atomically bumps to MAJOR = 2 with the layout change; CI grep asserts the bump happens on every sub-PR that touches layout files |
U1 |
Alternative Approaches Considered
- Sub-phase split (2a/2b). Rejected in brainstorm §D1 before this plan opened. Accumulates unstated deferrals; the 15-unit PR decomposition managed via this plan is lower-risk than a sub-release cut.
Option<StableSelectors>with#[serde(flatten)]wrapper. Rejected during planning (§Open Questions §Resolved). The inline struct with per-fieldskip_serializing_ifpreserves JSON wire shape with no behavioral change; the Option wrapper adds a layer of indirection that doesn't buy anything.- Persistent modifier-state file for key-down/key-up. Rejected. Adds state to a stateless CLI; the whole-combo safety check in
blocked_combos()is sufficient because every blocked combo includes at least one non-modifier key. - Shared bounded worker-thread pool for U7 and U9. Rejected. Different lifetimes (observer loops vs single-shot captures); different threading models (MTA vs no specific apartment). Revisit with Phase 4 daemon.
windows 0.58as originally named in the early draft. Rejected. Patched towindows 0.62.2in origin §D15 becausewindows-capture 1.5.xrequires 0.62+ and the workspace pins the current compatible release.inventoryORlinkmefor the command registry. BOTH rejected (research-driven). Research Topic B found neither survives link-GC reliably across ld64, ld-prime, GNU ld, lld, MSVC link.exe for cdylib consumers. Replaced withbuild.rsfilesystem enumeration — deterministic, no linker magic.- Auto-commit regenerated FFI wrappers from
build.rsback intocrates/ffi/src/generated/. Rejected per learning 3 — auto-heal masks CI drift. The committed copy is the ABI contract;cargo xtask gen-ffirefreshes on demand. 7a. Codegen viabuild.rsmacro expansion insidecrates/ffi/src/lib.rs. Rejected (deepening pass) — has no committed artifact to drift-check against, which violates learning 3 and means rename-detection / shape-drift caught only at runtime.xtaskpath (chosen) keeps the committed file as the ABI contract. 7b. Codegen via a proc-macro crate that expandsdeterministic registry iterationat thefficrate's top level. Rejected for the same reason as 7a — no committed artifact. screencapturekit 0.3from the early draft. Rejected for1.5per origin §D15 — the 0.3 series is older and lacksSCScreenshotManagerstable shape.- Widen
WindowInfo.pidtoi64for Windows DWORD safety. Rejected per KD3 — breaks JSON and FFI ABI for zero practical benefit; narrow-at-boundary is adequate.
Phased Delivery
Calendar-month ceilings (hard, not commitments — if breached, invoke the Deferral Order below).
Phase 0 — v0.1.14 prep release (review-added, ~1 week)
Before Phase 2 proper opens, ship a minimal non-breaking release that gives downstream consumers FFI-stability primitives without a major bump. Scope:
#[non_exhaustive]onErrorCode(no variant additions yet)ad_abi_version()exported returningAD_ABI_VERSION_MAJOR = 1(anchor Phase 1 value)ad_init(expected_major: u32) -> AdResultenforced version-negotiation handshakeAD_RESULT_UNKNOWN = -99sentinel exportedcrates/ffi/README.mddocumenting the pre-1.0 FFI policy (KD17)
Consumers adopt these and update their integration tests before v0.2.0 breaks the PermissionReport layout. Splits the 4-way break (MSRV + non_exhaustive + tri-state + ABI bump) into 2+2, giving adopters breathing room.
Gate: v0.1.14 tag pushed; FFI consumers on the main integration list (if any) have adopted ad_init().
Phase A — Foundation (~4 weeks) — Unit 1 + Unit 2 + Unit 2.5 + Unit 2.6
U1 lands across 10 sub-PRs (1a–1j). U2 (registry migration, single-concern) across 6 category sub-PRs. U2.5 (ad_set_log_callback with redaction) and U2.6 (Phase 1.5 FFI backfill) land as small follow-ups. U7's opening spike PR lands between sub-PRs 1g and 1h (so trait signatures are informed by spike outcome). Gate: cargo test --workspace green on macOS; ad_init handshake working; parity const assertion intact; registry count matches clap command enumeration under all CI profiles.
Phase B — Windows foundation (~5 weeks) — Unit 3 + Unit 3a + Unit 3b; Unit 13 in parallel
U3 implements the Windows adapter across 4 sub-PRs (tree / actions+input / clipboard+app+window / permissions+screenshot-legacy). U3a and U3b follow. U13 opens in parallel to keep Windows CI running against U3 draft branches. Gate: Windows CI green; cross-platform JSON fixture test (R2) meets the review-refined metric (jaccard ≥ 0.85, identifier equality, ±15% ref count).
Phase C — Feature parity (~6 weeks) — Units 4–12, parallelizable
Units 4, 5, 6, 7, 8, 9, 10, 11, 12 all depend only on U1, U2, U3. They open in parallel worktrees.
Merge order (review-refined — value-dense first, not risk-cheapest first):
- U5 (stable-selector fields) — R8 directly benefits agent loops
- U7 (watch_element) — R11 replaces 100ms polling with sub-500ms push
- U4 (Windows Electron compat) — R15 unlocks VS Code / Slack / Cursor
- U8 (text range primitives) — R12 enables in-place editing flows
- U6 (action variants LongPress/ShowMenu/WindowRaise/Cancel) — completes the Action surface
- U11 (permission tri-state on macOS) — R17 unblocks Modern screenshot
- U9 (modern screenshot) — R13, depends on U11 for Screen Recording probe
- U10 (new surfaces) — R14, polish
- U12 (DeliverFiles + ForceClick) — remaining Action variants, highest-risk spike
Gate: every P2-O* metric green in CI integration tests.
Phase D — Release (~1 week) — Unit 14
U14 updates docs, strikes stale phases.md references, creates the Windows skill, publishes the FFI pre-1.0 policy, ports electron-compat to docs/solutions. release-please cuts v0.2.0. Gate: tag pushed; GitHub Release artifacts include all Windows + macOS CLI and FFI archives (aarch64 Windows marked -experimental); npm publish succeeds.
Total ceiling: 16 weeks
Deferral Order (if ceiling breached)
Defer last-to-first in this order (lowest-impact first):
- U10 (new surfaces: Spotlight / Dock / MenuBarExtras / Windows shell surfaces) → v0.2.1
- U3b (Windows tray commands) → v0.2.1
- U12 (DeliverFiles + ForceClick) → v0.2.1 or v0.3.0 (DeliverFiles spikes decide)
- U4 (Electron compat on Windows) → v0.2.1 — but prefer keeping, since this blocks VS Code / Slack adoption on Windows
R8, R11, R12, R13, R16, R17 are never deferred — they are the core agent-value wins of Phase 2.
Success Metrics
- R8 metric (review-refined): Primary — real-app integration: 10-step Slack sidebar navigation on macOS and Windows completes without
STALE_REF-induced re-snapshot in ≥80% of runs (10 runs minimum). Secondary —identifierfield populated on ≥70% of interactive nodes across VS Code + Slack + Calculator fixture set. The earlier "+20pp on curated fixture" is retained as a diagnostic, not a gate. - R11 metric:
watch --event value-changedreturns within 500 ms on both platforms. - R13 metric:
screenshot --app Finder(Modern) cold latency <50 ms median over 10 runs on both platforms. - R15 metric: VS Code snapshot with
--force-electron-a11yexposes ≥100 refs at default depth on both platforms. - R16 metric: Adding a new command (
hello-worldtest fixture) requires only creating one file undercrates/core/src/commands/; CLI, FFI wrappers, and schemas auto-register; asserted by an integration test in Unit 2. - FFI ABI stability:
ad_abi_version()returnsAD_ABI_VERSION_MAJOR = 2from Phase 2 onward;crates/ffi/README.mddocuments the bump policy. - Binary size: macOS CLI release binaries remain under 15 MB; Windows CLI initially unbounded (size check skipped on Windows in Unit 13 with TODO).
Documentation Plan
skills/agent-desktop-windows/SKILL.md— created in Unit 14 with/skill-creator.skills/agent-desktop/SKILL.md— updated for three-platform support in Unit 14.skills/agent-desktop-ffi/SKILL.md—ad_abi_version+ad_set_log_callbacknoted in Unit 14.crates/ffi/README.md— new file in Unit 2 / Unit 14 with the pre-1.0 FFI ABI policy.docs/migrations/0.1-to-0.2.md— created in Unit 14; consumer migration guide.docs/phases.md— stale-reference cleanup in Unit 14.README.md— three-platform install + permission pre-flight in Unit 14.docs/solutions/best-practices/electron-compat-cross-platform-2026-04-18.md— created in Unit 4 (ports private memory).CHANGELOG.md— human-curated v0.2.0 breaking-change summary in Unit 14 (release-please generates the base).MEMORY.md— update the user's project memory after v0.2.0 ships (Phase 2 status, Windows adapter, registry migration).
Operational / Rollout Notes
- Breaking release: v0.2.0. Downstream FFI consumers MUST call
ad_abi_version()at load time and compare against their expected major. Example inskills/agent-desktop-ffi/references/build-and-link.md. - Toolchain: MSRV bumps to 1.82. Pre-commit hook and CI cache keys refresh automatically on first PR after the bump.
- Permissions pre-flight: a new README section explains Screen Recording TCC grant on macOS before running
screenshot; Windows documents UIA integrity boundaries, WGC support checks, UAC/elevation mismatch, and shell-surface unsupported cases. - Rollback: v0.1.x remains installable via
npm install @lahfir/agent-desktop@0.1. ABI breakage means mixing 0.1 CLI with 0.2 FFI is undefined — migration guide says so. - Monitoring: post-release, watch
STALE_REFrate in field (user-reported) and compare against the Unit 5 baseline fixture. Phase 3 plan deepens with Linux AT-SPI coverage. - Follow-up PRs after v0.2.0 ships:
windows-capture 2.0upgrade evaluation (pending 2.x stability).- GitHub Actions
windows-11-armGA → wire aarch64 Windows testing matrix (today: build-only). - Binary size budget for Windows CLI (Phase 5 production-readiness work).
Swarm & Parallelization Strategy
Phase 2 is 16 weeks single-threaded. Most of Phase C and large parts of Phase A can run concurrently via Claude Code agent teams (spawn via Agent tool with isolation: "worktree") or parallel worktrees managed by the user. This section lists what can safely run in parallel and what cannot.
Hard serial dependencies (DO NOT parallelize)
These must land in strict order — parallelizing them creates merge conflicts on shared types or breaks intermediate builds:
| Dependency | Reason |
|---|---|
| v0.1.14 prep → Phase 2 start | Consumers need ad_init() available before ABI breaks |
| U1 sub-PRs 1a → 1b → 1c → 1d → 1e → 1f → 1g | Each sub-PR mutates crates/core/src/error.rs or node.rs in sequence; parallel edits conflict on the parity const assertion and serde derives |
| U7 spike → U1 sub-PR 1h → U7 implementation | Trait signature depends on spike outcome |
| U1 sub-PR 1g → U1 sub-PR 1i | PermissionReport layout change must precede the AD_ABI_VERSION_MAJOR bump test that reads it |
| U2 → U2.5, U2.6 | ad_set_log_callback and backfill are CommandDescriptor entries; registry must exist first |
| U2 → U3 | Windows adapter's new commands register via deterministic registry metadata; registry must exist |
| U3 → U3a, U3b, U4 | Sub-units extend the Windows adapter surface |
| U11 → U9 | Modern screenshot needs tri-state permission probe to distinguish Screen-Recording-denied from AX-denied |
| All units → U14 | Docs update reflects shipped behavior |
Parallelizable swarm fan-out points
Swarm Point 1 — Phase A (sub-PR 1i onward)
After Unit 1 sub-PR 1g has landed, these can fan out concurrently:
| Worker | Work | Isolation |
|---|---|---|
| A | U1 sub-PR 1h (trait method stubs) | main branch, serial |
| B | U1 sub-PR 1i (event.rs, text_range.rs, screenshot_backend.rs, 4 config structs) |
worktree |
| C | U1 sub-PR 1j (CLI arm reservations with #[clap(hide = true)]) |
worktree |
| D | U7 spike PR (validation-only, crates/macos/src/events/spike.rs throwaway) |
worktree, blocks sub-PR 1h |
Sub-PRs 1h, 1i, 1j can land in any order once 1g is in main, because they touch disjoint files.
Swarm Point 2 — Phase A (after U2 merges)
U2.5 and U2.6 are small and independent. Fan out 2 workers:
| Worker | Work | Estimated PR size |
|---|---|---|
| A | U2.5 (ad_set_log_callback + redaction layer) |
~400 LOC |
| B | U2.6 (Phase 1.5 FFI backfill: ad_execute_by_ref + descriptor confirms) |
~200 LOC |
Swarm Point 3 — Phase B (Windows foundation)
U3's 4 sub-PRs serialize internally (tree → actions → clipboard → permissions), but U13 (Windows CI + release pipeline) runs entirely in parallel against U3's draft branches:
| Worker | Work |
|---|---|
| A | U3 tree sub-PR (UITreeWalker, roles map, element wrapper) |
| B | U3 actions sub-PR — starts after A merges |
| C | U13 sub-PRs (CI job, release.yml matrix, npm postinstall) — parallel to A/B/C/D |
| D | docs/security/screencapturekit-fork-audit-2026-04-18.md audit (can start anytime) |
Swarm Point 4 — Phase C fan-out (highest parallelism)
After U3 merges, 9 workers can land concurrently. This is the densest parallelization point. Recommended team composition: 4–6 workers maximum (not 9 — reviewer bandwidth is the bottleneck).
| Worker | Recommended units | Isolation | Total LOC estimate |
|---|---|---|---|
| A | U5 (stable-selector population) | worktree | ~600 |
| B | U7 (watch_element — spike validated) | worktree | ~1200 |
| C | U4 (Windows Electron compat) | worktree | ~500 |
| D | U8 (text range primitives) | worktree | ~900 |
| E | U6 + U11 (action variants + macOS tri-state permissions) | worktree | ~700 |
| F | U9 (modern screenshot — after E's U11) + U10 (new surfaces) | worktree | ~1000 |
Worker E bundles U6 + U11 because both are small and independent. Worker F starts U9 only after U11 merges (serial dependency per Phase C merge order).
U12 (DeliverFiles + ForceClick) lands after F's U9 merges, with its own opening spikes. Treat U12 as single-worker.
Swarm Point 5 — Phase D (Documentation)
U14 fans out across skill references and docs:
| Worker | Work |
|---|---|
| A | skills/agent-desktop-windows/SKILL.md + references |
| B | docs/migrations/0.1-to-0.2.md + CHANGELOG curation |
| C | docs/phases.md cleanup + crates/ffi/README.md policy doc |
| D | docs/solutions/best-practices/electron-compat-cross-platform-2026-04-18.md port |
All four are independent; merge in any order.
Spawning workers with Claude Code Agent tool
Agent({
description: "U5 stable-selector implementation",
subagent_type: "general-purpose",
isolation: "worktree",
prompt: "Implement Unit 5 of docs/plans/2026-04-18-001-feat-phase2-windows-crossplatform-plan.md — stable-selector field population on macOS and Windows. Read the full unit definition including Files, Approach, Patterns to follow, Test scenarios, Verification. Land each of the test scenarios as a test case. Keep crates/macos/src/tree/element.rs under 400 LOC (split into element.rs + element_selectors.rs per the Files list). Return when all tests pass and cargo clippy is clean."
})
Spawn 4–6 in one message for true concurrent execution. Each worker operates in an isolated worktree so merge conflicts surface at PR review, not mid-write.
Swarm anti-patterns (avoid)
- Do not parallelize sub-PRs within U1. They all touch
crates/core/src/{error,action,node,adapter,refs}.rsin overlapping regions; parallel edits conflict on the parity assertion. - Do not fan out U3's 4 sub-PRs.
crates/windows/src/adapter.rsis the contended file; serialize. - Do not start U9 before U11. Screen Recording permission probe is a dependency.
- Do not start U12 before U9. DeliverFiles depends on Modern screenshot's TCC story for shared
objc2surface area; also U12's spikes need U9's dependency tree. - Do not assign two workers to the same unit. Units are atomic scope; splitting within a unit requires manual coordination that defeats the swarm.
Progressive Commit Checklist
Every box below maps to one PR-sized commit. Dependency order is top-to-bottom within each phase. Where [parallel with: …] appears, spawn a swarm worker.
Phase 0 — v0.1.14 prep
- C0.1: add
#[non_exhaustive]toErrorCode(no variant additions) —feat(ffi): prepare error enum for non-exhaustive evolution - C0.2: export
ad_abi_version()returning 1, addAD_RESULT_UNKNOWN = -99, addad_init(expected_major)handshake —feat(ffi): add abi version + init handshake - C0.3: publish
crates/ffi/README.mdFFI policy —docs(ffi): document pre-1.0 abi policy - C0.4: release v0.1.14 —
chore: release 0.1.14
Phase A — Foundation (Unit 1 + U2 + U2.5 + U2.6)
- C1.1 (sub-PR 1a): abi_version.rs + ad_init + AD_RESULT_UNKNOWN sentinel (if not in 0.1.14) — serial
- C1.2 (sub-PR 1b):
#[non_exhaustive]onErrorCode(if not in 0.1.14) — serial - C1.3 (sub-PR 1c): 4 new
ErrorCodevariants + matchingAdResult::Err*— serial - C1.4 (sub-PR 1d): 8 new
Actionvariants — serial - C1.5 (sub-PR 1e):
StableSelectors+#[serde(flatten)]onAccessibilityNode— serial - C1.6 (sub-PR 1f):
RefEntry.identifier: Option<String>— serial - C1.7 (sub-PR 1g):
PermissionReporttri-state +AD_ABI_VERSION_MAJORbumps to 2 atomically — serial, gates downstream - C1.8 (U7 spike): validate
AXObservernon-main-thread behavior —feat(spike): validate ax observer threading[parallel with: C1.9, C1.10] - C1.9 (sub-PR 1h): trait method stubs (signature informed by C1.8) — parallel with C1.10
- C1.10 (sub-PR 1i): supporting types + 4 shared configs — parallel with C1.9
- C1.11 (sub-PR 1j): reserve CLI arms with
#[clap(hide = true)]— parallel with C1.9, C1.10 - C2.1: U2 registry migration spike on
clickcommand only —feat(ffi): registry migration spike (click)[gates C2.2–C2.7] - C2.2: U2 observation category migration —
refactor(ffi): migrate observation commands to registry - C2.3: U2 interaction category migration —
refactor(ffi): migrate interaction commands - C2.4: U2 system category migration —
refactor(ffi): migrate system commands - C2.5: U2 clipboard category migration —
refactor(ffi): migrate clipboard commands - C2.6: U2 notifications category migration —
refactor(ffi): migrate notification commands - C2.7: U2 batch migration + xtask + drift check + link-GC mitigation —
refactor(ffi): complete registry migration with xtask - C2.5.1: U2.5 log_callback + redaction layer —
feat(ffi): ad_set_log_callback with redaction[parallel with C2.6.1] - C2.6.1: U2.6 Phase 1.5 backfill (execute_by_ref + descriptor confirms) —
feat(ffi): backfill phase 1.5 wrappers[parallel with C2.5.1]
Phase B — Windows foundation (U3 + U3a + U3b; U13 parallel)
- C3.1: U3 tree —
feat(windows): uia tree walker + role map - C3.2: U3 actions —
feat(windows): uia action dispatch with smart chain[after C3.1] - C3.3: U3 input —
feat(windows): sendinput keyboard/mouse + win32 clipboard[after C3.1] - C3.4: U3 system —
feat(windows): app lifecycle + window ops + legacy screenshot[after C3.1] - C3.5: U3a Windows notifications —
feat(windows): toast/action-center notifications[after C3.2] - C3.6: U3b Windows shell surfaces + tray —
feat(windows): shell surfaces and system tray commands[after C3.2, parallel with C3.5] - C13.1: U13 Windows CI job —
ci: add windows-latest test job[parallel with C3.*] - C13.2: U13 release matrix + postinstall —
ci: release pipeline for x86_64 + aarch64 windows[parallel with C3.*] - C13.3: U13 MSVC arm64 toolchain setup —
ci: install arm64 msvc build tools[after C13.2]
Phase C — Feature parity (U4–U12, heavily parallel)
Swarm fan-out at this point — spawn 4–6 workers.
- C5: U5 stable-selector population —
feat(tree): populate stable selector fields[parallel with C7, C4, C8, C6, C11] - C7: U7 watch_element —
feat(events): watch_element with push notifications[parallel with C5, C4, C8, C6, C11] - C4: U4 Windows Electron compat —
feat(windows): electron/webview2 depth-skip[parallel with C5, C7, C8, C6, C11] - C8: U8 text range primitives —
feat(text): text range primitives with password-field gate[parallel with C5, C7, C4, C6, C11] - C6: U6 action variants (LongPress/ShowMenu/WindowRaise/Cancel) + pre-work chain_steps.rs + dispatch.rs split —
refactor(macos): split actions files; feat(actions): 4 new variants[parallel with C5, C7, C4, C8, C11] - C11: U11 macOS permission tri-state —
feat(macos): screen recording + automation tri-state[parallel with C5, C7, C4, C8, C6; gates C9] - C9.1: U9 screencapturekit fork audit —
docs(security): audit screencapturekit doom-fish fork[parallel with all Phase C, gates C9.2] - C9.2: U9 modern screenshot —
feat(screenshot): modern backend via ScreenCaptureKit + windows-capture[after C11 + C9.1] - C10: U10 new surfaces (Toolbar/Spotlight/Dock/MenuBarExtras + tray commands on macOS) —
feat(surfaces): add toolbar/spotlight/dock/menubar-extras/tray[parallel with C9.2] - C12.1: U12 DeliverFiles macOS/Windows spikes —
feat(spike): validate deliver-files semantic paths[after C9.2, gates C12.2] - C12.2: U12 DeliverFiles + ForceClick implementations —
feat(actions): deliver-files (headless) + force-click with path validation[after C12.1]
Phase D — Documentation + Release (U14)
- C14.1:
skills/agent-desktop-windows/SKILL.md+ references —docs(skill): add windows skill[parallel with C14.2–C14.5] - C14.2:
docs/migrations/0.1-to-0.2.md+ CHANGELOG curation —docs: v0.2.0 migration guide[parallel] - C14.3:
docs/phases.mdcleanup +crates/ffi/README.mdpolicy updates —docs: phase 2 hygiene sweep[parallel] - C14.4: port
electron-compat.mdprivate memory →docs/solutions/best-practices/—docs(solutions): cross-platform electron compat[parallel] - C14.5:
docs/solutions/logic-errors/deliver-files-path-safety-2026-04-18.md+docs/solutions/logic-errors/watch-element-thread-safety-2026-04-18.md—docs(solutions): phase 2 threat models[parallel] - C14.6: release v0.2.0 —
chore: release 0.2.0
Progressive commit discipline
- Each box = one
feat:/fix:/refactor:/docs:/ci:conventional commit, single concern. - After every box merges to
main, the preceding deferred tests / CI workflow re-runs and must stay green. - If a swarm worker's commit would conflict with another worker's in-flight commit, the later-to-open PR rebases — never force-push an earlier PR.
- Sub-PR 1g (the
AD_ABI_VERSION_MAJOR = 1 → 2bump) is the one-way door; everything after it is v0.2.0.
Sources & References
- Origin document: docs/brainstorms/2026-04-18-phase2-windows-crossplatform-brainstorm.md
- Prior plan (superseded): docs/plans/2026-02-25-feat-windows-adapter-phase2-plan.md — covers a narrower P2 scope that this plan supersedes.
- Related plan: docs/plans/2026-04-16-001-fix-ffi-safety-abi-correctness-plan.md — FFI safety baseline this plan builds on.
- Phases reference: docs/phases.md §Phase 2 (cleaned up in Unit 14).
- Institutional learnings referenced:
- docs/solutions/best-practices/deduplicate-ref-allocator-via-config-struct-2026-04-14.md
- docs/solutions/logic-errors/progressive-snapshot-review-contract-2026-04-16.md
- docs/solutions/best-practices/deterministic-build-artifact-marker-2026-04-16.md
- docs/solutions/best-practices/identity-fingerprint-against-os-reorder-2026-04-16.md
- External references:
- UIA patterns: https://learn.microsoft.com/en-us/dotnet/framework/ui-automation/ui-automation-control-patterns-overview
uiautomationcrate: https://crates.io/crates/uiautomationwindowscrate: https://crates.io/crates/windowswindows-capturecrate: https://crates.io/crates/windows-capture- ScreenCaptureKit: https://developer.apple.com/documentation/screencapturekit
screencapturekitcrate (doom-fish fork): https://crates.io/crates/screencapturekitobjc2crate: https://crates.io/crates/objc2inventorycrate: https://crates.io/crates/inventoryschemarscrate: https://crates.io/crates/schemars- MS Agent Framework MCP transport: https://learn.microsoft.com/en-us/ai/agent-framework (stdio sufficient through 1.0)