No description
Find a file
Lahfir d4ddb5531d
feat: windows system lifecycle (sub-phase 2.9) (#124)
* docs: add sub-phase 2.9 system lifecycle plan

Plan the Windows System Lifecycle adapter fill — launch_app, close_app,
window_op, process_state, is_protected_process, press_key_for_app, and
the full window-activation focus policy over 2.6's minimal focus_window —
grounded in a five-dossier research pass and hardened by an adversarial
multi-lens review (verified-termination loop, WaitForSingleObject-gated
exit reads, fail-closed cross-integrity activation, exact-match protected
list, and press_key_for_app that verifies rather than re-activates).

Correct the docs/phases.md contradictions and register the deferrals the
planning research surfaced: drop the phantom WindowOp::Close variant the
core enum never had; extend the §2.12 split-integrity item to own the
cross-integrity window-activation/focus effect; assign the wait --menu
parity hole and its menu-detection primitive to §2.11; and record the
two press --app divergences (no semantic accelerator path, no headless
pid-targeted delivery) in §2.15's settlement list.

* docs: close the 2.9 plan's open questions with named owners

The three residuals left after review were all deferrals without a
receiving owner, which the repo's own rule forbids.

Settle the U1 leg-7 shell-binding branch at planning time instead of
"reconciling in U10": whichever way it measures, 2.9 keeps CreateProcessW
and Win32_UI_Shell stays out of the manifest, because the manifest
surface is a supply-chain decision rather than a probe outcome and KTD4
is load-bearing for U3's verification design. Reconciling after the fact
would have surfaced the KTD10 stop-condition contradiction only once the
code had shipped. Launch-by-display-name/AUMID now belongs to 2.14, the
sub-phase that already owns Windows shell-integration commands and would
already carry the shell dependency, written into its scope and its
dependency line.

Define envelope parity for failures macOS cannot produce: shared
failures assert equal to the macOS pair, platform-only failures assert
against the envelope contract and are recorded in 2.15's divergence
list, so a Windows-only pair can never be asserted as if it were parity.

Turn the FFI press-surface default from a floating concern into a U7
verification step against crates/ffi/src/actions/execute.rs, so a
changed FFI path corrects the stated corner rather than relaxing a test.

* test: measure windows system lifecycle gaps (A21)

Settle CreateProcessW/attach, exit-code, hang, window-op tolerance,
activation budget, and shell-binding facts before the adapter fills
the lifecycle surface.

* feat: add windows process_state and protected-process gate

Give close-app a wait-gated liveness classifier and a Windows-reasoned exact-name blocklist before verified termination lands.

* feat: launch windows apps via CreateProcessW

Honor LaunchOptions with system-dirs-only bare-name resolution, ToolHelp attach policy, and verified first-window wait.

* feat: close windows apps with verified termination

Report close success only after the process handle and creation-time token show the target is gone.

* feat: verify windows window ops via placement re-read

Execute resize/move/minimize/maximize/restore against Win32 placement truth with an 8px tolerance pinned by A21-5.

* feat: harden windows window-activation focus policy

Add restore-versus-raise ordering, a finite focus-steal budget, and fail-closed cross-integrity activation mapping.

* feat: compose windows press-key-for-app delivery

Verify foreground ownership and integrity before synthesizing through the existing keyboard primitive.

* test: stabilize press-key focus-lost invert under parallel tests

Arm a deterministic focus-lost gate after the verify hook so the refusal does not depend on SetForegroundWindow races.

* test: pin lifecycle envelope parity and cost baseline

Lock shared macOS code/disposition pairs, class-b Windows-only failures, and A20-style hot-path cost captures.

* docs: dogfood windows system lifecycle layer

Judge launch/close/window-op/press-app and protected-process refusal against real Notepad and Explorer targets.

* docs: sync windows lifecycle docs with shipped adapter

Record CreateProcessW system-dirs launch, verified close, and press-app divergences against A21 and dogfood evidence.

* refactor: split window-activate test hooks under file-size cap

Keep window_activate.rs under the 400-line limit by moving invert-test hooks to a sibling module.

* style: rustfmt elevation modules

Apply pending rustfmt import ordering left after the lifecycle fill.

* fix: bind terminate and wm-close to live ownership

Verify creation-time token on the open process handle before TerminateProcess, and re-check HWND owner before each WM_CLOSE.

* fix: unblock the windows lib-test gate after the ownership re-bind

The ownership re-bind landed after the last local clippy run, so two
gate failures reached CI.

Drop a redundant isize cast in the new close test: LocalFixture::handle
already returns isize, so `as isize` trips -D clippy::unnecessary_cast
and fails the lib-test build.

Take the on-screen stage lock in the focused-filter test. It reads the
foreground twice - once through the filter, once to corroborate - and
this sub-phase is the first to add tests that deliberately raise their
own windows, so a sibling activation test landing between those two
reads made it fail for the sibling's reason. It stages nothing itself,
but the lock guards screen state rather than only screen real estate.
Verified by running the suite in isolation (passes) and in parallel
(failed before, three consecutive clean runs after).

* fix: survive the mid-listing identity race in the live census test

This sub-phase's suite spawns and terminates real processes, which is
exactly the condition list_windows_live refuses on: a window whose owning
process changes mid-walk fails the whole inventory. The census test
expect()ed the listing instead, so a concurrent lifecycle test could
panic it - reproduced on CI, not on the quieter dev box.

Retry the listing rather than tolerate the first refusal, so the identity
assertions still run wherever the race is not permanent, and accept only
a refusal that survives every attempt and only as the exact WindowNotFound
the inventory exists to report. Its sibling focused-filter test already
handles the same refusal this way.

Invert-verified: breaking the token assertion turns the test red, so the
retry did not make it vacuous.

* fix: deliver close to every owned window and stop tests passing vacuously

Two defects the review pass found.

graceful_close aborted the WM_CLOSE fan-out on the first window that
refused the post, so every window enumerated after it never received the
request. R2 requires the request to reach every window the pid owns
precisely because the window that owns an app's shutdown may be
enumerated after one that already tore itself down - which is the
expected shape once an app starts closing in response to an earlier post
in that same loop. The fan-out now continues and reports a failure only
when no window accepted, leaving wait_for_exit's independent exit
observation as the source of truth. Extracted broadcast_close as a seam
over the poster so the behaviour is testable without Win32, and
post_wm_close_if_still_owned now reports whether it delivered so a
deliberate skip is distinguishable from a send.

Four live press_key_for_app tests gated their whole body behind a
foreground-staging bool and returned when staging did not land, so on any
run where activation lost the race they passed while asserting nothing -
including the only test proving the method is wired through SystemOps.
Each now asserts the fail-closed contract instead of returning: a target
that never reached the foreground must be refused with no synthesis,
which is real coverage of the documented no-pid-targeting divergence. The
trait-wiring test always calls through the trait object and cross-checks
the outcome against whether staging succeeded.

Invert-verified both: restoring abort-on-first-failure turns the fan-out
test red, and the close fan-out tests fail if a skip is counted as a send.

* fix: refuse window writes to a target that stops dispatching messages

window_op and focus_window issued ShowWindow/SetWindowPos/
SetForegroundWindow with no liveness check. Those calls reach the owning
thread's message queue, so a window whose thread never dispatches blocks
the caller inside the OS call - the same shape A14-11 recorded for
ElementFromHandle, and measured here as a call that never returned. A
Deadline cannot rescue it: the budget is checked before the call and the
block happens inside it, and the activation retry budget is consulted
between attempts rather than during one. Both paths now ask the crate's
existing SMTO_ABORTIFHUNG ping first and report APP_UNRESPONSIVE, which
is what R9 already required of any path that could hang.

The ping runs after identity verification, not before it, so a destroyed
or re-owned handle still reports the stale-identity envelope it earned
rather than being mislabelled unresponsive.

Bound the live title read while here. GetWindowTextW sends WM_GETTEXT for
a window owned by the calling process, so a same-process non-pumping
window blocked identity verification itself before any write was reached
- the documented cross-process behaviour returns the caption without
sending, so this only bites in-process, but the title is best-effort
evidence either way and identity still rests on the owner and generation
checks, which never touch the message queue.

Both guards are proven against the non-pumping StalledFixture, which
hangs indefinitely without them.

* fix: let the wait signal alone decide that a process is gone

close.rs's exit gate vetoed on an exit code of 259, which is STILL_ACTIVE
- the same value GetExitCodeProcess reports for a live process. A process
that legitimately exits with that code therefore read as still running
for as long as any handle kept it from being reaped, which the caller's
own child handle routinely does, so close_app spun to TIMEOUT and
delivered_unverified for a process that had already terminated cleanly.
KTD3 says the wait decides rather than the code, and process_state gates
that way already; close.rs had re-derived the check and got it wrong.

Also stop broadcast_close discarding a request it already delivered. Its
per-iteration budget check returned not_delivered without consulting the
delivered flag, so a deadline expiring after a real WM_CLOSE had been
posted told the caller nothing had happened and a retry was free. It now
stops the fan-out and lets wait_for_exit report the honest
delivered_unverified timeout instead.

Invert-verified: restoring the 259 veto turns the new regression test red.

* refactor: share the window liveness probe instead of duplicating it

The hang guard landed the same probe call and the same timeout constant
in both window_op.rs and window_activate.rs. It is one question about a
window - is its thread dispatching messages - so it is one predicate,
placed beside the handle type it takes in window_enum.rs. Only the probe
is shared: activation and window operations report the same condition
with different envelopes, so each caller still words its own refusal.

* fix: treat restore as undoing the minimize, not forcing a normal placement

show_verb demanded an exact SW_SHOWNORMAL placement to confirm a restore,
but a window minimized while maximized carries WPF_RESTORETOMAXIMIZED, so
SW_RESTORE correctly returns it to maximized. The ordinary maximize ->
minimize -> restore sequence therefore reported ACTION_FAILED with
delivered_unverified for an operation Windows had performed exactly as
asked, telling the caller a retry was unsafe while the window sat
correctly restored. Restore now succeeds when the window is no longer
minimized, which is what the verb promises; it never promised to
un-maximize.

The verb and its predicate moved to their own module to stay under the
file-size cap.

Invert-verified: demanding the exact normal placement again turns the new
maximize-minimize-restore test red.

* fix: stop a trailing backslash swallowing the next launch argument

Backslashes are literal except immediately before a quote, so an argument
ending in one escaped the closing quote quote_arg appends: the quoted
region never closed and every later argument was absorbed into it. A
Windows path ending in a separator is an ordinary value, and the merge
was silent - the launch still reported success. The trailing run is now
doubled.

An embedded quote stays doubled rather than backslash-escaped. Doubling
is a valid encoding for the consecutive-quote rule and is the form cmd
accepts; backslash-escaping is correct only for CommandLineToArgvW
consumers and breaks a cmd redirection that works today, so the narrower
fix closes the corruption without trading it for a different one.

Also collapse the second copy of the window-title reader. window_ops kept
its own unbounded GetWindowTextW while the identity module's copy was
already bounded, so list_windows could still block on a window whose
thread had stopped dispatching. One reader, already guarded.

Invert-verified: removing the trailing-run doubling turns the new test red.

* fix: correct the activation evidence and close the review's remaining gaps

The A21-6 ledger row asserted the opposite of its own committed capture.
The row claimed the uncontended first attempt always lands, 5/5; the
capture records the foreground transition landing on neither attempt in
any of five trials, with first_attempt_success_rate 0. The false sentence
had propagated into docs/phases.md and into a doc comment on the shipped
retry constant, so the product cited a measurement its evidence
contradicted. Row, phase document and doc comment now state what was
measured. The design stands: a finite budget with fail-closed exhaustion
is better supported by 0/5 than by 5/5, because an unbounded retry
against a target that never foregrounds would hang instead of failing.

Two lifecycle tests targeted std::process::id() and drove the real
graceful close, whose fan-out posts WM_CLOSE to every window the pid
owns - including every in-process fixture window belonging to tests
running in parallel, which DefWindowProcW then destroys. Both now target
a child-process fixture whose window set the test owns.

Production honesty: an all-skips fan-out no longer claims the OS accepted
a request that was never posted, and WaitForSingleObject's failure is no
longer read as "alive" when the wait could not judge liveness at all.
Launch closes its process handles before the first fallible step and
merges environment overrides case-insensitively, so an override of Path
no longer loses to an inherited PATH.

Test integrity: the launch probes no longer terminate every notepad.exe
on the host - a developer's open Notepad and its unsaved work were being
killed by a test run. A copied notepad is not a usable probe either; it
re-execs and exits before the process table is read, and which system
applications survive copying differs per image. The probe is now a copy
of the test binary hosting a real window, which is repo-controlled and
present everywhere. Also pins the previously untested keyboard-focus
timeout branch, replaces a tautological activation assertion with
independently read facts, stops a parity helper claiming a cross-platform
match it only restated from literals, and removes the cwd-marker read
race that CI reproduced.

Captures three learnings in docs/solutions: a cited measurement must
match its capture, a deadline cannot interrupt a blocking OS call, and a
test that acts on its own runner acts on every other test.

* docs: refresh the learnings this sub-phase moved or extended

The identity-fingerprint learning pointed at window_resolve.rs for
focus_window and its per-write ownership re-checks, which this sub-phase
moved to window_activate.rs. The solution it describes is unchanged; only
the references had drifted, so they now name where the code lives.

Record the falsifiability shape this review found: a setup guard that
returns instead of asserting. Four live tests opened by staging a real OS
activation and returning when it did not land, so a degraded desktop
passed them while asserting nothing - including the only test proving the
method was wired through its trait. It is the hardest shape to catch in
review because the early return reads as hygiene rather than a hole.
2026-08-09 03:49:06 -07:00
.githooks feat: windows resolution and live locator (sub-phase 2.5) (#120) 2026-08-06 16:14:04 -07:00
.github feat: windows input synthesis (sub-phase 2.8) (#123) 2026-08-08 05:29:03 -07:00
assets refactor!: unify command execution contracts 2026-05-19 18:27:08 -07:00
benchmarks/locator-resolution feat!: implement Playwright-grade foundation contract 2026-07-20 00:21:38 -07:00
crates feat: windows system lifecycle (sub-phase 2.9) (#124) 2026-08-09 03:49:06 -07:00
docs feat: windows system lifecycle (sub-phase 2.9) (#124) 2026-08-09 03:49:06 -07:00
npm chore(main): release 0.7.0 (#118) 2026-08-02 01:07:39 -04:00
probes/windows feat: windows system lifecycle (sub-phase 2.9) (#124) 2026-08-09 03:49:06 -07:00
scripts feat: windows input synthesis (sub-phase 2.8) (#123) 2026-08-08 05:29:03 -07:00
skills feat: windows system lifecycle (sub-phase 2.9) (#124) 2026-08-09 03:49:06 -07:00
src feat: windows observation read path — snapshot, inventories, chromium settle, drill-down (#119) 2026-08-02 20:31:09 -07:00
tests feat!: implement Playwright-grade foundation contract 2026-07-20 00:21:38 -07:00
.gitignore feat: windows system lifecycle (sub-phase 2.9) (#124) 2026-08-09 03:49:06 -07:00
.gitleaks.toml chore: track planning artifacts with secret and privacy scanning (#109) 2026-07-26 01:41:55 -07:00
.release-please-manifest.json chore(main): release 0.7.0 (#118) 2026-08-02 01:07:39 -04:00
Cargo.lock feat: windows observation read path — snapshot, inventories, chromium settle, drill-down (#119) 2026-08-02 20:31:09 -07:00
Cargo.toml chore(main): release 0.7.0 (#118) 2026-08-02 01:07:39 -04:00
CHANGELOG.md chore(main): release 0.7.0 (#118) 2026-08-02 01:07:39 -04:00
CLAUDE.md feat: windows system lifecycle (sub-phase 2.9) (#124) 2026-08-09 03:49:06 -07:00
clippy.toml feat: Phase 1 foundation — workspace scaffold, core engine, macOS adapter, 31 commands 2026-02-19 10:44:38 -08:00
CODE_OF_CONDUCT.md docs: add community health files for GitHub community standards (#83) 2026-06-28 21:41:39 -04:00
CONCEPTS.md feat: windows system lifecycle (sub-phase 2.9) (#124) 2026-08-09 03:49:06 -07:00
CONTRIBUTING.md docs: add community health files for GitHub community standards (#83) 2026-06-28 21:41:39 -04:00
deny.toml refactor!: unify command execution contracts 2026-05-19 18:27:08 -07:00
LICENSE feat(ffi): ship C-ABI cdylib with review hardening and release pipeline (#26) 2026-04-17 04:00:43 -07:00
README.md feat!: implement Playwright-grade foundation contract 2026-07-20 00:21:38 -07:00
release-please-config.json fix: correct GitHub Release download URL and simplify tag format 2026-02-23 01:20:16 -08:00
rust-toolchain.toml refactor!: remove speculative Win32 private-file layer from core, add real Windows/Linux test lanes (#106) 2026-07-25 23:25:28 -07:00
SECURITY.md refactor!: unify command execution contracts 2026-05-19 18:27:08 -07:00
SUPPORT.md docs: add community health files for GitHub community standards (#83) 2026-06-28 21:41:39 -04:00

AGENT DESKTOP

OBSERVE. DECIDE. ACT.

CI status GitHub release npm version ClawHub skill skills.sh listing Apache-2.0 License

agent-desktop tutorial demo

agent-desktop is a native desktop automation CLI designed for AI agents, built with Rust. It gives structured access to any application through OS accessibility trees — no screenshots, no pixel matching, no browser required.

Architecture

agent-desktop architecture diagram

agent-desktop real-world example — Slack accessibility tree with 97% token savings

Star history for lahfir/agent-desktop

Key Features

  • Native Rust CLI: Fast, single binary, no runtime dependencies
  • C-ABI cdylib (libagent_desktop_ffi): Load once from Python / Swift / Go / Ruby / Node / C instead of forking the CLI per call
  • 58 command names, 54 operational commands: Observation, interaction, keyboard, mouse, notifications, clipboard, window management, session lifecycle, trace read/export, plus a bundled skills doc loader. The four held-input names are reserved for a stateful daemon and fail closed in the stateless CLI.
  • Progressive skeleton traversal: 7896% token reduction on dense apps via shallow overview + targeted drill-down
  • Snapshot & refs: AI-optimized workflow using compact snapshot IDs and qualified element references (@s8f3k2p9:e1, @s8f3k2p9:e2)
  • Headless-by-default interactions: Ref actions use accessibility APIs and block silent focus, cursor, keyboard, or pasteboard side effects
  • Structured JSON output: Machine-readable responses with error codes and recovery hints
  • Works with any app: Finder, Safari, System Settings, Xcode, Slack — anything with an accessibility tree

Installation

npm install -g agent-desktop        # downloads prebuilt binary automatically

Or without installing:

npx agent-desktop snapshot --app Finder -i

From source

git clone https://github.com/lahfir/agent-desktop
cd agent-desktop
cargo build --release
cp target/release/agent-desktop /usr/local/bin/

Requires Rust 1.89+ and macOS 13.0+.

Permissions

macOS requires Accessibility permission. Screenshots also require Screen Recording permission, and the Notification Center opener requires Automation permission for System Events. Plain permission checks never prompt. Request missing permissions in a bounded isolated helper with:

agent-desktop permissions --request   # request missing permissions in an isolated helper

Permission fields are explicit objects, for example:

{
  "accessibility": { "state": "granted" },
  "screen_recording": { "state": "denied", "suggestion": "Grant Screen Recording permission" },
  "automation": { "state": "unknown" }
}

Automation reports granted, denied, or unknown; unknown means macOS would need to prompt or System Events could not be probed without prompting.

Language bindings (FFI)

Every GitHub Release ships a prebuilt C-ABI cdylib (libagent_desktop_ffi) for macOS, Linux, and Windows alongside the CLI tarballs. dlopen it and call the functions declared in agent_desktop.h for in-process calls instead of fork-exec per command.

import ctypes
lib = ctypes.CDLL("./lib/libagent_desktop_ffi.dylib")
lib.ad_init(3)  # verify ABI major (AD_ABI_VERSION_MAJOR) before any call
adapter = lib.ad_adapter_create()
# observe -> act: ad_snapshot -> parse a qualified ref -> ad_execute_by_ref ...
lib.ad_adapter_destroy(adapter)

Full consumer guide — entrypoints, ownership, threading, error-handling, build/link, release archives, and verification: skills/agent-desktop-ffi/.

Core Workflow for AI

For dense apps (Slack, VS Code, Notion), use progressive skeleton traversal to minimize token usage:

# 1. Shallow overview — depth-3 map, truncated containers show children_count
agent-desktop snapshot --skeleton --app Slack -i --compact
# Keep snapshot_id, for example s8f3k2p9

# 2. Drill into a region of interest (named containers get refs as drill targets)
agent-desktop snapshot --root @e3 --snapshot s8f3k2p9 -i --compact

# 3. Act on an element found in the drill-down
agent-desktop click @e12 --snapshot s8f3k2p9

# 4. Re-drill the same region to verify the state change
agent-desktop snapshot --root @e3 --snapshot s8f3k2p9 -i --compact

For simple apps, a full snapshot is fine:

agent-desktop snapshot --app Finder -i   # get interactive elements with refs and snapshot_id
agent-desktop click @e3 --snapshot s8f3k2p9  # click a button by ref
agent-desktop type @e5 --snapshot s8f3k2p9 "quarterly report"  # insert text into a field
agent-desktop press cmd+s               # keyboard shortcut
agent-desktop snapshot -i               # re-observe after UI changes
Agent loop:  snapshot → decide → act → snapshot → decide → act → ...

Trace viewer (read back a session)

session_id=$(agent-desktop session start --screenshots | jq -r '.data.session_id')
export AGENT_DESKTOP_SESSION="$session_id"
agent-desktop snapshot --app Finder -i       # work inside the explicit session scope
agent-desktop click @s8f3k2p9:e5
agent-desktop trace show --limit 500         # bounded JSON timeline for agents
agent-desktop trace export --out run.html    # single-file HTML viewer (works from file://)

trace show merges all segment files deterministically and requires no permissions. trace export embeds the timeline plus screenshots as base64 in one static HTML file. Without --out, the HTML is written to the session directory (~/.agent-desktop/sessions/<id>/trace-<id>.html), not the current directory; --out overrides the path. Treat exported HTML like a screenshot when artifacts: full was enabled.

Shared sessions for multi-agent workflows

Run session start once per agent run to create a trace-enabled session (manifest trace: on by default), then pass the returned ID with global --session <id> or AGENT_DESKTOP_SESSION=<id>. Commands in that explicit scope get automatic JSONL segments under ~/.agent-desktop/sessions/<id>/trace/ and share the session's latest-snapshot namespace — no --trace on every call.

For concurrent independent agents, set AGENT_DESKTOP_SESSION=<id> per process. When multiple agents share one session ID, each agent should act on the qualified refs from its own snapshot call rather than assuming the namespace's latest snapshot is unchanged.

Bare --session <id> without a manifest (no session start) still scopes the snapshot namespace only and writes no trace files. Snapshot IDs resolve only inside the selected session namespace; they never trigger a cross-session search.

agent-desktop session start --name release-fix          # note data.session_id
export AGENT_DESKTOP_SESSION=<session_id>
agent-desktop snapshot --app Xcode -i --compact          # uses selected session + tracing
agent-desktop wait --element @s8f3k2p9:e9 --predicate actionable --timeout 5000
agent-desktop click @s8f3k2p9:e9
agent-desktop click @e9 --snapshot s2                    # legacy bare ref, explicitly pinned
agent-desktop session end "$AGENT_DESKTOP_SESSION"
agent-desktop session gc

Commands

Observation

agent-desktop snapshot --app Safari -i           # accessibility tree with refs
agent-desktop snapshot --surface menu            # capture open menu
agent-desktop screenshot --app Finder            # PNG screenshot
agent-desktop find --role button --app TextEdit  # search by role, name, value, text
agent-desktop get @e3 --snapshot s8f3k2p9 --property value  # read element property
agent-desktop is @e7 --snapshot s8f3k2p9 --property checked # check boolean state
agent-desktop list-surfaces --app Notes          # list menus, sheets, popovers, alerts

get and is resolve the ref once, prefer live platform reads when available, and fall back only when that live read is unsupported by the adapter.

Interaction

agent-desktop click @s8f3k2p9:e3                  # strict headless AX click
agent-desktop --headed click @s8f3k2p9:e3         # physical click, focus/cursor allowed
agent-desktop --headed double-click @s8f3k2p9:e3  # physical double-click
agent-desktop --headed triple-click @s8f3k2p9:e3  # physical triple-click
agent-desktop right-click @s8f3k2p9:e3            # open context menu; inspect effect before retrying
agent-desktop type @s8f3k2p9:e5 "hello world"     # insert text into element
agent-desktop set-value @s8f3k2p9:e5 "new value"  # set value directly via AX
agent-desktop clear @s8f3k2p9:e5                  # clear element value
agent-desktop focus @s8f3k2p9:e5                  # set keyboard focus
agent-desktop select @s8f3k2p9:e9 "Option B"      # select verified dropdown/list option
agent-desktop toggle @s8f3k2p9:e12                # flip checkbox or switch
agent-desktop check @s8f3k2p9:e12                 # idempotent check
agent-desktop uncheck @s8f3k2p9:e12               # idempotent uncheck
agent-desktop expand @s8f3k2p9:e15                # expand disclosure/tree item
agent-desktop collapse @s8f3k2p9:e15              # collapse disclosure/tree item
agent-desktop scroll @s8f3k2p9:e1 --direction down --amount 3  # strict headless AX scroll
agent-desktop scroll-to @s8f3k2p9:e20             # scroll element into view

(macOS, Phase 1) Default ref actions are strict headless semantic operations. In headed mode, core focuses the exact ref window before dispatch; pointer commands additionally require a verified target point, while the adapter owns physical delivery. click, right-click, type, clear, and scroll are physical-first; double/triple-click, hover, and drag are physical-only; expand/collapse and other semantic commands remain semantic. Raw coordinates never imply a target window and therefore never steal focus. See skills/agent-desktop/references/commands-interaction.md.

Keyboard

agent-desktop press cmd+s               # key combo
agent-desktop press cmd+shift+z          # multi-modifier
agent-desktop press escape               # single key

key-down and key-up are reserved command names and return ACTION_NOT_SUPPORTED until a stateful daemon can own the held-key lifetime.

Mouse

agent-desktop --headed hover @s8f3k2p9:e3                  # move cursor to element
agent-desktop --headed hover --xy 500,300         # move cursor to coordinates
agent-desktop --headed drag --from @s8f3k2p9:e3 --to @s8f3k2p9:e8   # drag between elements
agent-desktop --headed drag --from-xy 100,200 --to-xy 400,200  # drag between coordinates
agent-desktop --headed mouse-click --xy 500,300   # click at coordinates

mouse-down and mouse-up are likewise reserved; use the atomic mouse-click or drag commands.

App & Window Management

agent-desktop launch Safari              # launch app by name
agent-desktop launch com.apple.Safari    # launch by bundle ID
agent-desktop close-app Safari           # quit app
agent-desktop close-app Safari --force   # force quit (SIGTERM, then SIGKILL if needed)
agent-desktop list-apps                  # list running GUI apps
agent-desktop list-windows               # list visible windows
agent-desktop list-windows --app Finder  # windows for specific app
agent-desktop focus-window --window-id w-4521  # bring exact window to front
agent-desktop resize-window --window-id w-4521 --width 800 --height 600
agent-desktop move-window --window-id w-4521 --x 100 --y 100
agent-desktop minimize --window-id w-4521
agent-desktop maximize --window-id w-4521
agent-desktop restore --window-id w-4521

Notifications (macOS only)

agent-desktop --headed list-notifications              # open Notification Center if needed, then list
agent-desktop --headed list-notifications --app "Slack"         # filter by app
agent-desktop --headed list-notifications --text "deploy" --limit 5  # filter by text
agent-desktop --headed dismiss-notification 1 --expected-app "Slack" --expected-title "Deploy complete"
agent-desktop --headed dismiss-all-notifications                # dismiss all
agent-desktop --headed dismiss-all-notifications --app "Slack"  # dismiss all from app
agent-desktop --headed notification-action 1 "Reply" --expected-app "Slack" --expected-title "Deploy complete"

Single-notification mutations require an app or title fingerprint from the same listing. Every mutation requires --headed because it opens and focuses Notification Center. Headless listing can only observe an already-open center; headed listing may open it and restore the prior frontmost app afterward.

Clipboard

agent-desktop clipboard-get              # read clipboard text
agent-desktop clipboard-set "copied"     # write to clipboard
agent-desktop clipboard-clear            # clear clipboard

Wait

agent-desktop wait 500                                       # sleep 500ms
agent-desktop wait --element @s8f3k2p9:e3 --timeout 5000              # wait for element
agent-desktop wait --element @s8f3k2p9:e3 --predicate actionable      # wait until safe to act
agent-desktop wait --element @s8f3k2p9:e5 --predicate value --value ready
agent-desktop wait --window "Save" --timeout 10000           # wait for window
agent-desktop wait --text "Loading complete" --app Safari    # wait for text
agent-desktop wait --text "Done" --count 1 --app Xcode       # wait for exact match count
agent-desktop wait --notification --text "Build Succeeded"   # wait for new matching notification
agent-desktop wait --menu --timeout 3000                     # wait for menu

Batch

agent-desktop batch '[
  {"command": "click", "args": {"ref_id": "@e2", "snapshot": "<snapshot_id>"}},
  {"command": "type", "args": {"ref_id": "@e5", "snapshot": "<snapshot_id>", "text": "hello"}},
  {"command": "press", "args": {"combo": "return"}}
]' --stop-on-error

agent-desktop --session run-a batch '[
  {"command": "snapshot", "args": {"app": "Finder", "interactive_only": true}},
  {"command": "status", "session": "run-b", "args": {}}
]'

System

agent-desktop session start [--name LABEL] [--no-trace]  # create session; pass returned ID explicitly
agent-desktop session end [id]
agent-desktop session list
agent-desktop session gc [--older-than SECS] [--ended]
agent-desktop status                     # platform, permissions, session_id, tracing, latest snapshot
agent-desktop permissions                # check accessibility/screen-recording/automation
agent-desktop permissions --request      # request in the bounded isolated helper
agent-desktop version                    # version string
agent-desktop skills get desktop --full  # bundled agent guidance

Snapshot Options

agent-desktop snapshot [OPTIONS]
Flag Default Description
--app <NAME> focused app Filter to a specific application
--window-id <ID> - Filter to a specific window
-i / --interactive-only off Only include interactive elements
--compact off Omit empty structural nodes
--include-bounds off Include pixel bounds (x, y, width, height)
--max-depth <N> 10 Maximum tree depth
--skeleton off Shallow 3-level overview; truncated containers show children_count and get refs as drill targets
--root <REF> - Start traversal from this ref; merges into existing refmap with scoped invalidation
--snapshot <snapshot_id> latest Snapshot ID to use when resolving --root
--surface <TYPE> window window, focused, menu, menubar, sheet, popover, alert

JSON Output

See the versioned JSON envelope, error-code, and exit-code contract.

Ref System

snapshot assigns local positions in depth-first order and emits qualified refs such as @s8f3k2p9:e1, @s8f3k2p9:e2, and @s8f3k2p9:e3. A qualified ref embeds the exact snapshot ID and needs no separate --snapshot. Legacy bare refs such as @e3 remain accepted only with an explicit --snapshot s8f3k2p9. Snapshot lookup stays inside the selected session namespace.

Interactive roles that receive refs: button, textfield, checkbox, link, menuitem, tab, slider, combobox, treeitem, cell, radiobutton, incrementor, menubutton, switch, colorwell, dockitem.

Static elements (labels, groups, containers) appear in the tree for context but have no ref.

Reliability contract:

  • session start creates and returns a manifest-gated session with automatic trace segments. It does not activate later processes. Activation resolves explicit --session first, then AGENT_DESKTOP_SESSION; otherwise the command uses the global, non-session namespace.
  • Bare --session <id> without a manifest scopes snapshots only — no surprise trace files for existing callers.
  • Snapshot lookup is confined to the selected namespace. A session-owned snapshot requires the same explicit --session or AGENT_DESKTOP_SESSION scope.
  • Ref actions re-identify targets at action time: a moved unique target can proceed, while missing or changed stable identity returns STALE_REF.
  • Mutable value text is not treated as stable identity, so text fields and timers can keep resolving when the saved window, path, role, and bounds evidence still identify the same element.
  • Multiple plausible targets return AMBIGUOUS_TARGET instead of choosing arbitrarily.
  • Actions run an actionability preflight before dispatch: visibility, stability, enabled state, supported action, policy, and editability.
  • wait --element @s8f3k2p9:e3 --predicate actionable polls until the target can be acted on.
  • With an active trace-enabled session, JSONL segments land under sessions/<id>/trace/<pid>-*.jsonl automatically. --trace <path> overrides to one file; --trace-strict fails on setup and pre-action writes (post-action traces are best-effort).

Stale ref recovery:

snapshot → act → STALE_REF or AMBIGUOUS_TARGET? → wait/snapshot again → retry with the new ref

Platform Support

macOS Windows Linux
Accessibility tree Yes Planned Planned
Click / type / keyboard Yes Planned Planned
Mouse input Yes Planned Planned
Screenshot Yes Planned Planned
Clipboard Yes Planned Planned
App & window management Yes Planned Planned
Notifications Yes Planned Planned

Development

cargo build                               # debug build
cargo build --release                     # optimized (<15MB)
cargo test --lib --workspace              # run tests
cargo clippy --all-targets -- -D warnings # lint (must pass with zero warnings)

FAQ

See the complete FAQ for architecture, platform support, installation, refs, licensing, and support links.

License

Apache-2.0