mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-26 17:12:15 +00:00
chore: track planning artifacts with secret and privacy scanning (#109)
Some checks are pending
CI / Format (push) Waiting to run
CI / Rust 1.89 MSRV (push) Waiting to run
CI / Native check (macOS) (push) Waiting to run
CI / Native check (Linux) (push) Waiting to run
CI / Native check (Windows) (push) Waiting to run
CI / Test (push) Waiting to run
CI / Test (Linux) (push) Waiting to run
CI / Test (Windows) (push) Waiting to run
CI / FFI Python Smoke (push) Waiting to run
CI / FFI Header Drift (push) Waiting to run
CI / FFI Panic Guard (push) Waiting to run
CI / FFI Stub-Adapter Passthrough (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (rust) (push) Waiting to run
Release / Release Please (push) Waiting to run
Release / Build (aarch64-apple-darwin) (push) Blocked by required conditions
Release / Build (x86_64-apple-darwin) (push) Blocked by required conditions
Release / Build FFI (aarch64-apple-darwin) (push) Blocked by required conditions
Release / Build FFI (x86_64-apple-darwin) (push) Blocked by required conditions
Release / Build FFI (x86_64-unknown-linux-gnu) (push) Blocked by required conditions
Release / Build FFI (aarch64-unknown-linux-gnu) (push) Blocked by required conditions
Release / Build FFI (x86_64-pc-windows-msvc) (push) Blocked by required conditions
Release / FFI Release Gates (push) Blocked by required conditions
Release / Publish to GitHub Release (push) Blocked by required conditions
Release / Publish to npm (push) Blocked by required conditions
Release / Publish Skills to ClawHub (push) Blocked by required conditions
Supply Chain / Audit (push) Waiting to run
Some checks are pending
CI / Format (push) Waiting to run
CI / Rust 1.89 MSRV (push) Waiting to run
CI / Native check (macOS) (push) Waiting to run
CI / Native check (Linux) (push) Waiting to run
CI / Native check (Windows) (push) Waiting to run
CI / Test (push) Waiting to run
CI / Test (Linux) (push) Waiting to run
CI / Test (Windows) (push) Waiting to run
CI / FFI Python Smoke (push) Waiting to run
CI / FFI Header Drift (push) Waiting to run
CI / FFI Panic Guard (push) Waiting to run
CI / FFI Stub-Adapter Passthrough (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (rust) (push) Waiting to run
Release / Release Please (push) Waiting to run
Release / Build (aarch64-apple-darwin) (push) Blocked by required conditions
Release / Build (x86_64-apple-darwin) (push) Blocked by required conditions
Release / Build FFI (aarch64-apple-darwin) (push) Blocked by required conditions
Release / Build FFI (x86_64-apple-darwin) (push) Blocked by required conditions
Release / Build FFI (x86_64-unknown-linux-gnu) (push) Blocked by required conditions
Release / Build FFI (aarch64-unknown-linux-gnu) (push) Blocked by required conditions
Release / Build FFI (x86_64-pc-windows-msvc) (push) Blocked by required conditions
Release / FFI Release Gates (push) Blocked by required conditions
Release / Publish to GitHub Release (push) Blocked by required conditions
Release / Publish to npm (push) Blocked by required conditions
Release / Publish Skills to ClawHub (push) Blocked by required conditions
Supply Chain / Audit (push) Waiting to run
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.
This commit is contained in:
parent
33bc47ca38
commit
dde0b98dc0
36 changed files with 17526 additions and 0 deletions
21
.github/workflows/supply-chain.yml
vendored
21
.github/workflows/supply-chain.yml
vendored
|
|
@ -23,6 +23,11 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
# Full history so the secret scan below can walk commits, not just
|
||||||
|
# the final tree. A shallow clone would silently reduce it to a
|
||||||
|
# working-tree scan.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check release metadata consistency
|
- name: Check release metadata consistency
|
||||||
run: scripts/check-release-consistency.sh
|
run: scripts/check-release-consistency.sh
|
||||||
|
|
@ -41,6 +46,22 @@ jobs:
|
||||||
arguments: --locked
|
arguments: --locked
|
||||||
command-arguments: advisories licenses bans sources
|
command-arguments: advisories licenses bans sources
|
||||||
|
|
||||||
|
- name: Secret and privacy scan
|
||||||
|
env:
|
||||||
|
GITLEAKS_VERSION: 8.30.1
|
||||||
|
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
archive="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
||||||
|
curl -fsSL -o "$archive" \
|
||||||
|
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${archive}"
|
||||||
|
echo "${GITLEAKS_SHA256} ${archive}" | sha256sum --check --status
|
||||||
|
tar -xzf "$archive" gitleaks
|
||||||
|
# Scan commit history, not just the checked-out tree: a value added
|
||||||
|
# in one commit and removed in a later one stays reachable in branch
|
||||||
|
# history, and a tree-only scan would pass it.
|
||||||
|
./gitleaks git . --config .gitleaks.toml --redact --no-banner --exit-code 1
|
||||||
|
|
||||||
- name: Workflow security audit
|
- name: Workflow security audit
|
||||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -84,6 +84,14 @@ docs/*
|
||||||
!docs/phases.md
|
!docs/phases.md
|
||||||
!docs/solutions/
|
!docs/solutions/
|
||||||
!docs/solutions/**
|
!docs/solutions/**
|
||||||
|
!docs/plans/
|
||||||
|
!docs/plans/**
|
||||||
|
!docs/brainstorms/
|
||||||
|
!docs/brainstorms/**
|
||||||
|
|
||||||
|
# macOS resource forks — never commit
|
||||||
|
._*
|
||||||
|
.DS_Store
|
||||||
todos/
|
todos/
|
||||||
.cursor/
|
.cursor/
|
||||||
.context/
|
.context/
|
||||||
|
|
|
||||||
84
.gitleaks.toml
Normal file
84
.gitleaks.toml
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
title = "agent-desktop secret and privacy scanning"
|
||||||
|
|
||||||
|
# Inherit gitleaks' built-in credential rules (API keys, tokens, private keys),
|
||||||
|
# then add project-specific privacy rules below. This repo is public and ships
|
||||||
|
# planning documents, probe evidence, and traces that are written on developer
|
||||||
|
# machines, so operator identity and filesystem layout are treated as leaks
|
||||||
|
# alongside conventional secrets.
|
||||||
|
#
|
||||||
|
# Regexes are Go RE2: no lookahead, no lookbehind, no backreferences. Anything
|
||||||
|
# that would need a negative lookahead is expressed as an allowlist entry.
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "ad-home-directory-path"
|
||||||
|
description = "Absolute home-directory path exposing an operator account name"
|
||||||
|
# The first character after the home root must not be a dot, so relative
|
||||||
|
# fragments like "$suite_root/home/.cache" are not mistaken for "/home/<user>".
|
||||||
|
regex = '''(?i)(/Users/|/home/|[A-Za-z]:\\Users\\)[A-Za-z0-9_-][A-Za-z0-9._-]{1,}'''
|
||||||
|
tags = ["privacy", "path", "identity"]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "ad-windows-account-sid"
|
||||||
|
description = "Windows account SID with machine-unique sub-authorities"
|
||||||
|
regex = '''S-1-5-21-[0-9]{5,}-[0-9]{5,}-[0-9]{5,}-[0-9]{3,}'''
|
||||||
|
tags = ["privacy", "identity"]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "ad-personal-email"
|
||||||
|
description = "Personal email address in tracked content"
|
||||||
|
regex = '''[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'''
|
||||||
|
tags = ["privacy", "identity"]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "ad-machine-hostname"
|
||||||
|
description = "Developer machine hostname captured from a probe or trace"
|
||||||
|
# Case-sensitive on purpose: Windows default hostnames are uppercase
|
||||||
|
# (DESKTOP-A1B2C3D). A case-insensitive form matches this project's own crate
|
||||||
|
# names (agent-desktop-core, agent-desktop-ffi, ...) and is pure noise.
|
||||||
|
regex = '''\b(DESKTOP|LAPTOP)-[A-Z0-9]{7}\b'''
|
||||||
|
tags = ["privacy", "identity"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Allowlist: documentation placeholders, generated artifacts, and OS vocabulary.
|
||||||
|
# The rules above intentionally match broadly; these entries keep the scan
|
||||||
|
# usable on content that only *looks* like a leak.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[allowlist]
|
||||||
|
description = "Documentation placeholders and generated artifacts"
|
||||||
|
# Match allowlist regexes against the whole line, not just the captured secret,
|
||||||
|
# so a placeholder is recognised from its surrounding context.
|
||||||
|
regexTarget = "line"
|
||||||
|
paths = [
|
||||||
|
'''Cargo\.lock''',
|
||||||
|
'''package-lock\.json''',
|
||||||
|
'''(^|/)tests/fixtures/''',
|
||||||
|
# Never-committed local state and build output. Listed so a local
|
||||||
|
# `gitleaks dir .` scan matches what CI sees on tracked content.
|
||||||
|
'''(^|/)target/''',
|
||||||
|
'''(^|/)node_modules/''',
|
||||||
|
'''(^|/)\.claude/''',
|
||||||
|
]
|
||||||
|
# Probe captures under probes/*/captures/ are deliberately NOT allowlisted:
|
||||||
|
# they are UIA tree dumps taken on a developer desktop and are the most likely
|
||||||
|
# place for a redaction failure, so gitleaks is the backstop behind the probe
|
||||||
|
# corpus's own redaction gate.
|
||||||
|
regexes = [
|
||||||
|
# Explicit redaction markers and angle-bracket placeholders.
|
||||||
|
'''<redacted[^>]*>''',
|
||||||
|
'''S-1-5-21-<[^>]*>''',
|
||||||
|
# Generic doc examples that teach path shape rather than expose one.
|
||||||
|
'''(?i)/Users/(name|user|username|you|youruser|me|someone)''',
|
||||||
|
'''(?i)/home/(name|user|username|you|youruser|runner|me|someone)''',
|
||||||
|
'''(?i)[A-Za-z]:\\Users\\(name|user|username|you|youruser|runner)''',
|
||||||
|
# CI runner home paths are not operator identity.
|
||||||
|
'''/Users/runner''',
|
||||||
|
'''/home/runner''',
|
||||||
|
# Well-known localized group principals are OS vocabulary, not identity.
|
||||||
|
'''(?i)BUILTIN\\Admin[a-z]*''',
|
||||||
|
# Addresses that are meant to be public.
|
||||||
|
'''(?i)[A-Za-z0-9._%+-]+@example\.(com|org|net)''',
|
||||||
|
'''(?i)noreply@''',
|
||||||
|
'''(?i)users\.noreply\.github\.com''',
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
---
|
||||||
|
date: 2026-02-19
|
||||||
|
topic: architecture-validation
|
||||||
|
category: design
|
||||||
|
tags: [rust, architecture, platform-adapter, refmap, testing]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture Validation: agent-desktop PRD v2
|
||||||
|
|
||||||
|
## What We're Validating
|
||||||
|
|
||||||
|
The PRD v2 is a complete engineering blueprint. This session pressure-tested three
|
||||||
|
gaps the PRD leaves underspecified that need decisions before code is written.
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
### 1. PlatformAdapter Trait — Stay Unified
|
||||||
|
|
||||||
|
**Question:** Should the trait split into `PlatformObserver` (reads) + `PlatformActor` (writes)?
|
||||||
|
|
||||||
|
**Decision:** Keep the unified 13-method trait as the PRD specifies.
|
||||||
|
|
||||||
|
**Rationale:** Splitting does not help command extensibility. The §4.5 extensibility
|
||||||
|
pattern (one new file + 2 registration lines) never touches the trait — commands call
|
||||||
|
existing adapter methods. Splitting adds two trait objects, more indirection, and no
|
||||||
|
benefit for the primary extensibility goal. The 13 methods are manageable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. NativeHandle Persistence — Optimistic with STALE_REF
|
||||||
|
|
||||||
|
**Question:** `AXUIElement` on macOS is a live CFTypeRef. It cannot be serialized
|
||||||
|
across process invocations. How should action commands resolve `@e1` on a fresh CLI
|
||||||
|
invocation?
|
||||||
|
|
||||||
|
**Decision:** Optimistic re-identification. The RefMap stores metadata per ref:
|
||||||
|
`(pid, role, name, bounds_hash)`. Action commands use this to locate the element in
|
||||||
|
the current AX tree. If the element is not found or metadata mismatches, return the
|
||||||
|
`STALE_REF` error (already in the PRD error taxonomy) with suggestion "Run 'snapshot'
|
||||||
|
to refresh, then retry with updated ref."
|
||||||
|
|
||||||
|
**Rationale:** Fast path — most of the time the UI hasn't changed, so no re-traversal
|
||||||
|
needed. Clean failure path — `STALE_REF` is already documented and agents know how to
|
||||||
|
handle it. In Phase 4, the daemon holds the RefMap in memory, making this moot.
|
||||||
|
|
||||||
|
**RefMap JSON entry shape:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@e3": {
|
||||||
|
"pid": 1234,
|
||||||
|
"role": "textfield",
|
||||||
|
"name": "Search",
|
||||||
|
"bounds_hash": "a3f9c2",
|
||||||
|
"available_actions": ["SetValue", "SetFocus"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Refmap write behavior:** Each snapshot REPLACES the refmap file entirely (not merges).
|
||||||
|
Agents should snapshot before acting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Testing Strategy — MockAdapter + Golden Fixtures
|
||||||
|
|
||||||
|
**Question:** macOS GitHub Actions runners have no active display session. How do you
|
||||||
|
test `SnapshotEngine`, `RefAllocator`, and serialization without live AX access?
|
||||||
|
|
||||||
|
**Decision:** Both approaches in parallel:
|
||||||
|
|
||||||
|
- **MockAdapter** (`agent-desktop-core` test module): An in-memory `PlatformAdapter`
|
||||||
|
implementation returning a hardcoded `AccessibilityNode` tree. Exercises the full
|
||||||
|
pipeline (adapter → filter → ref-allocate → serialize) with no OS dependency.
|
||||||
|
Used for unit tests.
|
||||||
|
|
||||||
|
- **Golden JSON fixtures** (`tests/fixtures/`): Real snapshots captured once from
|
||||||
|
Finder, TextEdit, etc. Checked into the repo. Used to regression-test that
|
||||||
|
serialization format changes don't silently alter the JSON contract.
|
||||||
|
|
||||||
|
macOS CI integration tests (GitHub Actions macOS runner) test the real AX adapter
|
||||||
|
against live apps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Workspace Bootstrap — All Platform Crates from Day One
|
||||||
|
|
||||||
|
**Decision:** Phase 1 creates all crates upfront:
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
core/ # agent-desktop-core — fully implemented in P1
|
||||||
|
macos/ # agent-desktop-macos — fully implemented in P1
|
||||||
|
windows/ # agent-desktop-windows — stub: all methods return Err(not_supported)
|
||||||
|
linux/ # agent-desktop-linux — stub: all methods return Err(not_supported)
|
||||||
|
mcp/ # agent-desktop-mcp — stub (implemented in P3)
|
||||||
|
src/ # agent-desktop binary
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** Enforces the platform isolation boundary (`core` never imports platform
|
||||||
|
crates) from the first commit. Prevents accidental coupling. The `PLATFORM_UNSUPPORTED`
|
||||||
|
error already exists in the PRD error taxonomy for stub responses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Validations (PRD Is Correct)
|
||||||
|
|
||||||
|
The following PRD decisions were validated as sound:
|
||||||
|
|
||||||
|
- **Additive phase model** — Phase 1 builds the complete vertical. Phases 2-4 add
|
||||||
|
adapters/transports without modifying core. This is the right design.
|
||||||
|
- **Command extensibility** — One file + 2 registration lines per command. Elegant.
|
||||||
|
Strongly enforce this in code review.
|
||||||
|
- **Ref system** — Depth-first `@e1, @e2` on interactive-only roles. Combined with
|
||||||
|
the <500 token budget via compact serialization, this is the right approach.
|
||||||
|
- **Dual-mode entry** — `--mcp` flag triggers MCP server mode, otherwise CLI.
|
||||||
|
Invariant: every MCP tool maps 1:1 to a CLI command.
|
||||||
|
- **Sync trait for Phase 1** — All macOS AX APIs are synchronous. The async runtime
|
||||||
|
(tokio) is only needed for Linux AT-SPI (Phase 2) and MCP server (Phase 3).
|
||||||
|
The trait stays sync; adapters handle async internally in Phase 2 via `block_on`.
|
||||||
|
|
||||||
|
## Open Questions for Planning
|
||||||
|
|
||||||
|
- Token estimation: the <500 token budget (G4) requires a measurement strategy during
|
||||||
|
development. Should `tiktoken-rs` or a simple char-count heuristic be used for the
|
||||||
|
optional warning?
|
||||||
|
- Ref stability for multi-window snapshots: confirm that a `snapshot --window w-123`
|
||||||
|
only writes that window's refs to the refmap (not a global merge across windows).
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
→ `/workflows:plan` to create the Phase 1 implementation plan (10-week milestone breakdown)
|
||||||
176
docs/brainstorms/2026-02-19-ax-tree-accuracy-speed-brainstorm.md
Normal file
176
docs/brainstorms/2026-02-19-ax-tree-accuracy-speed-brainstorm.md
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
---
|
||||||
|
date: 2026-02-19
|
||||||
|
topic: ax-tree-accuracy-speed
|
||||||
|
---
|
||||||
|
|
||||||
|
# AX Tree Accuracy & Speed: First-Principles Fix
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
A corrected, fast accessibility tree traversal engine that returns complete, readable node data
|
||||||
|
on every call. The current implementation has two compounding bugs that make the tool nearly
|
||||||
|
useless in practice: node names are missing for most elements, and traversal is 4–5 seconds
|
||||||
|
for a medium-sized UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Cause Diagnosis
|
||||||
|
|
||||||
|
### Bug 1 — Empty names
|
||||||
|
|
||||||
|
`fetch_node_attrs` tries `kAXTitleAttribute` then `kAXDescriptionAttribute` and calls it done.
|
||||||
|
|
||||||
|
The macOS AX tree does **not** guarantee that either attribute carries the visible label text.
|
||||||
|
For the vast majority of real-world controls:
|
||||||
|
|
||||||
|
| Element type | Where the label actually lives |
|
||||||
|
|---|---|
|
||||||
|
| `AXOutlineRow` (Finder sidebar) | `kAXTitleAttribute` ← **should work**, but our batch parse drops it |
|
||||||
|
| `AXRow` (Finder column browser) | child `AXCell` → child `AXStaticText` → `kAXValueAttribute` |
|
||||||
|
| `AXButton` with image only | `kAXDescriptionAttribute` |
|
||||||
|
| `AXTextField` | `kAXPlaceholderValueAttribute` if empty, else `kAXValueAttribute` |
|
||||||
|
| `AXStaticText` | `kAXValueAttribute` (not title!) |
|
||||||
|
| `AXMenuItem` | `kAXTitleAttribute` |
|
||||||
|
|
||||||
|
The batch fetch uses `AXUIElementCopyMultipleAttributeValues`. The result is a `CFArray` of
|
||||||
|
`CFType` values. Our parsing calls `item.downcast::<CFString>()`. This is correct for string
|
||||||
|
attrs — but **boolean attrs** (`kAXEnabledAttribute`, `kAXFocusedAttribute`) are `CFBoolean`,
|
||||||
|
so they downcast to `None`. That's fine because we re-fetch them individually. But the real
|
||||||
|
problem is more subtle: **some elements return their string attrs as `CFString` subclasses
|
||||||
|
or as `AXTextMarker` types** that do not downcast to plain `CFString`. In those cases we get
|
||||||
|
`None` even when data is present.
|
||||||
|
|
||||||
|
The fix requires a multi-attribute fallback chain **plus** a child-text fallback.
|
||||||
|
|
||||||
|
### Bug 2 — Unknown roles
|
||||||
|
|
||||||
|
`roles.rs` maps ~22 role strings but the AX API has ~55. Anything not mapped becomes
|
||||||
|
`"unknown"`. In Finder's column browser, most elements are `AXBrowser`, `AXColumn`, `AXRow`
|
||||||
|
(table row variant), `AXLayoutItem` — all currently `"unknown"`. This makes the tree
|
||||||
|
unreadable and prevents `find --role` from working.
|
||||||
|
|
||||||
|
Unmapped roles in the existing codebase:
|
||||||
|
`AXBrowser`, `AXColumn`, `AXRow` (table row), `AXGrid`, `AXHandle`, `AXPopover`,
|
||||||
|
`AXDockItem`, `AXRuler`, `AXRulerMarker`, `AXTimeField`, `AXDateField`, `AXHelpTag`,
|
||||||
|
`AXMatte`, `AXDrawer`, `AXLayoutArea`, `AXLayoutItem`, `AXLevelIndicator`,
|
||||||
|
`AXRelevanceIndicator`, `AXSearchField` (already covered by textfield but missing as alias),
|
||||||
|
`AXSwitch`, `AXMenuButton`.
|
||||||
|
|
||||||
|
### Bug 3 — Speed
|
||||||
|
|
||||||
|
Every node requires 2–4 round-trips across the Mach IPC boundary:
|
||||||
|
1. `AXUIElementCopyMultipleAttributeValues` — 1 call for 6 attrs
|
||||||
|
2. `copy_children` tries up to 3 attr fetches (`kAXChildren`, `kAXContents`,
|
||||||
|
`AXChildrenInNavigationOrder`) even when the first one succeeds
|
||||||
|
|
||||||
|
Each Mach IPC call costs ~1–5 ms. A 150-node tree = 300–600 round-trips = 2–5 seconds.
|
||||||
|
|
||||||
|
The fix is: stop on first successful children attribute, and batch-fetch **more** useful attrs
|
||||||
|
in the single `AXUIElementCopyMultipleAttributeValues` call so we never need per-attr fallback
|
||||||
|
calls for the common case.
|
||||||
|
|
||||||
|
### Non-bug: `-i` flag
|
||||||
|
|
||||||
|
Already implemented. `#[arg(long, short = 'i')]` on `interactive_only`. Works as
|
||||||
|
`agent-desktop snapshot -i`. The long form is `--interactive-only`, not `--interactive`.
|
||||||
|
We should add an alias `--interactive` to match user expectation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach A: Fix name resolution with fallback chain (Recommended)
|
||||||
|
|
||||||
|
**What it is:** Extend `fetch_node_attrs` to try 5 name sources in order, including
|
||||||
|
reading the first `AXStaticText` child. Expand `roles.rs` to cover all standard AX roles.
|
||||||
|
Stop `copy_children` on first non-empty result. Add `--interactive` as alias.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Targets the exact root cause. Minimal code change.
|
||||||
|
- No architecture change — same `build_subtree` recursion.
|
||||||
|
- Risk is low. Every change is additive.
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- The child-text fallback adds 1 extra IPC call per node that has no direct name.
|
||||||
|
For nodes that DO have `kAXTitleAttribute`, this extra call is skipped.
|
||||||
|
|
||||||
|
**Speed estimate:** ~2x improvement from stopping children fetch early.
|
||||||
|
Name accuracy: ~95% of real apps will have readable names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach B: Batch all attrs including children in one call
|
||||||
|
|
||||||
|
**What it is:** Use `AXUIElementCopyMultipleAttributeValues` to fetch role, subrole, title,
|
||||||
|
description, value, placeholder, help, enabled, focused, selected, expanded, children — all
|
||||||
|
in a single call. Parse `CFBoolean` and `CFArray` correctly alongside `CFString`.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Reduces per-node IPC calls from 2–4 down to 1.
|
||||||
|
- Could bring 150-node tree from 4s down to <1s.
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires correctly parsing mixed-type `CFArray` results (CFString, CFBoolean, CFArray).
|
||||||
|
- `AXUIElementCopyMultipleAttributeValues` doesn't support fetching array attrs
|
||||||
|
(kAXChildren) — children still need a separate call.
|
||||||
|
- Higher implementation risk; the CF type introspection is fragile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach C: AXUIElementCopyAttributeNames + full dynamic attribute dump
|
||||||
|
|
||||||
|
**What it is:** For each node, first call `AXUIElementCopyAttributeNames` to get the list of
|
||||||
|
all attributes the element supports, then batch-fetch only those. This is what macOS
|
||||||
|
Accessibility Inspector does internally.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Most complete and accurate — never misses a custom attribute.
|
||||||
|
- Would fully expose Electron app data, web areas, custom controls.
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Doubles the IPC calls per node (one for names, one for values).
|
||||||
|
- Much slower than Approach A or B for the common case.
|
||||||
|
- Over-engineered for Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Approach A, then B incrementally
|
||||||
|
|
||||||
|
Start with A because it fixes the accuracy problem with minimal risk and gives us a working,
|
||||||
|
readable tree. Then layer in B's batch parsing improvements as a separate optimization pass
|
||||||
|
once the output is verified correct.
|
||||||
|
|
||||||
|
The correctness of the tree is more valuable right now than raw speed. A 2-second tree with
|
||||||
|
readable names beats a 0.5-second tree of `"unknown"` nodes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- **Name fallback chain**: `kAXTitleAttribute` → `kAXDescriptionAttribute` →
|
||||||
|
`kAXValueAttribute` → first AXStaticText child's `kAXValueAttribute` →
|
||||||
|
`kAXPlaceholderValueAttribute`
|
||||||
|
- **Expand roles.rs**: Add all ~33 missing role mappings from `role_constants.rs`
|
||||||
|
- **Add subrole field**: Include `kAXSubroleAttribute` in output so agents can distinguish
|
||||||
|
`AXOutlineRow` vs `AXTableRow` vs plain `AXRow`
|
||||||
|
- **children fetch**: Break on first non-empty result (already partially there, needs
|
||||||
|
confirmation that `kAXChildrenAttribute` wins 99% of the time and others are rarely needed)
|
||||||
|
- **`--interactive` alias**: Add alongside existing `--interactive-only` and `-i`
|
||||||
|
- **Test harness**: Write a Rust integration test that snapshots Finder and asserts:
|
||||||
|
- ≥10 nodes have non-empty names
|
||||||
|
- Zero nodes have role `"unknown"`
|
||||||
|
- Total time < 3 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Do we want to include `kAXSubrole` in the JSON output, or keep the schema flat?
|
||||||
|
- Should we emit a `label` field (the human-readable role description) separately from `role`?
|
||||||
|
- For Finder's column browser specifically: are file rows exposed as `AXRow` children of
|
||||||
|
`AXColumn` children of `AXBrowser`? Need to confirm with a debug attr-dump.
|
||||||
|
- Should `find` command also search `value` field, not just `name`?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
→ `/workflows:plan` to break this into implementation tasks
|
||||||
|
|
@ -0,0 +1,467 @@
|
||||||
|
---
|
||||||
|
date: 2026-02-23
|
||||||
|
topic: macos-ax-first-robustness
|
||||||
|
---
|
||||||
|
|
||||||
|
# macOS AX-First Robustness
|
||||||
|
|
||||||
|
> Current contract note (2026-05-12): this brainstorm is historical. The current
|
||||||
|
> implementation keeps CLI ref commands headless by default; focus, cursor,
|
||||||
|
> keyboard, pasteboard, and CGEvent paths require explicit physical/headed
|
||||||
|
> commands or explicit FFI policy selection.
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
Harden the macOS adapter so every command optimizes for AX-first execution (headless-capable, background-safe), with multi-step fallback chains, actionable error suggestions, and resilience against transient IPC failures.
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
agent-desktop's core value proposition is headless/background desktop automation. AX API actions work on unfocused windows; CGEvent coordinate-based input requires the window to be in front. Every command that currently does a single AX call and gives up is a missed opportunity — and a broken experience when running in the background.
|
||||||
|
|
||||||
|
`smart_activate()` (click) already has a 14-step chain. `ax_scroll()` has an 8-step chain. The other commands (`set-value`, `type`, `focus`, `press`, `clear`, `hover`, `drag`) are single-shot with no fallback. This brainstorm covers bringing them all up to parity.
|
||||||
|
|
||||||
|
## Current State Audit
|
||||||
|
|
||||||
|
### Commands WITH Robust Fallback Chains
|
||||||
|
| Command | Chain | Steps | CGEvent Fallback |
|
||||||
|
|---------|-------|-------|------------------|
|
||||||
|
| click | `smart_activate()` | 14 | Yes (last resort) |
|
||||||
|
| double-click | `smart_double_activate()` | AXOpen -> 2x smart_activate -> CGEvent | Yes |
|
||||||
|
| right-click | `smart_right_activate()` | AXShowMenu -> focus+retry -> select+menu -> focus+menu -> parent -> child -> CGEvent | Yes |
|
||||||
|
| triple-click | `smart_triple_activate()` | 3x smart_activate -> CGEvent | Yes |
|
||||||
|
| scroll | `ax_scroll()` | 8 | Yes (mouse wheel, last) |
|
||||||
|
| select | `select_value()` | Role-specific (combobox/popup/list) | No |
|
||||||
|
| expand/collapse | AXExpand -> AXDisclosing | 2 steps | No |
|
||||||
|
| check/uncheck | State check -> smart_activate if needed | 2 | Yes (via smart_activate) |
|
||||||
|
| toggle | Role check -> smart_activate | 2 | Yes (via smart_activate) |
|
||||||
|
|
||||||
|
### Commands WITHOUT Fallback Chains (Single-Shot)
|
||||||
|
| Command | Current Behavior | Needs Chain |
|
||||||
|
|---------|-----------------|-------------|
|
||||||
|
| set-value | Single `AXUIElementSetAttributeValue` | Yes |
|
||||||
|
| type | Focus element + `synthesize_text()` (system-wide CGEvent keys) | Yes |
|
||||||
|
| clear | Delegates to `ax_set_value(el, "")` | Yes |
|
||||||
|
| focus | Single `AXUIElementSetAttributeValue(kAXFocusedAttribute)` | Yes |
|
||||||
|
| scroll-to | Single `AXScrollToVisible` | Yes |
|
||||||
|
| press | System-wide `AXUIElementPostKeyboardEvent` | Partial |
|
||||||
|
| hover | Returns `ActionNotSupported` at element level | Needs rethink |
|
||||||
|
| drag | Returns `ActionNotSupported` at element level | Needs rethink |
|
||||||
|
| key-down/key-up | Returns `ActionNotSupported` at element level | Needs rethink |
|
||||||
|
|
||||||
|
### Commands Missing Error Suggestions
|
||||||
|
| Command | Current Error | Missing Suggestion |
|
||||||
|
|---------|--------------|-------------------|
|
||||||
|
| set-value | "SetValue failed (err=N)" | "Try 'clear' then 'type', or check element is a text field." |
|
||||||
|
| focus | "SetFocus failed (err=N)" | "Try 'click' to focus the element instead." |
|
||||||
|
| type | (no error path for AX focus fail) | "If typing fails, try 'set-value' for direct text insertion." |
|
||||||
|
| press | "AXUIElementPostKeyboardEvent failed" | "Ensure the target app is focused, or use 'press' with --app flag." |
|
||||||
|
| clear | Same as set-value | "Try selecting all text (press cmd+a) then press delete." |
|
||||||
|
| scroll-to | Has suggestion already | OK |
|
||||||
|
| hover/drag/key-down/key-up | "requires adapter-level handling" | "Use 'mouse-move --xy X,Y' for hover" / "Use 'drag --from-xy --to-xy'" |
|
||||||
|
|
||||||
|
## Window Focus Policy
|
||||||
|
|
||||||
|
**Principle:** Only focus the window when falling back to CGEvent coordinate-based input.
|
||||||
|
|
||||||
|
| Action Type | Needs Window Focus | Why |
|
||||||
|
|-------------|-------------------|-----|
|
||||||
|
| AXUIElementPerformAction | No | AX IPC targets the element directly |
|
||||||
|
| AXUIElementSetAttributeValue | No | AX IPC targets the element directly |
|
||||||
|
| AXUIElementPostKeyboardEvent (to app) | No | Targets app AXElement by PID |
|
||||||
|
| CGEventPost (mouse/keyboard) | **Yes** | System-wide events go to frontmost window |
|
||||||
|
| `synthesize_text()` (system-wide) | **Yes** | Posts to system-wide AX element |
|
||||||
|
| `synthesize_mouse()` | **Yes** | CGEvent coordinate-based |
|
||||||
|
|
||||||
|
**Current right-click**: `smart_right_activate()` tries AXShowMenu first (no focus needed). Falls back to CGEvent right-click only as last resort (focuses window at that point). This is correct — AXShowMenu works on unfocused windows.
|
||||||
|
|
||||||
|
**Type command issue**: Currently uses system-wide `AXUIElementPostKeyboardEvent` via `synthesize_text()`. This means keys go to whatever is focused system-wide, NOT specifically to the target app. The chain should try `AXSetValue` first, then app-targeted key posting, then system-wide as last resort.
|
||||||
|
|
||||||
|
## Centralized Chain Architecture
|
||||||
|
|
||||||
|
### Problem with Per-Command Chains
|
||||||
|
|
||||||
|
The current codebase has 3 hand-written chains (`smart_activate` 14 steps, `smart_right_activate` 7 steps, `ax_scroll` 8 steps) with ~100-150 LOC of duplicated patterns:
|
||||||
|
|
||||||
|
- **AXScrollToVisible preamble** — 3 places, 3 different error strategies
|
||||||
|
- **list_ax_actions / has_ax_action** — near-duplicate functions across files
|
||||||
|
- **Attribute set boilerplate** — 6+ instances of CFString + AXUIElementSetAttributeValue + error check
|
||||||
|
- **Child/parent walk loops** — 5 near-identical traversals with different predicates
|
||||||
|
- **Sleep timing** — 7 instances, 4 different durations, no policy
|
||||||
|
|
||||||
|
Writing per-command chains would add 6 more instances of this same boilerplate. Instead: centralize.
|
||||||
|
|
||||||
|
### Design: Centralized Element Discovery + Chain Executor
|
||||||
|
|
||||||
|
Two layers: (1) discover what an element supports once, (2) execute a declarative chain.
|
||||||
|
|
||||||
|
#### Layer 1: Element Capabilities (one-time discovery)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// New file: crates/macos/src/actions/discovery.rs
|
||||||
|
|
||||||
|
pub struct ElementCaps {
|
||||||
|
pub actions: Vec<String>, // AXPress, AXConfirm, AXOpen, AXShowMenu, etc.
|
||||||
|
pub settable_value: bool, // kAXValueAttribute is settable
|
||||||
|
pub settable_focus: bool, // kAXFocusedAttribute is settable
|
||||||
|
pub settable_selected: bool, // AXSelected is settable
|
||||||
|
pub settable_disclosing: bool, // AXDisclosing is settable
|
||||||
|
pub role: Option<String>, // normalized role string
|
||||||
|
pub has_children: bool,
|
||||||
|
pub pid: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover(el: &AXElement) -> ElementCaps {
|
||||||
|
// Single batch call: list actions + check settable attrs + read role
|
||||||
|
// Replaces scattered is_attr_settable + list_ax_actions + element_role calls
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every chain step gets `&ElementCaps` instead of re-querying the element. This avoids redundant IPC calls to the accessibility server.
|
||||||
|
|
||||||
|
#### Layer 2: Chain Executor (declarative steps)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// New file: crates/macos/src/actions/chain.rs
|
||||||
|
|
||||||
|
pub enum ChainStep {
|
||||||
|
/// Try AXUIElementPerformAction with given action name
|
||||||
|
PerformAction(&'static str),
|
||||||
|
/// Try AXUIElementSetAttributeValue
|
||||||
|
SetAttr { attr: &'static str, value: AttrValue },
|
||||||
|
/// Try the step on the element, if fails try with focus first
|
||||||
|
WithFocus(Box<ChainStep>),
|
||||||
|
/// Try the step on up to N children
|
||||||
|
OnChildren { step: Box<ChainStep>, limit: usize },
|
||||||
|
/// Try the step on up to N ancestors
|
||||||
|
OnAncestors { step: Box<ChainStep>, limit: usize },
|
||||||
|
/// Custom logic (for command-specific steps that don't fit the pattern)
|
||||||
|
Custom(fn(&AXElement, &ElementCaps) -> bool),
|
||||||
|
/// Explicit physical policy step only
|
||||||
|
CGEvent(CGFallback),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum AttrValue {
|
||||||
|
Bool(bool),
|
||||||
|
Str(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum CGFallback {
|
||||||
|
Click(MouseButton, u32),
|
||||||
|
MouseWheel { dy: i32, dx: i32 },
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AXChain {
|
||||||
|
pub steps: Vec<ChainStep>,
|
||||||
|
pub suggestion: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_chain(
|
||||||
|
el: &AXElement,
|
||||||
|
caps: &ElementCaps,
|
||||||
|
chain: &AXChain,
|
||||||
|
) -> Result<(), AdapterError> {
|
||||||
|
// 1. Pre-action: AXScrollToVisible (best-effort)
|
||||||
|
// 2. Pre-action: AXUIElementSetMessagingTimeout(el, 3.0)
|
||||||
|
// 3. Walk steps, try each with retry on kAXErrorCannotComplete
|
||||||
|
// 4. If all fail: return error with chain.suggestion
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Layer 3: Command Definitions (data, not code)
|
||||||
|
|
||||||
|
Each command declares its chain as a static definition. No per-command function bodies needed.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In dispatch.rs or a new chain_defs.rs
|
||||||
|
|
||||||
|
fn click_chain() -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
PerformAction("AXPress"),
|
||||||
|
PerformAction("AXConfirm"),
|
||||||
|
PerformAction("AXOpen"),
|
||||||
|
PerformAction("AXPick"),
|
||||||
|
Custom(try_show_alternate_ui),
|
||||||
|
OnChildren { step: Box::new(PerformAction("AXPress")), limit: 3 },
|
||||||
|
SetAttr { attr: "AXSelected", value: AttrValue::Bool(true) },
|
||||||
|
OnAncestors { step: Box::new(SetAttr { attr: "AXSelectedRows", .. }), limit: 2 },
|
||||||
|
Custom(try_custom_actions),
|
||||||
|
WithFocus(Box::new(PerformAction("AXPress"))),
|
||||||
|
Custom(try_keyboard_activate),
|
||||||
|
OnAncestors { step: Box::new(PerformAction("AXPress")), limit: 2 },
|
||||||
|
CGEvent(CGFallback::Click(MouseButton::Left, 1)),
|
||||||
|
],
|
||||||
|
suggestion: "Element may not be interactable. Try 'mouse-click --xy X,Y'.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_value_chain(value: &str) -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
SetAttr { attr: "AXValue", value: AttrValue::Str(value.into()) },
|
||||||
|
WithFocus(Box::new(SetAttr { attr: "AXValue", value: AttrValue::Str(value.into()) })),
|
||||||
|
],
|
||||||
|
suggestion: "Try 'clear' then 'type', or check element is a text field.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_chain() -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
SetAttr { attr: "AXFocused", value: AttrValue::Bool(true) },
|
||||||
|
PerformAction("AXPress"), // clicking often sets focus as side effect
|
||||||
|
],
|
||||||
|
suggestion: "Try 'click' to focus the element instead.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_chain() -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
SetAttr { attr: "AXValue", value: AttrValue::Str(String::new()) },
|
||||||
|
WithFocus(Box::new(SetAttr { attr: "AXValue", value: AttrValue::Str(String::new()) })),
|
||||||
|
Custom(select_all_then_delete), // Cmd+A, Delete via app-targeted keys
|
||||||
|
],
|
||||||
|
suggestion: "Try 'press cmd+a' then 'press delete'.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn right_click_chain() -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
PerformAction("AXShowMenu"),
|
||||||
|
WithFocus(Box::new(PerformAction("AXShowMenu"))),
|
||||||
|
Custom(try_select_then_show_menu),
|
||||||
|
OnAncestors { step: Box::new(PerformAction("AXShowMenu")), limit: 3 },
|
||||||
|
OnChildren { step: Box::new(PerformAction("AXShowMenu")), limit: 5 },
|
||||||
|
CGEvent(CGFallback::Click(MouseButton::Right, 1)),
|
||||||
|
],
|
||||||
|
suggestion: "Try 'mouse-click --button right --xy X,Y'.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_to_chain() -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
PerformAction("AXScrollToVisible"),
|
||||||
|
Custom(visible_in_scroll_context),
|
||||||
|
],
|
||||||
|
suggestion: "Element may not be in a scrollable container.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_chain() -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
PerformAction("AXExpand"),
|
||||||
|
SetAttr { attr: "AXDisclosing", value: AttrValue::Bool(true) },
|
||||||
|
],
|
||||||
|
suggestion: "Try 'click' to open it instead.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collapse_chain() -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
PerformAction("AXCollapse"),
|
||||||
|
SetAttr { attr: "AXDisclosing", value: AttrValue::Bool(false) },
|
||||||
|
],
|
||||||
|
suggestion: "Try 'click' to close it instead.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Utilities (extracted from current duplicated code)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// New file: crates/macos/src/actions/ax_helpers.rs
|
||||||
|
|
||||||
|
/// Set a boolean attribute on an element. Returns true on success.
|
||||||
|
pub fn set_ax_bool(el: &AXElement, attr: &str, value: bool) -> bool
|
||||||
|
|
||||||
|
/// Set a string attribute on an element. Returns true on success.
|
||||||
|
pub fn set_ax_string(el: &AXElement, attr: &str, value: &str) -> bool
|
||||||
|
|
||||||
|
/// Perform a named AX action. Returns true on success.
|
||||||
|
pub fn try_ax_action(el: &AXElement, action: &str) -> bool
|
||||||
|
|
||||||
|
/// List all available AX actions on an element.
|
||||||
|
pub fn list_ax_actions(el: &AXElement) -> Vec<String>
|
||||||
|
// (replaces both list_ax_actions in activate.rs AND has_ax_action in dispatch.rs)
|
||||||
|
|
||||||
|
/// Try a predicate on each child element, up to limit.
|
||||||
|
pub fn try_each_child(el: &AXElement, f: impl Fn(&AXElement) -> bool, limit: usize) -> bool
|
||||||
|
|
||||||
|
/// Try a predicate on ancestors, up to limit.
|
||||||
|
pub fn try_each_ancestor(el: &AXElement, f: impl Fn(&AXElement) -> bool, limit: usize) -> bool
|
||||||
|
|
||||||
|
/// Retry an AX operation up to N times on kAXErrorCannotComplete.
|
||||||
|
pub fn with_retry<F, T>(f: F, max_retries: u32) -> Result<T, i32>
|
||||||
|
where F: Fn() -> Result<T, i32>
|
||||||
|
|
||||||
|
/// Set messaging timeout to prevent 6s hangs on unresponsive apps.
|
||||||
|
pub fn set_messaging_timeout(el: &AXElement, seconds: f32)
|
||||||
|
|
||||||
|
/// Best-effort AXScrollToVisible before acting.
|
||||||
|
pub fn ensure_visible(el: &AXElement)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What This Replaces
|
||||||
|
|
||||||
|
| Current Code | Replaced By |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `smart_activate()` 64 LOC | `click_chain()` 15 LOC + chain executor |
|
||||||
|
| `smart_right_activate()` 30 LOC | `right_click_chain()` 10 LOC + chain executor |
|
||||||
|
| `smart_double_activate()` 12 LOC | Composing click_chain with count=2 |
|
||||||
|
| `smart_triple_activate()` 7 LOC | Composing click_chain with count=3 |
|
||||||
|
| `try_child_activation()` 10 LOC | `OnChildren` step in chain |
|
||||||
|
| `try_parent_activation()` 13 LOC | `OnAncestors` step in chain |
|
||||||
|
| `try_child_show_menu()` 8 LOC | `OnChildren` step in right_click_chain |
|
||||||
|
| `try_parent_show_menu()` 13 LOC | `OnAncestors` step in right_click_chain |
|
||||||
|
| `is_attr_settable` + set pattern (6 instances) | `set_ax_bool()` / `set_ax_string()` |
|
||||||
|
| `has_ax_action()` + `list_ax_actions()` (2 files) | Single `list_ax_actions()` in ax_helpers |
|
||||||
|
| Individual perform_action match arms | Chain definitions (data, not code) |
|
||||||
|
|
||||||
|
### What Stays Custom
|
||||||
|
|
||||||
|
Some commands have truly unique logic that doesn't fit the chain pattern:
|
||||||
|
|
||||||
|
- **scroll** (`ax_scroll`): Its 8-step chain is scroll-specific (scroll bars, page actions, value shifts). Stays as custom function, but uses shared helpers.
|
||||||
|
- **select** (`select_value`): Role-specific branching (combobox vs popup vs list). Stays custom, uses shared helpers.
|
||||||
|
- **type**: Mixed AX + keyboard synthesis + non-ASCII clipboard paste. Uses chain for the AX steps, but the keyboard/clipboard fallback is a `Custom` step.
|
||||||
|
- **hover/drag/key-down/key-up**: Adapter-level (coordinate-based), not element actions. Just need better error suggestions at the element-action level.
|
||||||
|
|
||||||
|
### New File Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/macos/src/actions/
|
||||||
|
├── mod.rs # re-exports
|
||||||
|
├── dispatch.rs # perform_action match arms (slimmed down, delegates to chains)
|
||||||
|
├── chain.rs # ChainStep enum + execute_chain() executor
|
||||||
|
├── chain_defs.rs # All chain definitions (click, set-value, focus, clear, etc.)
|
||||||
|
├── discovery.rs # ElementCaps discovery (one-time per action)
|
||||||
|
├── ax_helpers.rs # Shared AX utilities (set_ax_bool, try_each_child, retry, etc.)
|
||||||
|
├── activate.rs # REMOVED (absorbed into chain_defs.rs + ax_helpers.rs)
|
||||||
|
└── extras.rs # select_value + ax_scroll (kept, refactored to use ax_helpers)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **No redundancy**: Each AX pattern (set attr, perform action, walk children) implemented once
|
||||||
|
2. **Adding new commands is trivial**: Define a chain (5-15 LOC), no new functions needed
|
||||||
|
3. **Easy to tune**: Reorder chain steps, add/remove steps without touching executor
|
||||||
|
4. **Testable**: Can unit-test the executor with mock elements and verify step ordering
|
||||||
|
5. **Self-documenting**: Chain definitions read like a recipe — "try AXPress, try AXConfirm, try children, fall back to CGEvent"
|
||||||
|
|
||||||
|
### Type Command Special Case
|
||||||
|
|
||||||
|
The `type` command is the most complex because it mixes AX actions with keyboard synthesis:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn type_chain(text: &str) -> AXChain {
|
||||||
|
AXChain {
|
||||||
|
steps: vec![
|
||||||
|
// Step 1: Append via AX (handles non-ASCII natively)
|
||||||
|
Custom(|el, caps| ax_append_value(el, text)),
|
||||||
|
// Step 2: Focus + app-targeted keyboard (ASCII only, or clipboard paste for non-ASCII)
|
||||||
|
Custom(|el, caps| focus_then_type_to_app(el, caps, text)),
|
||||||
|
// Step 3: Focus + system-wide keyboard (last resort, needs element focused)
|
||||||
|
Custom(|el, caps| {
|
||||||
|
set_ax_bool(el, "AXFocused", true);
|
||||||
|
synthesize_text(text).is_ok()
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
suggestion: "Try 'set-value' for direct text insertion.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### hover / drag / key-down / key-up
|
||||||
|
|
||||||
|
These are inherently coordinate-based (adapter-level, not element actions). They don't need AX chains. Just improve error messages in the element-level `perform_action` match:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Action::Hover | Action::Drag(_) | Action::KeyDown(_) | Action::KeyUp(_) => {
|
||||||
|
return Err(AdapterError::new(
|
||||||
|
ErrorCode::ActionNotSupported,
|
||||||
|
format!("{} is handled at the adapter level, not as an element action", label),
|
||||||
|
)
|
||||||
|
.with_suggestion(match action {
|
||||||
|
Action::Hover => "Use 'hover @ref' which resolves ref to coordinates automatically.",
|
||||||
|
Action::Drag(_) => "Use 'drag --from @ref --to @ref' for ref-based drag.",
|
||||||
|
Action::KeyDown(_) | Action::KeyUp(_) => "Use 'key-down'/'key-up' commands directly.",
|
||||||
|
_ => unreachable!(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
## Additional Gaps to Fix
|
||||||
|
|
||||||
|
### 1. AX Messaging Timeout
|
||||||
|
**Problem:** Unresponsive apps cause 6-second hangs per AX call (Apple default).
|
||||||
|
**Fix:** Call `AXUIElementSetMessagingTimeout(element, 3.0)` on every resolved element before performing actions. 3 seconds is generous enough for slow apps but prevents indefinite hangs.
|
||||||
|
**Where:** In `resolve_element_impl()` or at the start of `perform_action()`.
|
||||||
|
|
||||||
|
### 2. Retry on kAXErrorCannotComplete (-25204)
|
||||||
|
**Problem:** Transient IPC failure treated as terminal. Apple docs say this is a temporary condition.
|
||||||
|
**Fix:** Wrap AX calls in a retry helper: up to 2 retries with 100ms backoff for `kAXErrorCannotComplete`. Only retry idempotent operations (AXPress, AXSetValue, etc.).
|
||||||
|
**Where:** New utility function in `actions/` module, used by `perform_action()` and chain steps.
|
||||||
|
|
||||||
|
### 3. Non-ASCII Text Input
|
||||||
|
**Problem:** `synthesize_text()` silently drops any character without a keycode mapping (all non-ASCII).
|
||||||
|
**Fix:** For the `type` command chain, step 1 (AXSetValue append) handles non-ASCII natively. If falling back to key synthesis, detect non-ASCII characters and use clipboard-paste: save current clipboard, set clipboard to the non-ASCII segment, Cmd+V, restore clipboard.
|
||||||
|
**Where:** `input/keyboard.rs` `synthesize_text()` or the type chain in `dispatch.rs`.
|
||||||
|
|
||||||
|
### 4. NSPasteboard FFI for Clipboard
|
||||||
|
**Problem:** Current clipboard uses `pbpaste`/`pbcopy` shell subprocesses — slow (~50ms per call), fragile, and creates visible process spawns.
|
||||||
|
**Fix:** Use `NSPasteboard.generalPasteboard` via objc2/Cocoa FFI directly. This is ~10x faster and avoids subprocess overhead.
|
||||||
|
**Where:** Replace `input/clipboard.rs` implementation.
|
||||||
|
|
||||||
|
### 5. Stale Element Auto-Recovery
|
||||||
|
**Problem:** When an element ref becomes stale (`STALE_REF`), the agent must manually re-snapshot. Many actions could auto-recover by re-resolving the element.
|
||||||
|
**Fix:** In `execute_action_impl()`, if `resolve_element` returns `STALE_REF`, attempt one automatic re-resolution using the stored `(pid, role, name)` tuple. If the re-resolved element's bounds are close (within 50px), proceed with the action. Otherwise, return `STALE_REF` as before.
|
||||||
|
**Caution:** Only do this for single-element actions. Don't auto-recover during batch operations (could lead to wrong-element interactions).
|
||||||
|
**Where:** `adapter.rs` `execute_action_impl()` wrapper.
|
||||||
|
|
||||||
|
### 6. Post-Action State Hints
|
||||||
|
**Problem:** After a successful action, the agent has no feedback about what changed. It must re-snapshot to verify.
|
||||||
|
**Fix:** For certain actions, include a `hint` field in `ActionResult`:
|
||||||
|
- click/toggle/check/uncheck: read back the element's value/state after action
|
||||||
|
- set-value: read back value to confirm it was set
|
||||||
|
- type: read back value to confirm text was appended
|
||||||
|
|
||||||
|
This is optional enrichment — the `data` field can include `{ "post_state": { "value": "new text" } }`.
|
||||||
|
**Where:** After the action succeeds in `perform_action()`, do one quick attribute read.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- **Centralized chain architecture**: One executor + declarative step definitions, not per-command functions. Adding a command = defining a chain (5-15 LOC).
|
||||||
|
- **One-time element discovery**: `ElementCaps` struct queried once per action, shared across all chain steps. Eliminates redundant AX IPC calls.
|
||||||
|
- **AX-first, explicit physical paths only**: All default CLI ref commands try pure AX approaches and fail clearly rather than silently moving focus or the cursor.
|
||||||
|
- **Window focus only for explicit physical policy**: Never call `ensure_app_focused()` from the default ref command path.
|
||||||
|
- **App-targeted keys over system-wide**: `AXUIElementPostKeyboardEvent(app_element, ...)` targets a specific app. System-wide posting is last resort.
|
||||||
|
- **Non-ASCII via clipboard**: The most reliable cross-app method for typing non-ASCII characters on macOS.
|
||||||
|
- **3-second AX timeout**: Balances responsiveness with tolerance for slow apps.
|
||||||
|
- **Single retry for transient errors**: kAXErrorCannotComplete gets one retry with 100ms backoff, handled inside the executor.
|
||||||
|
- **Stale ref auto-recovery is opt-in**: Only for single-element actions, with bounds proximity check.
|
||||||
|
- **activate.rs absorbed**: All 400 LOC of activate.rs becomes ~50 LOC of chain definitions + shared helpers. No more per-command fallback functions.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the retry helper also handle `kAXErrorNotImplemented` (-25208) by skipping to next chain step instead of retrying?
|
||||||
|
- Should post-action state hints be opt-in via a `--verify` flag, or always-on?
|
||||||
|
- For non-ASCII clipboard paste: should we always restore the previous clipboard, or is that too slow for bulk typing?
|
||||||
|
- The NSPasteboard FFI change — should we add `objc2` as a dependency, or use raw `objc_msgSend` to keep deps minimal?
|
||||||
|
- `ChainStep::Custom` closures — use `fn` pointers (no captures, simple) or `Box<dyn Fn>` (can capture values like text string)?
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Build the foundation: `ax_helpers.rs` (shared utilities) + `discovery.rs` (ElementCaps)
|
||||||
|
2. Build `chain.rs` (executor)
|
||||||
|
3. Migrate `smart_activate` → `click_chain()` definition in `chain_defs.rs` (proves the pattern)
|
||||||
|
4. Migrate `smart_right_activate` → `right_click_chain()`
|
||||||
|
5. Add new chains: set-value, focus, clear, scroll-to, expand, collapse
|
||||||
|
6. Build type chain (complex: AX + keyboard + clipboard)
|
||||||
|
7. Refactor `ax_scroll` to use shared helpers (stays custom, but less duplicated)
|
||||||
|
8. Add error suggestions to hover/drag/key-down/key-up
|
||||||
|
9. Additional gaps: AX timeout, retry, non-ASCII, NSPasteboard, stale recovery, post-action hints
|
||||||
105
docs/brainstorms/2026-02-23-release-automation-brainstorm.md
Normal file
105
docs/brainstorms/2026-02-23-release-automation-brainstorm.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# Release Automation & npm Distribution Brainstorm
|
||||||
|
|
||||||
|
Date: 2026-02-23
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
An automated release pipeline that:
|
||||||
|
1. Uses **Conventional Commits** to determine version bumps (SemVer)
|
||||||
|
2. Uses **release-please** to create Release PRs with auto-generated CHANGELOGs
|
||||||
|
3. On Release PR merge: builds macOS binaries, creates GitHub Releases with tarballs, publishes to npm
|
||||||
|
4. Distributes via a **single npm package** (`agent-desktop`) with a postinstall script that downloads the correct platform binary from GitHub Releases
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
- **release-please** gives a natural gate (Release PR) before shipping, while automating CHANGELOG and version bumps
|
||||||
|
- **Single npm package + postinstall download** (like agent-browser) — one package to maintain, simpler publishing, npx support works out of the box
|
||||||
|
- **GitHub Release tarballs** serve dual purpose: npm postinstall downloads from them, and users can also download directly
|
||||||
|
- **Conventional Commits** already align with the project's imperative-mood commit style (`feat:`, `fix:`, `style:`, `docs:`)
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
- **SemVer** (`MAJOR.MINOR.PATCH`), starting from current `0.1.0`
|
||||||
|
- Single source of truth: `workspace.package.version` in root `Cargo.toml`
|
||||||
|
- release-please syncs version to both `Cargo.toml` and `package.json`
|
||||||
|
|
||||||
|
### Commit Convention
|
||||||
|
- `feat:` → minor bump
|
||||||
|
- `fix:` → patch bump
|
||||||
|
- `feat!:` or `BREAKING CHANGE:` footer → major bump
|
||||||
|
- `style:`, `docs:`, `refactor:`, `chore:`, `ci:` → no release (unless paired with feat/fix)
|
||||||
|
|
||||||
|
### npm Distribution Pattern (agent-browser style)
|
||||||
|
|
||||||
|
Single package: `agent-desktop`
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install -g agent-desktop # Global install
|
||||||
|
npx agent-desktop snapshot ... # Quick use via npx
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. User installs `agent-desktop` from npm
|
||||||
|
2. `postinstall` script detects platform (`darwin-arm64`, `darwin-x64`, etc.)
|
||||||
|
3. Downloads the matching binary from the GitHub Release (`v{version}/agent-desktop-{platform}`)
|
||||||
|
4. Places it in the package's `bin/` directory
|
||||||
|
5. A thin JS wrapper (`bin/agent-desktop.js`) resolves and spawns the native binary
|
||||||
|
6. On global installs, postinstall patches npm's symlink to point directly to the native binary (zero Node.js overhead)
|
||||||
|
|
||||||
|
**Fallback:** If postinstall download fails (corporate firewall, etc.), the JS wrapper shows a helpful error with manual download instructions.
|
||||||
|
|
||||||
|
### GitHub Actions Workflow (new: `release.yml`)
|
||||||
|
|
||||||
|
**Trigger:** Push to `main`
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
1. **release-please** — creates/updates Release PR, or creates a GitHub Release on PR merge
|
||||||
|
2. **build** (if release created) — matrix build for each platform target
|
||||||
|
- `aarch64-apple-darwin` (macOS ARM)
|
||||||
|
- `x86_64-apple-darwin` (macOS Intel)
|
||||||
|
- Future: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-pc-windows-msvc`
|
||||||
|
3. **publish-github** — attach platform tarballs to the GitHub Release
|
||||||
|
4. **publish-npm** — sync version to package.json, build JS wrapper, `npm publish`
|
||||||
|
|
||||||
|
### File Structure (new files)
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-desktop/
|
||||||
|
├── .github/
|
||||||
|
│ └── workflows/
|
||||||
|
│ ├── ci.yml # Existing (unchanged)
|
||||||
|
│ └── release.yml # New: release-please + build + publish
|
||||||
|
├── .release-please-manifest.json # Version tracking for release-please
|
||||||
|
├── release-please-config.json # release-please configuration
|
||||||
|
├── npm/ # npm package scaffolding
|
||||||
|
│ ├── package.json # name: "agent-desktop", bin, postinstall
|
||||||
|
│ ├── bin/
|
||||||
|
│ │ └── agent-desktop.js # JS wrapper: detects platform, spawns native binary
|
||||||
|
│ └── scripts/
|
||||||
|
│ └── postinstall.js # Downloads platform binary from GitHub Release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secrets Required
|
||||||
|
- `NPM_TOKEN` — npm publish token (stored as GitHub repo secret)
|
||||||
|
- `GITHUB_TOKEN` — already available in Actions (for release-please and GH Releases)
|
||||||
|
|
||||||
|
### Version Sync Strategy
|
||||||
|
- release-please bumps version in root `Cargo.toml` (`workspace.package.version`)
|
||||||
|
- CI script propagates the version to `npm/package.json` before publish
|
||||||
|
- `Cargo.lock` updated automatically by the version bump
|
||||||
|
|
||||||
|
### Installation Methods (at launch)
|
||||||
|
|
||||||
|
| Method | Command | Notes |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| npm (global) | `npm install -g agent-desktop` | Recommended, postinstall downloads native binary |
|
||||||
|
| npx | `npx agent-desktop snapshot --app Finder -i` | Slightly slower (Node.js wrapper overhead) |
|
||||||
|
| yarn | `yarn global add agent-desktop` | Same as npm |
|
||||||
|
| bun | `bun install -g agent-desktop` | May need `--trust` for postinstall scripts |
|
||||||
|
| Direct download | Download from GitHub Releases | No Node.js required |
|
||||||
|
| Build from source | `cargo build --release` | For contributors |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — all key decisions resolved during brainstorm.
|
||||||
225
docs/brainstorms/2026-02-25-windows-adapter-phase2-brainstorm.md
Normal file
225
docs/brainstorms/2026-02-25-windows-adapter-phase2-brainstorm.md
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
# Brainstorm: Phase 2 — Windows Adapter
|
||||||
|
|
||||||
|
**Date:** 2026-02-25
|
||||||
|
**Status:** Draft
|
||||||
|
**Phase:** 2 (Windows Adapter)
|
||||||
|
**Participants:** Lahfir, Claude
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
A Windows `PlatformAdapter` implementation that enables all 50 agent-desktop commands to work on Windows via the UI Automation COM API. The adapter uses `uiautomation` (v0.24+) for UIA operations and the `windows` crate for low-level Win32 APIs. It follows the same architecture as the macOS adapter: implements the 22-method `PlatformAdapter` trait, lives entirely within `crates/windows/`, and never touches core or macOS code.
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
### Foundation Assessment
|
||||||
|
|
||||||
|
The Phase 1 architecture was purpose-built for this exact expansion:
|
||||||
|
|
||||||
|
- **PlatformAdapter trait**: 22 methods, all with default `not_supported()` returns — enables incremental implementation
|
||||||
|
- **Core isolation**: Zero platform leaks, CI-enforced via `cargo tree` check
|
||||||
|
- **Dispatch wiring**: `build_adapter()` and target-gated deps already handle Windows — zero changes needed
|
||||||
|
- **Windows stub**: Already compiles, implements the trait, and is a workspace member
|
||||||
|
- **Error codes**: `PlatformNotSupported` and `ActionNotSupported` both available
|
||||||
|
- **RefMap**: Already handles `USERPROFILE` for Windows paths, has `#[cfg(not(unix))]` fallback
|
||||||
|
|
||||||
|
### Technology Choice: `uiautomation` + `windows` (Layered)
|
||||||
|
|
||||||
|
As specified in the PRD (Section 7.2), using `uiautomation` (v0.24+) for UI Automation operations, complemented by the `windows` crate for low-level Win32 APIs.
|
||||||
|
|
||||||
|
**Why layered, not either/or:**
|
||||||
|
|
||||||
|
| Responsibility | Crate | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Tree traversal, CacheRequest, UIA patterns, element finding | `uiautomation` | ~30% less code than raw COM, safe API, auto COM init, escape hatch via `Into<IUIAutomationElement>` |
|
||||||
|
| Input synthesis (SendInput), clipboard, screenshot, process lifecycle, window mgmt | `windows` | `uiautomation` doesn't wrap these at the level we need |
|
||||||
|
|
||||||
|
**Key properties of `uiautomation`:**
|
||||||
|
- v0.24.3 (Jan 2026), 184 stars, 0 open issues, quarterly releases
|
||||||
|
- Thin wrapper (~9K SLoC) over `windows` crate — no new dependency layer
|
||||||
|
- Covers all 31 UIA control patterns (Invoke, Value, ExpandCollapse, Selection, Scroll, Toggle, etc.)
|
||||||
|
- `UITreeWalker` with `UICacheRequest` for batch attribute retrieval
|
||||||
|
- `UIMatcher` fluent API for element finding and condition building
|
||||||
|
- Escape hatch: `UIElement: Into<IUIAutomationElement>` — can drop to raw COM for any edge case
|
||||||
|
- Auto COM initialization via `UIAutomation::new()` (calls `CoInitializeEx`)
|
||||||
|
|
||||||
|
**Why not raw `windows` for everything:** For UIA operations (tree traversal, patterns, element finding), `uiautomation` eliminates ~330 LOC of unsafe boilerplate, BSTR handling, control type mapping, condition building, and COM factory code.
|
||||||
|
|
||||||
|
**Why not `uiautomation` for everything:** It doesn't wrap `SendInput`, `BitBlt`, `CreateProcessW`, or the Win32 clipboard API at the level agent-desktop needs.
|
||||||
|
|
||||||
|
**Dependency config:**
|
||||||
|
```toml
|
||||||
|
# crates/windows/Cargo.toml
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
uiautomation = { version = "0.24", default-features = false, features = ["pattern", "control"] }
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
"Win32_System_DataExchange",
|
||||||
|
"Win32_Graphics_Gdi",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
### 1. Modifier::Cmd → Ctrl on Windows
|
||||||
|
|
||||||
|
**Decision:** `Cmd` maps to `Ctrl` on Windows.
|
||||||
|
|
||||||
|
**Rationale:** Agents write cross-platform automations. `cmd+c` (macOS copy) should "just work" as `ctrl+c` on Windows. The mapping happens in the Windows adapter's key synthesis layer, not in core. The core `Modifier::Cmd` enum variant stays unchanged.
|
||||||
|
|
||||||
|
### 2. Platform-Specific Blocked Combo Lists
|
||||||
|
|
||||||
|
**Decision:** Each platform adapter defines its own blocked combos. Core provides a validation hook but not the list.
|
||||||
|
|
||||||
|
**Current state:** `BLOCKED_COMBOS` in `crates/core/src/commands/press.rs` is macOS-specific (`cmd+q`, `cmd+shift+q`, etc.).
|
||||||
|
|
||||||
|
**Change needed:** Move the blocked list concept into platform adapters. The Windows adapter blocks: `alt+f4`, `ctrl+alt+delete`, `win+l`, etc. The core keeps a trait method or shared validation function signature, but the actual combos are platform-owned.
|
||||||
|
|
||||||
|
### 3. Surfaces Are Cross-Platform
|
||||||
|
|
||||||
|
**Decision:** `list-surfaces` is fully implementable on Windows. No commands are inherently macOS-only except `pinch`.
|
||||||
|
|
||||||
|
**Windows surface equivalents:**
|
||||||
|
| macOS Surface | Windows Equivalent |
|
||||||
|
|---------------|-------------------|
|
||||||
|
| Menu bar | Win32 menus / UIA Menu pattern |
|
||||||
|
| Sheet | Modal dialog |
|
||||||
|
| Alert/Dialog | MessageBox / Task Dialog |
|
||||||
|
| Popover | Flyout / Popup |
|
||||||
|
|
||||||
|
### 4. `pinch` Command Is macOS-Only
|
||||||
|
|
||||||
|
**Decision:** The PRD's `pinch <scale>` command (Section 5.5) is described as "Pinch zoom gesture (macOS trackpad)". On Windows, this returns `ACTION_NOT_SUPPORTED` with a clear message. Windows does not have trackpad gesture synthesis via accessibility APIs.
|
||||||
|
|
||||||
|
### 5. Implementation Priority: Observation First
|
||||||
|
|
||||||
|
**Decision:** Build observation commands first, then interactions.
|
||||||
|
|
||||||
|
**Priority order:**
|
||||||
|
1. **Observation tier:** `snapshot`, `list-windows`, `list-apps`, `find`, `get`, `is`, `list-surfaces`, `screenshot`
|
||||||
|
2. **Core interaction tier:** `click`, `type`, `set-value`, `focus`, `select`, `toggle`
|
||||||
|
3. **Extended interaction tier:** `double-click`, `triple-click`, `right-click`, `expand`, `collapse`, `check`, `uncheck`, `clear`
|
||||||
|
4. **Input tier:** `press`, `key-down`, `key-up`, `hover`, `drag`, `mouse-move`, `mouse-click`, `mouse-down`, `mouse-up`, `scroll`, `scroll-to`
|
||||||
|
5. **System tier:** `launch`, `close-app`, `focus-window`, `resize-window`, `move-window`, `minimize`, `maximize`, `restore`, `clipboard-get`, `clipboard-set`, `clipboard-clear`, `wait`
|
||||||
|
6. **Meta tier:** `status`, `permissions`, `version`, `batch` (most of these already work via core)
|
||||||
|
|
||||||
|
### 6. Development Workflow: Cross-Compile + Windows CI
|
||||||
|
|
||||||
|
**Decision:** Develop on macOS, cross-compile to verify builds, validate with Windows CI runner.
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
- Write code on macOS
|
||||||
|
- Cross-compile with `cargo check --target x86_64-pc-windows-gnu` (GNU target works without MSVC toolchain)
|
||||||
|
- Windows CI runner (`windows-latest`) executes actual UIA integration tests
|
||||||
|
- No Windows dev machine required for day-to-day work
|
||||||
|
|
||||||
|
### 7. Zero Core/macOS Changes
|
||||||
|
|
||||||
|
**Non-negotiable constraint:** Phase 2 touches only:
|
||||||
|
- `crates/windows/` — all new code
|
||||||
|
- `.github/workflows/ci.yml` — add Windows runner
|
||||||
|
- `docs/` — documentation updates
|
||||||
|
|
||||||
|
Nothing in `crates/core/`, `crates/macos/`, `crates/linux/`, or `src/` is modified. The one exception is if `BLOCKED_COMBOS` needs to move from core to platform adapters — this is a refactor that benefits all platforms and must be done carefully.
|
||||||
|
|
||||||
|
### 8. COM Threading / Send+Sync Strategy
|
||||||
|
|
||||||
|
**Decision:** Stateless `WindowsAdapter`, per-call COM initialization.
|
||||||
|
|
||||||
|
Both `UIElement` and `IUIAutomationElement` are `!Send, !Sync` — this is fundamental to COM, not a crate limitation. The strategy:
|
||||||
|
- `WindowsAdapter` stores zero COM state (no `UIAutomation` or `UIElement` fields)
|
||||||
|
- Each `PlatformAdapter` method creates a `UIAutomation` instance on entry
|
||||||
|
- All `UIElement` handles are consumed within the method call and dropped before returning
|
||||||
|
- `NativeHandle` continues to use `unsafe impl Send + Sync` with the raw pointer approach
|
||||||
|
- For `resolve_element`, store `(pid, control_type, name, bounds_hash)` in `RefEntry` and re-find via `UIMatcher` on each resolve
|
||||||
|
|
||||||
|
This is identical to how the macOS adapter works — `AXUIElementRef` is also `!Send`.
|
||||||
|
|
||||||
|
## Windows UIA Architecture Mapping
|
||||||
|
|
||||||
|
### PlatformAdapter → Windows Implementation
|
||||||
|
|
||||||
|
| Trait Method | Crate | Implementation |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_windows` | `uiautomation` + `windows` | `EnumWindows` + `UIAutomation` root element for accessible properties |
|
||||||
|
| `list_apps` | `windows` | `EnumProcesses` + `OpenProcess` + `QueryFullProcessImageName` |
|
||||||
|
| `get_tree` | `uiautomation` | `UITreeWalker` with `UICacheRequest` for batch attribute retrieval |
|
||||||
|
| `execute_action` | `uiautomation` | `UIInvokePattern::invoke()`, `UIValuePattern::set_value()`, etc. |
|
||||||
|
| `resolve_element` | `uiautomation` | `UIMatcher` re-find by `(pid, control_type, name, bounds_hash)` |
|
||||||
|
| `check_permissions` | `windows` | COM security / UAC check (UIA doesn't require special perms for most apps) |
|
||||||
|
| `focus_window` | `windows` | `SetForegroundWindow` + `uiautomation` `UIElement::set_focus()` |
|
||||||
|
| `launch_app` | `windows` | `CreateProcessW` / `ShellExecuteW` |
|
||||||
|
| `close_app` | `windows` | `WM_CLOSE` / `TerminateProcess` (force) |
|
||||||
|
| `screenshot` | `windows` | `BitBlt` / `PrintWindow` via GDI |
|
||||||
|
| `get_clipboard` | `windows` | `OpenClipboard` + `GetClipboardData(CF_UNICODETEXT)` |
|
||||||
|
| `set_clipboard` | `windows` | `OpenClipboard` + `SetClipboardData(CF_UNICODETEXT)` |
|
||||||
|
| `focused_window` | `windows` | `GetForegroundWindow` → `UIAutomation::element_from_handle()` |
|
||||||
|
| `press_key_for_app` | `windows` | `SendInput` with `KEYBDINPUT` |
|
||||||
|
| `mouse_event` | `windows` | `SendInput` with `MOUSEINPUT` |
|
||||||
|
| `drag` | `windows` | `SendInput` sequence: mouse down → move → mouse up |
|
||||||
|
| `window_op` | `windows` | `ShowWindow` (minimize/maximize/restore), `SetWindowPos` (move/resize) |
|
||||||
|
|
||||||
|
### Windows Crate Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/windows/src/
|
||||||
|
├── lib.rs # mod declarations + re-exports only
|
||||||
|
├── adapter.rs # PlatformAdapter trait impl
|
||||||
|
├── tree/
|
||||||
|
│ ├── mod.rs # re-exports
|
||||||
|
│ ├── element.rs # UIElement wrapper / attribute readers
|
||||||
|
│ ├── builder.rs # build_subtree via UITreeWalker + UICacheRequest
|
||||||
|
│ ├── roles.rs # ControlType → unified role mapping
|
||||||
|
│ ├── resolve.rs # Element re-identification via UIMatcher
|
||||||
|
│ └── surfaces.rs # Surface detection (menus, dialogs, popups)
|
||||||
|
├── actions/
|
||||||
|
│ ├── mod.rs # re-exports
|
||||||
|
│ ├── dispatch.rs # Action → UIA Pattern dispatch
|
||||||
|
│ ├── activate.rs # Window/element activation
|
||||||
|
│ └── extras.rs # SelectionPattern, ScrollPattern, TogglePattern
|
||||||
|
├── input/
|
||||||
|
│ ├── mod.rs # re-exports
|
||||||
|
│ ├── keyboard.rs # SendInput keyboard synthesis + Cmd→Ctrl mapping
|
||||||
|
│ ├── mouse.rs # SendInput mouse synthesis
|
||||||
|
│ └── clipboard.rs # Win32 clipboard API
|
||||||
|
└── system/
|
||||||
|
├── mod.rs # re-exports
|
||||||
|
├── app_ops.rs # CreateProcessW, TerminateProcess
|
||||||
|
├── window_ops.rs # ShowWindow, SetWindowPos, EnumWindows
|
||||||
|
├── key_dispatch.rs # App-targeted key press via SendInput
|
||||||
|
├── permissions.rs # COM security / UAC check
|
||||||
|
├── screenshot.rs # BitBlt / PrintWindow screen capture
|
||||||
|
└── wait.rs # WaitForInputIdle, polling
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Work Required
|
||||||
|
|
||||||
|
Before implementing the adapter logic:
|
||||||
|
|
||||||
|
1. **Move BLOCKED_COMBOS to platform adapters** — refactor the current macOS-specific blocked list out of core
|
||||||
|
2. **Add Windows CI job** — `windows-latest` runner in CI with appropriate binary size check
|
||||||
|
3. **Create folder skeleton** — all `mod.rs` files and empty stubs in the structure above
|
||||||
|
4. **Add `uiautomation` + `windows` dependencies** — target-gated in `crates/windows/Cargo.toml`
|
||||||
|
5. **Generalize `AdapterError::permission_denied()`** — remove hardcoded macOS suggestion from core
|
||||||
|
|
||||||
|
## Resolved Questions
|
||||||
|
|
||||||
|
1. **UIA crate choice:** Use `uiautomation` (v0.24+) for UIA operations + `windows` crate for Win32 APIs. The PRD specified this, and research confirms it saves ~30% LOC for UIA operations while providing an escape hatch to raw COM.
|
||||||
|
|
||||||
|
2. **COM initialization:** Per-call via `UIAutomation::new()` which auto-calls `CoInitializeEx`. Stateless `WindowsAdapter` with zero stored COM state — same pattern as macOS adapter.
|
||||||
|
|
||||||
|
3. **NativeHandle on Windows:** Store raw pointer from `IUIAutomationElement`. Use `unsafe impl Send + Sync` with documented safety comment, same as macOS. For resolve, re-find via `UIMatcher` using `(pid, control_type, name, bounds_hash)` from `RefEntry`.
|
||||||
|
|
||||||
|
4. **Cross-compilation toolchain:** Use `x86_64-pc-windows-gnu` target for macOS cross-compilation (works without MSVC). Actual UIA integration tests run on Windows CI only.
|
||||||
|
|
||||||
|
5. **Chromium detection:** Surface in `snapshot` output. When snapshot detects a Chromium-based app with an empty/minimal tree, add a warning in the JSON response suggesting `--force-renderer-accessibility`. Agents see the guidance in context when it matters.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — all questions resolved.
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
# Notification Management — macOS First
|
||||||
|
|
||||||
|
> Brainstorm date: 2026-02-27
|
||||||
|
> Status: Draft
|
||||||
|
> Scope: macOS notification commands (list, dismiss, dismiss-all, action, wait)
|
||||||
|
> Reference: [Phase Roadmap — Notification section](../phases.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
A set of notification management commands for agent-desktop, starting with macOS. These commands let AI agents read, dismiss, and interact with OS-level notifications — a capability gap in the current 50-command surface.
|
||||||
|
|
||||||
|
**New commands (5 touch points):**
|
||||||
|
|
||||||
|
| Command | Description | Args |
|
||||||
|
|---------|-------------|------|
|
||||||
|
| `list-notifications` | List current notifications with app, title, body, timestamp, actions | `--app`, `--limit` |
|
||||||
|
| `dismiss-notification` | Dismiss a specific notification by positional index | `<index>` |
|
||||||
|
| `dismiss-all-notifications` | Clear all notifications, optionally filtered by app | `--app` |
|
||||||
|
| `notification-action` | Click an action button on a notification | `<index> <action-name>` |
|
||||||
|
| `wait --notification` | Wait for a notification to appear (event-driven) | `--app`, `--text`, `--timeout` |
|
||||||
|
|
||||||
|
**New domain type:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct NotificationInfo {
|
||||||
|
pub index: usize,
|
||||||
|
pub app_name: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timestamp: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub actions: Vec<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `NotificationInfo` has 6 fields (under the 7-field limit). `is_persistent` from the phases.md spec was dropped — it's not reliably detectable from the AX tree and adds no value for the agent.
|
||||||
|
|
||||||
|
**New adapter trait methods (3):**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn list_notifications(&self, filter: &NotificationFilter) -> Result<Vec<NotificationInfo>, AdapterError>;
|
||||||
|
fn dismiss_notification(&self, index: usize, app_filter: Option<&str>) -> Result<(), AdapterError>;
|
||||||
|
fn interact_notification(&self, index: usize, action_name: &str) -> Result<ActionResult, AdapterError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**New error codes (2):**
|
||||||
|
|
||||||
|
- `NOTIFICATION_NOT_FOUND` — Index out of bounds or notification no longer exists
|
||||||
|
- `NOTIFICATION_UNSUPPORTED` — Platform doesn't support notification management (stub adapters)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
**Approach chosen: AX tree traversal + NSDistributedNotificationCenter observer**
|
||||||
|
|
||||||
|
### CRUD Operations — AX Tree
|
||||||
|
|
||||||
|
All list/dismiss/interact operations work by programmatically opening Notification Center, traversing its accessibility tree, and performing actions via AXPress.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. **Open NC silently:** Use AXUIElement targeting the `com.apple.notificationcenterui` process, or AppleScript `tell application "System Events"` to click the NC clock/date area in the menu bar. The tool opens NC automatically — agents don't need to do this manually.
|
||||||
|
|
||||||
|
2. **Traverse NC AX tree:** The Notification Center window exposes notifications as AXGroup children, each containing:
|
||||||
|
- AXStaticText elements for title and body
|
||||||
|
- AXButton elements for actions ("Reply", "Open", app-specific actions)
|
||||||
|
- AXButton for dismiss/close
|
||||||
|
|
||||||
|
3. **Identify by positional index:** Notifications are numbered 1-based from top to bottom (newest first). The agent calls `list-notifications` to see the current state, then uses the index for dismiss/interact. This mirrors the snapshot → ref → action pattern — the index is snapshot-scoped, not stable across calls.
|
||||||
|
|
||||||
|
4. **Close NC after operation:** After reading or acting, close NC to restore the user's screen state. Use AXPress on the NC toggle or send Escape key.
|
||||||
|
|
||||||
|
**Why not other approaches:**
|
||||||
|
- **SQLite DB approach** requires Full Disk Access permission — unacceptable adoption barrier on top of the existing Accessibility permission requirement.
|
||||||
|
- **Pure polling for wait** wastes resources and has minimum ~500ms granularity due to NC open/close visual flicker.
|
||||||
|
|
||||||
|
### Wait — NSDistributedNotificationCenter
|
||||||
|
|
||||||
|
`wait --notification` uses `NSDistributedNotificationCenter` to listen for system-level notification events. This is event-driven — instant detection, no polling, no visual impact.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. Register an observer on `NSDistributedNotificationCenter.defaultCenter()` for notification-related events
|
||||||
|
2. When a notification arrives, check against the filter criteria (`--app`, `--text`)
|
||||||
|
3. If matched, open NC briefly to read full notification details and return them
|
||||||
|
4. If `--timeout` exceeded, return a TIMEOUT error
|
||||||
|
|
||||||
|
**Fallback:** If the distributed notification mechanism fails (undocumented API changed in a new macOS version), fall back to AX polling with a 1-second interval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Platform scope | macOS first | Matches Phase 1 additive pattern. Windows/Linux follow in their respective phases. |
|
||||||
|
| NC access | Auto-open silently | Agents shouldn't need to manually open NC. The tool handles it transparently. |
|
||||||
|
| Notification identity | Positional index (1-based) | No stable IDs exist. Index is simple, matches the snapshot-scoped ref pattern. Agent must list before acting. |
|
||||||
|
| Wait mechanism | NSDistributedNotificationCenter + AX fallback | Event-driven for responsiveness. Polling fallback for resilience. |
|
||||||
|
| System tray | Separate feature | Different AX targets, different design questions. Brainstorm independently. |
|
||||||
|
| `is_persistent` field | Dropped | Not reliably detectable from AX tree. No agent use case identified. |
|
||||||
|
| `NotificationFilter` | App name + text content | `--app` filters by source app, `--text` filters by title/body substring. `--limit` caps result count. |
|
||||||
|
| DND detection | Include as metadata | `list-notifications` response includes a `do_not_disturb: bool` field so agents know if notifications are suppressed. Read via CoreFoundation preferences. |
|
||||||
|
| NC close behavior | Always close after operation | Prevents NC from staying open and blocking the user's view. Brief flash is acceptable. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Questions
|
||||||
|
|
||||||
|
1. **AX tree structure stability:** Use heuristic role/attribute matching (find AXGroup with AXStaticText children) rather than hardcoded tree paths. More resilient to macOS version changes. No version-specific parsers.
|
||||||
|
|
||||||
|
2. **Grouped notifications:** Flat list ordered by recency. App name is on each notification. Agents don't need to know about NC's grouping structure.
|
||||||
|
|
||||||
|
3. **Notification banners vs NC:** NC only. Banners are transient and require a separate AX tree target. Keeping scope to Notification Center contents is sufficient for v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JSON Output Examples
|
||||||
|
|
||||||
|
### list-notifications
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"ok": true,
|
||||||
|
"command": "list-notifications",
|
||||||
|
"data": {
|
||||||
|
"count": 3,
|
||||||
|
"do_not_disturb": false,
|
||||||
|
"notifications": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"app_name": "Messages",
|
||||||
|
"title": "John Doe",
|
||||||
|
"body": "Hey, are you free for lunch?",
|
||||||
|
"timestamp": "2026-02-27T10:30:00",
|
||||||
|
"actions": ["Reply", "Open"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"app_name": "Calendar",
|
||||||
|
"title": "Team Standup in 15 minutes",
|
||||||
|
"actions": ["Join", "Snooze"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"app_name": "Slack",
|
||||||
|
"title": "#engineering",
|
||||||
|
"body": "New message from Alice",
|
||||||
|
"actions": ["Open"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### dismiss-notification
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"ok": true,
|
||||||
|
"command": "dismiss-notification",
|
||||||
|
"data": {
|
||||||
|
"dismissed_index": 2,
|
||||||
|
"app_name": "Calendar",
|
||||||
|
"title": "Team Standup in 15 minutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### notification-action
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"ok": true,
|
||||||
|
"command": "notification-action",
|
||||||
|
"data": {
|
||||||
|
"index": 1,
|
||||||
|
"app_name": "Messages",
|
||||||
|
"action": "Reply",
|
||||||
|
"result": "performed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### wait --notification (success)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"ok": true,
|
||||||
|
"command": "wait",
|
||||||
|
"data": {
|
||||||
|
"condition": "notification",
|
||||||
|
"matched": true,
|
||||||
|
"notification": {
|
||||||
|
"index": 1,
|
||||||
|
"app_name": "Messages",
|
||||||
|
"title": "John Doe",
|
||||||
|
"body": "Hey, are you free for lunch?",
|
||||||
|
"actions": ["Reply", "Open"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## macOS Implementation Outline
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/core/src/
|
||||||
|
├── notification.rs # NotificationInfo, NotificationFilter structs
|
||||||
|
|
||||||
|
crates/core/src/commands/
|
||||||
|
├── list_notifications.rs # list-notifications handler
|
||||||
|
├── dismiss_notification.rs # dismiss-notification handler
|
||||||
|
├── dismiss_all_notifications.rs # dismiss-all-notifications handler
|
||||||
|
└── notification_action.rs # notification-action handler
|
||||||
|
|
||||||
|
crates/macos/src/notifications/
|
||||||
|
├── mod.rs # re-exports
|
||||||
|
├── list.rs # Open NC → traverse AX tree → parse notifications → close NC
|
||||||
|
├── dismiss.rs # Open NC → find by index → AXPress close button → close NC
|
||||||
|
├── interact.rs # Open NC → find by index → find action button → AXPress → close NC
|
||||||
|
├── nc_control.rs # Open/close Notification Center helpers
|
||||||
|
└── observer.rs # NSDistributedNotificationCenter observer for wait
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registration points (existing files to modify)
|
||||||
|
|
||||||
|
- `crates/core/src/lib.rs` — add `pub mod notification`
|
||||||
|
- `crates/core/src/adapter.rs` — add 3 trait methods with `not_supported` defaults
|
||||||
|
- `crates/core/src/error.rs` — add `NotificationNotFound`, `NotificationUnsupported` variants
|
||||||
|
- `crates/core/src/commands/mod.rs` — add 4 `pub mod` declarations
|
||||||
|
- `crates/macos/src/lib.rs` — add `pub mod notifications`
|
||||||
|
- `crates/macos/src/adapter.rs` — implement 3 trait methods delegating to notifications module
|
||||||
|
- `src/cli.rs` — add 4 command variants
|
||||||
|
- `src/cli_args.rs` — add arg structs
|
||||||
|
- `src/dispatch.rs` — add 4 match arms
|
||||||
|
- `src/dispatch.rs` — extend `wait` command match arm for `--notification` flag
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Electron Tree Compaction Brainstorm
|
||||||
|
|
||||||
|
**Date:** 2026-03-01
|
||||||
|
**Status:** Decided
|
||||||
|
**Trigger:** Electron depth-skip fix (commit a19c1b5) made Slack snapshots work but raised concern about context bloat for AI agents.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
After enabling Electron/web app support via depth-skip for non-semantic `AXGroup` wrappers, Slack snapshots with `-i` return **268 nodes / ~6,700 tokens** — 4x the cost of Finder's 96 nodes / ~1,625 tokens. 40% of Slack's nodes are structural wrappers carrying no useful info.
|
||||||
|
|
||||||
|
The question: should we build a smart architecture (e.g., a standalone Electron-to-AX bridge) to reduce this, or is there a simpler solution?
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
| Mode | Nodes | Refs | Tokens | Notes |
|
||||||
|
|------|-------|------|--------|-------|
|
||||||
|
| Slack `-i` (current) | 268 | 162 | ~6,700 | 40% structural noise |
|
||||||
|
| Slack `-i` + compact | 204 | 162 | ~5,475 | 18% savings |
|
||||||
|
| Slack full (no `-i`) | 528 | 162 | ~12,000 | 69% structural noise |
|
||||||
|
| Finder `-i` | 96 | 73 | ~1,625 | 24% structural (baseline) |
|
||||||
|
|
||||||
|
Snapshot speed: Slack ~220ms, Finder ~260ms. **Speed is not an issue.**
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
**Implement `--compact` flag** (already wired in CLI as a no-op) to collapse single-child non-interactive pass-through nodes during tree serialization.
|
||||||
|
|
||||||
|
**Collapse rule:** If a node has no `ref_id`, no `name`, no `value`, and exactly one child — skip it and hoist the child. If it has a `name` but the child doesn't, promote the name to the child and skip.
|
||||||
|
|
||||||
|
This is a tree serialization optimization, not an Electron-specific feature. Benefits all apps.
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
1. **The flag already exists** — `--compact` is wired through CLI, `TreeOptions`, and the snapshot pipeline. Just needs implementation.
|
||||||
|
2. **18% token savings for free** — 64 nodes removed from Slack with zero information loss.
|
||||||
|
3. **No separate package needed** — The Electron bridge idea (CDP-based, standalone crate) is YAGNI. The actual token cost is dominated by real content (162 interactive elements with descriptive names), not structural noise.
|
||||||
|
4. **Composable with `--surface`** — Agents that need even smaller snapshots should scope to a surface (sheet, menu, alert) rather than us over-pruning the full tree.
|
||||||
|
|
||||||
|
## Approaches Considered
|
||||||
|
|
||||||
|
### 1. Implement `--compact` with chain collapsing (CHOSEN)
|
||||||
|
- Collapse unnamed/valueless single-child non-interactive nodes
|
||||||
|
- 15-18% token reduction, zero information loss, trivial to implement
|
||||||
|
- Works for all apps, not Electron-specific
|
||||||
|
|
||||||
|
### 2. Standalone Electron-to-AX bridge package
|
||||||
|
- Separate crate that connects via CDP, extracts real DOM structure, maps to AX tree
|
||||||
|
- Would require `--remote-debugging-port` (invasive), becomes agent-browser territory
|
||||||
|
- **Rejected:** YAGNI. Pure AX approach already works. Adds dependency and complexity.
|
||||||
|
|
||||||
|
### 3. Flat interactive-only output (`--flat`)
|
||||||
|
- Output just refs as `[{ref, role, name, value, path}]` — no tree
|
||||||
|
- Minimal tokens (~2,000 for Slack) but agents lose spatial/hierarchy context
|
||||||
|
- **Deferred:** Could add later if compact isn't enough. Not needed now.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- **Chain collapsing preserves tree structure** — agents still see hierarchy, just without empty wrapper noise
|
||||||
|
- **Not Electron-specific** — universal tree optimization via `--compact` flag
|
||||||
|
- **No separate bridge package** — unnecessary given 220ms speed and ~5,475 post-compact tokens
|
||||||
|
- **Agents control scope via `--surface`** — surface-scoped snapshots are the right lever for smaller output, not tree pruning
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — approach is clear and implementation is straightforward.
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
# ClawHub Skill Publishing & Scalable Skill Architecture Brainstorm
|
||||||
|
|
||||||
|
**Date:** 2026-03-02
|
||||||
|
**Status:** Decided
|
||||||
|
**Trigger:** agent-desktop has a mature skill ready for public distribution. Need a scalable architecture for multi-platform, multi-app skill publishing to ClawHub.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The `agent-desktop` skill exists locally in `skills/agent-desktop/` with a SKILL.md and 5 reference files. A separate `agent-desktop-macos` skill lives in `.claude/skills/` (not git-tracked). As we add Windows/Linux platforms and app-specific knowledge (Slack, Notion, VS Code quirks), we need:
|
||||||
|
|
||||||
|
1. A **scalable directory structure** under `skills/` that supports platform and app-specific sub-skills
|
||||||
|
2. **Automated CI publishing** to ClawHub on every release (no manual steps)
|
||||||
|
3. A **scaffolding pattern** so adding a new platform or app skill is trivial
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Current skill files | 1 SKILL.md + 5 reference docs + 1 platform SKILL.md |
|
||||||
|
| Total content | ~42KB across all files |
|
||||||
|
| Commands documented | 54 |
|
||||||
|
| ClawHub skills total | 5,700+ |
|
||||||
|
| Desktop automation skills on ClawHub | Very few (niche) |
|
||||||
|
| ClawHub CLI batch command | `clawhub sync --root skills/ --all` |
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
A **nested skill hierarchy** where platform skills contain app-specific references, with CI-driven auto-publishing to ClawHub on every release.
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/
|
||||||
|
├── agent-desktop/ # Core skill (platform-agnostic)
|
||||||
|
│ ├── SKILL.md # Commands, observe-act loop, ref system, JSON contract
|
||||||
|
│ └── references/
|
||||||
|
│ ├── commands-observation.md
|
||||||
|
│ ├── commands-interaction.md
|
||||||
|
│ ├── commands-system.md
|
||||||
|
│ └── workflows.md # 12 common automation patterns
|
||||||
|
│
|
||||||
|
├── agent-desktop-macos/ # macOS platform skill
|
||||||
|
│ ├── SKILL.md # TCC permissions, AX API, smart activation chain, surfaces
|
||||||
|
│ └── references/
|
||||||
|
│ ├── slack.md # Slack on macOS: Electron quirks, clipboard paste pattern
|
||||||
|
│ ├── notion.md # Notion on macOS: block navigation, slash commands
|
||||||
|
│ ├── vscode.md # VS Code on macOS: command palette, editor interaction
|
||||||
|
│ └── notifications.md # NC lifecycle, dismiss strategies, stacked notifications
|
||||||
|
│
|
||||||
|
├── agent-desktop-windows/ # Windows platform skill (Phase 2)
|
||||||
|
│ ├── SKILL.md # UIA setup, permission model, COM automation
|
||||||
|
│ └── references/
|
||||||
|
│ ├── slack.md # Slack on Windows
|
||||||
|
│ ├── teams.md # Teams on Windows (UIA-native)
|
||||||
|
│ └── vscode.md # VS Code on Windows
|
||||||
|
│
|
||||||
|
└── agent-desktop-linux/ # Linux platform skill (Phase 3)
|
||||||
|
├── SKILL.md # AT-SPI setup, D-Bus, Wayland vs X11
|
||||||
|
└── references/
|
||||||
|
├── slack.md # Slack on Linux
|
||||||
|
└── vscode.md # VS Code on Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key design principles
|
||||||
|
|
||||||
|
1. **App quirks are platform-scoped** — Slack on macOS (AX + Electron depth-skip) differs from Slack on Windows (UIA + Chromium detection). App references live inside the platform skill, not in a separate top-level skill.
|
||||||
|
|
||||||
|
2. **Core skill stays platform-agnostic** — `agent-desktop/` documents commands, JSON contract, ref system. Zero platform detail. The `references/macos.md` that currently lives here moves to `agent-desktop-macos/SKILL.md`.
|
||||||
|
|
||||||
|
3. **Each `skills/*/` folder is independently publishable** — ClawHub `clawhub sync --root skills/ --all` discovers and publishes each subfolder as a separate skill.
|
||||||
|
|
||||||
|
4. **Git-tracked source of truth** — everything under `skills/` is committed. The `.claude/skills/` and `.agents/skills/` symlinks are derived (gitignored).
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
1. **Matches how the project actually scales** — each platform adapter (Phase 2, 3) brings platform-specific AX behavior AND app-specific quirks. The skill hierarchy mirrors the crate hierarchy.
|
||||||
|
|
||||||
|
2. **App knowledge compounds** — every time we test Slack/Notion/VS Code, we document quirks in the platform's `references/` folder. Future agents benefit automatically.
|
||||||
|
|
||||||
|
3. **`clawhub sync` handles multi-skill publishing** — one CI step publishes all skills in `skills/`. No per-skill config. Adding a new skill = adding a new folder.
|
||||||
|
|
||||||
|
4. **Fewer top-level skills** — users install `agent-desktop` (core) + `agent-desktop-macos` (their platform). App knowledge comes free inside the platform skill. No need to install `agent-desktop-slack` separately.
|
||||||
|
|
||||||
|
## Approaches Considered
|
||||||
|
|
||||||
|
### 1. Flat app-specific skills (agent-desktop-slack, agent-desktop-notion, etc.)
|
||||||
|
- One top-level skill per app, published separately
|
||||||
|
- **Rejected:** App behavior is platform-dependent. Slack on macOS vs Windows differs fundamentally. Flat app skills would either duplicate platform info or be incomplete.
|
||||||
|
|
||||||
|
### 2. Nested hierarchy — platform > apps (CHOSEN)
|
||||||
|
- Platform skills contain app-specific references
|
||||||
|
- App knowledge scoped to platform where it's actually useful
|
||||||
|
- Fewer skills to install, more content per skill
|
||||||
|
|
||||||
|
### 3. Monolithic single skill with everything
|
||||||
|
- One massive `agent-desktop` skill with all platform + app info
|
||||||
|
- **Rejected:** Too much context loaded at once. Users on macOS don't need Windows/Linux info in context.
|
||||||
|
|
||||||
|
## CI Automation
|
||||||
|
|
||||||
|
Add a `publish-skills` job to `.github/workflows/release.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
publish-skills:
|
||||||
|
needs: [release-please]
|
||||||
|
if: needs.release-please.outputs.release_created == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: '22' }
|
||||||
|
- run: npm i -g clawhub
|
||||||
|
- run: |
|
||||||
|
clawhub sync \
|
||||||
|
--root skills/ \
|
||||||
|
--all \
|
||||||
|
--bump patch \
|
||||||
|
--changelog "${{ needs.release-please.outputs.tag_name }}"
|
||||||
|
env:
|
||||||
|
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs automatically when release-please creates a new release. All skills under `skills/` are published in one step.
|
||||||
|
|
||||||
|
### Symlink Automation
|
||||||
|
|
||||||
|
Add a post-checkout script or Makefile target to wire `.claude/skills/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# scripts/link-skills.sh
|
||||||
|
for skill_dir in skills/*/; do
|
||||||
|
name=$(basename "$skill_dir")
|
||||||
|
mkdir -p .claude/skills
|
||||||
|
ln -sfn "../../$skill_dir" ".claude/skills/$name"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- **Nested hierarchy (platform > apps)** — app references scoped to platform
|
||||||
|
- **CI auto-publish via `clawhub sync`** on every release — zero manual steps
|
||||||
|
- **Move `references/macos.md` from core skill to `agent-desktop-macos/`** — core stays platform-agnostic
|
||||||
|
- **Move `agent-desktop-macos` from `.claude/skills/` to `skills/`** — git-tracked, publishable
|
||||||
|
- **Version synced to npm/Cargo version** via release-please
|
||||||
|
- **`CLAWHUB_TOKEN` as GitHub secret** for CI authentication
|
||||||
|
- **Symlink script** to wire `skills/` → `.claude/skills/` for local development
|
||||||
|
|
||||||
|
## Scaffolding: Adding a New App Reference
|
||||||
|
|
||||||
|
To add Slack macOS knowledge:
|
||||||
|
|
||||||
|
1. Create `skills/agent-desktop-macos/references/slack.md`
|
||||||
|
2. Document: Electron quirks, clipboard paste pattern, block structure, known AX issues
|
||||||
|
3. Reference it from `skills/agent-desktop-macos/SKILL.md` reference table
|
||||||
|
4. Commit → next release auto-publishes to ClawHub
|
||||||
|
|
||||||
|
To add a new platform (e.g., Windows):
|
||||||
|
|
||||||
|
1. Create `skills/agent-desktop-windows/SKILL.md` with platform-specific frontmatter
|
||||||
|
2. Create `skills/agent-desktop-windows/references/` for app-specific docs
|
||||||
|
3. Run `scripts/link-skills.sh` to wire local symlinks
|
||||||
|
4. Commit → next release auto-publishes
|
||||||
|
|
||||||
|
## Migration Steps (from current state)
|
||||||
|
|
||||||
|
1. Move `.claude/skills/agent-desktop-macos/SKILL.md` → `skills/agent-desktop-macos/SKILL.md`
|
||||||
|
2. Move `skills/agent-desktop/references/macos.md` → merge into `skills/agent-desktop-macos/SKILL.md`
|
||||||
|
3. Remove `macos.md` from core skill's reference table in SKILL.md
|
||||||
|
4. Add ClawHub metadata (`version`, `tags`, `requirements`) to all SKILL.md frontmatters
|
||||||
|
5. Create `scripts/link-skills.sh` for local symlink management
|
||||||
|
6. Add `publish-skills` job to `release.yml`
|
||||||
|
7. Store `CLAWHUB_TOKEN` in GitHub repo secrets
|
||||||
|
8. First publish: manual `clawhub sync --root skills/ --all` to verify
|
||||||
|
9. After verification: CI handles all future publishes
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — architecture is clear.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [ClawHub Docs](https://docs.openclaw.ai/tools/clawhub) — publish CLI, sync command, token management
|
||||||
|
- [ClawHub GitHub](https://github.com/openclaw/clawhub) — skill directory structure
|
||||||
|
- [Anthropic Skills Repo](https://github.com/anthropics/skills) — SKILL.md format standard
|
||||||
|
- [ClawHub Publisher Skill](https://playbooks.com/skills/openclaw/skills/openclaw-clawhub-publisher) — batch publish, CI integration
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
# Progressive Skeleton Traversal
|
||||||
|
|
||||||
|
**Date:** 2026-03-10
|
||||||
|
**Status:** Brainstorm
|
||||||
|
**Author:** Lahfir
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
A two-phase tree traversal system that lets AI agents explore dense accessibility trees incrementally instead of consuming the entire tree at once. The system introduces:
|
||||||
|
|
||||||
|
1. **Skeleton mode** — A shallow overview snapshot (2-3 levels deep) where truncated nodes show `children_count` instead of full subtrees. Named containers at the truncation boundary receive refs so agents can drill into them.
|
||||||
|
|
||||||
|
2. **Ref-rooted subtree** — A `--root @ref` flag on `snapshot` that starts traversal from a previously-discovered element instead of the window root. Combined with refmap merging, this lets agents accumulate knowledge across multiple targeted drill-downs.
|
||||||
|
|
||||||
|
3. **Epoch-tagged refmap merging** — Instead of replacing the entire refmap on each snapshot, drill-down snapshots merge new refs into the existing map. A monotonic epoch counter and root-ref tagging enable staleness detection and scoped invalidation.
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
Dense Electron apps (Slack, VS Code, Discord) produce massive accessibility trees — 500+ nodes, 12,000+ tokens per full snapshot. Even with `--interactive-only --compact`, Slack still produces ~200 nodes (~5,400 tokens). Agents pay per-token and struggle to navigate these trees efficiently.
|
||||||
|
|
||||||
|
Current optimization layers (depth-skip, compact, surfaces, interactive-only) reduce output size but still require full tree traversal. There's no way to say "I only care about the sidebar right now."
|
||||||
|
|
||||||
|
### Target Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: Cheap overview of the entire app (~500 tokens)
|
||||||
|
agent-desktop snapshot --app Slack --skeleton -i
|
||||||
|
# Returns 2-3 levels with children_count on truncated containers
|
||||||
|
|
||||||
|
# Step 2: Agent identifies sidebar (@e3) as interesting, drills in (~800 tokens)
|
||||||
|
agent-desktop snapshot --root @e3 -i --compact
|
||||||
|
# Returns @e3's full subtree, merges refs into existing refmap
|
||||||
|
|
||||||
|
# Step 3: Agent identifies a specific channel list (@e12), drills deeper (~400 tokens)
|
||||||
|
agent-desktop snapshot --root @e12 -i --compact
|
||||||
|
# Returns @e12's subtree, merges again
|
||||||
|
|
||||||
|
# Step 4: Agent acts on a discovered element
|
||||||
|
agent-desktop click @e45
|
||||||
|
# Uses ref from step 3 — still valid because refmap accumulated
|
||||||
|
|
||||||
|
# Total: ~1,700 tokens across 3 snapshots vs ~5,400 tokens for one full snapshot
|
||||||
|
# Plus: agent only processed relevant information at each step
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
### Why not Semantic Zone Auto-Detection?
|
||||||
|
|
||||||
|
Zone detection (auto-labeling "sidebar", "toolbar", "content" via role heuristics) is appealing but fragile:
|
||||||
|
- Not all apps use standard accessibility roles for layout
|
||||||
|
- Platform differences in role semantics make cross-platform heuristics unreliable
|
||||||
|
- Introduces a new concept (zones) alongside existing refs
|
||||||
|
- Can be layered on top of Progressive Skeleton later as sugar
|
||||||
|
|
||||||
|
### Why not Query-Driven Traversal?
|
||||||
|
|
||||||
|
Targeted search (`find --near "Channels"`) is token-efficient but requires agents to know what they're looking for. It fails at the "orient myself in an unfamiliar app" use case — which is the most common first step.
|
||||||
|
|
||||||
|
### Why Progressive Skeleton wins
|
||||||
|
|
||||||
|
1. **Composable** — Works with ALL existing flags (`--compact`, `--interactive-only`, `--max-depth`, `--surface`)
|
||||||
|
2. **Platform-agnostic** — Just new `TreeOptions` fields; each adapter implements the same interface
|
||||||
|
3. **Agent-controlled** — Agents decide exploration strategy based on their goals
|
||||||
|
4. **Familiar pattern** — Mirrors tree exploration in file systems (expand on demand)
|
||||||
|
5. **Minimal API surface** — Two new flags (`--skeleton`, `--root`), one new refmap behavior (merge)
|
||||||
|
6. **Incremental value** — Each feature is independently useful; skeleton without drill-down still saves tokens
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
### 1. Skeleton Output Format
|
||||||
|
|
||||||
|
Truncated nodes show `children_count` (direct child count) instead of full children arrays:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "group",
|
||||||
|
"name": "Channels",
|
||||||
|
"ref": "@e3",
|
||||||
|
"children_count": 47
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** Child count is O(1) to fetch on all platforms (it's a property of the element, not requiring subtree traversal). Gives agents enough signal to prioritize drill-downs. More detailed stats (interactive count, role distribution) would require full traversal, defeating the purpose.
|
||||||
|
|
||||||
|
### 2. Skeleton Ref Assignment
|
||||||
|
|
||||||
|
At the skeleton boundary, **named containers with children** receive refs even if they're non-interactive. These are "drill-down targets" — the agent uses them with `--root @ref` to explore deeper.
|
||||||
|
|
||||||
|
Rules for skeleton ref assignment:
|
||||||
|
- Interactive elements: always get refs (existing behavior)
|
||||||
|
- Named containers at truncation boundary with `children_count > 0`: get refs (NEW)
|
||||||
|
- Anonymous wrappers (no name, no value): never get refs (existing behavior)
|
||||||
|
|
||||||
|
### 3. RefMap Merge Strategy
|
||||||
|
|
||||||
|
Each snapshot call increments a monotonic `epoch` counter stored in the refmap file. RefEntries are tagged with:
|
||||||
|
|
||||||
|
- `epoch: u32` — when this ref was created
|
||||||
|
- `root_ref: Option<String>` — which drill-down root created this ref (`None` for skeleton/full snapshots)
|
||||||
|
|
||||||
|
**Merge behavior:**
|
||||||
|
- **Full/skeleton snapshot** (`--skeleton` or no `--root`): replaces entire refmap (current behavior). Resets epoch.
|
||||||
|
- **Drill-down** (`--root @e3`): loads existing refmap, removes all refs where `root_ref == "@e3"` (clear stale subtree), increments epoch, adds new refs with `root_ref: "@e3"`, saves merged refmap.
|
||||||
|
|
||||||
|
**Staleness detection:**
|
||||||
|
- On `STALE_REF`, error includes epoch info for recovery hints
|
||||||
|
- Agent can compare epochs across refs to detect relative freshness
|
||||||
|
- Re-drilling a region automatically invalidates only that region's refs
|
||||||
|
|
||||||
|
### 4. `--root` Implies Merge
|
||||||
|
|
||||||
|
When `--root @ref` is used, merge mode is automatic — there's no separate `--merge` flag. Rationale: drilling into a subtree without merging would discard all other refs, which is never what an agent wants.
|
||||||
|
|
||||||
|
### 5. Platform-Agnostic Design
|
||||||
|
|
||||||
|
New `PlatformAdapter` trait method:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn get_subtree(&self, handle: &NativeHandle, opts: &TreeOptions) -> Result<AccessibilityNode, AdapterError> {
|
||||||
|
Err(AdapterError::not_supported("get_subtree"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each platform adapter implements this by:
|
||||||
|
- macOS: extract `AXUIElementRef` from handle, call `build_subtree()` from that element
|
||||||
|
- Windows (Phase 2): extract `IUIAutomationElement` from handle, walk with TreeWalker from that element
|
||||||
|
- Linux (Phase 3): extract AT-SPI accessible from handle, walk from that element
|
||||||
|
|
||||||
|
The `TreeOptions` struct gains:
|
||||||
|
```rust
|
||||||
|
pub struct TreeOptions {
|
||||||
|
pub max_depth: u8,
|
||||||
|
pub include_bounds: bool,
|
||||||
|
pub interactive_only: bool,
|
||||||
|
pub compact: bool,
|
||||||
|
pub surface: SnapshotSurface,
|
||||||
|
pub skeleton: bool, // NEW: shallow overview with children_count
|
||||||
|
pub root_ref: Option<String>, // NEW: start from this ref instead of window root
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Skeleton Depth
|
||||||
|
|
||||||
|
Skeleton mode sets an internal depth limit independent of `--max-depth`. Default skeleton depth: **3 levels** from the root (or from `--root` element).
|
||||||
|
|
||||||
|
- Level 0: Root element (window or ref target)
|
||||||
|
- Level 1: Major sections (toolbar, sidebar, content)
|
||||||
|
- Level 2: Sub-sections (channel list, message list)
|
||||||
|
- Level 3: Individual groups/items (truncated here with `children_count`)
|
||||||
|
|
||||||
|
Agent can override: `--skeleton --max-depth 2` for even shallower overview.
|
||||||
|
|
||||||
|
## Token Budget Analysis
|
||||||
|
|
||||||
|
Based on real Slack data (528 nodes full tree):
|
||||||
|
|
||||||
|
| Mode | Nodes | Est. Tokens | Savings vs Full |
|
||||||
|
|------|-------|-------------|-----------------|
|
||||||
|
| Full snapshot | 528 | ~12,000 | baseline |
|
||||||
|
| + interactive-only | 268 | ~6,700 | 44% |
|
||||||
|
| + compact | 204 | ~5,400 | 55% |
|
||||||
|
| **Skeleton (depth 3)** | ~25-40 | ~500-800 | **93-96%** |
|
||||||
|
| Drill-down (sidebar) | ~30-60 | ~600-1,200 | scoped |
|
||||||
|
| Drill-down (content) | ~50-100 | ~1,000-2,000 | scoped |
|
||||||
|
| **Skeleton + 1 drill-down** | ~55-100 | ~1,100-2,000 | **83-91%** |
|
||||||
|
| **Skeleton + 2 drill-downs** | ~85-160 | ~1,700-3,200 | **73-86%** |
|
||||||
|
|
||||||
|
Even with 2 targeted drill-downs, agents use 1,700-3,200 tokens instead of 5,400. And they only process relevant information at each step, improving reasoning quality.
|
||||||
|
|
||||||
|
## Implementation Scope
|
||||||
|
|
||||||
|
### Core Changes
|
||||||
|
- `TreeOptions`: add `skeleton: bool`, `root_ref: Option<String>`
|
||||||
|
- `RefMap` / `RefEntry`: add `epoch: u32`, `root_ref: Option<String>` fields
|
||||||
|
- `snapshot.rs`: skeleton mode in `allocate_refs()` (assign refs to named containers, replace children with `children_count`)
|
||||||
|
- `snapshot.rs`: merge logic in `run()` (load existing refmap, scoped invalidation, merge)
|
||||||
|
- `AccessibilityNode`: add `children_count: Option<u32>` field with serde skip-if-none
|
||||||
|
- `PlatformAdapter`: add `get_subtree()` method with default `not_supported`
|
||||||
|
|
||||||
|
### macOS Changes
|
||||||
|
- `adapter.rs`: implement `get_subtree()` — resolve handle to AXElement, call `build_subtree()`
|
||||||
|
- `builder.rs`: support `skeleton` flag in `build_subtree()` — at depth limit, count children instead of recursing
|
||||||
|
|
||||||
|
### CLI Changes
|
||||||
|
- `cli_args.rs`: add `--skeleton` and `--root` flags to `SnapshotArgs`
|
||||||
|
- `dispatch.rs`: pass new args through to `TreeOptions`
|
||||||
|
|
||||||
|
### find Command Changes
|
||||||
|
- `find.rs`: add `--root @ref` support — resolve ref, build subtree from it, then search in-memory
|
||||||
|
- `cli_args.rs`: add `--root` flag to `FindArgs`
|
||||||
|
|
||||||
|
### No Changes Needed
|
||||||
|
- `resolve.rs` — existing resolution logic works as-is
|
||||||
|
- `get.rs` — works with merged refmaps transparently
|
||||||
|
- All action commands — work with any valid ref from the merged map
|
||||||
|
- `compact`, `interactive-only` — compose naturally with skeleton/drill-down
|
||||||
|
|
||||||
|
## Resolved Questions
|
||||||
|
|
||||||
|
1. **Should `find` support `--root @ref` too?** **Yes.** Agents can search within a drill-down region: `find --root @e3 --role button`. Low effort since find already operates on in-memory trees — just change where the tree comes from.
|
||||||
|
|
||||||
|
2. **What happens when the skeleton root ref itself is stale?** **Return STALE_REF, no auto-recovery.** The suggestion tells the agent to re-run skeleton. Auto-fallback hides UI state changes the agent should know about. Agents already handle STALE_REF.
|
||||||
|
|
||||||
|
3. **Should `children_count` count ALL descendants or just direct children?** **Direct children only.** O(1) on all platforms — it's an attribute of the element. Agent sees "this group has 47 direct children" which is sufficient to judge density. Total descendant count would require subtree traversal, defeating the purpose.
|
||||||
|
|
||||||
|
4. **Naming: `--skeleton` vs `--overview` vs `--shallow`?** **`--skeleton`.** Most descriptive — "shows structure without flesh." Technical but precise. Matches the pattern of other flags.
|
||||||
|
|
||||||
|
5. **Should skeleton be the default when `--compact --interactive-only` are both set?** **No, always opt-in.** Agents explicitly choose skeleton mode. Existing behavior unchanged. No surprise breakage for agents already using snapshot.
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
---
|
||||||
|
date: 2026-04-24
|
||||||
|
topic: phase2-windows-crossplatform
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 Windows + Cross-Platform Parity Requirements
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
agent-desktop is shipping as a strong macOS + FFI tool, but it is not yet a credible cross-platform automation runtime. Phase 2 must bring Windows online without weakening the core contract: agents observe and act through structured refs, not through foregrounded UI poking.
|
||||||
|
|
||||||
|
The next phase is larger than "add Windows." It also closes the parity gaps exposed by the FFI release: unstable element identity, polling-based waits, missing text-range operations, slower screenshots, incomplete permission reporting, and command-surface duplication risk. The goal is one coherent v0.2.0 release that makes Windows first-class while strengthening macOS in the same primitives.
|
||||||
|
|
||||||
|
The authoritative implementation plan already exists at `docs/plans/2026-04-18-001-feat-phase2-windows-crossplatform-plan.md`. This brief captures the product and architecture decisions that should guide execution.
|
||||||
|
|
||||||
|
## Release Gates
|
||||||
|
|
||||||
|
Phase 2 remains one v0.2.0 GA milestone, but execution should use internal gates so Windows value does not get buried under unrelated breadth:
|
||||||
|
|
||||||
|
- G1. Windows existing-command parity: the current macOS command surface works on Windows for Explorer, Notepad, Settings, VS Code, and Edge.
|
||||||
|
- G2. Headless contract: non-mouse commands on both macOS and Windows either use accessibility/headless APIs or return structured unsupported/action errors with guidance. Cursor or focus fallbacks are reserved for explicit mouse/window-focus commands.
|
||||||
|
- G3. Cross-platform primitives: stable selectors, event-backed waits, text ranges, modern screenshots, richer permission/error reporting, and command registration parity are implemented where the execution-prep spikes validate the API path.
|
||||||
|
- G4. Release readiness: Windows CI, Windows x86_64 and ARM64 artifacts, npm platform selection, FFI ABI handshaking, README, skills, and roadmap sync are complete.
|
||||||
|
|
||||||
|
G1 is the Windows-first MVP gate. G2-G4 decide whether the work is ready to be called Phase 2 GA rather than an internal checkpoint or prerelease.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- R1. Windows must support the existing command surface with the same JSON envelope and error contract as macOS.
|
||||||
|
- R2. Cross-platform parity is structural, not byte-identical: roles, refs, available actions, and stable identifiers must be comparable across equivalent apps while preserving platform-specific truth.
|
||||||
|
- R3. Headless-first behavior is non-negotiable: non-mouse commands must avoid focus steal, visible activation, and physical cursor movement. Existing macOS focus/cursor fallbacks must be migrated, gated behind explicit mouse/focus commands, or replaced with structured unsupported/action errors.
|
||||||
|
- R4. Windows skeleton traversal and ref-rooted drill-down must preserve the existing progressive traversal contract, including depth-3 skeletons, `children_count`, scoped invalidation, and the refmap size guard.
|
||||||
|
- R5. Stable selector fields must be added and used to reduce `STALE_REF` churn, especially in Electron, WebView2, localized, and custom-rendered apps.
|
||||||
|
- R6. Push-based event watching must complement polling waits through the public CLI shape `wait --event <kind> --ref @e... --timeout <ms>`. `watch_element` is the adapter primitive behind that command surface, not a separate Phase 2 CLI command.
|
||||||
|
- R7. Text range primitives must support selection, caret reads, range reads, and insert-at-caret on both macOS and Windows.
|
||||||
|
- R8. Modern per-window screenshot APIs must become the default on both platforms, with legacy paths retained as explicit fallbacks.
|
||||||
|
- R9. New action coverage must include `SelectRange` and `InsertAtCaret` for text workflows, plus `ShowMenu`, `WindowRaise`, `Cancel`, `DeliverFiles`, `LongPress`, and `ForceClick` only where the unit can prove a headless or platform-native path. If a variant has no native path on a platform, it must return a structured unsupported/action error rather than violating R3.
|
||||||
|
- R10. Missing surfaces and OS integration points must be promoted only when tied to Phase 2 workflows: Toolbar on both platforms for browser/editor automation; Windows SystemTray and notification parity for OS-level agent workflows; Spotlight, Dock, and MenuBarExtras on macOS as parity backfills only if their unit has a concrete target workflow and acceptance test.
|
||||||
|
- R11. Permission and error reporting must distinguish accessibility denial, permission revocation, screen-recording denial, automation denial, resource exhaustion, and AX messaging timeouts.
|
||||||
|
- R12. Command registration and FFI parity must use deterministic `build.rs` filesystem enumeration, not `inventory` or `linkme`, so one command file remains the durable source of truth across CLI, FFI, and future MCP.
|
||||||
|
- R13. Phase 2 must ship as v0.2.0 with Windows CI, Windows release artifacts, npm platform selection, FFI ABI handshaking, and updated skills/docs.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- `snapshot --app Explorer`, Notepad, Settings, VS Code, and Edge returns usable trees with refs on Windows.
|
||||||
|
- All existing commands run through Windows CI, and core still has zero platform crate leakage.
|
||||||
|
- Non-mouse command integration tests assert no focus change and no cursor movement; any fallback that would move focus/cursor returns a structured error with recovery guidance instead.
|
||||||
|
- Stable selectors measurably reduce stale-ref rate versus the Phase 1 resolver baseline.
|
||||||
|
- `wait --event value-changed --ref @e... --timeout 3000` receives events within 500 ms on macOS and Windows after the event-threading spike validates the path.
|
||||||
|
- Modern screenshot becomes the default after backend-specific benchmarks validate the threshold; legacy remains selectable. Do not hard-code a sub-50ms cold target before the screenshot spike because current ScreenCaptureKit crate docs report broader first-frame latency ranges.
|
||||||
|
- R9 action variants each have a target workflow, native/headless implementation path, or documented structured unsupported result before their unit opens.
|
||||||
|
- R10 surfaces each have a target workflow and acceptance test before their unit opens.
|
||||||
|
- Adding a new command requires one core command file plus type-level marshaling where necessary; it does not require hand-maintained per-transport command lists.
|
||||||
|
- v0.2.0 release artifacts include Windows x86_64 and ARM64 deliverables, with npm install working on Windows.
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- Linux adapter remains Phase 3.
|
||||||
|
- MCP server mode remains Phase 4.
|
||||||
|
- Daemon, sessions, audit log, policy engine, OCR fallback, and package-manager distribution remain later production-readiness work.
|
||||||
|
- UI recording, macro replay, and GUI/TUI surfaces remain non-goals.
|
||||||
|
- Platform divergence is acceptable when the OS genuinely differs, but the JSON envelope, error semantics, and command names must stay consistent.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- Treat `docs/plans/2026-04-18-001-feat-phase2-windows-crossplatform-plan.md` as authoritative where earlier brainstorm or roadmap wording conflicts.
|
||||||
|
- Ship Phase 2 as one coherent release, not a 2a/2b split. Manage risk with reviewable units, not with hidden deferrals.
|
||||||
|
- Preserve headless-first as the phase's top architectural constraint. Any approach requiring foreground activation or physical cursor movement for ordinary commands is rejected.
|
||||||
|
- Use Windows UIA-first behavior and SendInput only as a fallback. UIA patterns are the reliability baseline.
|
||||||
|
- Use `ControlViewWalker` for Windows tree traversal, fresh cache requests per drill-down, and no cross-apartment UIA element caching.
|
||||||
|
- Reject `inventory` and `linkme`; use deterministic `build.rs` enumeration of one-command-per-file sources.
|
||||||
|
- Rename the old `FileDrop` concept to `DeliverFiles` because macOS drag sessions violate headless-first.
|
||||||
|
- Keep public APIs synchronous for CLI/FFI. Async or streaming behavior belongs to MCP/daemon phases.
|
||||||
|
- Keep `WindowInfo.pid` as `i32`; narrow Windows PIDs at the adapter boundary with explicit failure if reality ever exceeds the representation.
|
||||||
|
- Add FFI ABI handshaking before the breaking v0.2.0 shape changes so consumers fail closed.
|
||||||
|
- Do not refactor existing flat `AccessibilityNode` fields just to satisfy aesthetic field-count pressure; nest only the new selector group.
|
||||||
|
- Update docs and skills as a release gate, not as cleanup afterthoughts.
|
||||||
|
|
||||||
|
## External Research Notes
|
||||||
|
|
||||||
|
Checked on 2026-04-25:
|
||||||
|
|
||||||
|
- Microsoft UI Automation guidance says desktop-wide UIA clients should make UIA calls from a separate COM MTA thread that owns no windows, add/remove event handlers on that same non-UI thread, and handle COM apartment-affinity invalidation. This supports the Windows event-threading and no-cross-apartment-caching decisions.
|
||||||
|
- Microsoft `SendInput` documentation says input injection is subject to UIPI and only works into equal-or-lower integrity processes; it also does not reliably identify UIPI as the failure reason. This supports UIA-first behavior and structured fallback errors.
|
||||||
|
- Microsoft `IGraphicsCaptureItemInterop::CreateForWindow` targets a single `HWND` and requires Windows 10 version 1903 / build 18362 or later. Phase 2 should make that the minimum for modern per-window Windows screenshots, with legacy fallback for unsupported environments.
|
||||||
|
- Apple documents `SCScreenshotManager.captureImage(contentFilter:configuration:)` for single-frame capture through ScreenCaptureKit. The current `screencapturekit` crate docs list version 1.5.4 and report typical Apple Silicon first-frame latency around 30-100ms at 1080p, so the screenshot unit must benchmark before pinning a colder threshold.
|
||||||
|
- `uiautomation` 0.24.4 currently depends on `windows` 0.62.2, and the `windows` crate's current docs list 0.62.2. The Phase 2 dependency pins should be rechecked in the unit, but the existing direction is still plausible.
|
||||||
|
- GitHub's hosted-runner reference now lists `windows-11-arm` for ARM64 Windows in public and private repositories. Phase 2 should try ARM64 CI on that runner; keep build-only fallback only if repository, minutes, or toolchain constraints block reliable CI.
|
||||||
|
|
||||||
|
## Dependencies / Assumptions
|
||||||
|
|
||||||
|
- Windows support targets interactive desktop sessions, not Session 0 or Server Core.
|
||||||
|
- Modern Windows screenshot support requires Windows Graphics Capture availability, with `CreateForWindow` requiring Windows 10 version 1903 / build 18362 or later.
|
||||||
|
- Some Chromium/Electron apps still require renderer accessibility to expose useful trees; Phase 2 should detect and guide rather than pretend otherwise.
|
||||||
|
- ARM64 Windows should use GitHub's `windows-11-arm` runner for at least CI smoke coverage if reliable in this repository; fall back to build-only only if runner/toolchain constraints block it.
|
||||||
|
- v0.2.0 may break JSON and FFI ABI in documented ways because the project is still pre-1.0.
|
||||||
|
|
||||||
|
## Outstanding Questions
|
||||||
|
|
||||||
|
### Resolve Before Planning
|
||||||
|
|
||||||
|
- None for product direction. The existing Phase 2 plan can proceed through Unit 1 / Unit 2 prep work.
|
||||||
|
|
||||||
|
### Execution-Prep Gates
|
||||||
|
|
||||||
|
- [Affects R3][Technical] Audit existing macOS non-mouse action fallbacks and decide which become headless-only, which move behind explicit mouse/focus commands, and which return structured unsupported/action errors.
|
||||||
|
- [Affects R6][Technical] Validate macOS `AXObserver` threading and teardown against real apps before opening the final event-backed wait implementation.
|
||||||
|
- [Affects R7][Technical] Confirm exact Windows UIA text-range behavior in Notepad, Settings, and WebView2 controls, including nil/empty/error cases.
|
||||||
|
- [Affects R8][Needs research] Verify ScreenCaptureKit and `windows-capture` API pins and benchmark cold/warm screenshot latency before setting final thresholds.
|
||||||
|
- [Affects R9][Technical] For each new action variant, record the target workflow and native/headless path or structured unsupported result before implementation.
|
||||||
|
- [Affects R10][Technical] Confirm Windows notification and tray UIA trees across current Windows 11 builds and tie every macOS-only surface backfill to a target workflow before implementation.
|
||||||
|
|
||||||
|
### Deferred to Implementation
|
||||||
|
|
||||||
|
- [Affects R12][Technical] Keep checking generated wrapper/header drift in CI as command registry codegen lands.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Use `docs/plans/2026-04-18-001-feat-phase2-windows-crossplatform-plan.md` for execution planning, but apply the gates above before opening affected implementation units. If moving directly into work, start with the v0.1.14 FFI prep / Unit 1 sequence before opening Windows adapter implementation.
|
||||||
1897
docs/plans/2026-02-19-feat-agent-desktop-phase1-foundation-plan.md
Normal file
1897
docs/plans/2026-02-19-feat-agent-desktop-phase1-foundation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,260 @@
|
||||||
|
---
|
||||||
|
title: "feat: Context Menu, Popup, and Overlay Handling via Accessibility APIs"
|
||||||
|
type: feat
|
||||||
|
date: 2026-02-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Context Menu, Popup, and Overlay Handling via Accessibility APIs
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Context menus, sheets, dialogs, and popovers are transient surfaces that appear and disappear outside the normal window hierarchy. The current `snapshot` command misses them entirely because `list_windows` filters `kCGWindowLayer != 0` and `window_element_for` only queries `kAXWindowsAttribute`. This plan introduces targeted surface commands that read exactly the live transient surface — no full-tree traversal required.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
1. **Context menus are invisible to snapshot.** Right-clicking in any app opens a menu at CG layer 8. `list_windows` rejects all `layer != 0` entries. `AXWindowsAttribute` does not include menus. An agent cannot read what is in the menu.
|
||||||
|
|
||||||
|
2. **Sheets and dialogs are not distinguished.** Save dialogs, alert panels, and popovers appear as children of windows but are not surfaced as distinct snapshot targets. An agent cannot tell if a modal is blocking interaction.
|
||||||
|
|
||||||
|
3. **No way to wait for a menu or popup.** `wait --element` polls the ref tree. There is no primitive to block until a context menu opens or a sheet appears.
|
||||||
|
|
||||||
|
4. **Comment noise.** Inline comments throughout the macOS crate explain implementation details that should be self-evident from names.
|
||||||
|
|
||||||
|
## Key Research Findings
|
||||||
|
|
||||||
|
### How macOS exposes transient surfaces via AX
|
||||||
|
|
||||||
|
| Surface | AX API | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Context menu (open) | `AXUIElementCopyAttributeValue(app, "AXMenus", …)` | Returns array of open `AXMenu` elements. Empty when closed. O(1) — no tree scan. |
|
||||||
|
| Context menu (notification) | `kAXMenuOpenedNotification` on the app element | Delivers the menu AXElement directly. |
|
||||||
|
| Focused window's sheet/dialog | `kAXFocusedWindowAttribute` on app element | Returns whatever window (or sheet) currently has focus. |
|
||||||
|
| Sheet attached to a window | Child with `AXSubrole == "AXSheet"` | Always a child of the parent window. |
|
||||||
|
| Alert/dialog | `kAXSubrole == "AXDialog"` or `kAXModalAttribute == true` | System alerts use subrole; app dialogs may use modal attribute. |
|
||||||
|
| Popover | `kAXSubrole == "AXPopover"` | Floats above the window hierarchy. |
|
||||||
|
|
||||||
|
### CG window layers (for reference, not for menu detection)
|
||||||
|
|
||||||
|
| Layer | Surface |
|
||||||
|
|-------|---------|
|
||||||
|
| 0 | Normal windows |
|
||||||
|
| 3 | Panels / popovers |
|
||||||
|
| 8 | Context menus |
|
||||||
|
| 20 | Alerts |
|
||||||
|
|
||||||
|
Using CG layers to find menus requires cross-referencing PID and is slower than reading `"AXMenus"` directly.
|
||||||
|
|
||||||
|
### `AXUIElementCreateSystemWide()`
|
||||||
|
|
||||||
|
Returns the system-wide accessibility element. `kAXFocusedApplicationAttribute` on it yields the currently focused app element. Useful when the caller has not already identified the target PID.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Phase 1 — `snapshot --surface` flag (new surface targeting)
|
||||||
|
|
||||||
|
Extend `SnapshotArgs` with `--surface <surface>` (default: `window`).
|
||||||
|
|
||||||
|
| `--surface` value | What is captured | Implementation |
|
||||||
|
|-------------------|-----------------|----------------|
|
||||||
|
| `window` | Current focused window (existing behavior) | unchanged |
|
||||||
|
| `focused` | Whatever `kAXFocusedWindowAttribute` returns (window, sheet, or dialog) | single AX attribute read, then build_subtree on result |
|
||||||
|
| `menu` | Open context menu on the target app | read `"AXMenus"` attribute on app element; error if no open menu |
|
||||||
|
| `sheet` | Sheet attached to focused window | walk `kAXChildrenAttribute` of focused window, find first `AXSubrole == "AXSheet"` |
|
||||||
|
| `popover` | Floating popover | walk children for `AXSubrole == "AXPopover"` |
|
||||||
|
| `alert` | Modal alert/dialog | walk children for `kAXModalAttribute == true` or `AXSubrole == "AXDialog"` |
|
||||||
|
|
||||||
|
All surfaces build their subtree with the existing `build_subtree` function. No new traversal logic needed.
|
||||||
|
|
||||||
|
### Phase 2 — `wait --menu` and `wait --popup`
|
||||||
|
|
||||||
|
Extend the `wait` command with surface-aware variants that use `AXObserver` notifications rather than polling:
|
||||||
|
|
||||||
|
| Flag | Notification | Timeout behavior |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| `wait --menu` | `kAXMenuOpenedNotification` on app element | error `TIMEOUT` if menu does not appear within `--timeout` ms |
|
||||||
|
| `wait --menu-closed` | `kAXMenuClosedNotification` | waits until menu dismisses |
|
||||||
|
| `wait --popup` | poll `"AXMenus"` or watch for `AXSubrole==AXPopover` child | polling fallback (250ms interval) |
|
||||||
|
|
||||||
|
`AXObserver` requires a `CFRunLoop`. The implementation spins a dedicated thread, runs the loop for the duration of the timeout, then shuts it down. This is safe for single-shot CLI use — no persistent daemon needed in Phase 1.
|
||||||
|
|
||||||
|
### Phase 3 — `list-surfaces` command
|
||||||
|
|
||||||
|
Enumerate all currently visible transient surfaces for a given app:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"surfaces": [
|
||||||
|
{ "type": "menu", "title": "Edit", "item_count": 12 },
|
||||||
|
{ "type": "sheet", "title": "Save", "ref": "@e1" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implemented by reading `"AXMenus"`, inspecting `kAXFocusedWindowAttribute` children for sheets/popovers/dialogs, and returning a flat list. No tree traversal.
|
||||||
|
|
||||||
|
### Phase 4 — Comment cleanup
|
||||||
|
|
||||||
|
Remove all inline `//` comments from macOS crate files. Retain `///` doc-comments on public items where the function name alone is insufficient.
|
||||||
|
|
||||||
|
Files to clean: `tree.rs`, `adapter.rs`, `actions.rs`, `screenshot.rs`, `app_ops.rs`, `input.rs`, `roles.rs`, `permissions.rs`.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1: Add `--surface` to `SnapshotArgs`
|
||||||
|
|
||||||
|
**File:** `src/cli.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(clap::ValueEnum, Clone, Debug, Default)]
|
||||||
|
pub enum Surface {
|
||||||
|
#[default]
|
||||||
|
Window,
|
||||||
|
Focused,
|
||||||
|
Menu,
|
||||||
|
Sheet,
|
||||||
|
Popover,
|
||||||
|
Alert,
|
||||||
|
}
|
||||||
|
|
||||||
|
// In SnapshotArgs:
|
||||||
|
#[arg(long, value_enum, default_value_t = Surface::Window)]
|
||||||
|
pub surface: Surface,
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/adapter.rs` — extend `ScreenshotTarget` analog with a `SnapshotSurface` enum mirroring the CLI enum.
|
||||||
|
|
||||||
|
### Step 2: Surface resolution in macOS adapter
|
||||||
|
|
||||||
|
**File:** `crates/macos/src/tree.rs` — add `menu_element_for_pid`, `focused_surface_for_pid`, `sheet_for_window`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Returns the first open context menu element for the given PID, if any.
|
||||||
|
pub fn menu_element_for_pid(pid: i32) -> Option<AXElement> { … }
|
||||||
|
|
||||||
|
/// Returns whatever kAXFocusedWindowAttribute points to (window, sheet, or dialog).
|
||||||
|
pub fn focused_surface_for_pid(pid: i32) -> Option<AXElement> { … }
|
||||||
|
|
||||||
|
/// Returns the first AXSheet child of the given window element.
|
||||||
|
pub fn sheet_for_window(win: &AXElement) -> Option<AXElement> { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
These are all single attribute reads — O(1) AX calls before any tree traversal begins.
|
||||||
|
|
||||||
|
**File:** `crates/macos/src/adapter.rs` — update `get_tree` to dispatch on `SnapshotSurface`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn get_tree(&self, win: &WindowInfo, opts: &TreeOptions) -> Result<AccessibilityNode, AdapterError> {
|
||||||
|
let el = match opts.surface {
|
||||||
|
SnapshotSurface::Window => crate::tree::window_element_for(win.pid, &win.title),
|
||||||
|
SnapshotSurface::Focused => crate::tree::focused_surface_for_pid(win.pid)
|
||||||
|
.ok_or_else(|| AdapterError::internal("no focused surface"))?,
|
||||||
|
SnapshotSurface::Menu => crate::tree::menu_element_for_pid(win.pid)
|
||||||
|
.ok_or_else(|| AdapterError::not_found("no open context menu"))?,
|
||||||
|
SnapshotSurface::Sheet => { … }
|
||||||
|
SnapshotSurface::Popover => { … }
|
||||||
|
SnapshotSurface::Alert => { … }
|
||||||
|
};
|
||||||
|
let mut visited = FxHashSet::default();
|
||||||
|
crate::tree::build_subtree(&el, 0, opts.max_depth, opts.include_bounds, &mut visited)
|
||||||
|
.ok_or_else(|| AdapterError::internal("empty tree"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: `wait --menu` via AXObserver
|
||||||
|
|
||||||
|
**File:** `crates/macos/src/wait.rs` (new file, ≤ 150 LOC)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Blocks until a context menu opens on the given PID or the timeout elapses.
|
||||||
|
pub fn wait_for_menu(pid: i32, timeout_ms: u64) -> Result<(), AdapterError> { … }
|
||||||
|
|
||||||
|
/// Blocks until the context menu on the given PID closes or the timeout elapses.
|
||||||
|
pub fn wait_for_menu_closed(pid: i32, timeout_ms: u64) -> Result<(), AdapterError> { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation uses `AXObserverCreate`, `AXObserverAddNotification` with `kAXMenuOpenedNotification`/`kAXMenuClosedNotification`, then runs a `CFRunLoop` with a deadline timer on a dedicated thread. The main thread joins with the specified timeout.
|
||||||
|
|
||||||
|
**File:** `crates/macos/src/lib.rs` — `pub mod wait;`
|
||||||
|
|
||||||
|
### Step 4: Extend `WaitArgs` in CLI
|
||||||
|
|
||||||
|
**File:** `src/cli.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Wait for a context menu to open (--menu) or close (--menu-closed)
|
||||||
|
#[arg(long)]
|
||||||
|
pub menu: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
pub menu_closed: bool,
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/wait.rs` — dispatch to `adapter.wait_for_surface(…)` with new `WaitSurface` enum.
|
||||||
|
|
||||||
|
### Step 5: `list-surfaces` command
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/list_surfaces.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ListSurfacesArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub app: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"ok": true,
|
||||||
|
"command": "list-surfaces",
|
||||||
|
"data": {
|
||||||
|
"surfaces": [
|
||||||
|
{ "type": "menu", "item_count": 8 },
|
||||||
|
{ "type": "sheet", "title": "Save" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Comment cleanup
|
||||||
|
|
||||||
|
Remove all inline `//` comments from `crates/macos/src/*.rs`. Rules:
|
||||||
|
- Delete comments that restate what the code does
|
||||||
|
- Delete comments that explain macOS constant values (constants are named)
|
||||||
|
- Keep `///` doc-comments on `pub` functions where the name alone is insufficient
|
||||||
|
- Keep `// SAFETY:` and `// kAX...` references where they provide non-obvious AX API context
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `agent-desktop snapshot --app WhatsApp --surface menu` returns the open context menu tree within 0.5s (no full tree traversal)
|
||||||
|
- [ ] `agent-desktop snapshot --app Finder --surface sheet` returns the save sheet when one is open
|
||||||
|
- [ ] `agent-desktop snapshot --app Finder --surface focused` returns a focused dialog, sheet, or window — whichever has focus
|
||||||
|
- [ ] `agent-desktop wait --app TextEdit --menu --timeout 5000` blocks until a context menu opens and exits 0; exits with `TIMEOUT` error if none appears in 5s
|
||||||
|
- [ ] `agent-desktop list-surfaces --app Finder` lists all currently visible transient surfaces
|
||||||
|
- [ ] All existing 30 commands continue to pass
|
||||||
|
- [ ] No inline `//` comments remain in `crates/macos/src/`
|
||||||
|
- [ ] Snapshot of a context menu does NOT traverse the full app tree first
|
||||||
|
- [ ] `--surface menu` returns `APP_NOT_FOUND`-equivalent error (`ELEMENT_NOT_FOUND`) when no menu is open
|
||||||
|
|
||||||
|
## File Checklist
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/cli.rs` | Add `--surface`, `--menu`, `--menu-closed` args |
|
||||||
|
| `crates/core/src/adapter.rs` | Add `SnapshotSurface` enum to `TreeOptions`; add `wait_for_surface` trait method |
|
||||||
|
| `crates/core/src/commands/snapshot.rs` | Pass `surface` from args to `TreeOptions` |
|
||||||
|
| `crates/core/src/commands/wait.rs` | Add `WaitSurface` dispatch |
|
||||||
|
| `crates/core/src/commands/list_surfaces.rs` | New file |
|
||||||
|
| `crates/core/src/commands/mod.rs` | Register `list_surfaces` |
|
||||||
|
| `crates/macos/src/tree.rs` | Add `menu_element_for_pid`, `focused_surface_for_pid`, `sheet_for_window` |
|
||||||
|
| `crates/macos/src/adapter.rs` | Dispatch on `SnapshotSurface` in `get_tree`; implement `wait_for_surface` |
|
||||||
|
| `crates/macos/src/wait.rs` | New file — AXObserver-based surface waiting |
|
||||||
|
| `crates/macos/src/lib.rs` | `pub mod wait` |
|
||||||
|
| `crates/macos/src/*.rs` | Remove inline comments |
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Does NOT use `ScreenshotBackend legacy capture` for menu capture (screenshot of a menu is separate from its AX tree)
|
||||||
|
- Does NOT implement persistent AXObserver across invocations (Phase 4 daemon scope)
|
||||||
|
- Does NOT handle Electron/custom-rendered menus that bypass the AX API
|
||||||
|
- Does NOT add Windows or Linux surface detection (Phase 2)
|
||||||
987
docs/plans/2026-02-19-fix-bugs-add-missing-commands-plan.md
Normal file
987
docs/plans/2026-02-19-fix-bugs-add-missing-commands-plan.md
Normal file
|
|
@ -0,0 +1,987 @@
|
||||||
|
---
|
||||||
|
title: "fix: Phase 1 bug fixes, AX-first execution, missing commands, adapter-ready architecture"
|
||||||
|
type: fix
|
||||||
|
date: 2026-02-19
|
||||||
|
deepened: 2026-02-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# fix: Phase 1 Bug Fixes + AX-First Execution + Missing Commands + Adapter-Ready Architecture
|
||||||
|
|
||||||
|
## Enhancement Summary
|
||||||
|
|
||||||
|
**Deepened on:** 2026-02-19
|
||||||
|
**Plans merged:** `fix-bugs-add-missing-commands-plan.md` + `fix-subtree-traversal-error-dedup-ax-only-plan.md`
|
||||||
|
**Research agents used:** AX-only execution, select semantics, agent-browser gap analysis, sub-window tree traversal, stderr duplication, cross-platform adapter design, drag-and-drop feasibility
|
||||||
|
|
||||||
|
### Key Improvements Over Original Plan
|
||||||
|
1. **AX-first philosophy**: Every command uses Accessibility API as primary execution path. CGEvent/osascript kept ONLY for operations with no AX equivalent (drag, hover, modified key combos without menu bar entries)
|
||||||
|
2. **Corrected root causes**: Stderr duplication is NOT a code bug (removed from plan). Sub-window tree fix is a ONE-LINE change in `list_windows_impl`, not `window_element_for`
|
||||||
|
3. **Role-aware `select`**: Branches by AX role — AXPopUpButton (press→menu→press item), AXComboBox (set kAXValueAttribute), AXList/AXTable (set kAXSelectedChildrenAttribute)
|
||||||
|
4. **New commands from agent-browser gap analysis**: `check`/`uncheck` (idempotent), `scroll-to`, `wait --text`, `find` enhancements (`--count`, `--first`, `--nth`), `hover`, `drag`
|
||||||
|
5. **Adapter extensibility**: Flat `#[non_exhaustive]` Action enum, WindowOp enum, MouseEvent struct, single `mouse_event` method
|
||||||
|
|
||||||
|
### AX-First Principle
|
||||||
|
|
||||||
|
> Every command MUST use the Accessibility API as its primary execution mechanism. CGEvent mouse/keyboard synthesis and osascript are NEVER the primary path. They are kept ONLY as documented fallbacks for operations that have no AX equivalent (drag, hover, arbitrary screen-coordinate clicks, modified key combos not in any menu bar).
|
||||||
|
|
||||||
|
**AX-Only Commands** (no CGEvent/osascript at all):
|
||||||
|
- `click`, `right-click`, `toggle`, `select`, `expand`, `collapse`, `set-value`, `focus`, `type` (via kAXValueAttribute)
|
||||||
|
- `focus-window` (via kAXMainAttribute + kAXRaiseAction)
|
||||||
|
- `close-app` graceful (via AX menu bar "Quit" item or kAXCloseButtonAttribute)
|
||||||
|
- `press` for Return/Escape/Space (via kAXConfirmAction/kAXCancelAction/kAXPressAction)
|
||||||
|
- `press` for cmd+shortcuts (via AX menu bar traversal matching kAXMenuItemCmdChar)
|
||||||
|
- `scroll` (via AXScrollBar kAXIncrementAction/kAXDecrementAction)
|
||||||
|
- `check`/`uncheck` (via kAXValueAttribute set on checkbox/switch)
|
||||||
|
- Window geometry: `resize-window`, `move-window`, `minimize`, `maximize`, `restore`
|
||||||
|
|
||||||
|
**Hybrid Commands** (AX resolve coordinates → CGEvent execute):
|
||||||
|
- `drag` — AX resolves element bounds, CGEvent performs LeftMouseDown→Dragged→Up
|
||||||
|
- `hover` — AX resolves bounds, CGEvent MouseMoved (tooltips require real cursor movement)
|
||||||
|
- `mouse-move`, `mouse-click`, `mouse-down`, `mouse-up` — coordinate-based, CGEvent only
|
||||||
|
|
||||||
|
**CGEvent-Only Commands** (no AX equivalent exists):
|
||||||
|
- `key-down`/`key-up` — holding modifier keys has no AX equivalent
|
||||||
|
- `press` for arbitrary key combos not in menu bar (e.g., `f5`, `ctrl+left`) — falls back to `AXUIElementPostKeyboardEvent`, then returns `ACTION_NOT_SUPPORTED` if both fail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A: Critical Bug Fixes (P0 + P1)
|
||||||
|
|
||||||
|
### A1. Fix sub-window tree missing elements
|
||||||
|
|
||||||
|
**Problem:** `snapshot --app "System Settings"` only captures the sidebar; the content pane is never reached.
|
||||||
|
|
||||||
|
**Root Cause (CORRECTED from original plan):** The bug is NOT in `window_element_for` or `build_subtree`. It's in `list_windows_impl` in `crates/macos/src/adapter.rs`. The CGWindow enumeration filters out windows with empty `kCGWindowName` via a `_ => continue` match arm. System Settings has a window whose CGWindow title is empty, so it's silently skipped. The content pane is NOT a separate AXWindow — it's inside a single AXSplitGroup within the one AXWindow, and `build_subtree` already recurses `kAXChildrenAttribute`, so it would find the content pane IF the window weren't filtered out.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/adapter.rs`
|
||||||
|
|
||||||
|
**Fix:** ONE-LINE change in `list_windows_impl`:
|
||||||
|
```rust
|
||||||
|
// BEFORE (adapter.rs, inside the CGWindow title match):
|
||||||
|
_ => continue,
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
_ => app_name.clone(),
|
||||||
|
```
|
||||||
|
|
||||||
|
When `kCGWindowName` is empty or missing, use the application name as fallback instead of skipping the window entirely. This ensures System Settings' content pane window (and similar empty-titled windows in other apps) appears in the window list and gets traversed.
|
||||||
|
|
||||||
|
**Verification:** `agent-desktop snapshot --app "System Settings"` returns both sidebar AND content pane elements (AXOutline + content controls like AXCheckBox, AXPopUpButton, etc.)
|
||||||
|
|
||||||
|
### A2. Fix `select` false success (P0)
|
||||||
|
|
||||||
|
**Problem:** `select @e4 "Courier"` returns `ok:true` but value stays "Helvetica".
|
||||||
|
|
||||||
|
**Root cause:** `actions.rs` maps `Action::Select` to a bare `kAXPressAction` click — it just clicks the element instead of performing role-aware selection.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/actions.rs`
|
||||||
|
|
||||||
|
**Critical insight from research:** Popup menu children DON'T EXIST in the AX tree until the menu is physically opened. You cannot query kAXChildrenAttribute on a closed AXPopUpButton to enumerate its options. The `select` command's purpose is to SET a value when the caller already knows what value they want. Option DISCOVERY uses a different workflow: `click @popup` → `snapshot` → see menu items → `click @menuitem`.
|
||||||
|
|
||||||
|
**Fix — role-aware branching:**
|
||||||
|
|
||||||
|
1. **AXPopUpButton** (dropdown menus):
|
||||||
|
- `AXUIElementPerformAction(kAXPressAction)` to open the menu
|
||||||
|
- Wait up to 500ms for menu to appear (poll kAXChildrenAttribute on the popup)
|
||||||
|
- Walk menu children, find `AXMenuItem` whose `kAXTitleAttribute` matches the target value (case-insensitive, trimmed)
|
||||||
|
- `AXUIElementPerformAction(kAXPressAction)` on the matched menu item
|
||||||
|
- If no match found: press Escape to close menu, return `ELEMENT_NOT_FOUND` with suggestion listing available options
|
||||||
|
- Read back `kAXValueAttribute` to confirm change, include in `post_state`
|
||||||
|
|
||||||
|
2. **AXComboBox** (editable dropdowns):
|
||||||
|
- `AXUIElementSetAttributeValue(kAXValueAttribute, value)` directly
|
||||||
|
- Read back to confirm, include in `post_state`
|
||||||
|
|
||||||
|
3. **AXList / AXTable** (list selections):
|
||||||
|
- Find child element matching `value` by name
|
||||||
|
- `AXUIElementSetAttributeValue(kAXSelectedChildrenAttribute, [matched_child])`
|
||||||
|
- Read back to confirm
|
||||||
|
|
||||||
|
4. **Any other role**: Return `ACTION_NOT_SUPPORTED` with suggestion: "Element role '{role}' doesn't support select. Use 'click' or 'set-value' instead."
|
||||||
|
|
||||||
|
### A3. Fix `toggle` false success (P0)
|
||||||
|
|
||||||
|
**Problem:** `toggle @e1` on a textfield returns `ok:true` with no effect.
|
||||||
|
|
||||||
|
**Root cause:** `actions.rs` maps `Action::Toggle` to `kAXPressAction` unconditionally — no role check.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/actions.rs`, `crates/core/src/commands/toggle.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Before executing, check element role from RefEntry
|
||||||
|
- Supported roles: `checkbox`, `switch`, `radiobutton`, `togglebutton`, `menuitemcheckbox`, `menuitemradio`
|
||||||
|
- For supported roles: `AXUIElementPerformAction(kAXPressAction)`, then read back `kAXValueAttribute` to confirm state changed
|
||||||
|
- For unsupported roles: return `ACTION_NOT_SUPPORTED` with suggestion: "Toggle only works on checkboxes, switches, and radio buttons. Use 'click' for other elements."
|
||||||
|
|
||||||
|
### A4. Fix `launch` window detection (P1)
|
||||||
|
|
||||||
|
**Problem:** `launch Calculator` returns error "no window found" even though the app launched fine.
|
||||||
|
|
||||||
|
**Root cause:** `app_ops.rs::launch_app_impl` non-wait path only sleeps 500ms. Many apps need 2-5s for window.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/app_ops.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Default behavior: poll `list_windows()` every 200ms for up to 5s (matching the wait path's pattern)
|
||||||
|
- When the app process exists but no window: return success with `"window": null` and `"note": "App launched but no window detected yet. Use 'wait --window' to poll."`
|
||||||
|
- Only return error if the `open -a` command itself fails (non-zero exit code)
|
||||||
|
|
||||||
|
### A5. Fix `close-app` not actually closing (P1)
|
||||||
|
|
||||||
|
**Problem:** `close-app Calculator` returns `ok:true` but app keeps running.
|
||||||
|
|
||||||
|
**Root cause:** The graceful quit via osascript doesn't verify termination.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/app_ops.rs`
|
||||||
|
|
||||||
|
**Fix (AX-first):**
|
||||||
|
- **Primary:** Find "Quit" menu item via AX menu bar traversal (`kAXMenuBarAttribute` → last `AXMenuBarItem` → walk `AXMenuItem` children → find item with title containing "Quit"), then `kAXPressAction` on it
|
||||||
|
- **Fallback:** If no menu bar accessible, use `AXUIElementSetAttributeValue(kAXHiddenAttribute)` or the existing osascript quit
|
||||||
|
- After sending quit, poll `list_apps()` every 200ms for up to 3s to verify app exited
|
||||||
|
- If still running after timeout: return `{ "ok": true, "data": { "closed": false, "note": "Quit requested but app may still be running. Use --force to kill." } }`
|
||||||
|
|
||||||
|
### A6. Fix `expand`/`collapse` wrong error code (P1)
|
||||||
|
|
||||||
|
**Problem:** Returns `ACTION_FAILED` with raw AX error code (-25205) instead of `ACTION_NOT_SUPPORTED`.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/actions.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Before attempting `AXExpand`/`AXCollapse`, call `AXUIElementCopyActionNames` and check if the action string exists
|
||||||
|
- If not present: return `AdapterError::new(ErrorCode::ActionNotSupported, "This element doesn't support expand/collapse").with_suggestion("Try 'click' to open it instead.")`
|
||||||
|
- If present but fails: keep `ACTION_FAILED` with the AX error detail
|
||||||
|
|
||||||
|
### A7. Fix `get --property bounds` returning null
|
||||||
|
|
||||||
|
**Problem:** `get @e1 --property bounds` returns `null` even though element has screen position.
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/get.rs`, `crates/macos/src/adapter.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `get bounds` should resolve the element handle via `resolve_element`, then query `kAXPositionAttribute` + `kAXSizeAttribute` LIVE from the AX tree
|
||||||
|
- Don't rely on RefEntry's cached bounds — query the adapter directly
|
||||||
|
- Add `get_element_bounds(handle: &NativeHandle) -> Result<Option<Rect>>` to PlatformAdapter trait (with default `not_supported()`)
|
||||||
|
- macOS impl queries `AXUIElementCopyAttributeValue` for position and size
|
||||||
|
|
||||||
|
### A8. Fix `focus-window` to use AX instead of osascript
|
||||||
|
|
||||||
|
**Problem:** `focus_window_impl` uses `osascript` → `tell application X to activate`.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/app_ops.rs`
|
||||||
|
|
||||||
|
**Fix (AX-first):**
|
||||||
|
- Get AXApplication element for the target PID
|
||||||
|
- `AXUIElementSetAttributeValue(appEl, kAXFrontmostAttribute, kCFBooleanTrue)` to bring app to front
|
||||||
|
- Get the target window element
|
||||||
|
- `AXUIElementSetAttributeValue(winEl, kAXMainAttribute, kCFBooleanTrue)` to make it the main window
|
||||||
|
- `AXUIElementPerformAction(winEl, kAXRaiseAction)` to raise it
|
||||||
|
- Remove osascript path entirely
|
||||||
|
|
||||||
|
### A9. Replace CGEvent fallbacks in action execution
|
||||||
|
|
||||||
|
**Problem:** `click`, `right-click`, `toggle`, `select` all fall back to `cg_mouse_click` CGEvent synthesis when AX action fails.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/actions.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- **`Action::Click`**: `kAXPressAction` only. If AX error, propagate `ACTION_FAILED` (no CGEvent fallback)
|
||||||
|
- **`Action::RightClick`**: `AXShowMenu` only. Remove CGEvent fallback. Already implemented correctly; just delete the fallback branch
|
||||||
|
- **`Action::DoubleClick`**: Try `AXOpen` first. If unsupported, `kAXPressAction` twice with 50ms sleep between. If still fails, return `ACTION_FAILED`
|
||||||
|
- **`Action::Toggle`**: `kAXPressAction` only after role validation (A3). Remove CGEvent fallback
|
||||||
|
- **`Action::Select`**: Role-aware AX implementation (A2). Remove CGEvent fallback
|
||||||
|
- **`Action::Scroll`**: Replace `CGEvent::new_scroll_event` with AX scroll (see A10)
|
||||||
|
- Remove `cg_mouse_click` helper function entirely from `actions.rs`
|
||||||
|
- Remove CGEvent mouse imports from `actions.rs`
|
||||||
|
|
||||||
|
### A10. Replace CGEvent scroll with AX scroll
|
||||||
|
|
||||||
|
**Problem:** `Action::Scroll` uses `CGEvent::new_scroll_event` (HID injection).
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/actions.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Find the `AXScrollArea` or `AXScrollBar` associated with the target element
|
||||||
|
- For vertical scroll: find the vertical `AXScrollBar` child, then:
|
||||||
|
- Scroll down: `AXUIElementPerformAction(scrollBar, kAXIncrementAction)` repeated `amount` times
|
||||||
|
- Scroll up: `AXUIElementPerformAction(scrollBar, kAXDecrementAction)` repeated `amount` times
|
||||||
|
- For horizontal: same with horizontal `AXScrollBar` and corresponding actions
|
||||||
|
- If no scroll bar found: return `ACTION_NOT_SUPPORTED` with suggestion "Element is not scrollable"
|
||||||
|
- Remove `CGEvent::new_scroll_event` import and usage
|
||||||
|
|
||||||
|
### A11. Replace CGEvent keyboard input with AX equivalents
|
||||||
|
|
||||||
|
**Problem:** `Action::TypeText` uses `synthesize_text` (CGEvent keyboard per character). `Action::PressKey` uses `synthesize_key` (CGEvent keyboard).
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/actions.rs`, `crates/macos/src/input.rs`
|
||||||
|
|
||||||
|
**Fix for TypeText (AX-first):**
|
||||||
|
- Set `kAXFocusedAttribute = true` on target element
|
||||||
|
- Read current `kAXValueAttribute`
|
||||||
|
- `AXUIElementSetAttributeValue(kAXValueAttribute, newText)` — this replaces the full value
|
||||||
|
- For append semantics: read current value, concatenate, set combined value
|
||||||
|
- The `type` command already has this distinction: if ref provided, set value on ref. If no ref, this is a "type into focused element" which still needs kAXValueAttribute set on the focused element
|
||||||
|
|
||||||
|
**Fix for PressKey (AX-first, multi-strategy):**
|
||||||
|
1. **Simple keys → AX actions on focused element:**
|
||||||
|
- `return`/`enter` → `kAXConfirmAction`
|
||||||
|
- `escape`/`esc` → `kAXCancelAction`
|
||||||
|
- `space` → `kAXPressAction`
|
||||||
|
- `tab` → `kAXNextContentsAction` or move focus
|
||||||
|
- Arrow keys on sliders → `kAXIncrementAction` / `kAXDecrementAction`
|
||||||
|
|
||||||
|
2. **Modifier combos → AX menu bar traversal:**
|
||||||
|
- Get `kAXMenuBarAttribute` from app element
|
||||||
|
- Walk `AXMenuBarItem` → expand each → walk `AXMenuItem` children
|
||||||
|
- Match `kAXMenuItemCmdChar` (e.g., "C" for Cmd+C) and `kAXMenuItemCmdModifiers`
|
||||||
|
- `kAXPressAction` on matched `AXMenuItem`
|
||||||
|
|
||||||
|
3. **Fallback for keys with no AX equivalent:**
|
||||||
|
- `AXUIElementPostKeyboardEvent(appEl, 0, keyCode, true/false)` — this is technically AX API (not CGEvent), available in accessibility-sys
|
||||||
|
- If that also fails: return `ACTION_NOT_SUPPORTED` with message: "No AX equivalent for key combo '{combo}'. This combo has no menu-bar action."
|
||||||
|
|
||||||
|
4. **Remove `synthesize_key` and `synthesize_text` from `input.rs`**
|
||||||
|
- If `input.rs` becomes empty, delete it and remove from `lib.rs`
|
||||||
|
|
||||||
|
### A12. Replace osascript in `press_for_app_impl`
|
||||||
|
|
||||||
|
**Problem:** `press --app TextEdit "cmd+c"` uses osascript System Events `keystroke`/`key code`.
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/app_ops.rs`
|
||||||
|
|
||||||
|
**Fix (AX-first):**
|
||||||
|
- Activate target app via AX: `AXUIElementSetAttributeValue(appEl, kAXFrontmostAttribute, kCFBooleanTrue)`
|
||||||
|
- Use the same AX menu bar traversal strategy from A11 step 2
|
||||||
|
- If menu bar match found: `kAXPressAction` on the `AXMenuItem`
|
||||||
|
- If no match: `AXUIElementPostKeyboardEvent` fallback
|
||||||
|
- Remove osascript `keystroke`/`key code` path entirely
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B: Improvements
|
||||||
|
|
||||||
|
### B1. Fix `list-apps` data shape + bundle_id
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/list_apps.rs`, `crates/macos/src/adapter.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Wrap `data` in `{"apps": [...]}` instead of bare array
|
||||||
|
- Populate `bundle_id` using `kAXBundleIdentifierAttribute` on the AXApplication element (pure AX, no osascript)
|
||||||
|
- Add `bundle_id: Option<String>` to `AppInfo` struct if not already present
|
||||||
|
|
||||||
|
### B2. Fix `is` returning `false` vs "not applicable"
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/is_check.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Return `{ "result": false, "applicable": false }` when querying a property that doesn't apply to the element's role
|
||||||
|
- Applicability rules:
|
||||||
|
- `checked` → applies to: checkbox, switch, radiobutton, menuitemcheckbox
|
||||||
|
- `expanded` → applies to: treeitem, combobox, disclosure, outlinerow
|
||||||
|
- `focused` → applies to: ALL interactive roles
|
||||||
|
- `visible` → applies to: ALL roles
|
||||||
|
- `enabled` → applies to: ALL interactive roles
|
||||||
|
|
||||||
|
### B3. Add `--help` descriptions to all commands and flags
|
||||||
|
|
||||||
|
**Files:** `src/cli.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Add `#[command(about = "...")]` to every `Commands` variant
|
||||||
|
- Add `#[arg(help = "...")]` to every flag
|
||||||
|
- Descriptions should be one-line, imperative mood, agent-oriented
|
||||||
|
|
||||||
|
### B4. Improve `find` output for unnamed elements
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/find.rs`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- When `name` is null, fall back to `description`, then `title`, then `"(unnamed {role})"`
|
||||||
|
- Include `description` field in find results when available
|
||||||
|
|
||||||
|
### B5. Filter stale menu items from window snapshots
|
||||||
|
|
||||||
|
**Files:** `crates/macos/src/adapter.rs` or snapshot engine
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- In `snapshot --surface window`: filter out nodes with role `menuitem` or `menu` that are direct children of the app/window root (not nested inside actual window content)
|
||||||
|
- This handles the race condition where pressing escape doesn't immediately remove menu items from the AX tree
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C: New Commands — Idempotent State Control
|
||||||
|
|
||||||
|
These commands address a critical gap: agents need idempotent state-setting, not just toggling.
|
||||||
|
|
||||||
|
### C1. `check` / `uncheck` commands
|
||||||
|
|
||||||
|
**Problem:** `toggle` is non-idempotent — calling it twice returns to original state. An agent that wants a checkbox ON must first query state, then conditionally toggle. `check` and `uncheck` are idempotent: `check` is always a no-op if already checked.
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop check @e5
|
||||||
|
agent-desktop uncheck @e5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/check.rs`, `crates/core/src/commands/uncheck.rs`
|
||||||
|
|
||||||
|
**New Action variants:** `Action::Check`, `Action::Uncheck`
|
||||||
|
|
||||||
|
**Implementation (macOS, AX-only):**
|
||||||
|
1. Resolve element, verify role is checkbox/switch/radiobutton/menuitemcheckbox
|
||||||
|
2. Read current `kAXValueAttribute` (0 = unchecked, 1 = checked)
|
||||||
|
3. For `check`: if already 1, return success with `"already_checked": true`. If 0, `kAXPressAction`
|
||||||
|
4. For `uncheck`: if already 0, return success with `"already_unchecked": true`. If 1, `kAXPressAction`
|
||||||
|
5. Read back value to confirm state change
|
||||||
|
6. For unsupported roles: return `ACTION_NOT_SUPPORTED`
|
||||||
|
|
||||||
|
**Cross-platform:** All platforms support querying checkbox state and pressing — this is pure AX.
|
||||||
|
|
||||||
|
### C2. `scroll-to` command (scroll element into view)
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop scroll-to @e15
|
||||||
|
agent-desktop scroll-to @e15 --align top
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/scroll_to.rs`
|
||||||
|
|
||||||
|
**Implementation (macOS, AX-only):**
|
||||||
|
1. Resolve target element
|
||||||
|
2. Query `kAXVisibleCharacterRangeAttribute` or check if element bounds are within scroll area visible rect
|
||||||
|
3. If not visible: find parent scroll area, use `AXUIElementPerformAction(kAXScrollToVisibleAction)` on the target element (this is a native AX action that scrolls the element into view)
|
||||||
|
4. If `kAXScrollToVisibleAction` not available: incrementally `kAXIncrementAction`/`kAXDecrementAction` on the scroll bar until element bounds are within viewport
|
||||||
|
|
||||||
|
**Cross-platform:** `kAXScrollToVisibleAction` is macOS. Windows has `IUIAutomationScrollItemPattern.ScrollIntoView()`. Linux AT-SPI has `scroll_to` in Component interface.
|
||||||
|
|
||||||
|
### C3. `wait --text` variant
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop wait --text "Loading complete" --app TextEdit --timeout 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/wait.rs` (extend existing)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Poll the app's AX tree every 200ms
|
||||||
|
- Search for any element whose `kAXValueAttribute` or `kAXTitleAttribute` or `kAXDescriptionAttribute` contains the target text (case-insensitive substring match)
|
||||||
|
- Return the matching element's ref when found
|
||||||
|
- Return `TIMEOUT` if not found within timeout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D: Enhanced `find` Command
|
||||||
|
|
||||||
|
### D1. `find --count` — return count only
|
||||||
|
|
||||||
|
**CLI:** `agent-desktop find --app TextEdit --role button --count`
|
||||||
|
|
||||||
|
**Output:** `{ "count": 3 }` instead of the full element list.
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/find.rs`
|
||||||
|
|
||||||
|
### D2. `find --first` / `find --last` / `find --nth N`
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop find --app TextEdit --role button --first
|
||||||
|
agent-desktop find --app TextEdit --role button --last
|
||||||
|
agent-desktop find --app TextEdit --role button --nth 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** Single element instead of array.
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/find.rs`
|
||||||
|
|
||||||
|
### D3. `find --text` — search by text content
|
||||||
|
|
||||||
|
**CLI:** `agent-desktop find --app TextEdit --text "Save"`
|
||||||
|
|
||||||
|
**Implementation:** Match against `name`, `value`, `title`, `description` attributes (any match counts).
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/find.rs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase E: New Commands — Mouse & Coordinate Control
|
||||||
|
|
||||||
|
These commands require CGEvent synthesis because the Accessibility API has no concept of cursor movement or raw mouse events. They are the documented exceptions to the AX-first principle.
|
||||||
|
|
||||||
|
### New Supporting Types
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Point {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum MouseButton {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Middle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DragParams {
|
||||||
|
pub from: Point,
|
||||||
|
pub to: Point,
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
pub steps: Option<u32>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### E1. `drag` command
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop drag --from @e5 --to @e12
|
||||||
|
agent-desktop drag --from @e5 --to-xy 500,300
|
||||||
|
agent-desktop drag --from-xy 100,200 --to-xy 500,300
|
||||||
|
agent-desktop drag --from @e5 --to @e12 --duration 500
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/drag.rs`
|
||||||
|
|
||||||
|
**Why CGEvent is required:** Pure AX drag is impossible on ALL three platforms. macOS has no `AXDragAction`. Windows `IDragPattern` is read-only (describes draggable state, doesn't perform drags). Linux AT-SPI has no drag interface.
|
||||||
|
|
||||||
|
**Implementation (macOS — hybrid AX+CGEvent):**
|
||||||
|
1. **AX phase:** Resolve source/target coordinates from refs (via `get_element_bounds`) or use raw `--xy` coordinates
|
||||||
|
2. **CGEvent phase:**
|
||||||
|
- `CGEvent::new_mouse_event(LeftMouseDown, source_point)` → post
|
||||||
|
- Sleep 50ms (initial hold for drag registration)
|
||||||
|
- 10 intermediate `CGEvent::new_mouse_event(LeftMouseDragged, interpolated_point)` events, 8ms apart
|
||||||
|
- `CGEvent::new_mouse_event(LeftMouseUp, target_point)` → post
|
||||||
|
- Sleep 50ms (settle time for app to process drop)
|
||||||
|
3. **Total duration:** ~230ms default, configurable via `--duration`
|
||||||
|
|
||||||
|
**Cross-platform mapping:**
|
||||||
|
- macOS: CGEvent `LeftMouseDown` → `LeftMouseDragged` (N steps) → `LeftMouseUp`
|
||||||
|
- Windows: `SendInput` with `MOUSEEVENTF_LEFTDOWN` → `MOUSEEVENTF_MOVE` (N steps) → `MOUSEEVENTF_LEFTUP`
|
||||||
|
- Linux: `atspi_generate_mouse_event` with `b1p` → `abs` (N steps) → `b1r`
|
||||||
|
|
||||||
|
### E2. `hover` command
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop hover @e5
|
||||||
|
agent-desktop hover --xy 500,300
|
||||||
|
agent-desktop hover @e5 --duration 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/hover.rs`
|
||||||
|
|
||||||
|
**Why CGEvent:** Tooltips and hover states require real cursor movement. `CGWarpMouseCursorPosition` is silent and won't trigger tooltips — must use `CGEvent::new_mouse_event(MouseMoved)`.
|
||||||
|
|
||||||
|
**Implementation (macOS):**
|
||||||
|
- Resolve target point (from ref bounds center or `--xy`)
|
||||||
|
- `CGEvent::new_mouse_event(MouseMoved, target_point)` → post
|
||||||
|
- If `--duration` specified, hold position for that time (cursor stays put naturally)
|
||||||
|
|
||||||
|
### E3. `mouse-down` / `mouse-up` commands
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop mouse-down @e5 --button left
|
||||||
|
agent-desktop mouse-up --xy 500,300 --button left
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/mouse_down.rs`, `crates/core/src/commands/mouse_up.rs`
|
||||||
|
|
||||||
|
**Implementation:** Individual CGEvent mouseDown/mouseUp events. Essential for custom drag sequences and long-press patterns.
|
||||||
|
|
||||||
|
### E4. `mouse-move` command
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop mouse-move --xy 500,300
|
||||||
|
agent-desktop mouse-move --relative -10,20
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/mouse_move.rs`
|
||||||
|
|
||||||
|
**Implementation:** `CGEvent::new_mouse_event(MouseMoved)`. `--relative` uses delta from current cursor position (query via `CGEvent::location()`).
|
||||||
|
|
||||||
|
### E5. `mouse-click` command
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop mouse-click --xy 500,300
|
||||||
|
agent-desktop mouse-click --xy 500,300 --button right --count 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/mouse_click.rs`
|
||||||
|
|
||||||
|
**Implementation:** Click at absolute coordinates, bypassing ref system. Useful when AX tree doesn't expose an element (e.g., Calculator display, game UIs, custom-rendered views).
|
||||||
|
|
||||||
|
### E6. `triple-click` command
|
||||||
|
|
||||||
|
**CLI:** `agent-desktop triple-click @e1`
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/triple_click.rs`
|
||||||
|
|
||||||
|
**Implementation:** AX-first attempt: three rapid `kAXPressAction` calls with 30ms sleep between. If that doesn't trigger line/paragraph selection, fall back to CGEvent triple-click at element center.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase F: New Commands — Window Geometry
|
||||||
|
|
||||||
|
All window geometry commands are **pure AX** — no CGEvent or osascript needed.
|
||||||
|
|
||||||
|
### F1. `resize-window`
|
||||||
|
|
||||||
|
**CLI:** `agent-desktop resize-window --app TextEdit --width 800 --height 600`
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/resize_window.rs`
|
||||||
|
|
||||||
|
**Implementation (macOS, AX-only):**
|
||||||
|
- Get window element for app
|
||||||
|
- `AXUIElementSetAttributeValue(winEl, kAXSizeAttribute, AXValueCreate(kAXValueCGSizeType, &CGSize { width, height }))`
|
||||||
|
|
||||||
|
### F2. `move-window`
|
||||||
|
|
||||||
|
**CLI:** `agent-desktop move-window --app TextEdit --x 100 --y 100`
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/move_window.rs`
|
||||||
|
|
||||||
|
**Implementation (macOS, AX-only):**
|
||||||
|
- `AXUIElementSetAttributeValue(winEl, kAXPositionAttribute, AXValueCreate(kAXValueCGPointType, &CGPoint { x, y }))`
|
||||||
|
|
||||||
|
### F3. `minimize` / `maximize` / `restore`
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop minimize --app TextEdit
|
||||||
|
agent-desktop maximize --app TextEdit
|
||||||
|
agent-desktop restore --app TextEdit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/minimize.rs`, `crates/core/src/commands/maximize.rs`, `crates/core/src/commands/restore.rs`
|
||||||
|
|
||||||
|
**Implementation (macOS, AX-only):**
|
||||||
|
- Minimize: `AXUIElementSetAttributeValue(winEl, kAXMinimizedAttribute, kCFBooleanTrue)`
|
||||||
|
- Maximize: `AXUIElementPerformAction(zoomButton, kAXPressAction)` where zoomButton is from `kAXZoomButtonAttribute`
|
||||||
|
- Restore: `AXUIElementSetAttributeValue(winEl, kAXMinimizedAttribute, kCFBooleanFalse)` for minimized windows, `AXUIElementPerformAction(zoomButton, kAXPressAction)` for fullscreen
|
||||||
|
|
||||||
|
**Cross-platform:**
|
||||||
|
- Windows: `IUIAutomationTransformPattern.Move/Resize`, `IUIAutomationWindowPattern.SetWindowVisualState`
|
||||||
|
- Linux: `atspi_component_set_extents`, window manager D-Bus calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase G: New Commands — Keyboard
|
||||||
|
|
||||||
|
### G1. `key-down` / `key-up`
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```
|
||||||
|
agent-desktop key-down shift
|
||||||
|
agent-desktop key-up shift
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files:** `crates/core/src/commands/key_down.rs`, `crates/core/src/commands/key_up.rs`
|
||||||
|
|
||||||
|
**Implementation:** `AXUIElementPostKeyboardEvent` with only the down or up flag. If that doesn't work for the key, fall back to `CGEventCreateKeyboardEvent` with only key-down or key-up. Essential for modifier-hold sequences (hold Shift while clicking multiple items).
|
||||||
|
|
||||||
|
### G2. `clear` command
|
||||||
|
|
||||||
|
**CLI:** `agent-desktop clear @e1`
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/clear.rs`
|
||||||
|
|
||||||
|
**Implementation (AX-only):** `AXUIElementSetAttributeValue(el, kAXValueAttribute, "")`. Simple set-value to empty string.
|
||||||
|
|
||||||
|
### G3. `clipboard-clear`
|
||||||
|
|
||||||
|
**CLI:** `agent-desktop clipboard-clear`
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/clipboard_clear.rs`
|
||||||
|
|
||||||
|
**Implementation (macOS):** `NSPasteboard.generalPasteboard.clearContents()` via Cocoa FFI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase H: Architecture — Adapter Extensibility
|
||||||
|
|
||||||
|
### H1. Make Action enum `#[non_exhaustive]`
|
||||||
|
|
||||||
|
**File:** `crates/core/src/action.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum Action {
|
||||||
|
Click,
|
||||||
|
DoubleClick,
|
||||||
|
RightClick,
|
||||||
|
SetValue(String),
|
||||||
|
SetFocus,
|
||||||
|
Expand,
|
||||||
|
Collapse,
|
||||||
|
Select(String),
|
||||||
|
Toggle,
|
||||||
|
Scroll(Direction, u32),
|
||||||
|
PressKey(KeyCombo),
|
||||||
|
TypeText(String),
|
||||||
|
Check,
|
||||||
|
Uncheck,
|
||||||
|
ScrollTo,
|
||||||
|
Drag(DragParams),
|
||||||
|
Hover,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding `#[non_exhaustive]` means platform adapters must have a `_ => Err(not_supported())` catch-all, which naturally handles new actions added in future phases.
|
||||||
|
|
||||||
|
### H2. WindowOp enum for window geometry
|
||||||
|
|
||||||
|
**File:** `crates/core/src/action.rs` (or new `crates/core/src/window_op.rs` if action.rs grows)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum WindowOp {
|
||||||
|
Resize { width: f64, height: f64 },
|
||||||
|
Move { x: f64, y: f64 },
|
||||||
|
Minimize,
|
||||||
|
Maximize,
|
||||||
|
Restore,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Single `window_op(&self, win: &WindowInfo, op: WindowOp)` method on PlatformAdapter instead of 5 separate methods.
|
||||||
|
|
||||||
|
### H3. MouseEvent struct for raw mouse control
|
||||||
|
|
||||||
|
**File:** `crates/core/src/action.rs` (or new `crates/core/src/mouse.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MouseEvent {
|
||||||
|
pub kind: MouseEventKind,
|
||||||
|
pub point: Point,
|
||||||
|
pub button: MouseButton,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum MouseEventKind {
|
||||||
|
Move,
|
||||||
|
Down,
|
||||||
|
Up,
|
||||||
|
Click { count: u32 },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Single `mouse_event(&self, event: MouseEvent)` method on PlatformAdapter. Handles `mouse-move`, `mouse-down`, `mouse-up`, `mouse-click`, and `hover` through one trait method.
|
||||||
|
|
||||||
|
### H4. Coordinate Resolution Helper
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/coords.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum CoordSource {
|
||||||
|
Ref(String),
|
||||||
|
Absolute(Point),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_coords(
|
||||||
|
source: &CoordSource,
|
||||||
|
adapter: &dyn PlatformAdapter,
|
||||||
|
) -> Result<Point, AppError> {
|
||||||
|
match source {
|
||||||
|
CoordSource::Ref(ref_id) => {
|
||||||
|
let (entry, handle) = resolve_ref(ref_id, adapter)?;
|
||||||
|
let bounds = adapter.get_element_bounds(&handle)?
|
||||||
|
.ok_or(AppError::internal("Element has no bounds"))?;
|
||||||
|
Ok(Point {
|
||||||
|
x: bounds.x + bounds.width / 2.0,
|
||||||
|
y: bounds.y + bounds.height / 2.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
CoordSource::Absolute(p) => Ok(p.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by `drag`, `hover`, `mouse-move`, `mouse-click`, `mouse-down`, `mouse-up`. All coordinate-based commands resolve through the same path.
|
||||||
|
|
||||||
|
### H5. Action Pre-Check Pattern
|
||||||
|
|
||||||
|
**File:** `crates/core/src/commands/action_check.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn check_action_supported(
|
||||||
|
entry: &RefEntry,
|
||||||
|
required_roles: &[&str],
|
||||||
|
action_name: &str,
|
||||||
|
suggestion: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if !required_roles.contains(&entry.role.as_str()) {
|
||||||
|
return Err(AppError::Adapter(AdapterError::new(
|
||||||
|
ErrorCode::ActionNotSupported,
|
||||||
|
format!("'{}' is not supported on role '{}'", action_name, entry.role),
|
||||||
|
).with_suggestion(suggestion)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by `toggle`, `check`, `uncheck`, `expand`, `collapse`, `select` to validate role before attempting action.
|
||||||
|
|
||||||
|
### H6. Extended PlatformAdapter Trait
|
||||||
|
|
||||||
|
**File:** `crates/core/src/adapter.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait PlatformAdapter: Send + Sync {
|
||||||
|
// --- Discovery ---
|
||||||
|
fn list_windows(&self, filter: &WindowFilter) -> Result<Vec<WindowInfo>, AdapterError> { not_supported() }
|
||||||
|
fn list_apps(&self) -> Result<Vec<AppInfo>, AdapterError> { not_supported() }
|
||||||
|
fn get_tree(&self, win: &WindowInfo, opts: &TreeOptions) -> Result<AccessibilityNode, AdapterError> { not_supported() }
|
||||||
|
fn resolve_element(&self, entry: &RefEntry) -> Result<NativeHandle, AdapterError> { not_supported() }
|
||||||
|
fn permission_report(&self) -> PermissionReport { PermissionReport::unknown() }
|
||||||
|
|
||||||
|
// --- AX Actions (primary execution path) ---
|
||||||
|
fn execute_action(&self, handle: &NativeHandle, request: ActionRequest) -> Result<ActionResult, AdapterError> { not_supported() }
|
||||||
|
fn get_element_bounds(&self, handle: &NativeHandle) -> Result<Option<Rect>, AdapterError> { not_supported() }
|
||||||
|
|
||||||
|
// --- Window Management ---
|
||||||
|
fn focus_window(&self, win: &WindowInfo) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
fn window_op(&self, win: &WindowInfo, op: WindowOp) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
fn launch_app(&self, id: &str, wait: bool) -> Result<WindowInfo, AdapterError> { not_supported() }
|
||||||
|
fn close_app(&self, id: &str, force: bool) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
|
||||||
|
// --- Mouse/Coordinate (CGEvent-based, documented exceptions) ---
|
||||||
|
fn mouse_event(&self, event: MouseEvent) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
fn drag(&self, params: DragParams) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
|
||||||
|
// --- Media ---
|
||||||
|
fn screenshot(&self, target: ScreenshotTarget) -> Result<ImageBuffer, AdapterError> { not_supported() }
|
||||||
|
fn get_clipboard(&self) -> Result<String, AdapterError> { not_supported() }
|
||||||
|
fn set_clipboard(&self, text: &str) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
fn clear_clipboard(&self) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
|
||||||
|
// --- App-Level Keyboard ---
|
||||||
|
fn press_key_for_app(&self, app: &str, combo: &KeyCombo) -> Result<ActionResult, AdapterError> { not_supported() }
|
||||||
|
fn focused_window(&self) -> Result<Option<WindowInfo>, AdapterError> { not_supported() }
|
||||||
|
|
||||||
|
// --- Surface Management ---
|
||||||
|
fn wait_for_menu(&self, pid: i32, open: bool, timeout: u64) -> Result<(), AdapterError> { not_supported() }
|
||||||
|
fn list_surfaces(&self, pid: i32) -> Result<Vec<SurfaceInfo>, AdapterError> { not_supported() }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All methods default to `not_supported()`. Phase 2 Windows/Linux adapters implement what they can — everything else gracefully fails with `PLATFORM_NOT_SUPPORTED`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Sprint 1: Critical Bug Fixes — AX-First Foundation (2-3 days)
|
||||||
|
|
||||||
|
Priority: Stop commands from lying about success, fix the tree traversal.
|
||||||
|
|
||||||
|
1. **A1** — Sub-window tree fix (ONE LINE in `list_windows_impl`)
|
||||||
|
2. **A9** — Remove CGEvent fallbacks from click/right-click/toggle/select in `actions.rs`
|
||||||
|
3. **A2** — Role-aware `select` implementation
|
||||||
|
4. **A3** — Toggle role validation
|
||||||
|
5. **A6** — Expand/collapse error codes
|
||||||
|
6. **A10** — Replace CGEvent scroll with AX scroll bar increment/decrement
|
||||||
|
7. **B3** — Help descriptions (touches `cli.rs` only, low risk, high value)
|
||||||
|
|
||||||
|
**Verify:** `cargo clippy --all-targets -- -D warnings && cargo test --workspace`
|
||||||
|
|
||||||
|
### Sprint 2: AX-First Keyboard & Window (2-3 days)
|
||||||
|
|
||||||
|
Priority: Eliminate remaining osascript and CGEvent keyboard paths.
|
||||||
|
|
||||||
|
8. **A11** — Replace CGEvent keyboard with AX (TypeText via kAXValueAttribute, PressKey via menu bar traversal + AXUIElementPostKeyboardEvent)
|
||||||
|
9. **A12** — Replace osascript in `press_for_app_impl`
|
||||||
|
10. **A8** — Replace osascript in `focus_window_impl`
|
||||||
|
11. **A5** — Fix close-app with AX menu bar "Quit" + verification
|
||||||
|
12. **A4** — Fix launch window detection polling
|
||||||
|
13. **A7** — Fix get bounds returning null
|
||||||
|
|
||||||
|
**After this sprint:** `input.rs` functions `synthesize_key` and `synthesize_text` should be dead code. Remove them and potentially the file.
|
||||||
|
|
||||||
|
### Sprint 3: Improvements + New Idempotent Commands (1-2 days)
|
||||||
|
|
||||||
|
14. **B1** — list-apps data shape + bundle_id
|
||||||
|
15. **B2** — is false vs not-applicable
|
||||||
|
16. **B4** — find unnamed elements
|
||||||
|
17. **B5** — stale menu items in snapshots
|
||||||
|
18. **C1** — `check` / `uncheck` commands (AX-only)
|
||||||
|
19. **C2** — `scroll-to` command (AX-only)
|
||||||
|
20. **C3** — `wait --text` variant
|
||||||
|
|
||||||
|
### Sprint 4: Architecture + Find Enhancements (1-2 days)
|
||||||
|
|
||||||
|
21. **H1** — Make Action enum `#[non_exhaustive]`, add new variants
|
||||||
|
22. **H2** — WindowOp enum
|
||||||
|
23. **H3** — MouseEvent struct
|
||||||
|
24. **H4** — Coordinate resolution helper
|
||||||
|
25. **H5** — Action pre-check pattern
|
||||||
|
26. **H6** — Extend PlatformAdapter trait
|
||||||
|
27. **D1-D3** — find --count, --first/--last/--nth, --text
|
||||||
|
|
||||||
|
### Sprint 5: Mouse/Coordinate Commands (2-3 days)
|
||||||
|
|
||||||
|
28. **E4** — mouse-move
|
||||||
|
29. **E2** — hover
|
||||||
|
30. **E5** — mouse-click
|
||||||
|
31. **E3** — mouse-down / mouse-up
|
||||||
|
32. **E6** — triple-click
|
||||||
|
33. **E1** — drag (most complex, depends on mouse-down/up being solid)
|
||||||
|
|
||||||
|
### Sprint 6: Window Geometry + Misc (1-2 days)
|
||||||
|
|
||||||
|
34. **F1** — resize-window (AX-only)
|
||||||
|
35. **F2** — move-window (AX-only)
|
||||||
|
36. **F3** — minimize / maximize / restore (AX-only)
|
||||||
|
37. **G1** — key-down / key-up
|
||||||
|
38. **G2** — clear (AX-only)
|
||||||
|
39. **G3** — clipboard-clear
|
||||||
|
|
||||||
|
### Sprint 7: Testing & Polish (1 day)
|
||||||
|
|
||||||
|
40. Full agentic test re-run (same task as original test in `tests/agentic_test_notes.md`)
|
||||||
|
41. Verify all 7 original bugs are fixed
|
||||||
|
42. Test all new commands end-to-end
|
||||||
|
43. Test cross-app drag (Finder → TextEdit file drag)
|
||||||
|
44. Verify no CGEvent imports remain in `actions.rs`
|
||||||
|
45. Verify no osascript calls remain in `app_ops.rs` except `launch_app_impl` (`open -a`) and `close_app_impl --force` (`pkill`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Count After Implementation
|
||||||
|
|
||||||
|
| Category | Phase 1 (existing) | New | Total |
|
||||||
|
|----------|-------------------|-----|-------|
|
||||||
|
| App/Window | 5 | 5 (resize, move, minimize, maximize, restore) | 10 |
|
||||||
|
| Observation | 7 | 3 (find --count/--first/--nth/--text via flags) | 7* |
|
||||||
|
| Interaction | 11 | 5 (check, uncheck, triple-click, scroll-to, clear) | 16 |
|
||||||
|
| Mouse/Coord | 0 | 5 (drag, hover, mouse-move, mouse-click, mouse-down/up) | 5 |
|
||||||
|
| Keyboard | 1 | 2 (key-down, key-up) | 3 |
|
||||||
|
| Clipboard | 2 | 1 (clipboard-clear) | 3 |
|
||||||
|
| Wait | 1 | 1 (wait --text via flag) | 1* |
|
||||||
|
| System | 3 | 0 | 3 |
|
||||||
|
| Batch | 1 | 0 | 1 |
|
||||||
|
| **Total** | **31** | **19** | **49** |
|
||||||
|
|
||||||
|
*find enhancements and wait --text are flags on existing commands, not new command files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Platform Adapter Contract
|
||||||
|
|
||||||
|
| Pattern | Commands | macOS | Windows | Linux |
|
||||||
|
|---------|----------|-------|---------|-------|
|
||||||
|
| **AX Action** | click, toggle, expand, collapse, select, set-value, focus, check, uncheck, scroll, scroll-to, clear | AXUIElementPerformAction / SetAttributeValue | IUIAutomationElement.Invoke/Toggle/ScrollIntoView | atspi_component_do_action |
|
||||||
|
| **AX Value Set** | type, set-value, clear | AXUIElementSetAttributeValue(kAXValueAttribute) | IUIAutomationValuePattern.SetValue | atspi_editable_text_set_text_contents |
|
||||||
|
| **AX Menu Traverse** | press (cmd+shortcuts), close-app (graceful) | kAXMenuBarAttribute → walk → kAXPressAction | IUIAutomationElement.FindAll for menu items | atspi_accessible_get_child_at_index |
|
||||||
|
| **AX Key Post** | press (fallback for non-menu keys) | AXUIElementPostKeyboardEvent | IUIAutomationElement.SendKeys | atspi_generate_keyboard_event |
|
||||||
|
| **AX Window Prop** | resize, move, minimize, maximize, restore, focus-window | AXUIElementSetAttributeValue (Position/Size/Minimized/Main) | IUIAutomationTransformPattern / WindowPattern | atspi_component_set_extents |
|
||||||
|
| **CGEvent Mouse** | drag, hover, mouse-move/click/down/up, triple-click | CGEvent sequences | SendInput sequences | atspi_generate_mouse_event |
|
||||||
|
| **CGEvent Keyboard** | key-down, key-up | CGEvent / AXUIElementPostKeyboardEvent | SendInput VK_ codes | atspi_generate_keyboard_event |
|
||||||
|
| **AX Attribute Get** | get, is, snapshot, find, wait --text | AXUIElementCopyAttributeValue | IUIAutomationElement.GetPropertyValue | atspi_accessible_get_* |
|
||||||
|
| **OS Shell** | launch, close-app --force | `open -a` / `pkill` | CreateProcess / TerminateProcess | xdg-open / kill |
|
||||||
|
| **OS Clipboard** | clipboard-* | NSPasteboard | Win32 Clipboard API | wl-copy / xclip |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Removed / Downgraded Items
|
||||||
|
|
||||||
|
### Stderr Duplication (was A1 in original plan — REMOVED)
|
||||||
|
|
||||||
|
**Research finding:** There is NO stderr duplication bug in the code. `emit_json()` writes exactly once to stdout via `BufWriter`. `Cli::try_parse()` does NOT write to stderr on its own. The reported "duplication" was most likely `cargo run` output noise (rustc warnings, build progress) or shell stream merging (`2>&1`). The release binary does NOT exhibit this behavior.
|
||||||
|
|
||||||
|
**Action:** No code change needed. Verified by reading `main.rs` error paths — all produce exactly 1 JSON object on stdout. If this resurfaces, the diagnostic is: run `./target/release/agent-desktop click @invalid 2>/dev/null | wc -l` and verify it outputs exactly 1 line.
|
||||||
|
|
||||||
|
### Commands NOT Added (from agent-browser gap analysis)
|
||||||
|
|
||||||
|
These agent-browser commands were evaluated and intentionally excluded:
|
||||||
|
|
||||||
|
| Command | Reason for exclusion |
|
||||||
|
|---------|---------------------|
|
||||||
|
| `navigate`, `go_back`, `go_forward`, `reload`, `wait_for_page` | Browser-only, no desktop equivalent |
|
||||||
|
| `evaluate`, `console`, `network` | Browser DevTools, no desktop equivalent |
|
||||||
|
| `pdf`, `snapshot_html`, `save_html` | Browser DOM, no desktop equivalent |
|
||||||
|
| `get_cookies`, `set_cookies`, `clear_cookies` | Browser storage, no desktop equivalent |
|
||||||
|
| `file_upload`, `file_download` | Browser file dialogs — desktop equivalent would be AX file picker interaction, deferred to Phase 2 |
|
||||||
|
| `dialog_accept`, `dialog_dismiss` | Browser dialogs — desktop alert/dialog handling is possible via AX but complex; deferred to Phase 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- [ ] `agent-desktop snapshot --app "System Settings"` returns both sidebar AND content pane elements
|
||||||
|
- [ ] `agent-desktop select @popup "Courier"` actually changes the value and reports new value in response
|
||||||
|
- [ ] `agent-desktop toggle @textfield` returns `ACTION_NOT_SUPPORTED` (not false success)
|
||||||
|
- [ ] `agent-desktop launch Calculator` succeeds and returns window info (with polling)
|
||||||
|
- [ ] `agent-desktop close-app Calculator` reports actual close status, not premature success
|
||||||
|
- [ ] `agent-desktop expand @combobox` returns `ACTION_NOT_SUPPORTED` with suggestion (not raw AX error)
|
||||||
|
- [ ] `agent-desktop get @e1 --property bounds` returns actual coordinates (not null)
|
||||||
|
|
||||||
|
### AX-First Execution
|
||||||
|
- [ ] `actions.rs` contains ZERO `cg_mouse_click` calls or CGEvent mouse imports
|
||||||
|
- [ ] `actions.rs` Click/RightClick/Toggle/Select use only AXUIElementPerformAction
|
||||||
|
- [ ] `actions.rs` Scroll uses AXScrollBar increment/decrement (no CGEvent::new_scroll_event)
|
||||||
|
- [ ] `actions.rs` TypeText uses kAXValueAttribute set (no synthesize_text)
|
||||||
|
- [ ] `app_ops.rs` focus_window uses AX (no osascript)
|
||||||
|
- [ ] `app_ops.rs` close_app graceful uses AX menu bar (no osascript quit)
|
||||||
|
- [ ] `app_ops.rs` press_for_app uses AX menu bar traversal (no osascript keystroke)
|
||||||
|
- [ ] `input.rs` synthesize_key and synthesize_text removed or only used by CGEvent-based commands (drag, hover, mouse-*, key-down/up)
|
||||||
|
|
||||||
|
### New Commands
|
||||||
|
- [ ] `agent-desktop check @checkbox` sets checked state idempotently
|
||||||
|
- [ ] `agent-desktop uncheck @checkbox` sets unchecked state idempotently
|
||||||
|
- [ ] `agent-desktop scroll-to @e15` scrolls element into view
|
||||||
|
- [ ] `agent-desktop wait --text "Done" --app TextEdit --timeout 3000` waits for text appearance
|
||||||
|
- [ ] `agent-desktop find --app TextEdit --role button --count` returns count
|
||||||
|
- [ ] `agent-desktop find --app TextEdit --role button --first` returns single element
|
||||||
|
- [ ] `agent-desktop drag --from @e1 --to @e5` performs drag via CGEvent
|
||||||
|
- [ ] `agent-desktop hover @e5` moves cursor to element
|
||||||
|
- [ ] `agent-desktop minimize --app TextEdit` minimizes window via AX
|
||||||
|
- [ ] `agent-desktop resize-window --app TextEdit --width 800 --height 600` resizes via AX
|
||||||
|
- [ ] `agent-desktop mouse-click --xy 500,300` clicks at coordinates
|
||||||
|
- [ ] `agent-desktop key-down shift` holds shift modifier
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
- [ ] `cargo clippy --all-targets -- -D warnings` passes
|
||||||
|
- [ ] `cargo test --workspace` passes
|
||||||
|
- [ ] No file exceeds 400 LOC
|
||||||
|
- [ ] `agent-desktop --help` shows descriptions for all commands
|
||||||
|
- [ ] Error JSON appears exactly once on stdout for all error cases
|
||||||
|
- [ ] Full agentic test re-run shows 0 bugs, 0 false successes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Change (Summary)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `crates/macos/src/adapter.rs` | ONE-LINE fix for sub-window tree; add `get_element_bounds`; populate `bundle_id` in list_apps |
|
||||||
|
| `crates/macos/src/actions.rs` | Remove ALL CGEvent mouse fallbacks; rewrite select (role-aware), toggle (role check), scroll (AX scroll bar), type (kAXValueAttribute), press (menu traverse + AXUIElementPostKeyboardEvent) |
|
||||||
|
| `crates/macos/src/input.rs` | Remove `synthesize_key`, `synthesize_text`; keep file only if CGEvent mouse commands need keyboard helpers, otherwise delete |
|
||||||
|
| `crates/macos/src/app_ops.rs` | Replace osascript in focus_window, close_app, press_for_app with AX equivalents |
|
||||||
|
| `crates/core/src/adapter.rs` | Extend PlatformAdapter with `get_element_bounds`, `window_op`, `mouse_event`, `drag`, `clear_clipboard`; add `#[non_exhaustive]` consideration |
|
||||||
|
| `crates/core/src/action.rs` | Add `#[non_exhaustive]`, new variants: Check, Uncheck, ScrollTo, Drag, Hover; add Point, MouseButton, DragParams, WindowOp, MouseEvent types |
|
||||||
|
| `src/cli.rs` | Add `#[command(about)]` to all variants; add new command structs for all Phase C-G commands |
|
||||||
|
| `src/dispatch.rs` | Add match arms for all new commands |
|
||||||
|
| `crates/core/src/commands/` | New files: `check.rs`, `uncheck.rs`, `scroll_to.rs`, `drag.rs`, `hover.rs`, `mouse_down.rs`, `mouse_up.rs`, `mouse_move.rs`, `mouse_click.rs`, `triple_click.rs`, `resize_window.rs`, `move_window.rs`, `minimize.rs`, `maximize.rs`, `restore.rs`, `key_down.rs`, `key_up.rs`, `clear.rs`, `clipboard_clear.rs`, `coords.rs`, `action_check.rs` |
|
||||||
|
| `crates/core/src/commands/find.rs` | Add `--count`, `--first`, `--last`, `--nth`, `--text` flags |
|
||||||
|
| `crates/core/src/commands/wait.rs` | Add `--text` flag |
|
||||||
|
| `crates/core/src/commands/get.rs` | Fix bounds to query live from AX tree |
|
||||||
|
| `crates/core/src/commands/is_check.rs` | Add `applicable` field based on role |
|
||||||
|
| `crates/core/src/commands/toggle.rs` | Add role validation |
|
||||||
|
| `crates/core/src/commands/list_apps.rs` | Wrap data in `{"apps": [...]}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Agentic test notes: `tests/agentic_test_notes.md`
|
||||||
|
- PRD v2.0: `docs/agent_desktop_prd_v2.pdf`
|
||||||
|
- Architecture brainstorm: `docs/brainstorms/2026-02-19-architecture-validation-brainstorm.md`
|
||||||
|
- Agent-browser command inventory: `github.com/vercel-labs/agent-browser` (used for gap analysis)
|
||||||
|
- macOS AX API: `AXUIElementPerformAction`, `AXUIElementSetAttributeValue`, `AXUIElementCopyActionNames`, `AXUIElementPostKeyboardEvent`
|
||||||
|
- macOS AX menu traversal: `kAXMenuBarAttribute` → `AXMenuBarItem` → `AXMenu` → `AXMenuItem` → `kAXMenuItemCmdChar` + `kAXMenuItemCmdModifiers`
|
||||||
|
- macOS AX scroll: `kAXScrollBarAttribute` → `kAXIncrementAction` / `kAXDecrementAction`
|
||||||
|
- Pure AX drag is impossible on all 3 platforms — CGEvent mouse synthesis required
|
||||||
|
- `accessibility-sys` crate: provides all kAX constants and AXUIElement FFI bindings
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
---
|
||||||
|
title: "fix: Sub-window tree, duplicate error output, AX-only commands"
|
||||||
|
type: fix
|
||||||
|
date: 2026-02-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# fix: Sub-window tree traversal, duplicate error output, and AX-only command execution
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three bugs identified during live testing against macOS System Settings:
|
||||||
|
|
||||||
|
1. **Sub-window tree missing** — `snapshot` only captures the sidebar; the content pane (a child AXWindow) is never reached because `window_element_for` queries only `kAXWindowsAttribute`, which excludes embedded sub-windows.
|
||||||
|
2. **Error emitted twice** — An invalid-argument error JSON appears twice in combined stdout+stderr output. Clap may write its own formatted output to stderr while `main.rs` independently emits the JSON to stdout.
|
||||||
|
3. **CGEvent and osascript in command paths** — `press`, `type`, `click` (fallback), `double-click`, `right-click` (fallback), `scroll`, `toggle` (fallback), `select` (fallback), `close-app` (graceful), `focus-window` all use CGEvent mouse/keyboard injection or osascript System Events. Every command must be AX-API-only; no CGEvent, no HID injection, no osascript for input delivery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Analysis
|
||||||
|
|
||||||
|
### 1 — Sub-window tree (`crates/macos/src/tree.rs:53–75`)
|
||||||
|
|
||||||
|
`window_element_for` walks `kAXWindowsAttribute` to find a matching AXWindow, falling back to the first window or the application element. On macOS, `kAXWindowsAttribute` returns only the **primary top-level windows** registered with the window server.
|
||||||
|
|
||||||
|
Apps like System Settings expose their content pane as a **separate AXWindow** accessible via `kAXChildrenAttribute` on the app element (or on the main window). This child window never appears in `kAXWindowsAttribute`, so the tree traversal starts from the sidebar window and never crosses into the content pane.
|
||||||
|
|
||||||
|
Concrete evidence: snapshotting System Settings shows `AXOutline (Sidebar)` with 40+ tree items but no content-pane elements. Taking a screenshot confirms the content pane (Appearance options) is present but invisible to the AX tree walk.
|
||||||
|
|
||||||
|
**Root cause:** `window_element_for` stops after `kAXWindowsAttribute`; it does not consult `kAXChildrenAttribute` on either the app element or on the matched window.
|
||||||
|
|
||||||
|
### 2 — Duplicate error output (`src/main.rs:10–30`)
|
||||||
|
|
||||||
|
`emit_json` writes exactly once to stdout (`src/main.rs:122–130`). However, the Claude Bash tool (and any caller that merges stdout + stderr) sees two copies of the error. Clap v4's error type, when converted via `.to_string()` or when the process exits via `std::process::exit`, may flush a buffered internal writer to stderr that contains the same message text. Independently, `BufWriter` wrapping stdout's lock means our JSON flushes to stdout. Together both streams appear in a combined capture.
|
||||||
|
|
||||||
|
Fix: configure the clap command to redirect its own error output to a null sink so our JSON is the sole output on any stream.
|
||||||
|
|
||||||
|
### 3 — CGEvent and osascript usage
|
||||||
|
|
||||||
|
| File | Location | Non-AX mechanism |
|
||||||
|
|------|----------|-----------------|
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::Click` fallback | `cg_mouse_click` → `CGEvent::new_mouse_event` |
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::DoubleClick` | `cg_mouse_click` → `CGEvent::new_mouse_event` |
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::RightClick` fallback | `cg_mouse_click` → `CGEvent::new_mouse_event` |
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::Toggle` fallback | `cg_mouse_click` → `CGEvent::new_mouse_event` |
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::Select` fallback | `cg_mouse_click` → `CGEvent::new_mouse_event` |
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::Scroll` | `CGEvent::new_scroll_event` → HID |
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::TypeText` | `synthesize_text` → `CGEvent::new_keyboard_event` |
|
||||||
|
| `crates/macos/src/actions.rs` | `Action::PressKey` | `synthesize_key` → `CGEvent::new_keyboard_event` |
|
||||||
|
| `crates/macos/src/input.rs` | `synthesize_key` | `CGEvent::new_keyboard_event` + HID post |
|
||||||
|
| `crates/macos/src/input.rs` | `synthesize_text` | `CGEvent::new_keyboard_event` + HID post |
|
||||||
|
| `crates/macos/src/app_ops.rs:27–39` | `press_for_app_impl` | `osascript` → System Events `keystroke`/`key code` |
|
||||||
|
| `crates/macos/src/app_ops.rs:9–18` | `focus_window_impl` | `osascript` → `tell application X to activate` |
|
||||||
|
| `crates/macos/src/app_ops.rs:150–173` | `close_app_impl` | `osascript` → quit / `pkill` |
|
||||||
|
|
||||||
|
**AX replacements available on macOS:**
|
||||||
|
|
||||||
|
| Replaced mechanism | AX replacement |
|
||||||
|
|-------------------|----------------|
|
||||||
|
| Mouse click (CGEvent) | `AXUIElementPerformAction(kAXPressAction)` |
|
||||||
|
| Double-click (CGEvent) | `AXUIElementPerformAction("AXOpen")` or two sequential `kAXPressAction` calls |
|
||||||
|
| Right-click (CGEvent) | `AXUIElementPerformAction("AXShowMenu")` (already implemented; just remove fallback) |
|
||||||
|
| Scroll (CGEvent) | `AXUIElementPerformAction(kAXScrollDownAction / kAXScrollUpAction / kAXScrollLeftAction / kAXScrollRightAction)` |
|
||||||
|
| Text input (CGEvent keyboard) | `AXUIElementSetAttributeValue(kAXValueAttribute, newText)` after `kAXFocusedAttribute = true` |
|
||||||
|
| Key press for Return/Escape/Space | `AXUIElementPerformAction(kAXConfirmAction / kAXCancelAction / kAXPressAction)` on focused element |
|
||||||
|
| Key combo shortcuts (cmd+c etc.) | Traverse menu bar AX tree: find `AXMenuItem` where `AXMenuItemCmdChar` + `AXMenuItemCmdModifiers` match, then `kAXPressAction` |
|
||||||
|
| Window activation (osascript) | `AXUIElementSetAttributeValue(windowEl, kAXMainAttribute, kCFBooleanTrue)` |
|
||||||
|
| Window close (osascript) | `AXUIElementPerformAction(kAXCloseButtonAttribute child, kAXPressAction)` |
|
||||||
|
|
||||||
|
**Limitation:** Arbitrary key combos with no menu-bar equivalent (e.g., `f5`, custom shortcuts not in any menu) cannot be delivered via pure AX API. These should return `ACTION_NOT_SUPPORTED` with a clear suggestion rather than silently falling back to HID injection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Fix 1 — Expand sub-window discovery (`crates/macos/src/tree.rs`)
|
||||||
|
|
||||||
|
After exhausting `kAXWindowsAttribute`, `window_element_for` must also:
|
||||||
|
1. Query `kAXChildrenAttribute` on the **application element** and collect any child whose role is `AXWindow`, `AXPanel`, `AXSheet`, or `AXDrawer`.
|
||||||
|
2. Attempt title-exact, then title-fuzzy match on those children.
|
||||||
|
3. If the matched window has **itself** an `AXSplitGroup` or secondary `AXWindow` child visible via `kAXChildrenAttribute`, include those in the subtree — `build_subtree` already recurses `kAXChildrenAttribute` so this comes for free once the root is correct.
|
||||||
|
|
||||||
|
No changes to `build_subtree` are required; only `window_element_for` needs expanding.
|
||||||
|
|
||||||
|
### Fix 2 — Silence clap's stderr (`src/main.rs`)
|
||||||
|
|
||||||
|
Replace bare `Cli::try_parse()` with an explicit command build that redirects clap's error writer to stderr (discarded via a custom `Write` impl that no-ops), OR use clap's `override_usage` to suppress the automatic error echo.
|
||||||
|
|
||||||
|
Concrete approach: configure the `Command` to use a null stderr writer before calling `try_get_matches_from`. In clap 4.x this is done by building the command with `.error_format(clap::ErrorFormat::Plain)` combined with `stderr_fn` override so our `emit_json` path is the only output.
|
||||||
|
|
||||||
|
Simplest viable fix: add an unconditional `eprintln!` suppression by redirecting stderr on the command, or simply verify with `2>/dev/null` that only one copy appears on stdout (confirming stderr is the source). If confirmed, wrap the `Cli::command()` call to set the error writer to `std::io::sink()` before `try_get_matches_from`.
|
||||||
|
|
||||||
|
### Fix 3 — AX-only command execution
|
||||||
|
|
||||||
|
**`crates/macos/src/actions.rs`** — Remove all CGEvent imports and `cg_mouse_click`. Update each action:
|
||||||
|
|
||||||
|
- `Action::Click` → `kAXPressAction` only; if it returns error, propagate `ACTION_FAILED` (no CGEvent fallback).
|
||||||
|
- `Action::DoubleClick` → `AXUIElementPerformAction("AXOpen")` first; if unsupported, `kAXPressAction` twice with a short sleep; if still unsupported, return `ACTION_NOT_SUPPORTED`.
|
||||||
|
- `Action::RightClick` → `AXUIElementPerformAction("AXShowMenu")` only; remove CGEvent fallback.
|
||||||
|
- `Action::Toggle` → `kAXPressAction` only; remove CGEvent fallback.
|
||||||
|
- `Action::Select` → `kAXPressAction` only; remove CGEvent fallback.
|
||||||
|
- `Action::Scroll` → Use `kAXScrollDownAction` / `kAXScrollUpAction` / `kAXScrollLeftAction` / `kAXScrollRightAction` from `accessibility_sys`; apply `amount` times. Remove `CGEvent::new_scroll_event`.
|
||||||
|
- `Action::TypeText` → `kAXFocusedAttribute = true` then `kAXValueAttribute = newText`. For append semantics, read current value first and concatenate. Remove `synthesize_text`.
|
||||||
|
- `Action::PressKey` → Implement `ax_press_key` (see below); remove `synthesize_key`.
|
||||||
|
|
||||||
|
**`crates/macos/src/input.rs`** — Remove `synthesize_key` and `synthesize_text` entirely. File may become empty/deleted; if so, remove from `lib.rs` too.
|
||||||
|
|
||||||
|
**`crates/macos/src/app_ops.rs`** — Replace:
|
||||||
|
|
||||||
|
- `press_for_app_impl` → Activate target app via AX (`kAXMainAttribute`), then call `ax_press_key`.
|
||||||
|
- `focus_window_impl` → `AXUIElementSetAttributeValue(windowEl, kAXMainAttribute, kCFBooleanTrue)`.
|
||||||
|
- `close_app_impl` (graceful) → Query `kAXCloseButtonAttribute` on the main window element, then `kAXPressAction` on the close button.
|
||||||
|
- `close_app_impl --force` → Keep `pkill` (explicit process termination is not an AX concern).
|
||||||
|
- `launch_app_impl` → Keep `open -a` (launching an app has no AX equivalent).
|
||||||
|
|
||||||
|
**New: `ax_press_key` function** (`crates/macos/src/actions.rs` or new `crates/macos/src/ax_key.rs`)
|
||||||
|
|
||||||
|
```
|
||||||
|
ax_press_key(app_pid: Option<i32>, combo: &KeyCombo) -> Result<ActionResult, AdapterError>
|
||||||
|
```
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Get the focused element via `kAXFocusedUIElement` on the app (or system-wide element if no app given).
|
||||||
|
2. Map simple keys to AX actions:
|
||||||
|
- `return` / `enter` → `kAXConfirmAction`
|
||||||
|
- `escape` / `esc` → `kAXCancelAction`
|
||||||
|
- `space` → `kAXPressAction`
|
||||||
|
- `up` / `down` / `left` / `right` on sliders → `kAXDecrementAction` / `kAXIncrementAction`
|
||||||
|
3. For modifier combos (cmd+c, cmd+v, etc.) with no focused-element AX action:
|
||||||
|
- Enumerate the menu bar: `kAXMenuBarAttribute` → iterate `AXMenuBarItem` → expand each → walk `AXMenuItem` children
|
||||||
|
- Match against `AXMenuItemCmdChar` and `AXMenuItemCmdModifiers`
|
||||||
|
- `kAXPressAction` on the matched `AXMenuItem`
|
||||||
|
4. If no match in steps 2–3: return `Err(AdapterError::new(ErrorCode::ActionNotSupported, "No AX equivalent for key combo '...'; this combo has no menu-bar action and no AX direct action"))`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `agent-desktop snapshot --app "System Settings"` returns both sidebar **and** content pane elements in a single tree
|
||||||
|
- [ ] Any invalid-argument invocation emits exactly **one** JSON error object on stdout; stderr is silent
|
||||||
|
- [ ] `agent-desktop press return` (focused text field active) succeeds without CGEvent
|
||||||
|
- [ ] `agent-desktop press cmd+z` finds and activates the Undo menu item via AX, returns success
|
||||||
|
- [ ] `agent-desktop press f5` returns `{"ok":false,"error":{"code":"ACTION_NOT_SUPPORTED",...}}` with a descriptive message
|
||||||
|
- [ ] `agent-desktop click @e1` uses only `kAXPressAction`; no CGEvent in the code path
|
||||||
|
- [ ] `agent-desktop scroll @e1 --direction down` uses `kAXScrollDownAction`
|
||||||
|
- [ ] `agent-desktop type @e1 "hello"` sets value via `kAXValueAttribute`
|
||||||
|
- [ ] `cargo clippy --all-targets -- -D warnings` passes
|
||||||
|
- [ ] `cargo test --workspace` passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Change
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `crates/macos/src/tree.rs` | Expand `window_element_for` to include `kAXChildrenAttribute` discovery of sub-windows |
|
||||||
|
| `src/main.rs` | Redirect clap's stderr error writer to suppress duplicate output |
|
||||||
|
| `crates/macos/src/actions.rs` | Remove all `cg_mouse_click` / `CGEvent` usage; rewrite each action with AX equivalents |
|
||||||
|
| `crates/macos/src/input.rs` | Remove `synthesize_key`, `synthesize_text`; delete file if empty |
|
||||||
|
| `crates/macos/src/app_ops.rs` | Replace `press_for_app_impl` and `focus_window_impl` with AX; update `close_app_impl` graceful path |
|
||||||
|
| `crates/macos/src/lib.rs` | Remove `input` module export if file deleted |
|
||||||
|
|
||||||
|
New file (optional, if `ax_press_key` grows beyond ~80 LOC and would push `actions.rs` over 400 LOC):
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `crates/macos/src/ax_key.rs` | `ax_press_key` — focused-element action dispatch + menu-bar shortcut traversal |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `kAXScrollDownAction` / `kAXScrollUpAction` / `kAXScrollLeftAction` / `kAXScrollRightAction` — standard AX scroll actions in `accessibility_sys`
|
||||||
|
- `kAXConfirmAction`, `kAXCancelAction` — `accessibility_sys` constants for Return / Escape on focused elements
|
||||||
|
- `kAXMenuBarAttribute`, `AXMenuItemCmdChar`, `AXMenuItemCmdModifiers` — AX menu traversal attributes
|
||||||
|
- `kAXCloseButtonAttribute` — AX attribute for window close button element
|
||||||
|
- `kAXMainAttribute` — AX attribute to make a window the main window
|
||||||
|
- `kAXFocusedUIElementAttribute` — system-wide or app-level focused element query
|
||||||
|
- Clap v4 `Command::error_format` / null error writer — suppress stderr echo of parse errors
|
||||||
334
docs/plans/2026-02-20-feat-smart-ax-click-chain-plan.md
Normal file
334
docs/plans/2026-02-20-feat-smart-ax-click-chain-plan.md
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
---
|
||||||
|
title: "feat: Smart AX-First Click Chain — Universal Element Activation Without CGEvent"
|
||||||
|
type: feat
|
||||||
|
date: 2026-02-20
|
||||||
|
---
|
||||||
|
|
||||||
|
# Smart AX-First Click Chain
|
||||||
|
|
||||||
|
> Current contract note (2026-05-12): this historical plan is superseded where it
|
||||||
|
> describes automatic CGEvent fallback for normal ref commands. The current
|
||||||
|
> command path is headless by default. Coordinate clicks run only through
|
||||||
|
> explicit mouse commands or an explicit FFI physical policy.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace hardcoded `AXPress -> AXConfirm -> CGEvent` fallbacks with a **dynamic, discovery-based activation chain** that queries each element's capabilities at runtime and exhausts every pure-AX strategy before touching the cursor. The goal: any interactive element in any app activates without moving the mouse.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current click implementation tries 2 hardcoded AX actions, then falls back to CGEvent mouse synthesis. CGEvent:
|
||||||
|
- **Moves the visible cursor** — disruptive to the user
|
||||||
|
- **Requires the window to be frontmost** — blocks background operation
|
||||||
|
- **Fails on multi-monitor setups** where bounds are in different coordinate spaces
|
||||||
|
- **Is non-deterministic by nature** — relies on screen geometry, not semantic interaction
|
||||||
|
|
||||||
|
Meanwhile, macOS exposes multiple pure-AX strategies (selection attributes, hierarchy walking, focus+confirm) that would work silently in the background. We just never try them.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
A `smart_activate()` function that dynamically discovers and attempts every available AX strategy in priority order, falling through to CGEvent only as absolute last resort.
|
||||||
|
|
||||||
|
### The 10-Step Activation Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
1. AXPress on element (if in action list)
|
||||||
|
2. AXConfirm on element (if in action list)
|
||||||
|
3. AXOpen on element (if in action list — Finder tree items)
|
||||||
|
4. AXPick on element (if in action list — menu items)
|
||||||
|
5. Set AXSelected=true (if attribute is writable on element)
|
||||||
|
6. Set AXSelectedRows=[el] (on parent table/outline/list)
|
||||||
|
7. Explicit-policy only: Focus + AXConfirm/AXPress
|
||||||
|
8. Walk DOWN: try child actions (first child, then grandchild)
|
||||||
|
9. Walk UP: try parent actions (parent, then grandparent)
|
||||||
|
10. Explicit-policy only: CGEvent click at center
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps 1-6 and 8-9 are semantic AX paths. Steps that focus or use CGEvent are not part of the default CLI ref-command path; they require an explicit focus/physical policy. The chain is **non-deterministic** — it queries `AXUIElementCopyActionNames()` and `AXUIElementIsAttributeSettable()` at runtime for each element, never assuming what an app supports.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Prerequisite: Folder Restructure
|
||||||
|
|
||||||
|
Before implementing the activation chain, the macOS crate must be restructured from flat files into the standard subfolder layout (see CLAUDE.md "Platform Crate Folder Structure"). This is a prerequisite because:
|
||||||
|
- `tree.rs` (512 LOC) and `adapter.rs` (438 LOC) exceed the 400 LOC limit
|
||||||
|
- The new `activate.rs` file belongs in `actions/`, not at the root
|
||||||
|
|
||||||
|
The restructure splits files into `tree/`, `actions/`, `input/`, and `system/` subfolders. All `mod` paths and `crate::` imports update accordingly.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
#### New file: `crates/macos/src/actions/activate.rs` (~180 LOC)
|
||||||
|
|
||||||
|
Contains the smart activation logic. Lives in the `actions/` subfolder alongside `dispatch.rs` and `extras.rs`.
|
||||||
|
|
||||||
|
**Public API:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn smart_activate(el: &AXElement) -> Result<(), AdapterError>
|
||||||
|
pub fn smart_double_activate(el: &AXElement) -> Result<(), AdapterError>
|
||||||
|
pub fn smart_right_activate(el: &AXElement) -> Result<(), AdapterError>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Internal helpers:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn list_ax_actions(el: &AXElement) -> Vec<String>
|
||||||
|
fn try_action_from_list(el: &AXElement, actions: &[String], targets: &[&str]) -> bool
|
||||||
|
fn try_set_selected(el: &AXElement) -> bool
|
||||||
|
fn try_select_via_parent(el: &AXElement) -> bool
|
||||||
|
fn try_focus_then_activate(el: &AXElement) -> bool
|
||||||
|
fn try_child_activation(el: &AXElement) -> bool
|
||||||
|
fn try_parent_activation(el: &AXElement) -> bool
|
||||||
|
fn is_attr_settable(el: &AXElement, attr: &str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Modified file: `crates/macos/src/actions/dispatch.rs` (~350 LOC, net decrease)
|
||||||
|
|
||||||
|
Replace inline fallback logic with calls to `activate::smart_activate()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Action::Click => {
|
||||||
|
crate::actions::activate::smart_activate(el)?;
|
||||||
|
}
|
||||||
|
Action::DoubleClick => {
|
||||||
|
crate::actions::activate::smart_double_activate(el)?;
|
||||||
|
}
|
||||||
|
Action::RightClick => {
|
||||||
|
crate::actions::activate::smart_right_activate(el)?;
|
||||||
|
}
|
||||||
|
Action::Toggle => {
|
||||||
|
crate::actions::activate::smart_activate(el)?;
|
||||||
|
}
|
||||||
|
Action::TripleClick => {
|
||||||
|
crate::actions::activate::smart_triple_activate(el)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`check_uncheck()` also delegates to `smart_activate()` after its role/value checks.
|
||||||
|
|
||||||
|
#### Modified file: `crates/macos/src/actions/mod.rs`
|
||||||
|
|
||||||
|
Add `pub mod activate;` registration.
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
#### Phase 0: Folder Restructure (prerequisite, no behavior change)
|
||||||
|
|
||||||
|
Split the flat macOS crate into the standard subfolder layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/macos/src/
|
||||||
|
├── lib.rs # mod declarations only
|
||||||
|
├── adapter.rs # PlatformAdapter impl (~175 LOC)
|
||||||
|
├── tree/
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── element.rs # AXElement struct + attribute readers (~180)
|
||||||
|
│ ├── builder.rs # build_subtree, tree traversal (~250)
|
||||||
|
│ ├── roles.rs # Role mapping (75)
|
||||||
|
│ ├── resolve.rs # Element re-identification (~100)
|
||||||
|
│ └── surfaces.rs # Surface detection (252)
|
||||||
|
├── actions/
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── dispatch.rs # perform_action match arms (~350)
|
||||||
|
│ ├── activate.rs # NEW — smart AX-first chain (~180)
|
||||||
|
│ └── extras.rs # select_value, ax_scroll (182)
|
||||||
|
├── input/
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── keyboard.rs # Key synthesis (281)
|
||||||
|
│ ├── mouse.rs # Mouse events (147)
|
||||||
|
│ └── clipboard.rs # Clipboard get/set (55)
|
||||||
|
└── system/
|
||||||
|
├── mod.rs
|
||||||
|
├── app_ops.rs # launch, close, focus (165)
|
||||||
|
├── window_ops.rs # window operations (113)
|
||||||
|
├── key_dispatch.rs # app-targeted key press (105)
|
||||||
|
├── permissions.rs # permission checks (19)
|
||||||
|
├── screenshot.rs # screen capture (79)
|
||||||
|
└── wait.rs # wait utilities (77)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification:** `cargo build && cargo test --workspace && cargo clippy --all-targets -- -D warnings` — zero behavior change, only file moves and import path updates.
|
||||||
|
|
||||||
|
#### Phase 1: Core Smart Activate (~130 LOC in `actions/activate.rs`)
|
||||||
|
|
||||||
|
**`list_ax_actions()`** — Query element's action list dynamically:
|
||||||
|
```
|
||||||
|
AXUIElementCopyActionNames → iterate CFArray → collect Vec<String>
|
||||||
|
```
|
||||||
|
This is the foundation — every decision is based on what the element actually reports.
|
||||||
|
|
||||||
|
**`is_attr_settable()`** — Check if a writable attribute exists:
|
||||||
|
```
|
||||||
|
AXUIElementIsAttributeSettable(el, attr) → bool
|
||||||
|
```
|
||||||
|
Required import from `accessibility_sys`: `AXUIElementIsAttributeSettable`.
|
||||||
|
|
||||||
|
**`smart_activate()`** — The 10-step chain:
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1-4: Query actions once, try matching activation actions in order
|
||||||
|
Step 5: is_attr_settable("AXSelected") → set true
|
||||||
|
Step 6: copy_element_attr(el, "AXParent") → check parent role →
|
||||||
|
is_attr_settable(parent, "AXSelectedRows") → set [el]
|
||||||
|
Step 7: explicit-policy only: set AXFocused=true → sleep(50ms) → retry AXConfirm/AXPress
|
||||||
|
Step 8: copy_ax_array(el, "AXChildren") → take(3) → try actions on each
|
||||||
|
Step 9: copy_element_attr(el, "AXParent") → try actions, then grandparent
|
||||||
|
Step 10: explicit physical policy only: click_via_bounds(el, Left, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
|
||||||
|
- **Query actions ONCE per activate call** — store in a local Vec, don't re-query per step
|
||||||
|
- **Parent role check for step 6** — only try AXSelectedRows on AXTable/AXOutline/AXList roles
|
||||||
|
(prevents setting selection attributes on random groups)
|
||||||
|
- **Child walk limit** — try first 3 children only (avoid expensive deep traversal)
|
||||||
|
- **Parent walk limit** — 2 levels max (parent + grandparent)
|
||||||
|
- **Focus settle delay** — 50ms between AXFocused set and retry (matches existing codebase pattern)
|
||||||
|
|
||||||
|
#### Phase 2: Specialized Variants (~50 LOC in `actions/activate.rs`)
|
||||||
|
|
||||||
|
**`smart_double_activate()`:**
|
||||||
|
```
|
||||||
|
1. AXOpen (if in action list)
|
||||||
|
2. smart_activate() twice with 50ms gap
|
||||||
|
3. CGEvent double-click fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
**`smart_right_activate()`:**
|
||||||
|
```
|
||||||
|
1. AXShowMenu (if in action list)
|
||||||
|
2. CGEvent right-click fallback (no AX alternative for context menus)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`smart_triple_activate()`:**
|
||||||
|
```
|
||||||
|
1. smart_activate() three times with 30ms gaps
|
||||||
|
2. CGEvent triple-click fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
Right-click has fewer AX alternatives — `AXShowMenu` is the only pure-AX option. CGEvent is a reasonable fallback here since context menus are inherently visual.
|
||||||
|
|
||||||
|
#### Phase 3: Wire Into actions/dispatch.rs (net LOC decrease)
|
||||||
|
|
||||||
|
Replace the inline `try_ax_action` + `click_via_bounds` chains in `perform_action()` with single-line calls to `activate.rs`. This will **reduce** `dispatch.rs` LOC since the complex fallback logic moves out.
|
||||||
|
|
||||||
|
The `try_ax_action()`, `click_via_bounds()`, and `has_ax_action()` functions stay in `dispatch.rs` as they're used by other code paths. But they also get `pub(crate)` visibility for `activate.rs` to call `click_via_bounds()` as the final fallback.
|
||||||
|
|
||||||
|
#### Phase 4: Expand/Collapse AXDisclosing Fallback (~15 LOC in actions/dispatch.rs)
|
||||||
|
|
||||||
|
For `Action::Expand` and `Action::Collapse`, add `AXDisclosing` attribute as fallback:
|
||||||
|
|
||||||
|
```
|
||||||
|
Expand: AXExpand action → set AXDisclosing=true → error
|
||||||
|
Collapse: AXCollapse action → set AXDisclosing=false → error
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses `is_attr_settable()` from `actions/activate.rs`. Small addition, stays in `actions/dispatch.rs`.
|
||||||
|
|
||||||
|
### Selection via Parent: Building the CFArray
|
||||||
|
|
||||||
|
For step 6 (set AXSelectedRows on parent), we need to build a CFArray containing the target element. The pattern from tree.rs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Retain the element, build a single-element array
|
||||||
|
unsafe { CFRetain(el.0 as CFTypeRef) };
|
||||||
|
let el_as_cftype = unsafe { CFType::wrap_under_create_rule(el.0 as CFTypeRef) };
|
||||||
|
let arr = CFArray::from_CFTypes(&[el_as_cftype]);
|
||||||
|
AXUIElementSetAttributeValue(parent.0, attr, arr.as_CFTypeRef())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The element must be CFRetained before wrapping, since `from_CFTypes` does its own retain, and `wrap_under_create_rule` takes ownership.
|
||||||
|
|
||||||
|
## File Changes Summary
|
||||||
|
|
||||||
|
### Phase 0: Folder Restructure (move only, no behavior change)
|
||||||
|
|
||||||
|
| Old Path | New Path |
|
||||||
|
|----------|----------|
|
||||||
|
| `crates/macos/src/tree.rs` | Split → `tree/element.rs`, `tree/builder.rs` |
|
||||||
|
| `crates/macos/src/actions.rs` | → `actions/dispatch.rs` |
|
||||||
|
| `crates/macos/src/action_extras.rs` | → `actions/extras.rs` |
|
||||||
|
| `crates/macos/src/keyboard.rs` | → `input/keyboard.rs` |
|
||||||
|
| `crates/macos/src/mouse.rs` | → `input/mouse.rs` |
|
||||||
|
| `crates/macos/src/clipboard.rs` | → `input/clipboard.rs` |
|
||||||
|
| `crates/macos/src/roles.rs` | → `tree/roles.rs` |
|
||||||
|
| `crates/macos/src/surfaces.rs` | → `tree/surfaces.rs` |
|
||||||
|
| `crates/macos/src/app_ops.rs` | → `system/app_ops.rs` |
|
||||||
|
| `crates/macos/src/window_ops.rs` | → `system/window_ops.rs` |
|
||||||
|
| `crates/macos/src/key_dispatch.rs` | → `system/key_dispatch.rs` |
|
||||||
|
| `crates/macos/src/permissions.rs` | → `system/permissions.rs` |
|
||||||
|
| `crates/macos/src/screenshot.rs` | → `system/screenshot.rs` |
|
||||||
|
| `crates/macos/src/wait.rs` | → `system/wait.rs` |
|
||||||
|
|
||||||
|
Plus extract `tree/resolve.rs` from `adapter.rs` (element re-identification logic).
|
||||||
|
|
||||||
|
### Phases 1-4: Smart Activation Chain
|
||||||
|
|
||||||
|
| File | Action | LOC Change |
|
||||||
|
|------|--------|------------|
|
||||||
|
| `crates/macos/src/actions/activate.rs` | **Create** | +180 |
|
||||||
|
| `crates/macos/src/actions/dispatch.rs` | Modify (simplify) | -30 (~350 LOC) |
|
||||||
|
| `crates/macos/src/actions/mod.rs` | Add `pub mod activate` | +1 |
|
||||||
|
|
||||||
|
**Net:** +151 LOC. No file exceeds 400 LOC.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- [ ] `click @ref` on System Settings treeitems works without moving cursor (AXSelected or AXSelectedRows)
|
||||||
|
- [ ] `click @ref` on standard buttons still works via AXPress (no regression)
|
||||||
|
- [ ] `click @ref` on Finder sidebar items works (AXOpen or AXSelected)
|
||||||
|
- [ ] `click @ref` on custom SwiftUI controls works (hierarchy walking or focus+confirm)
|
||||||
|
- [ ] `double-click @ref` works via AXOpen or repeated activation
|
||||||
|
- [ ] `right-click @ref` works via AXShowMenu where possible
|
||||||
|
- [ ] `toggle`, `check`, `uncheck` use smart activation
|
||||||
|
- [ ] `expand`/`collapse` falls back to AXDisclosing attribute
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- [ ] No app-specific code — all behavior is discovered at runtime via `AXUIElementCopyActionNames` and `AXUIElementIsAttributeSettable`
|
||||||
|
- [ ] CGEvent is only reached when ALL pure-AX strategies fail
|
||||||
|
- [ ] No file exceeds 400 LOC
|
||||||
|
- [ ] `cargo clippy --all-targets -- -D warnings` passes
|
||||||
|
- [ ] `cargo test --workspace` passes
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
- [ ] Smoke test on 5+ apps: System Settings, Finder, TextEdit, Safari, Xcode
|
||||||
|
- [ ] Verify cursor does NOT move during activation on at least 3 test cases
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo fmt --all
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# Smoke tests
|
||||||
|
cargo run -- launch "System Settings"
|
||||||
|
cargo run -- snapshot --app "System Settings" -i
|
||||||
|
cargo run -- click @eN # Appearance treeitem — should NOT move cursor
|
||||||
|
cargo run -- click @eN # Light button — should activate
|
||||||
|
|
||||||
|
cargo run -- launch "Finder"
|
||||||
|
cargo run -- snapshot --app "Finder" -i
|
||||||
|
cargo run -- click @eN # Sidebar item
|
||||||
|
|
||||||
|
cargo run -- launch "TextEdit"
|
||||||
|
cargo run -- snapshot --app "TextEdit" -i
|
||||||
|
cargo run -- click @eN # Toolbar button
|
||||||
|
|
||||||
|
wc -l crates/macos/src/actions/activate.rs # must be <= 400
|
||||||
|
wc -l crates/macos/src/actions/dispatch.rs # must be <= 400
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Brainstorm research: AX action catalog (11 documented + app-specific custom actions)
|
||||||
|
- `AXUIElementIsAttributeSettable` available in `accessibility-sys 0.2.0`
|
||||||
|
- Existing parent-walking pattern: `actions/extras.rs` (`find_scroll_area`)
|
||||||
|
- Existing action list query: `actions/dispatch.rs` (`has_ax_action`)
|
||||||
|
- `copy_element_attr` supports "AXParent": `tree/element.rs`
|
||||||
324
docs/plans/2026-02-21-fix-fallback-chains-edge-cases-plan.md
Normal file
324
docs/plans/2026-02-21-fix-fallback-chains-edge-cases-plan.md
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
---
|
||||||
|
title: "fix: Add fallback chains and focus guards to all commands"
|
||||||
|
type: fix
|
||||||
|
date: 2026-02-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Fallback Chains and Focus Guards to All Commands
|
||||||
|
|
||||||
|
> Current contract note (2026-05-12): this historical plan is superseded where it
|
||||||
|
> treats focus or CGEvent paths as automatic fallbacks for normal ref commands.
|
||||||
|
> The current command path is headless by default; physical/headed behavior is
|
||||||
|
> explicit.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Audit every command in agent-desktop to ensure: (1) every action has an AX-first multi-step fallback chain, (2) every CGEvent path has a focus guard, and (3) edge cases where AX fails due to missing focus/state are handled. Commands like `type`, `press`, `set-value`, `set-focus`, `expand`, `collapse`, `scroll-to`, and `select` currently have single-strategy or no fallback — they either succeed on the first try or return an error.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Three categories of issues:
|
||||||
|
|
||||||
|
### 1. Commands with NO fallback chain (single AX strategy, error on failure)
|
||||||
|
|
||||||
|
| Command | Current Implementation | Problem |
|
||||||
|
|---------|----------------------|---------|
|
||||||
|
| `set-value` | `AXUIElementSetAttributeValue(AXValue)` | Must verify app-observable state and fail clearly if unchanged |
|
||||||
|
| `set-focus` | `AXUIElementSetAttributeValue(AXFocused, true)` | Fails on some elements, no fallback to click-to-focus |
|
||||||
|
| `scroll-to` | `AXPerformAction(AXScrollToVisible)` | Fails on elements not in scroll area, no other strategies |
|
||||||
|
| `clear` | `AXUIElementSetAttributeValue(AXValue, "")` | Same as set-value — fails on read-only |
|
||||||
|
| `expand` | AXExpand → AXDisclosing=true (2 steps) | Missing: click fallback, AXPress on disclosure triangle |
|
||||||
|
| `collapse` | AXCollapse → AXDisclosing=false (2 steps) | Missing: click fallback, AXPress on disclosure triangle |
|
||||||
|
| `select` (popup) | AXPress → search children → AXPress item | Missing: retry with app focus, fallback to AXShowMenu |
|
||||||
|
| `type` | Set AXFocused → `synthesize_text()` via system-wide AX | No focus guard — text goes to whichever app is frontmost |
|
||||||
|
| `press` | `synthesize_key()` via system-wide AX | No focus guard — keystroke goes to whatever is focused |
|
||||||
|
|
||||||
|
### 2. Edge cases where app focus is needed but not ensured
|
||||||
|
|
||||||
|
| Command | Scenario | Impact |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| `type @e5 "hello"` | Terminal is focused, TextEdit behind | Text types into Terminal instead of TextEdit |
|
||||||
|
| `press cmd+s` | Target app not frontmost | Saves wrong app's document |
|
||||||
|
| `select` (popup) | Popup opens in background app | Menu items can't be found |
|
||||||
|
|
||||||
|
### 3. Commands that are correct (no changes needed)
|
||||||
|
|
||||||
|
| Command | Why it's fine |
|
||||||
|
|---------|--------------|
|
||||||
|
| `click` | Semantic activation chain; CGEvent requires explicit physical policy in current code |
|
||||||
|
| `double-click` | Uses smart_activate (14-step) |
|
||||||
|
| `right-click` | 7-step chain with explicit ensure_app_focused |
|
||||||
|
| `triple-click` | Uses smart_activate (14-step) |
|
||||||
|
| `scroll` | Semantic scroll chain; wheel input requires explicit physical policy in current code |
|
||||||
|
| `toggle` / `check` / `uncheck` | Delegates to smart_activate (14-step) |
|
||||||
|
| `mouse-*` / `drag` / `hover` | Intentionally raw CGEvent (agent controls coordinates) |
|
||||||
|
| All read-only commands | No action to fail |
|
||||||
|
| `press --app` | Already has focus guard in key_dispatch.rs |
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Phase 1: Focus Guards for TypeText and PressKey
|
||||||
|
|
||||||
|
**File: `crates/macos/src/actions/dispatch.rs`**
|
||||||
|
|
||||||
|
#### TypeText — ensure target element's app is focused before typing
|
||||||
|
|
||||||
|
Current code (line 125-135):
|
||||||
|
```rust
|
||||||
|
Action::TypeText(text) => {
|
||||||
|
let cf_attr = CFString::new(kAXFocusedAttribute);
|
||||||
|
unsafe { AXUIElementSetAttributeValue(...) };
|
||||||
|
crate::input::keyboard::synthesize_text(text)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
```rust
|
||||||
|
Action::TypeText(text) => {
|
||||||
|
if let Some(pid) = crate::system::app_ops::pid_from_element(el) {
|
||||||
|
let _ = crate::system::app_ops::ensure_app_focused(pid);
|
||||||
|
}
|
||||||
|
let cf_attr = CFString::new(kAXFocusedAttribute);
|
||||||
|
unsafe { AXUIElementSetAttributeValue(...) };
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
|
crate::input::keyboard::synthesize_text(text)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PressKey — ensure target element's app is focused before keystroke
|
||||||
|
|
||||||
|
Current code (line 137-139):
|
||||||
|
```rust
|
||||||
|
Action::PressKey(combo) => {
|
||||||
|
crate::input::keyboard::synthesize_key(combo)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to: Use the SAME strategy as `press --app` in `key_dispatch.rs` — try menu bar shortcut first, then AX keyboard event to app (not system-wide), then fall back to system-wide:
|
||||||
|
```rust
|
||||||
|
Action::PressKey(combo) => {
|
||||||
|
if let Some(pid) = crate::system::app_ops::pid_from_element(el) {
|
||||||
|
let _ = crate::system::app_ops::ensure_app_focused(pid);
|
||||||
|
let app_el = crate::tree::element_for_pid(pid);
|
||||||
|
// Try AX action on focused element first (return/escape/space)
|
||||||
|
// Then try menu bar shortcut
|
||||||
|
// Then AXUIElementPostKeyboardEvent to app
|
||||||
|
// Last: system-wide synthesize_key
|
||||||
|
}
|
||||||
|
crate::input::keyboard::synthesize_key(combo)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: SetValue / Clear Fallback Chain
|
||||||
|
|
||||||
|
**File: `crates/macos/src/actions/dispatch.rs`**
|
||||||
|
|
||||||
|
Replace `ax_set_value(el, val)` with a multi-step chain:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. AXUIElementSetAttributeValue(AXValue, val) — direct set
|
||||||
|
2. If element is a textfield/textarea: focus → select-all → type replacement text
|
||||||
|
3. If element has AXInsertText action (parameterized): use it
|
||||||
|
4. Focus + clear via keyboard (Cmd+A, then type new value)
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn smart_set_value(el: &AXElement, val: &str) -> Result<(), AdapterError> {
|
||||||
|
// Step 1: Direct AX value set
|
||||||
|
if ax_set_value(el, val).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Focus + select all + type
|
||||||
|
let role = element_role(el);
|
||||||
|
if matches!(role.as_deref(), Some("textfield" | "textarea" | "combobox")) {
|
||||||
|
if let Some(pid) = crate::system::app_ops::pid_from_element(el) {
|
||||||
|
let _ = crate::system::app_ops::ensure_app_focused(pid);
|
||||||
|
}
|
||||||
|
let cf_attr = CFString::new(kAXFocusedAttribute);
|
||||||
|
let focus_err = unsafe {
|
||||||
|
AXUIElementSetAttributeValue(
|
||||||
|
el.0, cf_attr.as_concrete_TypeRef(),
|
||||||
|
CFBoolean::true_value().as_CFTypeRef(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if focus_err == kAXErrorSuccess {
|
||||||
|
std::thread::sleep(Duration::from_millis(30));
|
||||||
|
// Select all existing text
|
||||||
|
crate::input::keyboard::synthesize_key(&KeyCombo {
|
||||||
|
key: "a".to_string(),
|
||||||
|
modifiers: vec![Modifier::Cmd],
|
||||||
|
})?;
|
||||||
|
std::thread::sleep(Duration::from_millis(30));
|
||||||
|
// Type replacement
|
||||||
|
crate::input::keyboard::synthesize_text(val)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AdapterError::new(
|
||||||
|
ErrorCode::ActionFailed,
|
||||||
|
"SetValue failed: element value not settable",
|
||||||
|
).with_suggestion("Try 'click' to focus, then 'type' to enter text"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For **Clear**: Same chain but with empty string (select-all + delete):
|
||||||
|
```
|
||||||
|
1. AXUIElementSetAttributeValue(AXValue, "")
|
||||||
|
2. Focus + Cmd+A + Delete
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: SetFocus Fallback Chain
|
||||||
|
|
||||||
|
**File: `crates/macos/src/actions/dispatch.rs`**
|
||||||
|
|
||||||
|
Current: single `AXUIElementSetAttributeValue(AXFocused, true)` call.
|
||||||
|
|
||||||
|
Add fallback:
|
||||||
|
```
|
||||||
|
1. Set AXFocused=true
|
||||||
|
2. If fail: click the element (smart_activate) — clicking often focuses
|
||||||
|
3. If fail: Set AXSelected=true (some list elements gain focus via selection)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Expand/Collapse Enhanced Chain
|
||||||
|
|
||||||
|
**File: `crates/macos/src/actions/dispatch.rs`**
|
||||||
|
|
||||||
|
Current chain: AXExpand → AXDisclosing=true (2 steps).
|
||||||
|
|
||||||
|
Enhanced chain:
|
||||||
|
```
|
||||||
|
1. AXExpand / AXCollapse action
|
||||||
|
2. Set AXDisclosing = true/false
|
||||||
|
3. AXPress on element itself (toggles disclosure state in many apps)
|
||||||
|
4. AXPress on disclosure triangle child (look for child with subrole AXDisclosureTriangle)
|
||||||
|
5. smart_activate (click fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Select with Focus Guard
|
||||||
|
|
||||||
|
**File: `crates/macos/src/actions/extras.rs`**
|
||||||
|
|
||||||
|
For popup/menubutton: ensure app focused before opening popup.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Some("popupbutton") | Some("menubutton") => {
|
||||||
|
// Ensure app is focused so popup menu is visible
|
||||||
|
if let Some(pid) = crate::system::app_ops::pid_from_element(el) {
|
||||||
|
let _ = crate::system::app_ops::ensure_app_focused(pid);
|
||||||
|
}
|
||||||
|
ax_press_or_fail(el, "select (open popup)")?;
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
// ... rest of menu search
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: ScrollTo Fallback
|
||||||
|
|
||||||
|
**File: `crates/macos/src/actions/dispatch.rs`**
|
||||||
|
|
||||||
|
Current: single `AXScrollToVisible` action.
|
||||||
|
|
||||||
|
Enhanced:
|
||||||
|
```
|
||||||
|
1. AXScrollToVisible on element
|
||||||
|
2. If fail: scroll parent to bring element into view using ax_scroll()
|
||||||
|
3. If fail: error with suggestion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Phase 1: Focus Guards
|
||||||
|
- [ ] Add `ensure_app_focused(pid)` before `synthesize_text()` in TypeText handler
|
||||||
|
- [ ] Add 30ms sleep after focus before AXFocused set
|
||||||
|
- [ ] For PressKey: focus app, try AX action on focused element, try menu bar, then AX keyboard to app, then system-wide fallback
|
||||||
|
- [ ] Test: open TextEdit behind Terminal, `type @e1 "test"` should type into TextEdit
|
||||||
|
|
||||||
|
### Phase 2: SetValue/Clear Chain
|
||||||
|
- [ ] Extract `smart_set_value(el, val)` function
|
||||||
|
- [ ] Step 1: Direct AXValue set
|
||||||
|
- [ ] Step 2: Focus + Cmd+A + type replacement (for text fields)
|
||||||
|
- [ ] Update Clear to use same chain with empty/delete
|
||||||
|
- [ ] Test: `set-value @textfield "new text"` on a read-only label should fail gracefully
|
||||||
|
|
||||||
|
### Phase 3: SetFocus Chain
|
||||||
|
- [ ] Add click fallback after AXFocused=true fails
|
||||||
|
- [ ] Add AXSelected fallback for list items
|
||||||
|
- [ ] Test: `focus @e5` on an element that doesn't support AXFocused
|
||||||
|
|
||||||
|
### Phase 4: Expand/Collapse Chain
|
||||||
|
- [ ] Add AXPress on element after AXDisclosing fails
|
||||||
|
- [ ] Add disclosure triangle child search
|
||||||
|
- [ ] Add smart_activate as last AX resort
|
||||||
|
- [ ] Test: expand/collapse on Finder sidebar disclosure triangles
|
||||||
|
|
||||||
|
### Phase 5: Select Focus Guard
|
||||||
|
- [ ] Add ensure_app_focused before popup open in select_value
|
||||||
|
- [ ] Test: select dropdown value in background app
|
||||||
|
|
||||||
|
### Phase 6: ScrollTo Fallback
|
||||||
|
- [ ] Add parent scroll fallback when AXScrollToVisible fails
|
||||||
|
- [ ] Test: scroll-to on element outside visible area
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Change | LOC Impact |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `crates/macos/src/actions/dispatch.rs` | TypeText/PressKey focus, SetValue/Clear/Focus/Expand/Collapse/ScrollTo chains | +80 (~447 → need to extract) |
|
||||||
|
| `crates/macos/src/actions/extras.rs` | Select focus guard | +5 |
|
||||||
|
|
||||||
|
**LOC concern:** dispatch.rs is currently 367 LOC. Adding ~80 LOC pushes to ~447, over the 400 limit. Solution: extract `smart_set_value()` and the enhanced expand/collapse into a new file `crates/macos/src/actions/value_ops.rs` (~80 LOC).
|
||||||
|
|
||||||
|
| File | Final LOC |
|
||||||
|
|------|----------|
|
||||||
|
| `dispatch.rs` | ~380 (after extracting value_ops) |
|
||||||
|
| `value_ops.rs` | ~80 (new: smart_set_value, smart_clear, smart_expand, smart_collapse) |
|
||||||
|
| `extras.rs` | ~400 (unchanged + 5 for focus guard) |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build && cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
# Phase 1: TypeText focus guard
|
||||||
|
cargo run -- launch "TextEdit"
|
||||||
|
cargo run -- launch "Finder" # Finder now focused
|
||||||
|
cargo run -- snapshot --app "TextEdit" -i
|
||||||
|
cargo run -- type @e1 "hello world" # Should focus TextEdit first, then type
|
||||||
|
|
||||||
|
# Phase 1: PressKey focus guard
|
||||||
|
cargo run -- snapshot --app "TextEdit" -i
|
||||||
|
cargo run -- press cmd+a # Should select all in TextEdit, not Finder
|
||||||
|
|
||||||
|
# Phase 2: SetValue fallback
|
||||||
|
cargo run -- snapshot --app "TextEdit" -i
|
||||||
|
cargo run -- set-value @e1 "replaced text"
|
||||||
|
|
||||||
|
# Phase 4: Expand/Collapse
|
||||||
|
cargo run -- snapshot --app "Finder" -i
|
||||||
|
cargo run -- expand @eN # disclosure triangle
|
||||||
|
cargo run -- collapse @eN
|
||||||
|
|
||||||
|
# Phase 5: Select with focus
|
||||||
|
cargo run -- launch "System Settings"
|
||||||
|
cargo run -- snapshot --app "System Settings" -i
|
||||||
|
cargo run -- select @eN "Dark" # appearance dropdown
|
||||||
|
|
||||||
|
# LOC checks
|
||||||
|
wc -l crates/macos/src/actions/dispatch.rs # <= 400
|
||||||
|
wc -l crates/macos/src/actions/value_ops.rs # <= 400
|
||||||
|
wc -l crates/macos/src/actions/extras.rs # <= 400
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. Phase 1: Focus guards for TypeText and PressKey (highest impact, most common edge case)
|
||||||
|
2. Phase 2: SetValue/Clear fallback chain
|
||||||
|
3. Phase 3: SetFocus fallback chain
|
||||||
|
4. Phase 4: Expand/Collapse enhanced chain
|
||||||
|
5. Phase 5: Select focus guard
|
||||||
|
6. Phase 6: ScrollTo fallback
|
||||||
|
7. Build, lint, test everything
|
||||||
|
8. Update README if chain descriptions changed
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
---
|
||||||
|
title: "feat: Automated releases with GitHub Releases and npm distribution"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-02-23
|
||||||
|
origin: docs/brainstorms/2026-02-23-release-automation-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Automated releases with GitHub Releases and npm distribution
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Set up a fully automated release pipeline for agent-desktop: Conventional Commits determine SemVer version bumps, release-please creates gated Release PRs with auto-generated CHANGELOGs, merging the Release PR triggers macOS binary builds, GitHub Release creation with tarballs, and npm publication of the `agent-desktop` package with a postinstall binary downloader.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
agent-desktop is currently install-from-source only (`cargo build --release`). There is no versioning strategy, no CHANGELOG, no GitHub Releases, and no package manager distribution. Users must have the Rust toolchain installed to use the tool. This limits adoption, especially for AI agent developers who may not have Rust set up.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Follow the agent-browser pattern (see brainstorm: `docs/brainstorms/2026-02-23-release-automation-brainstorm.md`):
|
||||||
|
|
||||||
|
1. **Conventional Commits** → SemVer version bumps
|
||||||
|
2. **release-please** → Release PRs with CHANGELOG
|
||||||
|
3. **GitHub Actions** → build macOS binaries on Release PR merge
|
||||||
|
4. **GitHub Releases** → attach platform tarballs + SHA-256 checksums
|
||||||
|
5. **npm** → single `agent-desktop` package with postinstall binary downloader
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Push to main (feat:/fix: commits)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
release-please creates/updates Release PR
|
||||||
|
(bumps Cargo.toml version, generates CHANGELOG)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Developer merges Release PR
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
release-please creates GitHub Release (tag: v0.2.0)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Build job (matrix: aarch64-apple-darwin, x86_64-apple-darwin)
|
||||||
|
├── cargo build --release --target <target>
|
||||||
|
├── tar + gzip binary
|
||||||
|
└── generate SHA-256 checksum
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Upload tarballs + checksums.txt to GitHub Release
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Verify all assets present → npm publish --provenance
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
User: npm install -g agent-desktop
|
||||||
|
├── postinstall: detect platform → download binary from GH Release
|
||||||
|
└── bin/agent-desktop.js: spawn native binary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
#### Phase 1: release-please + CHANGELOG
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- `release-please-config.json` — configuration targeting root `Cargo.toml`
|
||||||
|
- `.release-please-manifest.json` — version tracker (initial: `{"." : "0.1.0"}`)
|
||||||
|
- `.github/workflows/release.yml` — release-please job
|
||||||
|
- Cargo.lock auto-update step in Release PR
|
||||||
|
|
||||||
|
**Files to create:**
|
||||||
|
- `release-please-config.json`
|
||||||
|
- `.release-please-manifest.json`
|
||||||
|
- `.github/workflows/release.yml`
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- [ ] Pushing a `feat:` commit to `main` creates a Release PR
|
||||||
|
- [ ] Release PR bumps `workspace.package.version` in root `Cargo.toml`
|
||||||
|
- [ ] Release PR includes auto-generated CHANGELOG.md
|
||||||
|
- [ ] Cargo.lock is updated in the Release PR
|
||||||
|
- [ ] Merging the Release PR creates a GitHub Release with tag `v{version}`
|
||||||
|
|
||||||
|
**Key decisions (see brainstorm):**
|
||||||
|
- `feat:` → minor, `fix:` → patch, `feat!:` → major
|
||||||
|
- `style:`, `docs:`, `refactor:`, `chore:`, `ci:` → excluded from CHANGELOG, no release
|
||||||
|
|
||||||
|
#### Phase 2: Binary builds + GitHub Release assets
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- Build matrix in `release.yml` for macOS targets
|
||||||
|
- Tarball creation + SHA-256 checksums
|
||||||
|
- Asset upload to GitHub Release
|
||||||
|
- Verification step before npm publish
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `.github/workflows/release.yml` (add build + upload jobs)
|
||||||
|
|
||||||
|
**Build matrix:**
|
||||||
|
|
||||||
|
| Target | Runner | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| `aarch64-apple-darwin` | `macos-latest` (ARM) | Native build |
|
||||||
|
| `x86_64-apple-darwin` | `macos-13` (Intel) | Native build on Intel runner |
|
||||||
|
|
||||||
|
**Tarball naming:** `agent-desktop-v{version}-{target}.tar.gz`
|
||||||
|
- e.g., `agent-desktop-v0.2.0-aarch64-apple-darwin.tar.gz`
|
||||||
|
- Binary inside tarball is just `agent-desktop` (no platform suffix)
|
||||||
|
|
||||||
|
**Checksum file:** `checksums.txt` attached to the release, containing SHA-256 hashes for all tarballs.
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- [ ] Release creates tarballs for both macOS architectures
|
||||||
|
- [ ] `checksums.txt` is attached to the GitHub Release
|
||||||
|
- [ ] Binary size is under 15MB (enforced)
|
||||||
|
- [ ] Both binaries execute correctly on their respective platforms
|
||||||
|
|
||||||
|
**CI runner note:** Use `macos-13` for x86_64 (Intel runner) and `macos-latest` for aarch64 (ARM runner) to avoid cross-compilation issues.
|
||||||
|
|
||||||
|
#### Phase 3: npm package + postinstall
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- `npm/package.json` — package metadata, bin entry, postinstall script
|
||||||
|
- `npm/bin/agent-desktop.js` — JS wrapper that spawns native binary
|
||||||
|
- `npm/scripts/postinstall.js` — downloads platform binary from GitHub Release
|
||||||
|
- npm publish step in `release.yml`
|
||||||
|
|
||||||
|
**Files to create:**
|
||||||
|
- `npm/package.json`
|
||||||
|
- `npm/bin/agent-desktop.js`
|
||||||
|
- `npm/scripts/postinstall.js`
|
||||||
|
|
||||||
|
**npm package structure:**
|
||||||
|
```
|
||||||
|
npm/
|
||||||
|
├── package.json # name: "agent-desktop", bin, postinstall
|
||||||
|
├── bin/
|
||||||
|
│ └── agent-desktop.js # JS wrapper: platform detect → spawn binary
|
||||||
|
└── scripts/
|
||||||
|
└── postinstall.js # Download binary from GitHub Release
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json key fields:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "agent-desktop",
|
||||||
|
"bin": { "agent-desktop": "./bin/agent-desktop.js" },
|
||||||
|
"scripts": { "postinstall": "node scripts/postinstall.js" },
|
||||||
|
"files": ["bin", "scripts"],
|
||||||
|
"os": ["darwin"],
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**postinstall.js behavior:**
|
||||||
|
1. Detect platform via `process.platform` + `process.arch`
|
||||||
|
2. Map to Rust target triple (`darwin`+`arm64` → `aarch64-apple-darwin`)
|
||||||
|
3. If unsupported platform → print friendly message, exit 0 (not error)
|
||||||
|
4. Download tarball from `https://github.com/lahfir/agent-desktop/releases/download/v{version}/agent-desktop-v{version}-{target}.tar.gz`
|
||||||
|
5. Download `checksums.txt`, verify SHA-256
|
||||||
|
6. Extract binary to `bin/agent-desktop-{platform-arch}`
|
||||||
|
7. `chmod 755` the binary
|
||||||
|
8. On global installs: attempt to patch npm's symlink for zero-overhead execution
|
||||||
|
|
||||||
|
**postinstall robustness:**
|
||||||
|
- Respect `HTTPS_PROXY` / `HTTP_PROXY` env vars
|
||||||
|
- Support `AGENT_DESKTOP_BINARY_PATH` override for pre-placed binaries
|
||||||
|
- Support `AGENT_DESKTOP_SKIP_DOWNLOAD=1` for offline environments
|
||||||
|
- 3 retries with exponential backoff (2s, 4s, 8s)
|
||||||
|
- 60-second timeout per download attempt
|
||||||
|
- Atomic file writes (download to `.tmp`, then `rename()`)
|
||||||
|
- Detect Bun (`process.versions.bun`) and suggest `--trust` if binary missing
|
||||||
|
- Print progress to stderr (not stdout, to avoid interfering with JSON output)
|
||||||
|
|
||||||
|
**bin/agent-desktop.js behavior:**
|
||||||
|
1. Detect platform, resolve binary path
|
||||||
|
2. If binary missing → clear error with recovery instructions
|
||||||
|
3. Ensure binary is executable (`chmod 755` if needed)
|
||||||
|
4. `spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit' })`
|
||||||
|
5. Forward exit code
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- [ ] `npm install -g agent-desktop` works on macOS (ARM + Intel)
|
||||||
|
- [ ] `npx agent-desktop version` returns correct version
|
||||||
|
- [ ] `npm install -g agent-desktop` on Linux/Windows prints "macOS only" message and exits cleanly
|
||||||
|
- [ ] Binary integrity verified via SHA-256 checksum
|
||||||
|
- [ ] Postinstall handles network failures gracefully with retry
|
||||||
|
- [ ] Global install symlink optimization works (best-effort)
|
||||||
|
|
||||||
|
## Alternative Approaches Considered
|
||||||
|
|
||||||
|
1. **Per-platform npm packages** (`@agent-desktop/darwin-arm64`, etc.) — like esbuild/turbo. More complex: multiple packages to publish, npm org required, optionalDependencies wiring. Rejected in favor of the simpler single-package approach (see brainstorm).
|
||||||
|
|
||||||
|
2. **semantic-release** (fully automatic) — every merge to main auto-releases. No gate. Rejected because release-please's Release PR provides a natural review point (see brainstorm).
|
||||||
|
|
||||||
|
3. **cargo-release** — Rust-native tooling. More manual, requires running commands locally. Rejected for being less automated (see brainstorm).
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
### Interaction Graph
|
||||||
|
|
||||||
|
- Push to `main` → `release.yml` triggers → release-please creates/updates Release PR
|
||||||
|
- Release PR merge → release-please creates GitHub Release → build job → publish-github job → publish-npm job
|
||||||
|
- `ci.yml` (existing) is NOT modified — continues running on all pushes/PRs independently
|
||||||
|
- npm postinstall → HTTPS request to GitHub Releases CDN → binary placed in package
|
||||||
|
|
||||||
|
### Error Propagation
|
||||||
|
|
||||||
|
- Build failure → no tarballs uploaded → asset verification fails → npm publish blocked (safe)
|
||||||
|
- Postinstall download failure → JS wrapper detects missing binary → clear error message (graceful degradation)
|
||||||
|
- Version sync failure (Cargo.toml vs package.json) → CI script handles sync before `npm publish` (single source of truth is Cargo.toml)
|
||||||
|
|
||||||
|
### State Lifecycle Risks
|
||||||
|
|
||||||
|
- **Partial release:** GitHub Release created but binaries not yet uploaded. Mitigated by strict `needs:` job dependencies — npm publish waits for all assets.
|
||||||
|
- **npm publish without assets:** Mitigated by explicit asset verification step before publish.
|
||||||
|
- **Stale npm cache:** `npx` may cache old versions. Documented: use `npx agent-desktop@latest`.
|
||||||
|
|
||||||
|
### API Surface Parity
|
||||||
|
|
||||||
|
- No API changes. The binary's CLI interface, JSON output contract, and exit codes are unchanged.
|
||||||
|
- The `version` command output will reflect the new version number automatically (uses `env!("CARGO_PKG_VERSION")`).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- [ ] Conventional commits (`feat:`, `fix:`, `feat!:`) correctly determine version bumps
|
||||||
|
- [ ] release-please creates a Release PR with CHANGELOG on releasable commits
|
||||||
|
- [ ] Merging the Release PR creates a GitHub Release with tag `v{version}`
|
||||||
|
- [ ] macOS binaries (ARM + Intel) are built and attached as tarballs
|
||||||
|
- [ ] SHA-256 checksums are attached to the release
|
||||||
|
- [ ] `npm install -g agent-desktop` downloads and installs the correct binary
|
||||||
|
- [ ] `npx agent-desktop version` works
|
||||||
|
- [ ] `agent-desktop` (after global install) runs with zero Node.js overhead
|
||||||
|
- [ ] Non-macOS platforms get a friendly "not supported yet" message on npm install
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- [ ] Binary size under 15MB (existing CI check)
|
||||||
|
- [ ] Postinstall download completes within 60 seconds on reasonable connections
|
||||||
|
- [ ] Postinstall respects proxy environment variables
|
||||||
|
- [ ] npm publish includes Sigstore provenance attestation (`--provenance`)
|
||||||
|
- [ ] GitHub Actions permissions are minimal per job
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
- [ ] Existing CI (`ci.yml`) continues to pass — no modifications to it
|
||||||
|
- [ ] `cargo clippy --all-targets -- -D warnings` passes
|
||||||
|
- [ ] `cargo test --lib --workspace` passes
|
||||||
|
- [ ] Manual end-to-end test: merge a Release PR → verify npm install works
|
||||||
|
|
||||||
|
## Dependencies & Prerequisites
|
||||||
|
|
||||||
|
- **npm account** with publish access to the `agent-desktop` package name (must be available)
|
||||||
|
- **NPM_TOKEN** GitHub secret configured for the repository
|
||||||
|
- **Conventional commit discipline** — all future commits to `main` should follow the convention
|
||||||
|
- No new Rust dependencies required
|
||||||
|
- Node.js 18+ for the postinstall/wrapper scripts
|
||||||
|
|
||||||
|
## Risk Analysis & Mitigation
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|------|----------|------------|
|
||||||
|
| npm package name `agent-desktop` taken | Critical | Check availability first. Fallback: `@agent-desktop/cli` |
|
||||||
|
| Binary download blocked by firewall | Important | Proxy support, `AGENT_DESKTOP_BINARY_PATH` override, manual download docs |
|
||||||
|
| release-please workspace version handling | Important | Test with dry run, use `extra-files` config if needed |
|
||||||
|
| Race: Release exists before binaries uploaded | Important | Strict `needs:` job deps + asset verification before npm publish |
|
||||||
|
| Broken version published to npm | Important | `npm deprecate` + quick patch release procedure |
|
||||||
|
| macOS Gatekeeper warnings on unsigned binaries | Important | Document `xattr -cr` workaround; defer code signing to later |
|
||||||
|
| Cargo.lock out of sync in Release PR | Important | Auto-update step: `cargo update --workspace` in Release PR |
|
||||||
|
| CI token cannot trigger workflows on Release PR | Important | Use PAT or GitHub App token for release-please |
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- **Phase 2 platforms:** Add Linux and Windows targets to the build matrix. Extend postinstall platform mapping. Consider musl vs glibc for Linux. Add Windows `.exe` handling.
|
||||||
|
- **macOS code signing + notarization:** Apple Developer ID for signed binaries. Eliminates Gatekeeper warnings.
|
||||||
|
- **Homebrew tap:** Leverage GitHub Release tarballs to create a Homebrew formula.
|
||||||
|
- **Prerelease channel:** `0.3.0-rc.1` releases via release-please prerelease config.
|
||||||
|
- **crates.io publishing:** Publish `agent-desktop-core` to crates.io for downstream Rust consumers.
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
### Origin
|
||||||
|
|
||||||
|
- **Brainstorm document:** [docs/brainstorms/2026-02-23-release-automation-brainstorm.md](docs/brainstorms/2026-02-23-release-automation-brainstorm.md) — Key decisions carried forward: SemVer + Conventional Commits, release-please for gated releases, single npm package with postinstall download (agent-browser pattern), GitHub Release tarballs for non-npm distribution.
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
|
||||||
|
- Existing CI: `.github/workflows/ci.yml`
|
||||||
|
- Version source: `Cargo.toml:10` (`workspace.package.version`)
|
||||||
|
- Version command: `crates/core/src/commands/version.rs`
|
||||||
|
- Rust targets: `rust-toolchain.toml`
|
||||||
|
- Release build profile: `Cargo.toml:22-28`
|
||||||
|
|
||||||
|
### External References
|
||||||
|
|
||||||
|
- [release-please documentation](https://github.com/googleapis/release-please)
|
||||||
|
- [release-please Cargo/Rust support](https://github.com/googleapis/release-please/blob/main/docs/customizing.md)
|
||||||
|
- [agent-browser npm distribution pattern](https://github.com/vercel-labs/agent-browser) — reference implementation for postinstall binary download
|
||||||
|
- [npm provenance attestations](https://docs.npmjs.com/generating-provenance-statements)
|
||||||
|
|
@ -0,0 +1,812 @@
|
||||||
|
---
|
||||||
|
title: "refactor: centralize macOS action dispatch via chain executor"
|
||||||
|
type: refactor
|
||||||
|
status: active
|
||||||
|
date: 2026-02-23
|
||||||
|
origin: docs/brainstorms/2026-02-23-macos-ax-first-robustness-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# refactor: centralize macOS action dispatch via chain executor
|
||||||
|
|
||||||
|
> Current contract note (2026-05-12): this historical plan is superseded where it
|
||||||
|
> implies automatic focus, cursor, keyboard, pasteboard, or CGEvent fallback from
|
||||||
|
> normal ref commands. The current command path is headless by default. Physical
|
||||||
|
> or headed behavior is available only through explicit mouse/focus/keyboard
|
||||||
|
> commands or an explicit FFI policy.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace per-command fallback functions (`smart_activate`, `smart_right_activate`, etc.) with a centralized chain executor where commands declare AX fallback steps as data. Also add error suggestions to all commands, AX messaging timeout, transient error retry, non-ASCII text support, NSPasteboard FFI clipboard, and post-action state hints.
|
||||||
|
|
||||||
|
This is a pure macOS adapter refactoring. Core crate and PlatformAdapter trait are unchanged.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current macOS action dispatch has three problems:
|
||||||
|
|
||||||
|
1. **Duplicated patterns**: `activate.rs` (400 LOC), `dispatch.rs` (367 LOC), and `extras.rs` (396 LOC) share ~100-150 LOC of duplicated code: attribute setting, action enumeration, child/parent traversal, sleep timing. All three files are at the 400 LOC ceiling — any additions require splitting.
|
||||||
|
|
||||||
|
2. **Inconsistent robustness**: `click` has a 14-step chain, `scroll` has 8 steps, but `set-value`, `type`, `clear`, `focus`, `scroll-to`, and `press` are single-shot with no fallback. This breaks headless/background operation when the single AX call fails.
|
||||||
|
|
||||||
|
3. **Missing error guidance**: Several commands (`set-value`, `focus`, `type`, `press`, `clear`) return raw AX error codes with no suggestions for the calling agent.
|
||||||
|
|
||||||
|
(see brainstorm: `docs/brainstorms/2026-02-23-macos-ax-first-robustness-brainstorm.md`)
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Architecture: 3-Layer Chain System
|
||||||
|
|
||||||
|
**Layer 1 — Shared Utilities (`ax_helpers.rs`)**
|
||||||
|
Extract all duplicated AX patterns into reusable functions: `try_ax_action()`, `set_ax_bool()`, `set_ax_string()`, `try_each_child()`, `try_each_ancestor()`, `with_retry()`, `ensure_visible()`, `set_messaging_timeout()`.
|
||||||
|
|
||||||
|
**Layer 2 — Element Discovery (`discovery.rs`)**
|
||||||
|
One-time `ElementCaps` struct queried once per action: available actions, settable attributes, role, PID. Eliminates redundant AX IPC calls across chain steps.
|
||||||
|
|
||||||
|
**Layer 3 — Chain Executor + Definitions (`chain.rs` + `chain_defs.rs`)**
|
||||||
|
`ChainStep` enum defines what to try. `execute_chain()` walks steps with pre-action setup (scroll-to-visible, messaging timeout) and post-action enrichment (state hints). Each command is a static chain definition — data, not code.
|
||||||
|
|
||||||
|
### Concrete Data Model
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ChainStep {
|
||||||
|
/// Try AXUIElementPerformAction with the given action name.
|
||||||
|
Action(&'static str),
|
||||||
|
|
||||||
|
/// Try AXUIElementSetAttributeValue with given attribute and value.
|
||||||
|
SetAttr {
|
||||||
|
attr: &'static str,
|
||||||
|
value: AttrValue,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Focus the element first (AXFocused=true), then retry the inner step.
|
||||||
|
WithFocus(Box<ChainStep>),
|
||||||
|
|
||||||
|
/// Try the inner step on up to `limit` child elements.
|
||||||
|
OnChildren {
|
||||||
|
step: Box<ChainStep>,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Try the inner step on up to `limit` ancestor elements.
|
||||||
|
OnAncestors {
|
||||||
|
step: Box<ChainStep>,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Custom logic that cannot be expressed declaratively.
|
||||||
|
/// Receives element + caps, returns true if the step succeeded.
|
||||||
|
Custom {
|
||||||
|
label: &'static str,
|
||||||
|
func: fn(&AXElement, &ElementCaps) -> bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// CGEvent coordinate-based fallback. Automatically focuses the window.
|
||||||
|
CGEvent(CGFallback),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum AttrValue {
|
||||||
|
Bool(bool),
|
||||||
|
StaticStr(&'static str),
|
||||||
|
Dynamic, // Placeholder — actual value passed via ChainContext at runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum CGFallback {
|
||||||
|
Click(MouseButton, u32),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ChainDef {
|
||||||
|
pub steps: &'static [ChainStep],
|
||||||
|
pub suggestion: &'static str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why `Custom` variant**: Steps like `try_show_alternate_ui` (AXShowAlternateUI → sleep → child AXPress), `try_select_via_parent` (walk parent, check role, set AXSelectedRows), and `try_keyboard_activate` (focus + space key) involve multi-step conditional logic that cannot be expressed as flat declarative steps without over-engineering. `Custom { label, func }` is the escape hatch — the label enables tracing, the func contains the logic.
|
||||||
|
|
||||||
|
**Why `AttrValue::Dynamic`**: `set-value` and `type` chains need runtime text values. The executor checks for `Dynamic` and substitutes from a `ChainContext` struct passed alongside.
|
||||||
|
|
||||||
|
### File Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/macos/src/actions/
|
||||||
|
├── mod.rs # mod declarations + pub use perform_action
|
||||||
|
├── ax_helpers.rs # NEW: shared AX utilities (~200 LOC)
|
||||||
|
├── discovery.rs # NEW: ElementCaps one-time query (~120 LOC)
|
||||||
|
├── chain.rs # NEW: ChainStep enum + execute_chain() (~250 LOC)
|
||||||
|
├── chain_defs.rs # NEW: all chain definitions (~300 LOC)
|
||||||
|
├── dispatch.rs # SIMPLIFIED: perform_action delegates to chains (~200 LOC)
|
||||||
|
├── activate.rs # DELETED: absorbed into chain_defs.rs + ax_helpers.rs
|
||||||
|
└── extras.rs # REFACTORED: select_value + ax_scroll use ax_helpers (~300 LOC)
|
||||||
|
```
|
||||||
|
|
||||||
|
LOC budget per file stays under 400.
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
### Retry Policy
|
||||||
|
|
||||||
|
- **Transient errors**: Only `kAXErrorCannotComplete` (-25204). Apple docs classify this as "target app temporarily unable to process request."
|
||||||
|
- **Terminal errors**: `kAXErrorInvalidUIElement`, `kAXErrorNotImplemented`, `kAXErrorAPIDisabled`, `kAXErrorNoValue`.
|
||||||
|
- **Max retries**: 1 retry per step (not per chain).
|
||||||
|
- **Delay**: 100ms fixed between retry attempts.
|
||||||
|
- **Idempotency guard**: For stateful actions (`Toggle`, `Check`, `Uncheck`), read element value before retry. If value already changed from pre-action state, skip retry (action succeeded despite error code).
|
||||||
|
- **Retry scope**: Inside the chain executor's step loop. A step that fails after retry simply falls through to the next step.
|
||||||
|
|
||||||
|
### Side Effect Safety
|
||||||
|
|
||||||
|
```
|
||||||
|
Before chain: read element value/state (if action is stateful)
|
||||||
|
Step N: try AX action → returns error
|
||||||
|
Retry: read element value/state again
|
||||||
|
if changed → action succeeded, do NOT retry
|
||||||
|
if unchanged → retry the step
|
||||||
|
```
|
||||||
|
|
||||||
|
This handles the "retried AXPress toggles checkbox back" problem.
|
||||||
|
|
||||||
|
### Window Focus Policy
|
||||||
|
|
||||||
|
(from brainstorm: Window Focus Policy section)
|
||||||
|
|
||||||
|
| Step Type | Focuses Window | Reason |
|
||||||
|
|-----------|---------------|--------|
|
||||||
|
| `Action(name)` | No | AX IPC targets element directly |
|
||||||
|
| `SetAttr { .. }` | No | AX IPC targets element directly |
|
||||||
|
| `WithFocus(step)` | No | Sets AXFocused on element, not window |
|
||||||
|
| `OnChildren/OnAncestors` | No | AX IPC |
|
||||||
|
| `Custom { .. }` | Depends on impl | Custom decides |
|
||||||
|
| `CGEvent(..)` | **Yes** | CGEvent is coordinate-based |
|
||||||
|
|
||||||
|
The executor only calls `ensure_app_focused()` inside the `CGEvent` step handler.
|
||||||
|
|
||||||
|
### Non-ASCII Text Input
|
||||||
|
|
||||||
|
Strategy: per-segment splitting.
|
||||||
|
|
||||||
|
1. Scan input text, split into runs of ASCII and non-ASCII characters.
|
||||||
|
2. ASCII runs: keyboard synthesis via `AXUIElementPostKeyboardEvent`.
|
||||||
|
3. Non-ASCII runs: clipboard paste strategy:
|
||||||
|
a. Save current clipboard text (best-effort — non-text clipboard data types are lost on restore).
|
||||||
|
b. Set clipboard to the non-ASCII segment.
|
||||||
|
c. Synthesize Cmd+V via app-targeted keyboard event.
|
||||||
|
d. Restore clipboard (log warning via `tracing::warn!` if restore fails, but report action as success).
|
||||||
|
|
||||||
|
**Prerequisite**: NSPasteboard FFI must be implemented first to avoid ~200ms subprocess overhead per clipboard operation.
|
||||||
|
|
||||||
|
### NSPasteboard FFI
|
||||||
|
|
||||||
|
Replace `pbcopy`/`pbpaste` subprocess calls with direct Cocoa FFI. Use raw `objc_msgSend` from `core-foundation` to avoid adding new dependencies:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Pseudocode — actual impl uses core-foundation-sys + manual objc runtime calls
|
||||||
|
fn clipboard_get() -> Result<String, AdapterError> {
|
||||||
|
let pasteboard = msg_send![class!(NSPasteboard), generalPasteboard];
|
||||||
|
let string = msg_send![pasteboard, stringForType: NSPasteboardTypeString];
|
||||||
|
// Convert NSString to Rust String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No new crate dependency needed — `core-foundation-sys` already provides the FFI primitives. The `objc_msgSend` calls are ~10 LOC per function.
|
||||||
|
|
||||||
|
### Stale Ref Auto-Recovery
|
||||||
|
|
||||||
|
Lives inside the macOS adapter's `resolve_element` implementation (not in core). PlatformAdapter trait stays unchanged.
|
||||||
|
|
||||||
|
```
|
||||||
|
resolve_element(entry) called
|
||||||
|
→ normal resolution: match (pid, role, name, bounds_hash)
|
||||||
|
→ if ELEMENT_NOT_FOUND:
|
||||||
|
→ relaxed resolution: match (pid, role, name) only
|
||||||
|
→ if found: check bounds proximity (within 50px any edge)
|
||||||
|
→ if bounds acceptable: return handle
|
||||||
|
→ if multiple matches: return first in DFS order (same as snapshot)
|
||||||
|
→ if still not found: return STALE_REF with existing suggestion
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not applied during batch**: The `--stop-on-error` flag already provides batch failure handling. Auto-recovery in batch could interact with wrong elements. Only single-command execution triggers auto-recovery.
|
||||||
|
|
||||||
|
### Post-Action State Hints
|
||||||
|
|
||||||
|
After a successful chain execution, the executor reads element state for these action types:
|
||||||
|
|
||||||
|
| Action | What to read | Delay |
|
||||||
|
|--------|-------------|-------|
|
||||||
|
| Click, Toggle, Check, Uncheck | value + states | 50ms |
|
||||||
|
| SetValue, Clear | value | 0ms (AX set is synchronous) |
|
||||||
|
| TypeText | value | 50ms |
|
||||||
|
| Expand, Collapse | states (expanded/collapsed) | 0ms |
|
||||||
|
| All others | nothing | - |
|
||||||
|
|
||||||
|
If the state read fails (element disappeared), return `ActionResult` with `post_state: None`. The action itself is still reported as success.
|
||||||
|
|
||||||
|
### AX Messaging Timeout
|
||||||
|
|
||||||
|
Current: `element_for_pid()` in `element.rs` already sets 2.0s timeout on app-level elements.
|
||||||
|
|
||||||
|
Change: In `execute_chain()`, before walking steps, call `AXUIElementSetMessagingTimeout(el, 3.0)` on the target element. This is separate from the app-level timeout — it applies to the specific element being acted upon.
|
||||||
|
|
||||||
|
### Global Chain Timeout
|
||||||
|
|
||||||
|
10 seconds max for entire chain execution. Tracked via `Instant::now()` at chain start. Before each step, check elapsed time. If exceeded, return `AdapterError::timeout("Chain execution exceeded 10s")`.
|
||||||
|
|
||||||
|
### Error Suggestions (Complete List)
|
||||||
|
|
||||||
|
| Command | On Failure | Suggestion |
|
||||||
|
|---------|-----------|------------|
|
||||||
|
| set-value | Chain exhausted | "Try 'clear' then 'type', or check element is a text field." |
|
||||||
|
| type | Chain exhausted | "Try 'set-value' for direct text insertion." |
|
||||||
|
| clear | Chain exhausted | "Try 'press cmd+a' then 'press delete'." |
|
||||||
|
| focus | Chain exhausted | "Try 'click' to focus the element instead." |
|
||||||
|
| scroll-to | Chain exhausted | "Element may not be in a scrollable container." (existing) |
|
||||||
|
| press | Key synth failed | "Ensure the target app is active, or use 'press' with --app flag." |
|
||||||
|
| click | Chain exhausted | "Element may not be interactable. Try 'mouse-click --xy X,Y'." |
|
||||||
|
| right-click | Chain exhausted | "Try 'mouse-click --button right --xy X,Y'." |
|
||||||
|
| expand | Chain exhausted | "Try 'click' to open it instead." (existing) |
|
||||||
|
| collapse | Chain exhausted | "Try 'click' to close it instead." (existing) |
|
||||||
|
| toggle | Wrong role | "Toggle works on checkboxes, switches, and radio buttons. Use 'click' for other elements." (existing) |
|
||||||
|
| hover | Element-level call | "Use 'hover @ref' which resolves ref to coordinates automatically." |
|
||||||
|
| drag | Element-level call | "Use 'drag --from @ref --to @ref' for ref-based drag." |
|
||||||
|
| key-down/key-up | Element-level call | "Use 'key-down'/'key-up' commands directly." |
|
||||||
|
| set-value (AX err) | Specific AX error | Include `platform_detail` with AX error code for debugging. |
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
### Interaction Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI command → core command handler → adapter.execute_action()
|
||||||
|
→ dispatch.rs perform_action() → chain executor
|
||||||
|
→ discovery.rs (one AX IPC batch)
|
||||||
|
→ chain.rs execute_chain() (walks ChainStep list)
|
||||||
|
→ ax_helpers.rs (try_ax_action, set_ax_bool, etc.)
|
||||||
|
→ explicit physical policy path (only when the caller selected it)
|
||||||
|
→ post-state read (optional)
|
||||||
|
→ JSON ActionResult response
|
||||||
|
```
|
||||||
|
|
||||||
|
No callbacks, middleware, or observers. Pure function call chain. No new event system.
|
||||||
|
|
||||||
|
### Error Propagation
|
||||||
|
|
||||||
|
```
|
||||||
|
AX API error code → with_retry() classifies as transient/terminal
|
||||||
|
→ transient: retry once, then fall through to next chain step
|
||||||
|
→ terminal: fall through immediately
|
||||||
|
→ all steps exhausted: AdapterError with suggestion
|
||||||
|
→ wrapped in AppError → JSON error envelope
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Lifecycle Risks
|
||||||
|
|
||||||
|
- **Clipboard corruption during non-ASCII**: Save/restore is best-effort. Non-text clipboard data (images, files) cannot be preserved across the save/restore cycle. Documented limitation.
|
||||||
|
- **Element disappears mid-chain**: Each step independently handles AX errors. If element disappears, remaining steps fail fast (kAXErrorInvalidUIElement is terminal). Final error includes "Run 'snapshot' to refresh" suggestion.
|
||||||
|
- **Stale ref auto-recovery false positive**: Mitigated by 50px bounds proximity check. In worst case, agent acts on wrong element — same risk as manually re-snapshotting and re-resolving.
|
||||||
|
|
||||||
|
### API Surface Parity
|
||||||
|
|
||||||
|
- `PlatformAdapter` trait: **No changes**. All refactoring is internal to macOS crate.
|
||||||
|
- `ActionResult` struct: Already has `post_state: Option<ElementState>`. No schema change.
|
||||||
|
- JSON output format: `post_state` field may now be populated (was always None). Agents that don't read it are unaffected.
|
||||||
|
- CLI interface: **No changes**. Same commands, same flags, same output format.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- [ ] All 50 existing commands produce identical JSON output for the happy path
|
||||||
|
- [ ] `click` chain matches current `smart_activate` 14-step behavior
|
||||||
|
- [ ] `right-click` chain matches current `smart_right_activate` 7-step behavior
|
||||||
|
- [ ] `set-value`, `type`, `clear`, `focus`, `scroll-to` have multi-step fallback chains
|
||||||
|
- [ ] Non-ASCII text input works via clipboard paste (e.g., `type @e1 "Hola"`)
|
||||||
|
- [ ] Clipboard operations use NSPasteboard FFI (no subprocess)
|
||||||
|
- [ ] All error responses include `suggestion` field
|
||||||
|
- [ ] `post_state` populated for stateful actions
|
||||||
|
- [ ] Transient AX errors retried once with 100ms delay
|
||||||
|
- [ ] Chain execution times out at 10 seconds
|
||||||
|
- [ ] AX messaging timeout set to 3s on target elements
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- [ ] No new crate dependencies added
|
||||||
|
- [ ] All files under 400 LOC
|
||||||
|
- [ ] `cargo clippy --all-targets -- -D warnings` passes
|
||||||
|
- [ ] `cargo test --lib --workspace` passes
|
||||||
|
- [ ] `cargo tree -p agent-desktop-core` shows zero platform crate names
|
||||||
|
- [ ] Release binary stays under 15MB
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
- [ ] `activate.rs` fully absorbed — file deleted
|
||||||
|
- [ ] No duplicated AX patterns across files (attribute setting, action enumeration, tree traversal)
|
||||||
|
- [ ] Every `ChainStep::Custom` has a descriptive label for tracing
|
||||||
|
- [ ] Every error path has a suggestion
|
||||||
|
|
||||||
|
## Implementation Approach: Two Stages
|
||||||
|
|
||||||
|
### Why Two Stages
|
||||||
|
|
||||||
|
The existing click chain (14-step `smart_activate`) and scroll chain (8-step `ax_scroll`) were built through **manual trial and error** — testing AX APIs directly against real macOS apps (TextEdit, Finder, System Settings, Safari, Xcode) to discover what works, then coding the proven sequences. The same approach is needed for the new chains.
|
||||||
|
|
||||||
|
We cannot guess what AX actions or attributes work for `set-value`, `type`, `clear`, `focus`, or `scroll-to` across different app frameworks (AppKit, SwiftUI, Electron, Chromium). We must test first.
|
||||||
|
|
||||||
|
**Stage A (Code Directly):** Build the chain executor infrastructure + migrate existing proven chains. This is pure refactoring of known-working code.
|
||||||
|
|
||||||
|
**Stage B (Explore Then Code):** For each command that needs a NEW chain, manually probe real apps using the built binary, record what works, then write the chain definition based on observations.
|
||||||
|
|
||||||
|
### Stage B Exploration Protocol
|
||||||
|
|
||||||
|
For each command needing a new chain (set-value, type, clear, focus, scroll-to):
|
||||||
|
|
||||||
|
1. **Build and run the binary** with the chain executor from Stage A
|
||||||
|
2. **Open target apps**: TextEdit, System Settings, Safari, Finder, Notes, Calculator
|
||||||
|
3. **Snapshot and identify elements** of the relevant type:
|
||||||
|
```bash
|
||||||
|
agent-desktop snapshot --app "TextEdit" -i
|
||||||
|
# Find text fields, buttons, checkboxes, sliders, etc.
|
||||||
|
```
|
||||||
|
4. **Test AX actions directly** against each element using the existing commands:
|
||||||
|
```bash
|
||||||
|
# For set-value: does direct AXValue set work on this element?
|
||||||
|
agent-desktop set-value @e3 "test"
|
||||||
|
# For clear: does AXValue="" work? Does Cmd+A then Delete work?
|
||||||
|
agent-desktop clear @e3
|
||||||
|
# For focus: does AXFocused=true work? Does AXPress set focus?
|
||||||
|
agent-desktop focus @e3
|
||||||
|
```
|
||||||
|
5. **Record results** in a discovery log per command:
|
||||||
|
- Which elements/roles support the primary AX approach?
|
||||||
|
- Which need focus first?
|
||||||
|
- Which need an alternative approach entirely?
|
||||||
|
- Which apps behave differently?
|
||||||
|
6. **Write the chain definition** based on the actual observed behavior
|
||||||
|
7. **Test the chain** against all target apps to verify it works end-to-end
|
||||||
|
|
||||||
|
This ensures chains are built on empirical evidence, not assumptions.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Stage A: Infrastructure + Known Chain Migration (Code Directly)
|
||||||
|
|
||||||
|
These phases involve pure refactoring of existing, proven code. No AX exploration needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: Foundation (`ax_helpers.rs` + `discovery.rs`)
|
||||||
|
|
||||||
|
Extract shared utilities from duplicated code across activate.rs, dispatch.rs, extras.rs.
|
||||||
|
|
||||||
|
**Files created:**
|
||||||
|
- `crates/macos/src/actions/ax_helpers.rs` (~200 LOC)
|
||||||
|
- `crates/macos/src/actions/discovery.rs` (~120 LOC)
|
||||||
|
|
||||||
|
**ax_helpers.rs contents:**
|
||||||
|
```rust
|
||||||
|
pub fn try_ax_action(el: &AXElement, name: &str) -> bool
|
||||||
|
pub fn set_ax_bool(el: &AXElement, attr: &str, value: bool) -> bool
|
||||||
|
pub fn set_ax_string(el: &AXElement, attr: &str, value: &str) -> bool
|
||||||
|
pub fn try_each_child(el: &AXElement, f: impl Fn(&AXElement) -> bool, limit: usize) -> bool
|
||||||
|
pub fn try_each_ancestor(el: &AXElement, f: impl Fn(&AXElement) -> bool, limit: usize) -> bool
|
||||||
|
pub fn list_ax_actions(el: &AXElement) -> Vec<String>
|
||||||
|
pub fn ensure_visible(el: &AXElement)
|
||||||
|
pub fn set_messaging_timeout(el: &AXElement, seconds: f32)
|
||||||
|
pub fn with_retry<F>(f: F, max_retries: u32, delay_ms: u64) -> Result<bool, i32>
|
||||||
|
```
|
||||||
|
|
||||||
|
**discovery.rs contents:**
|
||||||
|
```rust
|
||||||
|
pub struct ElementCaps {
|
||||||
|
pub actions: Vec<String>,
|
||||||
|
pub settable_value: bool,
|
||||||
|
pub settable_focus: bool,
|
||||||
|
pub settable_selected: bool,
|
||||||
|
pub settable_disclosing: bool,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub has_children: bool,
|
||||||
|
pub pid: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover(el: &AXElement) -> ElementCaps
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create `ax_helpers.rs` with all shared functions
|
||||||
|
- [ ] Create `discovery.rs` with `ElementCaps` and `discover()`
|
||||||
|
- [ ] Register both modules in `actions/mod.rs`
|
||||||
|
- [ ] Update `extras.rs` to use `ax_helpers` functions (remove duplicates)
|
||||||
|
- [ ] Verify `cargo test --lib --workspace && cargo clippy`
|
||||||
|
|
||||||
|
### Phase 2: Chain Executor (`chain.rs`)
|
||||||
|
|
||||||
|
Build the executor that walks a `ChainDef` against an element.
|
||||||
|
|
||||||
|
**Files created:**
|
||||||
|
- `crates/macos/src/actions/chain.rs` (~250 LOC)
|
||||||
|
|
||||||
|
**Contents:**
|
||||||
|
- `ChainStep` enum (Action, SetAttr, WithFocus, OnChildren, OnAncestors, Custom, CGEvent)
|
||||||
|
- `AttrValue` enum (Bool, StaticStr, Dynamic)
|
||||||
|
- `CGFallback` enum (Click, None)
|
||||||
|
- `ChainDef` struct (steps, suggestion)
|
||||||
|
- `ChainContext` struct (dynamic_value: Option<String>, pre_state: Option<String>)
|
||||||
|
- `execute_chain(el: &AXElement, def: &ChainDef, ctx: &ChainContext) -> Result<(), AdapterError>`
|
||||||
|
|
||||||
|
**Executor logic:**
|
||||||
|
1. `ensure_visible(el)` — best-effort AXScrollToVisible
|
||||||
|
2. `set_messaging_timeout(el, 3.0)` — prevent hangs
|
||||||
|
3. `discover(el)` — one-time capability query
|
||||||
|
4. Walk steps: for each step, try with retry wrapper
|
||||||
|
5. For stateful actions: read pre-state before first step, check before retry
|
||||||
|
6. Track elapsed time — abort at 10 seconds
|
||||||
|
7. All steps failed → `AdapterError::new(ActionFailed, "...").with_suggestion(def.suggestion)`
|
||||||
|
8. `tracing::debug!` for each step attempt
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create `chain.rs` with all types and executor
|
||||||
|
- [ ] Add unit test: executor with mock steps (Custom steps that return true/false)
|
||||||
|
- [ ] Add unit test: timeout behavior
|
||||||
|
- [ ] Add unit test: retry on transient error
|
||||||
|
- [ ] Register in `actions/mod.rs`
|
||||||
|
|
||||||
|
### Phase 3: Migrate Known Chains (`chain_defs.rs`)
|
||||||
|
|
||||||
|
Migrate ONLY the existing, proven chains into the new system. New chains come later in Stage B.
|
||||||
|
|
||||||
|
**Files created:**
|
||||||
|
- `crates/macos/src/actions/chain_defs.rs` (~200 LOC initially, grows in Stage B)
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- `crates/macos/src/actions/dispatch.rs` — simplified to delegate to chains
|
||||||
|
- `crates/macos/src/actions/activate.rs` — **deleted**
|
||||||
|
|
||||||
|
**Migrated chains (proven, existing logic):**
|
||||||
|
|
||||||
|
| Chain | Steps | Origin |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| `click_chain` | 14 steps (migrated from `smart_activate`) | activate.rs |
|
||||||
|
| `double_click_chain` | AXOpen → 2x click → CGEvent double | activate.rs |
|
||||||
|
| `right_click_chain` | 7 steps (migrated from `smart_right_activate`) | activate.rs |
|
||||||
|
| `triple_click_chain` | 3x click → CGEvent triple | activate.rs |
|
||||||
|
| `expand_chain` | Action(AXExpand) → SetAttr(AXDisclosing=true) | dispatch.rs inline |
|
||||||
|
| `collapse_chain` | Action(AXCollapse) → SetAttr(AXDisclosing=false) | dispatch.rs inline |
|
||||||
|
|
||||||
|
**NOT migrated yet** (need AX exploration in Stage B): set-value, focus, clear, scroll-to, type.
|
||||||
|
These stay as their current single-shot implementation until Stage B discovers proper chains.
|
||||||
|
|
||||||
|
**Custom step functions** (migrated from activate.rs):
|
||||||
|
- `try_show_alternate_ui(el, caps)` — from activate.rs
|
||||||
|
- `try_keyboard_activate(el, caps)` — from activate.rs
|
||||||
|
- `try_select_via_parent(el, caps)` — from activate.rs
|
||||||
|
- `try_select_then_show_menu(el, caps)` — from activate.rs
|
||||||
|
|
||||||
|
**dispatch.rs after Phase 3 (~250 LOC):**
|
||||||
|
```rust
|
||||||
|
pub fn perform_action(el: &AXElement, action: &Action) -> Result<ActionResult, AdapterError> {
|
||||||
|
let label = action_label(action);
|
||||||
|
match action {
|
||||||
|
// Migrated to chain executor
|
||||||
|
Action::Click => execute_chain(el, &CLICK_CHAIN, &ChainContext::default())?,
|
||||||
|
Action::DoubleClick => execute_chain(el, &DOUBLE_CLICK_CHAIN, &ChainContext::default())?,
|
||||||
|
Action::RightClick => execute_chain(el, &RIGHT_CLICK_CHAIN, &ChainContext::default())?,
|
||||||
|
Action::TripleClick => execute_chain(el, &TRIPLE_CLICK_CHAIN, &ChainContext::default())?,
|
||||||
|
Action::Expand => execute_chain(el, &EXPAND_CHAIN, &ChainContext::default())?,
|
||||||
|
Action::Collapse => execute_chain(el, &COLLAPSE_CHAIN, &ChainContext::default())?,
|
||||||
|
|
||||||
|
// Kept as-is until Stage B exploration
|
||||||
|
Action::SetValue(val) => ax_set_value(el, val)?,
|
||||||
|
Action::TypeText(text) => { /* current impl */ },
|
||||||
|
Action::Clear => ax_set_value(el, "")?,
|
||||||
|
Action::SetFocus => { /* current impl */ },
|
||||||
|
Action::ScrollTo => { /* current impl */ },
|
||||||
|
|
||||||
|
// Stays custom (not chain-based)
|
||||||
|
Action::Toggle => { /* role check + click_chain */ },
|
||||||
|
Action::Check => { /* state check + click_chain */ },
|
||||||
|
Action::Uncheck => { /* state check + click_chain */ },
|
||||||
|
Action::Select(value) => extras::select_value(el, value)?,
|
||||||
|
Action::Scroll(d, a) => extras::ax_scroll(el, d, *a)?,
|
||||||
|
Action::PressKey(combo) => crate::input::keyboard::synthesize_key(combo)?,
|
||||||
|
|
||||||
|
// Adapter-level (coordinate-based)
|
||||||
|
Action::Hover | Action::Drag(_) | Action::KeyDown(_) | Action::KeyUp(_) => {
|
||||||
|
return Err(adapter_level_error(action, &label));
|
||||||
|
}
|
||||||
|
_ => return Err(AdapterError::not_supported(&label)),
|
||||||
|
}
|
||||||
|
Ok(ActionResult::new(label))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create `chain_defs.rs` with migrated chain definitions and custom step functions
|
||||||
|
- [ ] Migrate `smart_activate` → `click_chain` (14 steps)
|
||||||
|
- [ ] Migrate `smart_right_activate` → `right_click_chain` (7 steps)
|
||||||
|
- [ ] Migrate expand/collapse inline logic → `expand_chain`/`collapse_chain`
|
||||||
|
- [ ] Simplify `dispatch.rs` to delegate migrated chains, keep others as-is
|
||||||
|
- [ ] Delete `activate.rs`
|
||||||
|
- [ ] Verify all existing behavior preserved: `cargo test --lib --workspace`
|
||||||
|
- [ ] **Manual verification**: test click, right-click, double-click on TextEdit, Finder, System Settings
|
||||||
|
- [ ] **Manual verification**: test expand/collapse on Finder sidebar, System Settings disclosure
|
||||||
|
|
||||||
|
### Phase 4: NSPasteboard FFI
|
||||||
|
|
||||||
|
Replace `pbcopy`/`pbpaste` subprocess calls with direct Cocoa FFI. Done early because it's a prerequisite for the non-ASCII type chain in Stage B.
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- `crates/macos/src/input/clipboard.rs` — rewrite implementation (~80 LOC)
|
||||||
|
|
||||||
|
**Implementation approach:**
|
||||||
|
Use `objc_msgSend` via the `core-foundation-sys` crate (already a dependency). No new crate needed.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
extern "C" {
|
||||||
|
fn objc_msgSend(receiver: *const Object, sel: Sel, ...) -> *const Object;
|
||||||
|
fn objc_getClass(name: *const c_char) -> *const Object;
|
||||||
|
fn sel_registerName(name: *const c_char) -> Sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get() -> Result<String, AdapterError> {
|
||||||
|
let pasteboard_class = objc_getClass("NSPasteboard");
|
||||||
|
let pasteboard = objc_msgSend(pasteboard_class, sel_registerName("generalPasteboard"));
|
||||||
|
let nsstring = objc_msgSend(pasteboard, sel_registerName("stringForType:"), ns_pasteboard_type_string());
|
||||||
|
// Convert NSString → Rust String via CFString bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(text: &str) -> Result<(), AdapterError> {
|
||||||
|
let pasteboard = /* generalPasteboard */;
|
||||||
|
objc_msgSend(pasteboard, sel_registerName("clearContents"));
|
||||||
|
let ns_string = CFString::new(text);
|
||||||
|
objc_msgSend(pasteboard, sel_registerName("setString:forType:"), ns_string, ns_pasteboard_type_string());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Rewrite `clipboard.rs` using objc_msgSend FFI
|
||||||
|
- [ ] Keep same public API: `get()`, `set()`, `clear()`
|
||||||
|
- [ ] Test clipboard roundtrip: set → get → verify
|
||||||
|
- [ ] Test with non-ASCII content
|
||||||
|
- [ ] Test with empty string
|
||||||
|
- [ ] Benchmark: verify < 1ms per operation (vs ~50ms with subprocess)
|
||||||
|
|
||||||
|
### Phase 5: Error Suggestions
|
||||||
|
|
||||||
|
Add suggestions to every command that's missing them. Can be done independently of Stage B.
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- `crates/macos/src/actions/dispatch.rs` — add suggestions to match arms for hover/drag/key-down/key-up and current single-shot commands
|
||||||
|
- `crates/macos/src/actions/chain_defs.rs` — each chain's `suggestion` field (already there for migrated chains)
|
||||||
|
- `crates/macos/src/input/keyboard.rs` — suggestion on unknown key error
|
||||||
|
|
||||||
|
All suggestions listed in the "Error Suggestions (Complete List)" table above.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Add suggestions to hover/drag/key-down/key-up element-level errors in dispatch.rs
|
||||||
|
- [ ] Add suggestions to set-value, focus, clear, type, press, scroll-to errors in dispatch.rs
|
||||||
|
- [ ] Verify all chain definitions have suggestion fields
|
||||||
|
- [ ] Add suggestion to keyboard.rs unknown key error: "Valid keys: a-z, 0-9, return, escape, tab, space, delete, arrow keys, f1-f12."
|
||||||
|
- [ ] Add `platform_detail` with AX error codes to all AX-originated errors
|
||||||
|
- [ ] Audit: grep for `AdapterError::new` without `.with_suggestion` — all should have one
|
||||||
|
|
||||||
|
### Phase 6: Resilience (Timeout + Retry + Stale Recovery)
|
||||||
|
|
||||||
|
These are integrated into the chain executor and ax_helpers built in Phases 1-2.
|
||||||
|
|
||||||
|
**AX Messaging Timeout:**
|
||||||
|
- In `chain.rs` `execute_chain()`: call `set_messaging_timeout(el, 3.0)` before step loop
|
||||||
|
- Already in `ax_helpers.rs` from Phase 1
|
||||||
|
|
||||||
|
**Retry Logic:**
|
||||||
|
- Already in `ax_helpers.rs` `with_retry()` from Phase 1
|
||||||
|
- In `chain.rs`: wrap each step execution with retry
|
||||||
|
|
||||||
|
**Stale Ref Auto-Recovery:**
|
||||||
|
- In `crates/macos/src/tree/resolve.rs`: add relaxed resolution fallback
|
||||||
|
- Normal resolve fails → try again without bounds_hash → check bounds proximity (50px)
|
||||||
|
- Only for single-command execution (add flag to distinguish from batch)
|
||||||
|
|
||||||
|
**Global Chain Timeout:**
|
||||||
|
- In `chain.rs`: `Instant::now()` at start, check before each step
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Verify `set_messaging_timeout()` is called in execute_chain (should be from Phase 2)
|
||||||
|
- [ ] Verify `with_retry()` is integrated into step execution (should be from Phase 2)
|
||||||
|
- [ ] Add global 10s timeout to chain executor if not already present
|
||||||
|
- [ ] Add relaxed resolution to resolve.rs
|
||||||
|
- [ ] Add 50px bounds proximity check
|
||||||
|
- [ ] Skip auto-recovery when called from batch dispatch
|
||||||
|
- [ ] Test: verify retry does not double-toggle a checkbox
|
||||||
|
|
||||||
|
**Stage A complete.** At this point the binary is functional with the chain executor, migrated click/right-click/expand/collapse chains, NSPasteboard FFI, error suggestions, and resilience. The remaining single-shot commands still work as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stage B: AX Exploration + New Chain Definitions (Explore Then Code)
|
||||||
|
|
||||||
|
These phases require **manual AX testing against real apps** to discover proper fallback sequences. Each phase follows the exploration protocol defined above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: Explore + Define set-value Chain
|
||||||
|
|
||||||
|
**Apps to test:** TextEdit (text field), System Settings (text inputs, sliders), Safari (URL bar), Notes (text area), Calculator (display)
|
||||||
|
|
||||||
|
**Questions to answer through exploration:**
|
||||||
|
- Does `AXUIElementSetAttributeValue(kAXValueAttribute, value)` work on all text field roles?
|
||||||
|
- Which elements need `AXFocused=true` set before `AXSetValue` succeeds?
|
||||||
|
- Do sliders support `AXSetValue`? What format (string number)?
|
||||||
|
- Do combo boxes support `AXSetValue` directly?
|
||||||
|
- What happens when `AXSetValue` is called on a read-only element?
|
||||||
|
|
||||||
|
**Expected chain shape** (hypothesis — to be validated):
|
||||||
|
```
|
||||||
|
SetAttr(AXValue, value) → WithFocus(SetAttr(AXValue, value)) → error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Run exploration protocol on 5+ apps for set-value
|
||||||
|
- [ ] Record discovery log: which roles/apps work with which approach
|
||||||
|
- [ ] Write `set_value_chain` definition in chain_defs.rs based on findings
|
||||||
|
- [ ] Wire into dispatch.rs
|
||||||
|
- [ ] Test chain against all explored apps
|
||||||
|
|
||||||
|
### Phase 8: Explore + Define clear Chain
|
||||||
|
|
||||||
|
**Apps to test:** TextEdit, System Settings, Safari URL bar, Notes, any search fields
|
||||||
|
|
||||||
|
**Questions to answer:**
|
||||||
|
- Does `AXSetValue("")` clear all text field types?
|
||||||
|
- For elements where `AXSetValue` doesn't work, does focus + Cmd+A + Delete work?
|
||||||
|
- Are there elements where neither approach works?
|
||||||
|
- Does app-targeted keyboard (Cmd+A to app PID) work for select-all, or must it be system-wide?
|
||||||
|
|
||||||
|
**Expected chain shape** (hypothesis):
|
||||||
|
```
|
||||||
|
SetAttr(AXValue, "") → WithFocus(SetAttr(AXValue, "")) → Custom(select_all_then_delete) → error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Run exploration protocol on 5+ apps for clear
|
||||||
|
- [ ] Record discovery log
|
||||||
|
- [ ] Write `clear_chain` definition based on findings
|
||||||
|
- [ ] Implement `select_all_then_delete` custom step if needed
|
||||||
|
- [ ] Wire into dispatch.rs and test
|
||||||
|
|
||||||
|
### Phase 9: Explore + Define focus Chain
|
||||||
|
|
||||||
|
**Apps to test:** TextEdit, Finder (sidebar items, file list), Safari (tabs, URL bar), System Settings
|
||||||
|
|
||||||
|
**Questions to answer:**
|
||||||
|
- Does `AXUIElementSetAttributeValue(kAXFocusedAttribute, true)` work universally?
|
||||||
|
- Which elements don't support `AXFocused` but can be focused via `AXPress`?
|
||||||
|
- Does `AXSelected=true` function as a focus mechanism for list/table items?
|
||||||
|
- Are there elements that cannot be focused at all?
|
||||||
|
|
||||||
|
**Expected chain shape** (hypothesis):
|
||||||
|
```
|
||||||
|
SetAttr(AXFocused, true) → Action(AXPress) → SetAttr(AXSelected, true) → error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Run exploration protocol on 5+ apps for focus
|
||||||
|
- [ ] Record discovery log
|
||||||
|
- [ ] Write `focus_chain` definition based on findings
|
||||||
|
- [ ] Wire into dispatch.rs and test
|
||||||
|
|
||||||
|
### Phase 10: Explore + Define scroll-to Chain
|
||||||
|
|
||||||
|
**Apps to test:** Finder (long file lists), Safari (long pages), System Settings (scrollable panels), Xcode (code editor)
|
||||||
|
|
||||||
|
**Questions to answer:**
|
||||||
|
- Does `AXScrollToVisible` work reliably across apps?
|
||||||
|
- When it fails, can we walk parents to find a scroll area and verify visibility without sending physical wheel input?
|
||||||
|
- How do we verify the element is actually visible after scrolling?
|
||||||
|
- Does `AXScrollToVisible` work on elements inside nested scroll views?
|
||||||
|
|
||||||
|
**Expected chain shape** (hypothesis):
|
||||||
|
```
|
||||||
|
Action(AXScrollToVisible) → Custom(visible_in_scroll_context) → error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Run exploration protocol on 5+ apps for scroll-to
|
||||||
|
- [ ] Record discovery log
|
||||||
|
- [ ] Write `scroll_to_chain` definition based on findings
|
||||||
|
- [ ] Implement a visibility-check custom step with no cursor or wheel input
|
||||||
|
- [ ] Wire into dispatch.rs and test
|
||||||
|
|
||||||
|
### Phase 11: Explore + Define type Chain
|
||||||
|
|
||||||
|
This is the most complex chain — it mixes AX attribute setting with keyboard synthesis and non-ASCII clipboard paste. Requires NSPasteboard FFI from Phase 4.
|
||||||
|
|
||||||
|
**Apps to test:** TextEdit, Safari URL bar, Notes, System Settings search, Spotlight, Terminal
|
||||||
|
|
||||||
|
**Questions to answer:**
|
||||||
|
- Does `AXSetValue(current + new_text)` (append) work on text fields? Or does it replace?
|
||||||
|
- Does `AXUIElementPostKeyboardEvent` to the app PID (not system-wide) deliver keys to the focused element within that app?
|
||||||
|
- For non-ASCII: does clipboard paste (Cmd+V) via app-targeted keyboard reliably insert text?
|
||||||
|
- What is the timing needed between focus + keyboard delivery?
|
||||||
|
- Do some apps intercept Cmd+V differently (e.g., Terminal)?
|
||||||
|
|
||||||
|
**Expected chain shape** (hypothesis):
|
||||||
|
```
|
||||||
|
Custom(ax_append_value) → Custom(focus_then_app_keyboard) → Custom(focus_then_system_keyboard_with_non_ascii) → error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Run exploration protocol on 5+ apps for type
|
||||||
|
- [ ] Test ASCII typing via app-targeted keyboard events
|
||||||
|
- [ ] Test non-ASCII typing via clipboard paste strategy
|
||||||
|
- [ ] Test mixed ASCII + non-ASCII text
|
||||||
|
- [ ] Record discovery log
|
||||||
|
- [ ] Implement `execute_type()` in dispatch.rs based on findings
|
||||||
|
- [ ] Implement `try_ax_append_value()`, `type_via_app_keyboard()`, `type_with_non_ascii_support()`
|
||||||
|
- [ ] Test against all explored apps
|
||||||
|
|
||||||
|
### Phase 12: Post-Action State Hints
|
||||||
|
|
||||||
|
Can be done after Stage B chains are finalized — it's an enrichment layer on top of the chain executor.
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- `crates/macos/src/actions/dispatch.rs` — add `read_post_state()` after chain execution
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```rust
|
||||||
|
fn read_post_state(el: &AXElement, action: &Action) -> Option<ElementState> {
|
||||||
|
let delay = match action {
|
||||||
|
Action::Click | Action::Toggle | Action::Check
|
||||||
|
| Action::Uncheck | Action::TypeText(_) => 50,
|
||||||
|
Action::SetValue(_) | Action::Clear
|
||||||
|
| Action::Expand | Action::Collapse => 0,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if delay > 0 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||||
|
}
|
||||||
|
let value = copy_string_attr(el, "AXValue");
|
||||||
|
let states = read_element_states(el);
|
||||||
|
Some(ElementState { role: None, value, states })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Implement `read_post_state()` in dispatch.rs
|
||||||
|
- [ ] Wire into `perform_action()` return path
|
||||||
|
- [ ] Test: click a checkbox → verify post_state shows checked
|
||||||
|
- [ ] Test: set-value on text field → verify post_state shows new value
|
||||||
|
- [ ] Test: action on disappearing element → verify post_state is None (not an error)
|
||||||
|
|
||||||
|
## Dependencies & Prerequisites
|
||||||
|
|
||||||
|
- No new crate dependencies
|
||||||
|
- Requires Rust stable (already pinned via `rust-toolchain.toml`)
|
||||||
|
- NSPasteboard FFI uses `core-foundation-sys` (already in deps)
|
||||||
|
- `objc_msgSend` FFI declarations need `extern "C"` blocks — no crate needed
|
||||||
|
|
||||||
|
## Risk Analysis & Mitigation
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Chain executor doesn't capture all activate.rs nuances | Medium | High | Migrate click_chain first, test manually against TextEdit/Finder/System Settings before proceeding |
|
||||||
|
| Explicit physical path regression | Low | High | Existing integration tests catch this; add new test for physical-policy path |
|
||||||
|
| Non-ASCII clipboard restore fails silently | Medium | Low | Log via tracing::warn; agent can clipboard-get to verify |
|
||||||
|
| Stale ref auto-recovery picks wrong element | Low | Medium | 50px bounds check; only for single commands |
|
||||||
|
| NSPasteboard FFI crashes on edge cases | Low | High | Test with empty clipboard, non-text content, locked pasteboard |
|
||||||
|
| Global 10s timeout too aggressive for slow apps | Medium | Medium | Make timeout configurable via env var AGENT_DESKTOP_CHAIN_TIMEOUT_MS |
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
### Origin
|
||||||
|
|
||||||
|
- **Brainstorm document:** [docs/brainstorms/2026-02-23-macos-ax-first-robustness-brainstorm.md](docs/brainstorms/2026-02-23-macos-ax-first-robustness-brainstorm.md) — Key decisions: AX-first architecture, centralized chain executor, window focus only for CGEvent, per-segment non-ASCII handling.
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
|
||||||
|
- Current action dispatch: `crates/macos/src/actions/dispatch.rs`
|
||||||
|
- Current activation chains: `crates/macos/src/actions/activate.rs`
|
||||||
|
- Current scroll chain: `crates/macos/src/actions/extras.rs`
|
||||||
|
- Error types: `crates/core/src/error.rs`
|
||||||
|
- ActionResult/ElementState: `crates/core/src/action.rs`
|
||||||
|
- Current clipboard: `crates/macos/src/input/clipboard.rs`
|
||||||
|
- Current keyboard: `crates/macos/src/input/keyboard.rs`
|
||||||
|
- Element resolution: `crates/macos/src/tree/resolve.rs`
|
||||||
|
- Prior plan (smart click chain): `docs/plans/2026-02-20-feat-smart-ax-click-chain-plan.md`
|
||||||
|
- Prior plan (fallback chains): `docs/plans/2026-02-21-fix-fallback-chains-edge-cases-plan.md`
|
||||||
1308
docs/plans/2026-02-25-feat-windows-adapter-phase2-plan.md
Normal file
1308
docs/plans/2026-02-25-feat-windows-adapter-phase2-plan.md
Normal file
File diff suppressed because it is too large
Load diff
991
docs/plans/2026-02-27-feat-notification-management-macos-plan.md
Normal file
991
docs/plans/2026-02-27-feat-notification-management-macos-plan.md
Normal file
|
|
@ -0,0 +1,991 @@
|
||||||
|
---
|
||||||
|
title: "feat: add notification management commands (macOS)"
|
||||||
|
type: feat
|
||||||
|
status: completed
|
||||||
|
date: 2026-02-27
|
||||||
|
deepened: 2026-02-27
|
||||||
|
origin: docs/brainstorms/2026-02-27-notification-management-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: add notification management commands (macOS)
|
||||||
|
|
||||||
|
## Enhancement Summary
|
||||||
|
|
||||||
|
**Deepened on:** 2026-02-27
|
||||||
|
**Sections enhanced:** 7 (all major sections)
|
||||||
|
**Review agents used:** architecture-strategist, performance-oracle, security-sentinel, code-simplicity-reviewer, pattern-recognition-specialist, agent-native-reviewer, best-practices-researcher
|
||||||
|
|
||||||
|
### Key Improvements
|
||||||
|
|
||||||
|
1. **Dropped NSDistributedNotificationCenter observer** — Polling-only for v1. Observer adds thread-safety risks (HIGH), undocumented API surface, and complexity for marginal latency gain. The reliable path is AX polling.
|
||||||
|
2. **Reduced adapter surface from 5 methods to 3** — `list_notifications`, `dismiss_notification`, `notification_action`. Dismiss-all composes on `list_notifications`. Wait composes on `list_notifications` polling.
|
||||||
|
3. **Dropped `NotificationUnsupported` error code** — Reuse existing `PlatformNotSupported` (pattern-recognition, architecture-strategist).
|
||||||
|
4. **Added content-based verification for dismiss/interact** — TOCTOU mitigation: verify `app_name` + `title` match before acting on a positional index (agent-native-reviewer CRITICAL).
|
||||||
|
5. **Replaced AppleScript NC open with pure AX** — Eliminates shell injection risk from existing AppleScript pattern (security-sentinel HIGH).
|
||||||
|
6. **Added index >= 1 validation** — Index 0 with 1-based-to-0-based conversion causes `usize` underflow → panic (security-sentinel MEDIUM).
|
||||||
|
7. **Deferred v1 scope**: focus mode detection, inline action UI, auto-expansion of collapsed groups, `observer.rs` — all deferred to v2 to keep initial implementation minimal (~315 LOC reduction).
|
||||||
|
|
||||||
|
### New Considerations Discovered
|
||||||
|
|
||||||
|
- macOS Sequoia (15) added extra AXGroup nesting level in NC AX tree — heuristic parser must handle both Sonoma and Sequoia layouts
|
||||||
|
- NC close buttons only appear on hover in Sequoia — mouse hover synthesis required before dismiss
|
||||||
|
- Notification text may be `NSConcreteAttributedString` in Sequoia — need to handle both plain and attributed string extraction
|
||||||
|
- `typed batch path` is already at 472 LOC (over 400 limit) — adding 4 commands requires splitting first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add 4 new commands + 1 wait extension enabling AI agents to read, dismiss, and interact with macOS notifications via the Notification Center accessibility tree. This is the first cross-platform notification feature — macOS ships first, Windows/Linux implementations follow in their respective phases.
|
||||||
|
|
||||||
|
The implementation follows the existing extensibility pattern: new domain types in core → new adapter trait methods with `not_supported` defaults → command handlers → CLI wiring → macOS-specific implementation in a new `notifications/` subfolder.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
agent-desktop's 50-command surface covers app lifecycle, UI interaction, clipboard, screenshots, and keyboard/mouse — but has zero visibility into OS-level notifications. Agents automating desktop workflows frequently need to:
|
||||||
|
|
||||||
|
- Detect when a notification arrives (e.g., download complete, message received)
|
||||||
|
- Read notification content to decide next steps
|
||||||
|
- Dismiss notifications to clear clutter
|
||||||
|
- Click notification action buttons (Reply, Open, Join, etc.)
|
||||||
|
|
||||||
|
Without notification support, agents must resort to screenshot → OCR workflows or ignore notifications entirely.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
**Approach: AX tree traversal with polling-based wait** (see brainstorm: `docs/brainstorms/2026-02-27-notification-management-brainstorm.md`)
|
||||||
|
|
||||||
|
- **CRUD operations** (list, dismiss, dismiss-all, notification-action): Programmatically open Notification Center, traverse its AX tree, perform actions, close NC — all within a single RAII-guarded session per command invocation
|
||||||
|
- **Wait operation** (`wait --notification`): AX polling at configurable intervals (default 3 seconds), opening/closing NC per cycle
|
||||||
|
|
||||||
|
### Research Insights — Solution Design
|
||||||
|
|
||||||
|
**Architecture (architecture-strategist):**
|
||||||
|
- Keep CLI wiring incremental with each phase rather than a separate Phase 6. Each phase should produce a testable `cargo build` — wire CLI as you go.
|
||||||
|
- `list_notifications` return type must be consistent between trait signature and macOS impl. V1 returns `Vec<NotificationInfo>` from the trait; focus mode metadata is macOS-specific and returned via a wrapper type in the macOS crate, not the trait.
|
||||||
|
|
||||||
|
**Simplicity (code-simplicity-reviewer):**
|
||||||
|
- 3 adapter methods, not 5. `dismiss_all` composes on `list_notifications` in the command handler. `wait_for_notification` composes on `list_notifications` polling in the command handler.
|
||||||
|
- Merge `dismiss.rs` + `interact.rs` into a single `actions.rs` (~150 LOC combined, well under 400 limit).
|
||||||
|
- Drop `observer.rs` entirely from v1.
|
||||||
|
|
||||||
|
### Alternative Approaches Considered
|
||||||
|
|
||||||
|
| Approach | Why Rejected |
|
||||||
|
|----------|-------------|
|
||||||
|
| NSDistributedNotificationCenter for wait | Thread-safety risks (HIGH — security-sentinel), undocumented API surface, not all apps broadcast. Polling is the reliable path. Observer adds complexity for marginal latency gain. Deferred to v2 if polling proves insufficient. |
|
||||||
|
| Pure AX polling for wait (original brainstorm) | Adopted as the v1 approach after security/simplicity review. 3-second default interval, configurable via `--poll-interval`. |
|
||||||
|
| SQLite notification database | Requires Full Disk Access permission — unacceptable adoption barrier on top of existing Accessibility permission. Database schema is undocumented and changes across macOS versions. |
|
||||||
|
| Banner-only capture | Banners are transient (~5s display). Requires separate AX target. NC contents are sufficient for v1. |
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ Binary crate (src/) │
|
||||||
|
│ ┌─────────┐ ┌───────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ cli.rs │→ │cli_args.rs│→ │ dispatch.rs │ │
|
||||||
|
│ │ 4 new │ │ 4 new arg │ │ 4 new match arms │ │
|
||||||
|
│ │ variants│ │ structs │ │ + wait extension │ │
|
||||||
|
│ └─────────┘ └───────────┘ └────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
├───────────────────────────────────────┼──────────────┤
|
||||||
|
│ Core crate (crates/core/) │ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │ │
|
||||||
|
│ │notification.rs│ │ error.rs │ │ │
|
||||||
|
│ │NotifInfo │ │+1 error code │ │ │
|
||||||
|
│ │NotifFilter │ └──────────────┘ │ │
|
||||||
|
│ └──────────────┘ │ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │ │
|
||||||
|
│ │ adapter.rs │ │ commands/ │ │ │
|
||||||
|
│ │ +3 methods │ │ 4 new files │◄────┘ │
|
||||||
|
│ │ (defaults) │ └─────────────┘ │
|
||||||
|
│ └─────────────┘ │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ macOS crate (crates/macos/) │
|
||||||
|
│ ┌────────────┐ ┌──────────────────────────────┐ │
|
||||||
|
│ │ adapter.rs │→ │ notifications/ (NEW) │ │
|
||||||
|
│ │ +3 impls │ │ ├── mod.rs │ │
|
||||||
|
│ │ │ │ ├── nc_session.rs (RAII) │ │
|
||||||
|
│ │ │ │ ├── list.rs │ │
|
||||||
|
│ │ │ │ └── actions.rs │ │
|
||||||
|
│ └────────────┘ └──────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights — Architecture
|
||||||
|
|
||||||
|
**Naming conventions (pattern-recognition-specialist):**
|
||||||
|
- `dismiss-all-notifications` has 3 kebab segments — breaks the existing 2-segment convention (`list-windows`, `focus-window`). Consider `clear-notifications` instead.
|
||||||
|
- `notification-action` breaks the `{verb}-{noun}` pattern. Consider `click-notification` (verb-noun) with the action name as a required argument.
|
||||||
|
- Decision: Keep `list-notifications`, `dismiss-notification`, `dismiss-all-notifications`, `notification-action` as-is from the brainstorm. The 3-segment name for dismiss-all is acceptable because it disambiguates from dismiss-single. `notification-action` is acceptable because the action name is a positional arg.
|
||||||
|
|
||||||
|
**macOS version compatibility (best-practices-researcher):**
|
||||||
|
- NC process name: `NotificationCenter` (not `notificationcenterui`)
|
||||||
|
- NC is opened via SystemUIServer menu bar click (AX click on clock area), NOT directly via the NC process
|
||||||
|
- Sequoia (macOS 15) added an extra AXGroup nesting level — parser must handle variable nesting depth
|
||||||
|
- Close buttons only appear on hover in Sequoia — must synthesize mouse hover before looking for dismiss button
|
||||||
|
- Notification body text may be `NSConcreteAttributedString` — use `AXValueAttribute` with string coercion fallback
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
#### Phase 1: Core Types and Adapter Trait + CLI Wiring
|
||||||
|
|
||||||
|
Establish the platform-agnostic foundation with CLI wiring. Produces a compilable binary where notification commands return `PLATFORM_NOT_SUPPORTED` on all platforms.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
- [x] Create `crates/core/src/notification.rs` — `NotificationInfo` and `NotificationFilter` structs
|
||||||
|
- [x] Add 3 new methods to `PlatformAdapter` trait in `crates/core/src/adapter.rs` with `not_supported` defaults
|
||||||
|
- [x] Add 1 error code variant (`NotificationNotFound`) to `ErrorCode` enum in `crates/core/src/error.rs` + convenience constructor
|
||||||
|
- [x] Register module in `crates/core/src/lib.rs`
|
||||||
|
- [x] Create 4 command handlers in `crates/core/src/commands/`
|
||||||
|
- [x] Register 4 new modules in `crates/core/src/commands/mod.rs`
|
||||||
|
- [x] Add 4 command variants to `src/cli.rs` + `name()` arms
|
||||||
|
- [x] Add 4 arg structs to `src/cli_args.rs`
|
||||||
|
- [x] Add 4 match arms to `src/dispatch.rs` + extend Wait arm for `--notification`
|
||||||
|
- [x] Add 4 command routing arms to `src/typed batch path`
|
||||||
|
- [x] Add `--notification` flag + `--poll-interval` to `WaitArgs`
|
||||||
|
|
||||||
|
**New types in `crates/core/src/notification.rs`:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NotificationInfo {
|
||||||
|
pub index: usize,
|
||||||
|
pub app_name: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timestamp: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub actions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct NotificationFilter {
|
||||||
|
pub app: Option<String>,
|
||||||
|
pub text: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- 6 fields on `NotificationInfo` (under 7-field limit per CLAUDE.md)
|
||||||
|
- `is_persistent` dropped — not reliably detectable from AX, no agent use case (see brainstorm)
|
||||||
|
- `timestamp` is ISO 8601 string when absolute time is extractable from AX, `None` when only relative time is available
|
||||||
|
- `NotificationFilter` derives `Default` and `Clone` (pattern-recognition fix: missing derives)
|
||||||
|
- `focus_mode` is NOT in `NotificationInfo` or the trait return type — it's macOS-specific metadata added by the macOS command layer
|
||||||
|
|
||||||
|
**New adapter methods in `crates/core/src/adapter.rs`:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn list_notifications(&self, _filter: &NotificationFilter) -> Result<Vec<NotificationInfo>, AdapterError> {
|
||||||
|
Err(AdapterError::not_supported("list_notifications"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dismiss_notification(&self, _index: usize, _app_filter: Option<&str>) -> Result<NotificationInfo, AdapterError> {
|
||||||
|
Err(AdapterError::not_supported("dismiss_notification"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_action(&self, _index: usize, _action_name: &str) -> Result<ActionResult, AdapterError> {
|
||||||
|
Err(AdapterError::not_supported("notification_action"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- **3 methods** (reduced from 5). `dismiss_all_notifications` and `wait_for_notification` compose on `list_notifications` in command handlers — no separate adapter methods needed.
|
||||||
|
- `dismiss_notification` returns `NotificationInfo` of the dismissed notification for agent verification
|
||||||
|
- Reuse existing `ActionResult` for `notification_action` (code-simplicity: no new result type needed)
|
||||||
|
|
||||||
|
**New error code in `crates/core/src/error.rs`:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Add to ErrorCode enum:
|
||||||
|
NotificationNotFound,
|
||||||
|
|
||||||
|
// Add to as_str():
|
||||||
|
Self::NotificationNotFound => "NOTIFICATION_NOT_FOUND",
|
||||||
|
|
||||||
|
// Add convenience constructor to AdapterError:
|
||||||
|
pub fn notification_not_found(index: usize) -> Self {
|
||||||
|
Self::new(
|
||||||
|
ErrorCode::NotificationNotFound,
|
||||||
|
format!("Notification at index {index} not found"),
|
||||||
|
)
|
||||||
|
.with_suggestion("Notification may have been dismissed or expired. Run 'list-notifications' to see current notifications")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes (architecture-strategist, pattern-recognition, code-simplicity):
|
||||||
|
- **Only 1 new error code**, not 2. `NotificationUnsupported` is dropped — the existing `PlatformNotSupported` (returned by default `not_supported()` implementations) already covers this case identically.
|
||||||
|
- This matches the existing pattern: `clipboard_get` doesn't have a `ClipboardUnsupported` error, it just returns `PlatformNotSupported` on unsupported platforms.
|
||||||
|
|
||||||
|
### Research Insights — Error Handling
|
||||||
|
|
||||||
|
**Index validation (security-sentinel MEDIUM):**
|
||||||
|
- Index 0 with 1-based-to-0-based conversion (`index - 1`) causes `usize` underflow → `panic!` (since `panic = "abort"` in release profile, this is a process kill)
|
||||||
|
- **Must validate `index >= 1`** at the argument parsing level (clap `value_parser` with range) or in the command handler before subtraction
|
||||||
|
- Recommended: Add `#[arg(value_parser = clap::value_parser!(usize).range(1..))]` to all index args
|
||||||
|
|
||||||
|
**CLI args (`src/cli_args.rs`):**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct ListNotificationsArgs {
|
||||||
|
#[arg(long, help = "Filter to notifications from this app")]
|
||||||
|
pub app: Option<String>,
|
||||||
|
#[arg(long, help = "Filter to notifications containing this text")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
#[arg(long, help = "Maximum number of notifications to return")]
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct DismissNotificationArgs {
|
||||||
|
#[arg(value_name = "INDEX", help = "1-based notification index from list-notifications",
|
||||||
|
value_parser = clap::value_parser!(usize).range(1..))]
|
||||||
|
pub index: usize,
|
||||||
|
#[arg(long, help = "Filter notifications by app before selecting index")]
|
||||||
|
pub app: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct DismissAllNotificationsArgs {
|
||||||
|
#[arg(long, help = "Only dismiss notifications from this app")]
|
||||||
|
pub app: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct NotificationActionArgs {
|
||||||
|
#[arg(value_name = "INDEX", help = "1-based notification index from list-notifications",
|
||||||
|
value_parser = clap::value_parser!(usize).range(1..))]
|
||||||
|
pub index: usize,
|
||||||
|
#[arg(value_name = "ACTION", help = "Name of the action button to click (e.g., Reply, Open)")]
|
||||||
|
pub action: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- `DismissNotificationArgs` now includes `--app` filter (pattern-recognition fix: missing from original plan)
|
||||||
|
- Both index args use `value_parser` with `range(1..)` to reject index 0 at parse time (security-sentinel fix)
|
||||||
|
|
||||||
|
**Wait args extension (`src/cli_args.rs`):**
|
||||||
|
|
||||||
|
Add to existing `WaitArgs`:
|
||||||
|
```rust
|
||||||
|
#[arg(long, help = "Wait for a notification to appear")]
|
||||||
|
pub notification: bool,
|
||||||
|
#[arg(long, help = "Poll interval in ms for notification wait (default: 3000)", default_value = "3000")]
|
||||||
|
pub poll_interval: Option<u64>,
|
||||||
|
```
|
||||||
|
|
||||||
|
**Command handlers (`crates/core/src/commands/`):**
|
||||||
|
|
||||||
|
Each handler follows Pattern C (args struct → execute → json). Example for `list_notifications.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn execute(args: ListNotificationsArgs, adapter: &dyn PlatformAdapter) -> Result<Value, AppError> {
|
||||||
|
let filter = NotificationFilter {
|
||||||
|
app: args.app,
|
||||||
|
text: args.text,
|
||||||
|
limit: args.limit,
|
||||||
|
};
|
||||||
|
let notifications = adapter.list_notifications(&filter)?;
|
||||||
|
Ok(json!({
|
||||||
|
"count": notifications.len(),
|
||||||
|
"notifications": notifications,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`dismiss_all_notifications.rs` composes on list:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn execute(args: DismissAllNotificationsArgs, adapter: &dyn PlatformAdapter) -> Result<Value, AppError> {
|
||||||
|
// List all notifications matching filter, then dismiss each
|
||||||
|
let filter = NotificationFilter { app: args.app.clone(), ..Default::default() };
|
||||||
|
let notifications = adapter.list_notifications(&filter)?;
|
||||||
|
let mut dismissed = 0;
|
||||||
|
// Dismiss in reverse order (highest index first) to avoid index shifting
|
||||||
|
for notif in notifications.iter().rev() {
|
||||||
|
match adapter.dismiss_notification(notif.index, args.app.as_deref()) {
|
||||||
|
Ok(_) => dismissed += 1,
|
||||||
|
Err(_) => {} // notification may have already been dismissed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(json!({
|
||||||
|
"dismissed_count": dismissed,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- `cargo test --lib -p agent-desktop-core` passes
|
||||||
|
- `cargo clippy --all-targets -- -D warnings` clean
|
||||||
|
- All 4 commands parse and dispatch correctly
|
||||||
|
- `wait --notification` flag is recognized
|
||||||
|
- `--notification` is mutually exclusive with `--element`/`--window`
|
||||||
|
- Windows/Linux stubs compile without changes (default `not_supported` implementations)
|
||||||
|
- Index 0 rejected at parse time with clear error message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Phase 2: NC Session Guard (macOS)
|
||||||
|
|
||||||
|
The critical safety layer. Every notification command operates within a `NcSession` that guarantees Notification Center is opened before work and closed after — even on errors or panics.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
- [x] Create `crates/macos/src/notifications/mod.rs` — module re-exports
|
||||||
|
- [x] Create `crates/macos/src/notifications/nc_session.rs` — RAII NC lifecycle guard
|
||||||
|
- [x] Register `notifications` module in `crates/macos/src/lib.rs`
|
||||||
|
|
||||||
|
**`NcSession` design (`crates/macos/src/notifications/nc_session.rs`):**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct NcSession {
|
||||||
|
was_already_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NcSession {
|
||||||
|
pub fn open() -> Result<Self, AdapterError> {
|
||||||
|
let was_already_open = is_nc_open()?;
|
||||||
|
if !was_already_open {
|
||||||
|
open_nc()?;
|
||||||
|
wait_for_nc_ready()?; // wait for animation + AX tree population
|
||||||
|
}
|
||||||
|
Ok(Self { was_already_open })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit close with error reporting. Prefer this over relying on Drop.
|
||||||
|
pub fn close(self) -> Result<(), AdapterError> {
|
||||||
|
if !self.was_already_open {
|
||||||
|
close_nc()?;
|
||||||
|
}
|
||||||
|
std::mem::forget(self); // prevent Drop from double-closing
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for NcSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.was_already_open {
|
||||||
|
// Fire-and-forget close — log error but don't propagate
|
||||||
|
if let Err(e) = close_nc() {
|
||||||
|
tracing::warn!("Failed to close NC in Drop: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights — NC Session
|
||||||
|
|
||||||
|
**Explicit close + Drop fallback (best-practices-researcher, performance-oracle):**
|
||||||
|
- Provide `close(self)` for the happy path where callers want error feedback
|
||||||
|
- Drop handles panic/error paths as best-effort fallback
|
||||||
|
- Use `std::mem::forget(self)` in `close()` to prevent double-close from Drop
|
||||||
|
- Alternative: `scopeguard` crate — but adds a dependency for a pattern that's straightforward to implement manually
|
||||||
|
|
||||||
|
**Pure AX for NC open (security-sentinel HIGH, performance-oracle):**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn open_nc() -> Result<(), AdapterError> {
|
||||||
|
// 1. Find SystemUIServer process
|
||||||
|
// 2. Get its AXApplication element
|
||||||
|
// 3. Find the menu bar (AXMenuBar)
|
||||||
|
// 4. Find the clock/date menu bar item
|
||||||
|
// 5. AXPress to toggle NC open
|
||||||
|
// NO AppleScript — eliminates shell injection attack surface
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `app_ops.rs:172-192` uses `osascript -e` with string interpolation — this is a known injection vector (security-sentinel). NC open must NOT replicate this pattern. Pure AX click on the SystemUIServer menu bar item is both safer and faster (~50ms vs ~200ms for AppleScript).
|
||||||
|
|
||||||
|
**NC state detection (best-practices-researcher):**
|
||||||
|
- Check `com.apple.notificationcenterui` process for visible AXWindow
|
||||||
|
- Do NOT use toggle — toggling inverts state if NC is already open
|
||||||
|
- NC process name is `NotificationCenter` on modern macOS, bundle ID is `com.apple.notificationcenterui`
|
||||||
|
|
||||||
|
**NC close mechanism (performance-oracle):**
|
||||||
|
- Send Escape key via `CGEventCreateKeyboardEvent` (fastest, most reliable)
|
||||||
|
- Fire-and-forget variant in Drop: dispatch close without waiting for confirmation
|
||||||
|
- Blocking variant in `close()`: wait up to 500ms for NC window to disappear
|
||||||
|
|
||||||
|
**NC ready wait (performance-oracle):**
|
||||||
|
- After open, poll for AX tree population (notification children exist)
|
||||||
|
- Max wait: 2 seconds with 50ms polling interval
|
||||||
|
- NC animation is typically 200-400ms
|
||||||
|
|
||||||
|
**Already-open handling:** If NC was already open when `NcSession::open()` is called, the session skips both the open and the close steps. The agent (or user) is responsible for NC in this case.
|
||||||
|
|
||||||
|
**SIGKILL risk (security-sentinel MEDIUM):**
|
||||||
|
- If the process is killed during a notification operation, NC may remain open
|
||||||
|
- No mitigation possible — same risk as any system UI interaction
|
||||||
|
- NC will close when the user clicks elsewhere or presses Escape
|
||||||
|
- With `panic = "abort"`, Drop does NOT run — NC will be left open on panic
|
||||||
|
- Acceptable: NC auto-closes on user interaction, and this is an edge case
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- `NcSession::open()` reliably detects whether NC is already open
|
||||||
|
- NC is always closed on session drop (verified by test that opens, panics, and checks NC state)
|
||||||
|
- `close(self)` provides error feedback without double-close
|
||||||
|
- Animation timing is handled (no empty traversals due to reading mid-animation)
|
||||||
|
- No AppleScript anywhere in the notification module
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Phase 3: List Implementation (macOS)
|
||||||
|
|
||||||
|
The core traversal that all other notification commands depend on.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
- [x] Create `crates/macos/src/notifications/list.rs` — AX tree traversal of NC
|
||||||
|
- [x] Wire `list_notifications` in macOS adapter
|
||||||
|
|
||||||
|
**`list.rs` implementation:**
|
||||||
|
|
||||||
|
The function receives an open `NcSession` reference (to ensure NC is open) and a `NotificationFilter`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn list_notifications(filter: &NotificationFilter) -> Result<Vec<NotificationInfo>, AdapterError> {
|
||||||
|
let session = NcSession::open()?;
|
||||||
|
let notifications = list_from_nc(&session, filter)?;
|
||||||
|
session.close()?; // explicit close with error reporting
|
||||||
|
Ok(notifications)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights — AX Tree Traversal
|
||||||
|
|
||||||
|
**Heuristic matching strategy (best-practices-researcher):**
|
||||||
|
|
||||||
|
The NC AX tree structure varies by macOS version. Use role + subrole heuristics:
|
||||||
|
|
||||||
|
1. Find NC process by bundle ID `com.apple.notificationcenterui`
|
||||||
|
2. Get application AX element via `AXUIElementCreateApplication(pid)`
|
||||||
|
3. Find main NC window (AXWindow or AXSheet child)
|
||||||
|
4. Traverse children looking for notification elements:
|
||||||
|
- **Sonoma (14):** Notifications are AXGroup with subrole `AXNotificationCenterAlert` or within `AXNotificationCenterAlertStack`
|
||||||
|
- **Sequoia (15):** Extra AXGroup nesting level — notifications may be nested one level deeper
|
||||||
|
- Match by role pattern: AXGroup containing AXStaticText children
|
||||||
|
- Notification titles: AXStaticText with `AXValue` attribute
|
||||||
|
- Action buttons: AXButton children within the notification group
|
||||||
|
5. Build flat list ordered by tree position (newest first in NC)
|
||||||
|
6. Apply filters during traversal, not after (performance-oracle: avoid building full list then filtering)
|
||||||
|
- `--app`: case-insensitive substring match
|
||||||
|
- `--text`: substring match on title+body
|
||||||
|
- `--limit`: stop traversal early when limit reached
|
||||||
|
|
||||||
|
**Performance (performance-oracle):**
|
||||||
|
- Use `AXUIElementCopyMultipleAttributeValues` for batch attribute fetch (3-5x faster than individual calls)
|
||||||
|
- Apply `--app` filter during traversal to skip non-matching groups entirely
|
||||||
|
- If `--limit` is set, stop traversal once limit is reached
|
||||||
|
|
||||||
|
**Grouped notification handling — DEFERRED to v2 (code-simplicity-reviewer):**
|
||||||
|
|
||||||
|
macOS groups notifications by app when count > 1. V1 behavior:
|
||||||
|
- If a group is collapsed, list the group header only (showing app name + count badge text)
|
||||||
|
- Do NOT auto-expand groups — this adds complexity, timing issues, and visual disruption
|
||||||
|
- Include a `"grouped": true` hint on collapsed group entries so agents know to use `--app` filter for details
|
||||||
|
- V2 can add `--expand` flag to auto-expand groups before traversal
|
||||||
|
|
||||||
|
**Timestamp extraction:**
|
||||||
|
|
||||||
|
macOS NC shows relative timestamps ("2m ago"). The AX tree may expose:
|
||||||
|
- `AXValue` on the timestamp element → relative string
|
||||||
|
- `AXDescription` or custom attributes → sometimes absolute time
|
||||||
|
|
||||||
|
Strategy: extract whatever is available. If an absolute time can be parsed, return ISO 8601. If only relative, return `None` for `timestamp`.
|
||||||
|
|
||||||
|
**Focus mode / DND detection — DEFERRED to v2 (code-simplicity-reviewer):**
|
||||||
|
|
||||||
|
Reading focus mode requires CoreFoundation preferences API which adds complexity. V1 returns notifications only. V2 can add `focus_mode` metadata.
|
||||||
|
|
||||||
|
**NSConcreteAttributedString handling (best-practices-researcher):**
|
||||||
|
|
||||||
|
On Sequoia, notification body text may be `NSConcreteAttributedString` instead of plain NSString. Use `AXValueAttribute` with string coercion:
|
||||||
|
```rust
|
||||||
|
// Try AXValue as string first, then AXTitle, then AXDescription
|
||||||
|
// If the value is an attributed string, CFStringGetCString will still extract the text content
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- Returns correct flat list for NC with 0, 1, 5, 20+ notifications
|
||||||
|
- Filters work correctly (--app, --text, --limit)
|
||||||
|
- Returns empty list (not error) when NC has zero notifications
|
||||||
|
- Handles both Sonoma and Sequoia AX tree layouts
|
||||||
|
- Uses batch attribute fetch for performance
|
||||||
|
- Stops traversal early when --limit reached
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Phase 4: Dismiss and Interact Implementation (macOS)
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
- [x] Create `crates/macos/src/notifications/actions.rs` — dismiss + interact combined
|
||||||
|
- [x] Handle hover-to-reveal close button (Sequoia behavior)
|
||||||
|
- [x] Wire `dismiss_notification` and `notification_action` in macOS adapter
|
||||||
|
|
||||||
|
### Research Insights — TOCTOU Mitigation
|
||||||
|
|
||||||
|
**Content-based verification (agent-native-reviewer CRITICAL):**
|
||||||
|
|
||||||
|
Positional indices are inherently racy — a notification may arrive or disappear between `list-notifications` and `dismiss-notification`. Within a single command invocation, the index is stable (NC is open and frozen). But across separate invocations, the index may point to a different notification.
|
||||||
|
|
||||||
|
**Mitigation: Verify before acting.** When the agent provides an index from a previous `list-notifications` call:
|
||||||
|
1. Open NC session
|
||||||
|
2. Re-list notifications to get current state
|
||||||
|
3. Find notification at the given index
|
||||||
|
4. Verify that `app_name` and `title` match what the agent expects (passed via optional `--verify-app` and `--verify-title` args, or by the command handler comparing against what list returned)
|
||||||
|
5. If mismatch → return `NOTIFICATION_NOT_FOUND` with suggestion to re-list
|
||||||
|
6. If match → perform the action
|
||||||
|
|
||||||
|
**V1 approach:** Within a single command (dismiss/interact), the tool lists and acts in the same NC session. The index is consistent within that session. The TOCTOU risk is only when the agent uses an index from a **previous** `list-notifications` call — but since agents should always list-then-act in quick succession, this is acceptable for v1. Content verification can be added in v2 via optional verify args.
|
||||||
|
|
||||||
|
**`actions.rs` — single notification dismiss:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn dismiss_notification(index: usize, app_filter: Option<&str>) -> Result<NotificationInfo, AdapterError> {
|
||||||
|
let session = NcSession::open()?;
|
||||||
|
let notifications = list_from_nc(&session, &build_filter(app_filter))?;
|
||||||
|
let target = notifications.get(index - 1) // 1-based to 0-based (index >= 1 validated by clap)
|
||||||
|
.ok_or_else(|| AdapterError::notification_not_found(index))?;
|
||||||
|
let info = target.clone();
|
||||||
|
|
||||||
|
// Hover over notification to reveal close button (Sequoia)
|
||||||
|
hover_over_element(&target.ax_handle)?;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
|
||||||
|
// Find and click the close/dismiss button
|
||||||
|
perform_dismiss_on_element(&target.ax_handle)?;
|
||||||
|
session.close()?;
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Clear All" hover requirement (best-practices-researcher, SpecFlow Gap 11):**
|
||||||
|
|
||||||
|
On macOS Sonoma/Sequoia, "Clear" and "Clear All" buttons appear only on hover. The implementation must:
|
||||||
|
1. Find the notification group header AX element
|
||||||
|
2. Get its bounds via `kAXPositionAttribute` + `kAXSizeAttribute`
|
||||||
|
3. Synthesize a `CGEventCreateMouseEvent` mouseMoved to the center of those bounds
|
||||||
|
4. Wait 200ms for the button to appear
|
||||||
|
5. Re-traverse to find the now-visible "Clear" / "Clear All" button
|
||||||
|
6. AXPress the button
|
||||||
|
|
||||||
|
**`actions.rs` — notification action:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn notification_action(index: usize, action_name: &str) -> Result<ActionResult, AdapterError> {
|
||||||
|
let session = NcSession::open()?;
|
||||||
|
let notifications = list_from_nc(&session, &NotificationFilter::default())?;
|
||||||
|
let target = notifications.get(index - 1)
|
||||||
|
.ok_or_else(|| AdapterError::notification_not_found(index))?;
|
||||||
|
// Find action button matching action_name among the notification's AXButton children
|
||||||
|
let button = find_action_button(&target.ax_handle, action_name)
|
||||||
|
.ok_or_else(|| AdapterError::action_failed(
|
||||||
|
format!("Action '{action_name}' not found on notification {index}"),
|
||||||
|
))?;
|
||||||
|
perform_ax_press(&button)?;
|
||||||
|
session.close()?;
|
||||||
|
Ok(ActionResult::new(action_name))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Inline action UI — DEFERRED to v2 (code-simplicity-reviewer):**
|
||||||
|
|
||||||
|
Some actions (like "Reply") open an inline text field within NC. V1 does not detect or handle this — it just clicks the button and returns success. V2 can add `"inline_ui": true` to the result and allow the agent to type into the field.
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- Single dismiss by index works correctly
|
||||||
|
- Dismiss-all (via command handler composing list + dismiss) works with and without `--app`
|
||||||
|
- Hover-to-reveal close button works on Sequoia
|
||||||
|
- Notification action buttons are correctly discovered and clicked
|
||||||
|
- Returns `NOTIFICATION_NOT_FOUND` for invalid index
|
||||||
|
- Returns `ACTION_FAILED` if action button not found by name
|
||||||
|
- Index 0 is impossible (validated at parse time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Phase 5: Wait Implementation (Core + macOS)
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
- [x] Extend the `wait` command handler in `crates/core/src/commands/wait.rs` for `--notification` flag
|
||||||
|
- [x] Implement polling loop using `list_notifications`
|
||||||
|
- [x] No new macOS files needed — wait composes on existing `list_notifications`
|
||||||
|
|
||||||
|
**Wait architecture (simplified from original plan):**
|
||||||
|
|
||||||
|
```
|
||||||
|
wait --notification --app "Slack" --timeout 10000
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────┐
|
||||||
|
│ AX polling loop │
|
||||||
|
│ every 3s (default) │
|
||||||
|
│ or --poll-interval │
|
||||||
|
│ │
|
||||||
|
│ Each cycle: │
|
||||||
|
│ 1. list_notifs() │
|
||||||
|
│ 2. compare baseline│
|
||||||
|
│ 3. if new → return │
|
||||||
|
│ 4. sleep interval │
|
||||||
|
└───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The wait command lives entirely in core (`wait.rs`), calling `adapter.list_notifications()` in a loop. No separate adapter method needed. No observer. No macOS-specific wait code.
|
||||||
|
|
||||||
|
**Polling strategy:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In wait.rs, when --notification flag is set:
|
||||||
|
let filter = NotificationFilter { app: args.app.clone(), text: args.text.clone(), ..Default::default() };
|
||||||
|
let baseline = adapter.list_notifications(&filter)?;
|
||||||
|
let baseline_count = baseline.len();
|
||||||
|
let interval = Duration::from_millis(args.poll_interval.unwrap_or(3000));
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(args.timeout.unwrap_or(30000));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(interval);
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
return Err(AppError::from(AdapterError::timeout("notification")));
|
||||||
|
}
|
||||||
|
let current = adapter.list_notifications(&filter)?;
|
||||||
|
if current.len() > baseline_count {
|
||||||
|
// New notification arrived — return the newest one
|
||||||
|
return Ok(json!({
|
||||||
|
"condition": "notification",
|
||||||
|
"matched": true,
|
||||||
|
"notification": current[0], // newest is index 1, which is current[0]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights — Wait
|
||||||
|
|
||||||
|
**Performance (performance-oracle):**
|
||||||
|
- Each poll cycle opens and closes NC (visual flash). 3-second default interval minimizes visual disruption.
|
||||||
|
- `--poll-interval` allows agents to tune: faster for time-critical workflows, slower for background monitoring.
|
||||||
|
- NC open/close overhead is ~300-500ms per cycle. With 3s interval, that's ~10-15% overhead — acceptable.
|
||||||
|
|
||||||
|
**`--notification` flag semantics (agent-native-reviewer):**
|
||||||
|
- `--notification` is mutually exclusive with `--element`, `--window`, `--menu`
|
||||||
|
- When `--notification` is set, `--app` filters by notification source app (NOT the app to snapshot — different semantics from `--element` mode)
|
||||||
|
- When `--notification` is set, `--text` filters by notification title/body content
|
||||||
|
- This semantic overload is documented in CLI help text
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- `wait --notification` blocks until a notification arrives
|
||||||
|
- `wait --notification --app "Messages"` only matches Messages notifications
|
||||||
|
- `wait --notification --text "hello"` matches title or body content
|
||||||
|
- Timeout returns structured `TIMEOUT` error
|
||||||
|
- `--poll-interval` controls polling frequency
|
||||||
|
- NC is properly opened/closed each cycle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Phase 6: Testing
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
- [x] Unit tests for `NotificationInfo` serialization (core)
|
||||||
|
- [x] Unit tests for `NotificationFilter` logic (core)
|
||||||
|
- [x] Unit tests for new error code (core)
|
||||||
|
- [ ] MockAdapter tests for notification commands (core) — deferred, no MockAdapter infra yet
|
||||||
|
- [ ] Integration tests for NC session lifecycle (macOS CI) — requires CI runner with Accessibility
|
||||||
|
- [ ] Integration tests for list/dismiss/interact (macOS CI) — requires CI runner with Accessibility
|
||||||
|
- [ ] Golden fixture for NC AX tree structure (tests/fixtures/) — deferred to v2
|
||||||
|
|
||||||
|
**Unit tests (core — `crates/core/`):**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// notification.rs tests
|
||||||
|
#[test]
|
||||||
|
fn notification_info_serialization_omits_none_fields() { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_info_serialization_omits_empty_actions() { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_filter_default_is_unfiltered() { ... }
|
||||||
|
|
||||||
|
// error.rs tests
|
||||||
|
#[test]
|
||||||
|
fn notification_not_found_error_serialization() { ... }
|
||||||
|
|
||||||
|
// commands/ tests (using MockAdapter)
|
||||||
|
#[test]
|
||||||
|
fn list_notifications_returns_empty_on_mock() { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dismiss_notification_returns_not_supported_on_mock() { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dismiss_all_composes_list_and_dismiss() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration tests (macOS CI — `tests/integration/`):**
|
||||||
|
|
||||||
|
These require a macOS runner with Accessibility permission:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn list_notifications_returns_valid_json() {
|
||||||
|
// Run: agent-desktop list-notifications
|
||||||
|
// Verify: valid JSON envelope with "ok": true
|
||||||
|
// Verify: "notifications" is an array
|
||||||
|
// Verify: each notification has required fields (index, app_name, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dismiss_notification_invalid_index_returns_error() {
|
||||||
|
// Run: agent-desktop dismiss-notification 999
|
||||||
|
// Verify: NOTIFICATION_NOT_FOUND error
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dismiss_notification_zero_index_rejected() {
|
||||||
|
// Run: agent-desktop dismiss-notification 0
|
||||||
|
// Verify: parse error (exit code 2), not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_notifications_with_app_filter() {
|
||||||
|
// Run: agent-desktop list-notifications --app "NonexistentApp"
|
||||||
|
// Verify: empty notifications array, ok: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Golden fixtures:**
|
||||||
|
|
||||||
|
Capture real NC AX tree snapshots and commit to `tests/fixtures/`:
|
||||||
|
- `notification_center_macos14.json` — Sonoma layout
|
||||||
|
- `notification_center_macos15.json` — Sequoia layout (with extra nesting)
|
||||||
|
|
||||||
|
### Research Insights — Testing
|
||||||
|
|
||||||
|
**Security testing (security-sentinel):**
|
||||||
|
- Add test for index 0 rejection (parse error, not panic)
|
||||||
|
- Add tracing assertions: notification content reads are logged at `info` level for audit trail
|
||||||
|
- Test concurrent NC access: two simultaneous list calls should not corrupt each other
|
||||||
|
|
||||||
|
**Performance benchmarks (performance-oracle):**
|
||||||
|
- NC open-to-first-result: target < 1 second
|
||||||
|
- List 20 notifications: target < 2 seconds
|
||||||
|
- Single dismiss: target < 1.5 seconds (includes hover delay)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
### Interaction Graph
|
||||||
|
|
||||||
|
1. Agent calls `list-notifications` → dispatch.rs → list_notifications::execute() → adapter.list_notifications() → macOS: NcSession::open() → AX tree traversal → NcSession::close()
|
||||||
|
2. Agent calls `dismiss-notification 3` → dispatch.rs → dismiss_notification::execute() → adapter.dismiss_notification() → macOS: NcSession::open() → list + find by index → hover → AXPress close button → NcSession::close()
|
||||||
|
3. Agent calls `wait --notification` → dispatch.rs → wait::execute() → polling loop calling adapter.list_notifications() every N seconds
|
||||||
|
|
||||||
|
No callbacks, middleware, or observers fire beyond the AX system. The only external side effect is the NC open/close animation visible to the user.
|
||||||
|
|
||||||
|
### Error Propagation
|
||||||
|
|
||||||
|
```
|
||||||
|
macOS AX API error
|
||||||
|
→ AdapterError (with ErrorCode, message, suggestion, platform_detail)
|
||||||
|
→ AppError::Adapter (via #[from])
|
||||||
|
→ JSON error envelope (main.rs)
|
||||||
|
→ exit code 1
|
||||||
|
```
|
||||||
|
|
||||||
|
NC close failure in `NcSession::drop()` is logged via `tracing::warn` but does not propagate (Drop cannot return errors). This is acceptable because:
|
||||||
|
- The primary operation already succeeded or failed with a proper error
|
||||||
|
- NC will auto-close after user interaction or on next session open
|
||||||
|
|
||||||
|
### State Lifecycle Risks
|
||||||
|
|
||||||
|
- **NC left open on crash:** If the process is killed (SIGKILL) or panics (panic=abort), NC may remain open. No mitigation — same risk as any system UI interaction. NC will close when the user clicks elsewhere.
|
||||||
|
- **RefMap interaction:** Notification commands do NOT interact with the RefMap. They use their own index scheme. No risk of corrupting snapshot refs.
|
||||||
|
- **Concurrent access:** Two simultaneous `list-notifications` calls will each open their own NC session. Since NC is a singleton UI, the second open attempt will find NC already open and skip the open step (via `NcSession::was_already_open`). Both traversals will read the same AX tree. This is safe but may produce interleaved results in edge cases.
|
||||||
|
|
||||||
|
### API Surface Parity
|
||||||
|
|
||||||
|
- Notification commands are new — no existing interfaces expose equivalent functionality
|
||||||
|
- The `snapshot` command will NOT snapshot NC content (NC is a system process, not a user app). The notification commands are the only way to access NC content.
|
||||||
|
- Batch dispatch (`batch` command) must handle all 4 new commands
|
||||||
|
- **Note:** `typed batch path` is at 472 LOC (over 400 limit) — must split before adding 4 new arms (pattern-recognition-specialist)
|
||||||
|
|
||||||
|
### Research Insights — Security
|
||||||
|
|
||||||
|
**Notification content sensitivity (security-sentinel CRITICAL):**
|
||||||
|
|
||||||
|
Notifications may contain sensitive data: 2FA codes, private messages, financial alerts, medical reminders. The tool faithfully returns this content — it's the agent's responsibility to handle it appropriately.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- Add `tracing::info!` logging when notifications are read (audit trail, not prevention)
|
||||||
|
- Documentation should warn: "Notification content may include sensitive information (2FA codes, private messages). Agents should not log, store, or transmit notification content without user consent."
|
||||||
|
- RefMap-style file permissions (`0o600`) not needed — notification data is transient, not persisted to disk
|
||||||
|
|
||||||
|
**AppleScript injection (security-sentinel HIGH):**
|
||||||
|
- NC open uses pure AX, not AppleScript — no injection vector
|
||||||
|
- Existing `app_ops.rs:172-192` has the vulnerability but is out of scope for this PR
|
||||||
|
- Flag for separate security fix PR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- [ ] `list-notifications` returns a flat JSON array of notifications from NC
|
||||||
|
- [ ] `list-notifications --app "Messages"` filters to Messages notifications only
|
||||||
|
- [ ] `list-notifications --limit 5` caps results at 5
|
||||||
|
- [ ] `dismiss-notification 2` dismisses the 2nd notification and returns its info
|
||||||
|
- [ ] `dismiss-all-notifications` clears all notifications from NC
|
||||||
|
- [ ] `dismiss-all-notifications --app "Mail"` clears only Mail notifications
|
||||||
|
- [ ] `notification-action 1 "Reply"` clicks the Reply button on notification 1
|
||||||
|
- [ ] `wait --notification --timeout 5000` blocks until a notification arrives or times out
|
||||||
|
- [ ] `wait --notification --app "Slack"` only matches Slack notifications
|
||||||
|
- [ ] All commands return valid JSON envelopes matching the existing output contract
|
||||||
|
- [ ] All commands work when NC starts closed (auto-open) and when NC starts open (skip open)
|
||||||
|
- [ ] NC is always closed after command completes (verified on success and error paths)
|
||||||
|
- [ ] Index 0 is rejected at parse time with clear error (not panic)
|
||||||
|
- [ ] No AppleScript used anywhere in notification module
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- [ ] NC open-to-first-result latency < 1 second (excluding NC animation)
|
||||||
|
- [ ] No new dependencies added to core crate (notification types are pure serde structs)
|
||||||
|
- [ ] Binary size increase < 50KB (notification code is thin AX wrappers)
|
||||||
|
- [ ] `cargo tree -p agent-desktop-core` still contains zero platform crate names
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
- [ ] `cargo clippy --all-targets -- -D warnings` — zero warnings
|
||||||
|
- [ ] `cargo test --lib --workspace` — all tests pass
|
||||||
|
- [ ] `cargo fmt --all -- --check` — formatted
|
||||||
|
- [ ] New commands follow existing patterns exactly (file structure, naming, error handling)
|
||||||
|
- [ ] No `unwrap()` in non-test code
|
||||||
|
- [ ] All files under 400 LOC
|
||||||
|
- [ ] `typed batch path` split before adding new commands (if currently over 400 LOC)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- Agents can detect, read, and dismiss notifications in automated workflows
|
||||||
|
- `list-notifications` on a typical NC (5-15 notifications) completes in < 2 seconds
|
||||||
|
- `wait --notification` detects new notifications within 5 seconds (one poll cycle + overhead)
|
||||||
|
- Zero regressions in existing 50-command test suite
|
||||||
|
|
||||||
|
## Dependencies & Prerequisites
|
||||||
|
|
||||||
|
- macOS 14+ (Sonoma) — primary target. macOS 15 (Sequoia) tested with extra nesting handling. Ventura (13) best-effort.
|
||||||
|
- Accessibility permission already granted (same as all existing commands)
|
||||||
|
- No new crate dependencies — uses existing `accessibility-sys`, `core-foundation`, `core-graphics` FFI
|
||||||
|
|
||||||
|
## Risk Analysis & Mitigation
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| NC AX tree structure changes across macOS versions | High | High | Heuristic matching by role/subrole patterns (`AXNotificationCenterAlert`, `AXNotificationCenterAlertStack`), not hardcoded paths. Golden fixtures for Sonoma + Sequoia. |
|
||||||
|
| Sequoia extra nesting level breaks parser | High | High | Variable-depth traversal with recursive heuristic matching. Test on both macOS 14 and 15. |
|
||||||
|
| "Clear All" / close buttons hidden until hover | High | Medium | Synthesize mouse hover event before looking for button. 200ms wait for button to appear. |
|
||||||
|
| Collapsed notification groups return headers not individual notifications | Medium | Medium | V1: return group header with `"grouped": true` hint. V2: add `--expand` flag. |
|
||||||
|
| NC animation timing causes empty/partial reads | Medium | Medium | Wait for AX tree population after open (poll for children, max 2s, 50ms interval). |
|
||||||
|
| NC left open after error/crash | Low | Medium | RAII NcSession guard with explicit close() + Drop fallback. `panic=abort` means Drop won't run on panic — acceptable risk. |
|
||||||
|
| 3-second poll interval too slow for wait | Low | Medium | `--poll-interval` flag allows agents to tune. Default is conservative. |
|
||||||
|
| typed batch path over 400 LOC | High | Low | Split typed batch path before adding notification commands. |
|
||||||
|
|
||||||
|
## Documentation Plan
|
||||||
|
|
||||||
|
- [ ] Update `docs/phases.md` — mark notification commands as implemented for macOS
|
||||||
|
- [ ] Update `README.md` — add notification commands to command reference table
|
||||||
|
- [ ] Update `.claude/skills/agent-desktop/` — add notification command documentation
|
||||||
|
- [ ] Add golden fixtures to `tests/fixtures/` for NC AX tree structure (Sonoma + Sequoia)
|
||||||
|
- [ ] Add security note to docs: notification content may contain sensitive data
|
||||||
|
|
||||||
|
## V2 Backlog (Deferred from v1)
|
||||||
|
|
||||||
|
Items explicitly deferred during deepening to keep v1 minimal:
|
||||||
|
|
||||||
|
| Item | Source | Rationale for deferral |
|
||||||
|
|------|--------|----------------------|
|
||||||
|
| NSDistributedNotificationCenter observer | code-simplicity, security-sentinel | Thread-safety risk, undocumented API. Polling is reliable. |
|
||||||
|
| Focus mode / DND detection | code-simplicity | Requires CoreFoundation preferences API. Not essential for core notification ops. |
|
||||||
|
| Inline action UI detection | code-simplicity | "Reply" text field handling is edge case. V1 clicks button and returns success. |
|
||||||
|
| Auto-expansion of collapsed groups | code-simplicity | Adds timing complexity and visual disruption. V1 returns group headers. |
|
||||||
|
| Content-based TOCTOU verification | agent-native-reviewer | `--verify-app`/`--verify-title` args for cross-invocation index safety. V1 is safe within single invocation. |
|
||||||
|
| `total_count` + `has_more` in list response | agent-native-reviewer | Useful for pagination. V1 returns all matching (with --limit). |
|
||||||
|
| `click-notification` body action (open source app) | agent-native-reviewer | Different from action button click. Needs research on AX default action. |
|
||||||
|
| `--poll-interval` auto-tuning | performance-oracle | Adaptive interval based on NC change frequency. V1 uses fixed interval. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
### Origin
|
||||||
|
|
||||||
|
- **Brainstorm document:** [docs/brainstorms/2026-02-27-notification-management-brainstorm.md](../brainstorms/2026-02-27-notification-management-brainstorm.md) — Key decisions carried forward: macOS first, AX-based approach, positional index identification, flat list (no grouping), NC-only (no banners), heuristic AX matching
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
|
||||||
|
- Adapter trait pattern: `crates/core/src/adapter.rs:114-216`
|
||||||
|
- Domain type pattern (SurfaceInfo): `crates/core/src/node.rs:84-92`
|
||||||
|
- Command handler pattern: `crates/core/src/commands/clipboard_get.rs`, `list_surfaces.rs`
|
||||||
|
- Error code pattern: `crates/core/src/error.rs:4-18`, `53-116`
|
||||||
|
- macOS adapter delegation: `crates/macos/src/adapter.rs`
|
||||||
|
- cfg-gated imp pattern: `crates/macos/src/input/clipboard.rs`
|
||||||
|
- Wait command: `crates/core/src/commands/wait.rs:12-55`
|
||||||
|
- AppleScript pattern (has injection vulnerability): `crates/macos/src/system/app_ops.rs:157-196`
|
||||||
|
- CLI registration: `src/cli.rs`, `src/cli_args.rs`, `src/dispatch.rs`
|
||||||
|
- AX-first activation pattern: `docs/brainstorms/2026-02-23-macos-ax-first-robustness-brainstorm.md`
|
||||||
|
|
||||||
|
### Review Agent Sources
|
||||||
|
|
||||||
|
- **architecture-strategist:** Incremental CLI wiring, return type consistency, method count reduction
|
||||||
|
- **performance-oracle:** Batch AX attribute fetch, early filter application, fire-and-forget close, AX timeout setting
|
||||||
|
- **security-sentinel:** Index 0 crash, AppleScript injection, notification content sensitivity, observer thread safety
|
||||||
|
- **code-simplicity-reviewer:** Observer removal, adapter method reduction, v2 backlog items
|
||||||
|
- **pattern-recognition-specialist:** Missing derives, --app on dismiss, naming conventions, typed batch path LOC
|
||||||
|
- **agent-native-reviewer:** TOCTOU verification, total_count/has_more, click-notification-body, poll-interval
|
||||||
|
- **best-practices-researcher:** NC process name, Sequoia nesting, hover-only buttons, attributed strings, RAII patterns
|
||||||
|
|
||||||
|
### Files to Create
|
||||||
|
|
||||||
|
| File | Purpose | Est. LOC |
|
||||||
|
|------|---------|----------|
|
||||||
|
| `crates/core/src/notification.rs` | NotificationInfo, NotificationFilter structs | ~40 |
|
||||||
|
| `crates/core/src/commands/list_notifications.rs` | list-notifications command handler | ~30 |
|
||||||
|
| `crates/core/src/commands/dismiss_notification.rs` | dismiss-notification command handler | ~30 |
|
||||||
|
| `crates/core/src/commands/dismiss_all_notifications.rs` | dismiss-all-notifications command handler | ~40 |
|
||||||
|
| `crates/core/src/commands/notification_action.rs` | notification-action command handler | ~30 |
|
||||||
|
| `crates/macos/src/notifications/mod.rs` | Module re-exports | ~15 |
|
||||||
|
| `crates/macos/src/notifications/nc_session.rs` | RAII NC lifecycle guard | ~120 |
|
||||||
|
| `crates/macos/src/notifications/list.rs` | AX tree traversal of NC | ~200 |
|
||||||
|
| `crates/macos/src/notifications/actions.rs` | Dismiss + interact combined | ~150 |
|
||||||
|
|
||||||
|
**Total new code: ~655 LOC** (reduced from ~840 in original plan by removing observer.rs and merging dismiss+interact)
|
||||||
|
|
||||||
|
### Files to Modify (Registration Points Only)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `crates/core/src/lib.rs` | `pub mod notification;` + re-export |
|
||||||
|
| `crates/core/src/adapter.rs` | +3 trait methods with `not_supported` defaults |
|
||||||
|
| `crates/core/src/error.rs` | +1 ErrorCode variant + constructor |
|
||||||
|
| `crates/core/src/commands/mod.rs` | +4 `pub mod` declarations |
|
||||||
|
| `crates/core/src/commands/wait.rs` | +`notification` field on WaitArgs + handler branch |
|
||||||
|
| `crates/macos/src/lib.rs` | `pub mod notifications;` |
|
||||||
|
| `crates/macos/src/adapter.rs` | +3 trait method implementations |
|
||||||
|
| `src/cli.rs` | +4 Commands variants + name() arms |
|
||||||
|
| `src/cli_args.rs` | +4 arg structs + notification/poll-interval fields on WaitArgs |
|
||||||
|
| `src/dispatch.rs` | +4 match arms + extend Wait arm |
|
||||||
|
| `src/typed batch path` | +4 command routing arms (split file first if over 400 LOC) |
|
||||||
148
docs/plans/2026-03-01-feat-compact-tree-collapsing-plan.md
Normal file
148
docs/plans/2026-03-01-feat-compact-tree-collapsing-plan.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
---
|
||||||
|
title: "feat: implement --compact flag for tree chain collapsing"
|
||||||
|
type: feat
|
||||||
|
status: completed
|
||||||
|
date: 2026-03-01
|
||||||
|
origin: docs/brainstorms/2026-03-01-electron-tree-compaction-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: implement --compact flag for tree chain collapsing
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `--compact` flag is fully wired through CLI → TreeOptions → snapshot pipeline but is a no-op. Implement it to collapse single-child non-interactive pass-through nodes, reducing structural noise in the JSON tree by ~15% tokens. Primary beneficiary: Electron/web apps (Slack, VS Code) where HTML `<div>` wrappers create deep group chains.
|
||||||
|
|
||||||
|
(see brainstorm: docs/brainstorms/2026-03-01-electron-tree-compaction-brainstorm.md)
|
||||||
|
|
||||||
|
## Collapse Rule
|
||||||
|
|
||||||
|
A node is collapsible when ALL of these are true:
|
||||||
|
- `ref_id` is `None` (not interactive)
|
||||||
|
- `name` is `None` (unnamed)
|
||||||
|
- `value` is `None` (no value)
|
||||||
|
- `description` is `None` (no description)
|
||||||
|
- `states` is empty (no semantic state like "disabled")
|
||||||
|
- Has exactly **1 child**
|
||||||
|
- Is **not the root node**
|
||||||
|
|
||||||
|
When a node is collapsible, it is removed and its single child is hoisted to take its place. Cascading is natural: bottom-up recursion means inner collapses happen first, potentially making outer nodes collapsible too.
|
||||||
|
|
||||||
|
**Deliberately excluded: name promotion.** The brainstorm considered promoting a wrapper's name to a nameless child, but this risks contaminating `RefEntry.name` and causing `STALE_REF` on re-identification. The 3% additional savings is not worth the correctness risk.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `--compact` collapses single-child unnamed pass-through nodes in snapshot output
|
||||||
|
- [x] All interactive refs preserved (zero information loss)
|
||||||
|
- [x] Root node never collapsed
|
||||||
|
- [x] Nodes with `description` or non-empty `states` are never collapsed
|
||||||
|
- [x] Cascading collapse works (group > group > group > button → button)
|
||||||
|
- [x] Works correctly when combined with `--interactive-only`
|
||||||
|
- [x] Works correctly in batch mode (`{"command": "snapshot", "args": {"compact": true}}`)
|
||||||
|
- [x] `--compact` alone (without `-i`) also works
|
||||||
|
- [x] Clippy clean, fmt clean, all existing tests pass
|
||||||
|
- [x] New unit tests cover: cascading, description preservation, states preservation, compact+interactive_only
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### `crates/core/src/snapshot.rs`
|
||||||
|
|
||||||
|
**1. Add helper predicate** (~10 LOC):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn is_collapsible(node: &AccessibilityNode) -> bool {
|
||||||
|
node.ref_id.is_none()
|
||||||
|
&& node.name.is_none()
|
||||||
|
&& node.value.is_none()
|
||||||
|
&& node.description.is_none()
|
||||||
|
&& node.states.is_empty()
|
||||||
|
&& node.children.len() == 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Add `compact: bool` param to `allocate_refs`** — extend signature alongside existing `interactive_only`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn allocate_refs(
|
||||||
|
mut node: AccessibilityNode,
|
||||||
|
refmap: &mut RefMap,
|
||||||
|
include_bounds: bool,
|
||||||
|
interactive_only: bool,
|
||||||
|
compact: bool, // new
|
||||||
|
window_pid: i32,
|
||||||
|
source_app: Option<&str>,
|
||||||
|
) -> AccessibilityNode
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Add compact logic in the existing `filter_map`** — after children are recursed, before the interactive_only check:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
node.children = node
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|child| {
|
||||||
|
let child = allocate_refs(child, refmap, include_bounds, interactive_only, compact, ...);
|
||||||
|
|
||||||
|
// Compact: hoist single child of unnamed pass-through containers
|
||||||
|
if compact && is_collapsible(&child) {
|
||||||
|
return child.children.into_iter().next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive-only: prune non-interactive leaves
|
||||||
|
if interactive_only && child.ref_id.is_none() && child.children.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(child)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
```
|
||||||
|
|
||||||
|
Order: compact fires first (hoists child), then interactive_only may prune the hoisted child if it's a non-interactive leaf. This is correct.
|
||||||
|
|
||||||
|
**4. Thread `compact` through the call site** in `build()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut tree = allocate_refs(raw_tree, &mut refmap, opts.include_bounds, opts.interactive_only, opts.compact, ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Update tracing log** in `snapshot::execute()` to include `compact={}`.
|
||||||
|
|
||||||
|
### `src/cli_args.rs`
|
||||||
|
|
||||||
|
**6. Update help text** from "Omit empty structural nodes from output" to "Collapse single-child unnamed nodes to reduce tree depth":
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[arg(long, help = "Collapse single-child unnamed nodes to reduce tree depth")]
|
||||||
|
pub compact: bool,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests — `crates/core/src/snapshot.rs` (unit tests)
|
||||||
|
|
||||||
|
**7. Add tests** (~60 LOC):
|
||||||
|
|
||||||
|
- `test_compact_collapses_single_child_chain` — group > group > group > button → button
|
||||||
|
- `test_compact_preserves_named_containers` — group("Sidebar") > button stays
|
||||||
|
- `test_compact_preserves_description` — group(desc="toolbar") > button stays
|
||||||
|
- `test_compact_preserves_states` — group(states=["disabled"]) > button stays
|
||||||
|
- `test_compact_preserves_multi_child` — group > (button + textfield) stays
|
||||||
|
- `test_compact_with_interactive_only` — both flags together work correctly
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `crates/core/src/snapshot.rs` | Add `is_collapsible`, add `compact` param to `allocate_refs`, compact logic in filter_map, thread through call site, unit tests |
|
||||||
|
| `src/cli_args.rs` | Update `--compact` help text |
|
||||||
|
|
||||||
|
## Not Changing
|
||||||
|
|
||||||
|
- `append_surface_refs` — already uses `interactive_only: true`, compact adds negligible value for surface overlays
|
||||||
|
- `node.rs` — no struct changes needed
|
||||||
|
- `hints.rs` — runs after compact, indexes are correct post-collapse
|
||||||
|
- macOS adapter (`builder.rs`) — compact is a core-level tree transform, not platform-level
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- **Origin brainstorm:** [docs/brainstorms/2026-03-01-electron-tree-compaction-brainstorm.md](../brainstorms/2026-03-01-electron-tree-compaction-brainstorm.md) — key decisions: no separate bridge package, chain collapsing over flattening, universal optimization not Electron-specific
|
||||||
|
- Existing pattern: `interactive_only` filter in `snapshot.rs:191`
|
||||||
|
- Existing pattern: `add_structural_hints` in `hints.rs` (post-processing pass)
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
---
|
||||||
|
title: "feat: scalable skill architecture with ClawHub auto-publishing"
|
||||||
|
type: feat
|
||||||
|
status: completed
|
||||||
|
date: 2026-03-02
|
||||||
|
origin: docs/brainstorms/2026-03-02-clawhub-skill-publishing-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: scalable skill architecture with ClawHub auto-publishing
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Restructure the `skills/` directory into a nested hierarchy (core → platform → app references), sync stale skill content from `.agents/` to `skills/`, add ClawHub metadata to all SKILL.md files, create a symlink script for local development, and wire CI auto-publishing to ClawHub on every release.
|
||||||
|
|
||||||
|
(see brainstorm: docs/brainstorms/2026-03-02-clawhub-skill-publishing-brainstorm.md)
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
1. **Skills are stale** — `skills/agent-desktop/` has 50 commands documented, but `.agents/skills/agent-desktop/` has 54 (notifications added Feb 27). Git-tracked version lags.
|
||||||
|
2. **macOS skill not publishable** — lives in `.claude/skills/agent-desktop-macos/` (gitignored), not in `skills/`
|
||||||
|
3. **No publishing pipeline** — ClawHub integration doesn't exist. Manual publish only.
|
||||||
|
4. **No scaffolding** — adding a new platform or app-specific skill has no clear pattern or automation.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Directory Structure (Target)
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/
|
||||||
|
├── agent-desktop/ # Core skill (platform-agnostic)
|
||||||
|
│ ├── SKILL.md # Commands, observe-act loop, ref system, JSON contract
|
||||||
|
│ └── references/
|
||||||
|
│ ├── commands-observation.md
|
||||||
|
│ ├── commands-interaction.md
|
||||||
|
│ ├── commands-system.md
|
||||||
|
│ └── workflows.md
|
||||||
|
│
|
||||||
|
├── agent-desktop-macos/ # macOS platform skill
|
||||||
|
│ ├── SKILL.md # TCC, AX API, smart activation chain, surfaces, NC
|
||||||
|
│ └── references/
|
||||||
|
│ └── notifications.md # NC lifecycle, dismiss strategies (extracted from SKILL.md)
|
||||||
|
│
|
||||||
|
scripts/
|
||||||
|
└── link-skills.sh # Symlinks skills/ → .claude/skills/ for local dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI Pipeline Addition
|
||||||
|
|
||||||
|
New `publish-skills` job in `.github/workflows/release.yml` after `publish-npm`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `skills/agent-desktop/` synced to match `.agents/skills/agent-desktop/` (54 commands, notifications section)
|
||||||
|
- [x] `skills/agent-desktop-macos/` created from `.claude/skills/agent-desktop-macos/` (git-tracked)
|
||||||
|
- [x] `references/macos.md` removed from core skill (moved to platform skill)
|
||||||
|
- [x] Core skill SKILL.md reference table updated (no macos.md row)
|
||||||
|
- [x] ClawHub metadata added to all SKILL.md frontmatters (`version`, `tags`, `requirements`)
|
||||||
|
- [x] `scripts/link-skills.sh` created and working
|
||||||
|
- [x] `publish-skills` job added to `.github/workflows/release.yml`
|
||||||
|
- [x] npm postinstall prompts user to install Claude Code skills (with platform auto-detection)
|
||||||
|
- [x] All SKILL.md files reviewed using `/skill-creator` skill for best practices
|
||||||
|
- [x] All existing tests still pass
|
||||||
|
- [x] Clippy clean, fmt clean
|
||||||
|
|
||||||
|
## Build Guidance
|
||||||
|
|
||||||
|
**Use the `/skill-creator` skill** when writing or updating any SKILL.md file. It provides best practices for frontmatter structure, trigger keywords, reference file organization, and description quality. Invoke it before finalizing each skill to ensure the content meets Claude Code skill standards.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Phase 1: Sync & Restructure Skills Directory
|
||||||
|
|
||||||
|
#### 1.1 Sync core skill from `.agents/` to `skills/`
|
||||||
|
|
||||||
|
The `.agents/skills/agent-desktop/` directory is the most up-to-date version (54 commands, includes notifications). Copy it over the stale `skills/agent-desktop/`.
|
||||||
|
|
||||||
|
**Files to sync:**
|
||||||
|
|
||||||
|
| Source (`.agents/skills/agent-desktop/`) | Destination (`skills/agent-desktop/`) |
|
||||||
|
|---|---|
|
||||||
|
| `SKILL.md` | `SKILL.md` (overwrite — adds notifications section) |
|
||||||
|
| `references/commands-observation.md` | `references/commands-observation.md` |
|
||||||
|
| `references/commands-interaction.md` | `references/commands-interaction.md` |
|
||||||
|
| `references/commands-system.md` | `references/commands-system.md` (adds notification commands) |
|
||||||
|
| `references/workflows.md` | `references/workflows.md` |
|
||||||
|
|
||||||
|
**After sync, delete:** `skills/agent-desktop/references/macos.md` — this content moves to the platform skill.
|
||||||
|
|
||||||
|
**Update:** `skills/agent-desktop/SKILL.md` reference table — remove the `macos.md` row.
|
||||||
|
|
||||||
|
#### 1.2 Move macOS skill to `skills/`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p skills/agent-desktop-macos/references/
|
||||||
|
cp .claude/skills/agent-desktop-macos/SKILL.md skills/agent-desktop-macos/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If the macOS SKILL.md contains a Notification Center section that's large enough to be a reference, extract it to `skills/agent-desktop-macos/references/notifications.md` and reference it from the SKILL.md table. Otherwise keep it inline.
|
||||||
|
|
||||||
|
### Phase 2: Add ClawHub Metadata
|
||||||
|
|
||||||
|
#### 2.1 Core skill frontmatter
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# skills/agent-desktop/SKILL.md
|
||||||
|
---
|
||||||
|
name: agent-desktop
|
||||||
|
version: 0.1.8
|
||||||
|
tags: desktop-automation, accessibility, ai-agent, gui-automation, cli
|
||||||
|
requirements:
|
||||||
|
- agent-desktop
|
||||||
|
description: >
|
||||||
|
Desktop automation via native OS accessibility trees...
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 macOS skill frontmatter
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# skills/agent-desktop-macos/SKILL.md
|
||||||
|
---
|
||||||
|
name: agent-desktop-macos
|
||||||
|
version: 0.1.8
|
||||||
|
tags: desktop-automation, macos, accessibility, ax-api, tcc-permissions
|
||||||
|
requirements:
|
||||||
|
- agent-desktop
|
||||||
|
description: >
|
||||||
|
macOS platform details for agent-desktop...
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Create Scaffolding Scripts
|
||||||
|
|
||||||
|
#### 3.1 `scripts/link-skills.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Links skills/ directories to .claude/skills/ for local Claude Code use.
|
||||||
|
# Run after clone or when adding new skills.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
CLAUDE_SKILLS="$REPO_ROOT/.claude/skills"
|
||||||
|
|
||||||
|
mkdir -p "$CLAUDE_SKILLS"
|
||||||
|
|
||||||
|
for skill_dir in "$REPO_ROOT"/skills/*/; do
|
||||||
|
name=$(basename "$skill_dir")
|
||||||
|
target="../../skills/$name"
|
||||||
|
link="$CLAUDE_SKILLS/$name"
|
||||||
|
|
||||||
|
if [ -L "$link" ]; then
|
||||||
|
rm "$link"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -s "$target" "$link"
|
||||||
|
echo "Linked: .claude/skills/$name → skills/$name"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Post-Install Skill Prompt
|
||||||
|
|
||||||
|
#### 4.1 Extend `npm/scripts/postinstall.js`
|
||||||
|
|
||||||
|
After the binary download succeeds, detect the platform and prompt the user to install the agent-desktop Claude Code skills. This runs in the terminal so we can use stdin.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// npm/scripts/postinstall.js — append after binary install success
|
||||||
|
|
||||||
|
function promptSkillInstall() {
|
||||||
|
const os = require('os');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
// Check if Claude Code CLI is available
|
||||||
|
try {
|
||||||
|
execSync('claude --version', { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
log('Tip: Install Claude Code skills for agent-desktop with:');
|
||||||
|
log(' claude /plugin marketplace add lahfir/agent-desktop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect platform for the right skill
|
||||||
|
const plat = os.platform();
|
||||||
|
const platformSkill = {
|
||||||
|
darwin: 'agent-desktop-macos',
|
||||||
|
win32: 'agent-desktop-windows',
|
||||||
|
linux: 'agent-desktop-linux',
|
||||||
|
}[plat];
|
||||||
|
|
||||||
|
log('');
|
||||||
|
log('Claude Code skills available for agent-desktop!');
|
||||||
|
log('Install with:');
|
||||||
|
log(' claude /plugin marketplace add lahfir/agent-desktop');
|
||||||
|
if (platformSkill) {
|
||||||
|
log(` claude /plugin install ${platformSkill}@lahfir-agent-desktop`);
|
||||||
|
}
|
||||||
|
log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
promptSkillInstall();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design decision:** Print install instructions rather than auto-running `claude` commands. Postinstall scripts should not modify the user's Claude Code config without explicit consent. The user copies and runs the commands themselves.
|
||||||
|
|
||||||
|
#### 4.2 Platform detection mapping
|
||||||
|
|
||||||
|
| `os.platform()` | Core skill | Platform skill |
|
||||||
|
|---|---|---|
|
||||||
|
| `darwin` | `agent-desktop` | `agent-desktop-macos` |
|
||||||
|
| `win32` | `agent-desktop` | `agent-desktop-windows` |
|
||||||
|
| `linux` | `agent-desktop` | `agent-desktop-linux` |
|
||||||
|
|
||||||
|
### Phase 5: CI Auto-Publishing
|
||||||
|
|
||||||
|
#### 5.1 Add `publish-skills` job to `release.yml`
|
||||||
|
|
||||||
|
Add after the `publish-npm` job:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/release.yml
|
||||||
|
publish-skills:
|
||||||
|
needs: [release-please]
|
||||||
|
if: needs.release-please.outputs.release_created == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install ClawHub CLI
|
||||||
|
run: npm i -g clawhub
|
||||||
|
|
||||||
|
- name: Publish all skills to ClawHub
|
||||||
|
run: |
|
||||||
|
clawhub sync \
|
||||||
|
--root skills/ \
|
||||||
|
--all \
|
||||||
|
--bump patch \
|
||||||
|
--changelog "Release ${{ needs.release-please.outputs.tag_name }}"
|
||||||
|
env:
|
||||||
|
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Add `CLAWHUB_TOKEN` to GitHub repo secrets
|
||||||
|
|
||||||
|
Manual step: generate token at clawhub.ai, add to repo settings → Secrets → Actions.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `skills/agent-desktop/SKILL.md` | Sync from `.agents/`, add ClawHub metadata, remove macos.md reference |
|
||||||
|
| `skills/agent-desktop/references/commands-system.md` | Sync from `.agents/` (adds notification commands) |
|
||||||
|
| `skills/agent-desktop/references/macos.md` | **Delete** (moved to platform skill) |
|
||||||
|
| `skills/agent-desktop-macos/SKILL.md` | **New** — moved from `.claude/skills/`, add ClawHub metadata |
|
||||||
|
| `skills/agent-desktop-macos/references/notifications.md` | **New** — NC details extracted if SKILL.md is too large |
|
||||||
|
| `scripts/link-skills.sh` | **New** — symlink automation |
|
||||||
|
| `npm/scripts/postinstall.js` | Add skill install prompt after binary download |
|
||||||
|
| `.github/workflows/release.yml` | Add `publish-skills` job |
|
||||||
|
|
||||||
|
## Ongoing Convention: Skills Updated With Every Feature
|
||||||
|
|
||||||
|
This plan establishes a **permanent convention**: every new feature, command, or platform change must update the corresponding skill files. This is enforced via:
|
||||||
|
|
||||||
|
1. **Memory rule** — added to `MEMORY.md` under "Skill Maintenance (MANDATORY)" so it's loaded into every conversation context. Claude will automatically update skills as part of feature work.
|
||||||
|
2. **PRD addendum** — `docs/prd-addendum-skill-maintenance.md` has detailed per-phase rules.
|
||||||
|
3. **`/skill-creator` skill** — used when writing or updating any SKILL.md to ensure quality.
|
||||||
|
|
||||||
|
| Change Type | Skill Update Required |
|
||||||
|
|---|---|
|
||||||
|
| New CLI command | Add to `skills/agent-desktop/references/commands-*.md` |
|
||||||
|
| New platform adapter | Create `skills/agent-desktop-{platform}/SKILL.md` + `references/` |
|
||||||
|
| App-specific quirk discovered | Add `skills/agent-desktop-{platform}/references/{app}.md` |
|
||||||
|
| Changed CLI flags or JSON output | Update all affected skill files |
|
||||||
|
| New workflow pattern | Add to `skills/agent-desktop/references/workflows.md` |
|
||||||
|
|
||||||
|
## Not Changing
|
||||||
|
|
||||||
|
- `crates/` — no Rust code changes
|
||||||
|
- `src/` — no binary changes
|
||||||
|
- `release-please-config.json` — skill versions are managed by ClawHub CLI `--bump`, not release-please
|
||||||
|
- `.gitignore` — `skills/` is already tracked, `.claude/` is already ignored
|
||||||
|
|
||||||
|
## Dependencies & Risks
|
||||||
|
|
||||||
|
- **CLAWHUB_TOKEN secret required** — CI job will fail without it. Must be configured before first automated release.
|
||||||
|
- **ClawHub automated review** — published skills go through syntax validation and permission scanning (<5 min). If rejected, need to fix and re-publish.
|
||||||
|
- **First publish should be manual** — run `clawhub sync --root skills/ --all --dry-run` locally to verify before relying on CI.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- **Origin brainstorm:** [docs/brainstorms/2026-03-02-clawhub-skill-publishing-brainstorm.md](../brainstorms/2026-03-02-clawhub-skill-publishing-brainstorm.md) — key decisions: nested hierarchy, CI auto-publish, platform > apps scoping
|
||||||
|
- Existing CI pattern: `.github/workflows/release.yml` — 4-job pipeline (release-please → build → publish-github → publish-npm)
|
||||||
|
- Skill maintenance rules: `docs/prd-addendum-skill-maintenance.md`
|
||||||
|
- ClawHub CLI: [docs.openclaw.ai/tools/clawhub](https://docs.openclaw.ai/tools/clawhub)
|
||||||
|
|
@ -0,0 +1,480 @@
|
||||||
|
---
|
||||||
|
title: "feat: progressive skeleton traversal with ref-rooted drill-down"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-03-10
|
||||||
|
deepened: 2026-03-10
|
||||||
|
origin: docs/brainstorms/2026-03-10-progressive-skeleton-traversal-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Progressive Skeleton Traversal
|
||||||
|
|
||||||
|
## Enhancement Summary
|
||||||
|
|
||||||
|
**Deepened on:** 2026-03-10
|
||||||
|
**Agents used:** architecture-strategist, performance-oracle, security-sentinel, pattern-recognition-specialist, code-simplicity-reviewer, agent-native-reviewer, data-integrity-guardian, best-practices-researcher
|
||||||
|
|
||||||
|
### Key Improvements from Review
|
||||||
|
|
||||||
|
1. **Scope reduction** — Cut `epoch` (redundant with per-element staleness), `new_ref_count` (agent computes delta), `mode` response field (agent knows what it asked for). Defer `find --root` and `--skeleton --root` combo to follow-up.
|
||||||
|
2. **Naming fix** — Rename `drill_down()` to `run_from_ref()` for consistency with existing `build()`/`run()` pattern.
|
||||||
|
3. **Performance** — Add `count_children()` using raw `CFArrayGetCount` to avoid N `CFRetain`/`CFRelease` pairs at skeleton boundary.
|
||||||
|
4. **Data integrity** — Add write-side size check to `RefMap::save()` to prevent >1MB files that would fail on next load.
|
||||||
|
5. **LOC management** — `snapshot.rs` is at 354 LOC. Extract ref-rooted logic to `snapshot_ref.rs` to stay under 400.
|
||||||
|
6. **Security** — Document TOCTOU race on refmap as known limitation, defer file locking to Phase 4.
|
||||||
|
|
||||||
|
### Simplifications Applied
|
||||||
|
|
||||||
|
| Original | Decision | Rationale |
|
||||||
|
|----------|----------|-----------|
|
||||||
|
| `epoch: u32` on RefEntry/RefMap | **Cut** | Existing per-element re-identification via `(pid, role, name, bounds_hash)` already handles staleness. Epoch adds a second, redundant mechanism. |
|
||||||
|
| `new_ref_count` in response | **Cut** | Agent can diff `ref_count` across calls if needed. One number is sufficient. |
|
||||||
|
| `mode` field in response | **Cut** | Agent knows what it passed (`--skeleton` or `--root`). Presence of `children_count` is the structural signal. |
|
||||||
|
| `find --root` | **Deferred** | Scope creep. Agent can do `snapshot --root @e3` then `find` separately. Follow-up PR. |
|
||||||
|
| `--skeleton --root` combo | **Deferred** | Edge case for very deep subtrees. Add in follow-up if agents need it. Reduces testing matrix. |
|
||||||
|
| `increment_epoch()` | **Cut** | No epoch to increment. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a two-phase incremental tree traversal system that lets AI agents explore dense accessibility trees progressively — skeleton overview first, then targeted drill-downs — instead of consuming the full tree every time. This introduces two new `snapshot` flags (`--skeleton`, `--root @ref`), refmap merging with `root_ref`-tagged scoped invalidation, and a new `PlatformAdapter::get_subtree()` trait method.
|
||||||
|
|
||||||
|
Token savings for Slack: **73-96%** vs current full snapshot (see brainstorm: `docs/brainstorms/2026-03-10-progressive-skeleton-traversal-brainstorm.md`).
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Dense Electron apps (Slack, VS Code, Discord) produce massive accessibility trees — 500+ nodes, ~12,000 tokens per full snapshot. Even with `--interactive-only --compact`, Slack still costs ~5,400 tokens (~204 nodes). Agents pay per-token and struggle to navigate large trees efficiently.
|
||||||
|
|
||||||
|
Current optimizations (depth-skip, compact, surfaces, interactive-only) reduce output size but still require full tree traversal from the window root. There is no way to:
|
||||||
|
- Get a cheap structural overview of an app
|
||||||
|
- Drill into a specific region without re-traversing the entire tree
|
||||||
|
- Accumulate knowledge across multiple targeted observations
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Two new capabilities on the `snapshot` command:
|
||||||
|
|
||||||
|
1. **`--skeleton`** — Shallow overview (default depth 3) where truncated nodes show `children_count` instead of full subtrees. Named containers at the truncation boundary receive refs as drill-down targets.
|
||||||
|
|
||||||
|
2. **`--root @ref`** — Start traversal from a previously-discovered ref element instead of the window root. Merges new refs into the existing refmap with `root_ref`-tagged scoped invalidation.
|
||||||
|
|
||||||
|
These compose with all existing flags (`--compact`, `--interactive-only`, `--max-depth`, `--surface`).
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
The feature touches three layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI (cli_args.rs, dispatch.rs, typed batch path)
|
||||||
|
↓ --skeleton, --root flags
|
||||||
|
Core (adapter.rs, snapshot.rs, snapshot_ref.rs [NEW], refs.rs, node.rs)
|
||||||
|
↓ TreeOptions, skeleton logic, merge logic, children_count
|
||||||
|
Platform (macos/adapter.rs, macos/tree/builder.rs, macos/tree/element.rs)
|
||||||
|
↓ get_subtree(), skeleton-aware build_subtree(), count_children()
|
||||||
|
```
|
||||||
|
|
||||||
|
One new file: `crates/core/src/snapshot_ref.rs` — extracted drill-down logic to keep `snapshot.rs` under 400 LOC. The dependency inversion principle is preserved — core defines the interface, platform implements it.
|
||||||
|
|
||||||
|
### Research Insights: Architecture
|
||||||
|
|
||||||
|
**From architecture-strategist:**
|
||||||
|
- The plan initially proposed `get_subtree()` as a separate trait method. An alternative is to extend `get_tree()` with an optional `&NativeHandle` parameter. However, `NativeHandle` contains a raw pointer and would break `TreeOptions`'s `Clone`/`Copy` derives. **Decision: keep `get_subtree()` as additive trait method** — both `get_tree()` and `get_subtree()` delegate to the same internal `build_subtree()` in each platform adapter, preventing behavior divergence.
|
||||||
|
- `snapshot.rs` is at 354 LOC. Adding `run_from_ref()` and merge logic would push it over 400. **Decision: extract to `snapshot_ref.rs`** with `pub(crate)` visibility, re-exported from `snapshot.rs`.
|
||||||
|
|
||||||
|
**From pattern-recognition-specialist:**
|
||||||
|
- `drill_down()` does not follow the verb-first naming convention (existing: `build()`, `run()`, `allocate_refs()`). **Renamed to `run_from_ref()`** to match the existing `run()` pattern.
|
||||||
|
- TreeOptions grows from 5 to 7 fields (at the CLAUDE.md boundary of 7). Acceptable since both new fields are simple primitives. If more fields are needed later, extract a `SnapshotMode` enum.
|
||||||
|
|
||||||
|
### Data Flow: Skeleton Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
snapshot --app Slack --skeleton -i
|
||||||
|
→ SnapshotArgs { skeleton: true, interactive_only: true }
|
||||||
|
→ TreeOptions { skeleton: true, interactive_only: true, ... }
|
||||||
|
→ snapshot::build() [existing path]
|
||||||
|
→ adapter.get_tree(window, opts) [skeleton flag in opts]
|
||||||
|
→ build_subtree(el, 0, 3, true, ...) [skeleton_depth=3]
|
||||||
|
→ At depth limit: count_children() [O(1), no per-element CFRetain]
|
||||||
|
→ Set children_count on node, return with empty children vec
|
||||||
|
→ allocate_refs(tree, &mut fresh_refmap, opts)
|
||||||
|
→ Interactive elements: assign refs (existing)
|
||||||
|
→ Named containers at boundary with children_count > 0: assign refs (NEW)
|
||||||
|
→ Pruning: skip if non-interactive AND children empty AND children_count is None
|
||||||
|
→ refmap.save() [full replace]
|
||||||
|
→ JSON response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow: Drill-Down Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
snapshot --root @e3 -i --compact
|
||||||
|
→ SnapshotArgs { root_ref: Some("@e3"), interactive_only: true, compact: true }
|
||||||
|
→ snapshot_ref::run_from_ref(adapter, opts, "@e3") [NEW function, NEW file]
|
||||||
|
→ resolve_ref("@e3") → (RefEntry, NativeHandle)
|
||||||
|
→ Derive app/window context from RefEntry.pid
|
||||||
|
→ adapter.get_subtree(handle, opts) [NEW trait method]
|
||||||
|
→ macOS: ManuallyDrop AXElement from handle, build_subtree()
|
||||||
|
→ existing_refmap = RefMap::load()
|
||||||
|
→ existing_refmap.remove_by_root_ref("@e3") [scoped invalidation]
|
||||||
|
→ allocate_refs(subtree, &mut existing_refmap, opts) [counter continues]
|
||||||
|
→ New refs tagged with root_ref: "@e3"
|
||||||
|
→ existing_refmap.save() [merged, with write-side size check]
|
||||||
|
→ JSON response with ref_count (total)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Research Insights: Performance
|
||||||
|
|
||||||
|
**From performance-oracle:**
|
||||||
|
- **Children count at skeleton boundary**: `copy_children()` triggers a Mach IPC call that materializes the entire CFArray of child AXUIElementRefs. Even calling `.len()` pays full IPC + CFArray allocation. **Recommendation: Add a `count_children()` function** that calls `AXUIElementCopyAttributeValue` then `CFArrayGetCount` on the raw `CFArrayRef`, then releases — without materializing per-element `AXElement` wrappers. Saves N `CFRetain`/`CFRelease` pairs per truncation boundary node.
|
||||||
|
- **RefMap file I/O**: ~3-4ms for load+save of a 500-entry refmap (50-80KB). Negligible against 50-200ms total CLI invocation time. No optimization needed.
|
||||||
|
- **Counter collision**: Safe — counter continues from loaded refmap. `allocate()` increments before formatting, so first new ref is `@e{loaded_counter+1}`.
|
||||||
|
- **Branch cost of `skeleton: bool` in recursive `build_subtree()`**: Near-zero. Branch prediction handles it (the flag is constant across the entire recursive call tree).
|
||||||
|
- **Net savings**: CLI startup is ~10ms. Even with 3 invocations (skeleton + 2 drill-downs), total overhead is ~30ms vs ~5,400 tokens saved. Token cost dominates by orders of magnitude.
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
#### Phase 1: Data Model Foundation
|
||||||
|
|
||||||
|
Add new fields to core types. No behavioral changes yet — all new fields are optional with serde defaults for backward compatibility.
|
||||||
|
|
||||||
|
**`crates/core/src/node.rs`** — AccessibilityNode:
|
||||||
|
- [ ] Add `children_count: Option<u32>` with `#[serde(skip_serializing_if = "Option::is_none", default)]`
|
||||||
|
- [ ] Place before `children` field (line ~29)
|
||||||
|
- [ ] Update test helper `make_node()` in `snapshot.rs` to include field
|
||||||
|
|
||||||
|
**`crates/core/src/refs.rs`** — RefEntry:
|
||||||
|
- [ ] Add `root_ref: Option<String>` with `#[serde(skip_serializing_if = "Option::is_none", default)]`
|
||||||
|
|
||||||
|
**`crates/core/src/refs.rs`** — RefMap:
|
||||||
|
- [ ] Add `fn remove_by_root_ref(&mut self, root: &str)` — `self.inner.retain(|_, v| v.root_ref.as_deref() != Some(root))`
|
||||||
|
- [ ] Update `allocate()` to accept `root_ref: Option<&str>` parameter, store on each new `RefEntry`
|
||||||
|
- [ ] Add write-side size check in `save()`: if serialized JSON exceeds `MAX_REFMAP_BYTES`, return error instead of writing (preserves previous valid refmap on disk)
|
||||||
|
|
||||||
|
**`crates/core/src/adapter.rs`** — TreeOptions:
|
||||||
|
- [ ] Add `skeleton: bool` (default `false`)
|
||||||
|
- [ ] Add `root_ref: Option<String>` (default `None`)
|
||||||
|
- [ ] Update `Default` impl
|
||||||
|
|
||||||
|
**`crates/core/src/adapter.rs`** — PlatformAdapter trait:
|
||||||
|
- [ ] Add `fn get_subtree(&self, handle: &NativeHandle, opts: &TreeOptions) -> Result<AccessibilityNode, AdapterError>` with default `not_supported("get_subtree")`
|
||||||
|
|
||||||
|
### Research Insights: Data Model
|
||||||
|
|
||||||
|
**From data-integrity-guardian:**
|
||||||
|
- **Write-side size check is critical.** Currently `MAX_REFMAP_BYTES` (1MB) is checked only on `load()`. A series of drill-downs could produce a >1MB file that then fails on every subsequent `load()`, making all refs inaccessible. The fix: check in `save()` and fail early, preserving the previous valid file (temp+rename never completes).
|
||||||
|
- **Orphaned refs from dead PIDs** accumulate but are harmless — `resolve_element()` returns `STALE_REF` naturally. No cleanup needed.
|
||||||
|
|
||||||
|
**From best-practices-researcher (serde):**
|
||||||
|
- `#[serde(default)]` on `Option<String>` fields defaults to `None`. Correct for backward compat.
|
||||||
|
- `#[serde(skip_serializing_if = "Option::is_none")]` prevents writing `null` fields. Correct.
|
||||||
|
- Do NOT use `#[serde(deny_unknown_fields)]` — allow unknown fields for forward compatibility (newer binary writes fields that older binary doesn't know about).
|
||||||
|
|
||||||
|
**Tests (Phase 1):**
|
||||||
|
- [ ] Backward compat: deserialize old RefEntry JSON (missing root_ref) → defaults to `root_ref: None`
|
||||||
|
- [ ] `remove_by_root_ref()`: removes only matching entries, preserves others
|
||||||
|
- [ ] `AccessibilityNode` with `children_count: Some(5)` and empty `children` serializes correctly (no `children` key, has `children_count`)
|
||||||
|
- [ ] `RefMap::save()` rejects oversized refmaps (>1MB) with actionable error
|
||||||
|
- [ ] `RefMap::save()` on oversized rejection: previous file on disk is preserved
|
||||||
|
|
||||||
|
#### Phase 2: Skeleton Mode
|
||||||
|
|
||||||
|
Implement the `--skeleton` flag end-to-end.
|
||||||
|
|
||||||
|
**`crates/macos/src/tree/element.rs`** — New `count_children()` function:
|
||||||
|
- [ ] Add `pub fn count_children(el: &AXElement) -> usize` that calls `AXUIElementCopyAttributeValue` for `kAXChildrenAttribute`, then `CFArrayGetCount` on raw `CFArrayRef`, then `CFRelease` — without materializing per-element `AXElement` wrappers
|
||||||
|
- [ ] Returns 0 if attribute fetch fails
|
||||||
|
|
||||||
|
**`crates/macos/src/tree/builder.rs`** — build_subtree():
|
||||||
|
- [ ] Accept `skeleton: bool` parameter (add to function signature)
|
||||||
|
- [ ] When `skeleton=true` and `depth >= skeleton_depth`: call `count_children(el)` instead of `copy_children()`, set `children_count` on the node, return the node with empty children vec
|
||||||
|
- [ ] Web-wrapper depth-skip still applies during skeleton traversal (existing behavior preserved)
|
||||||
|
- [ ] Skeleton depth constant: `SKELETON_DEFAULT_DEPTH: u8 = 3`
|
||||||
|
- [ ] Effective skeleton depth: `if skeleton { min(max_depth, SKELETON_DEFAULT_DEPTH) } else { max_depth }` — `--max-depth` can make it shallower but not deeper than 3 in skeleton mode
|
||||||
|
|
||||||
|
**`crates/core/src/snapshot.rs`** — allocate_refs():
|
||||||
|
- [ ] Accept `skeleton: bool` parameter
|
||||||
|
- [ ] New ref assignment rule for skeleton mode: if node has `children_count.is_some()` AND node has a non-empty name → assign a ref (drill-down target), even if role is not interactive
|
||||||
|
- [ ] `available_actions` for skeleton boundary refs: empty `vec![]`
|
||||||
|
- [ ] Fix `interactive_only` pruning: change guard to `child.ref_id.is_none() && child.children.is_empty() && child.children_count.is_none()` — do NOT prune truncated nodes that have `children_count` set
|
||||||
|
|
||||||
|
**`crates/core/src/commands/snapshot.rs`** — SnapshotArgs:
|
||||||
|
- [ ] Add `skeleton: bool` field
|
||||||
|
- [ ] Pass to TreeOptions construction
|
||||||
|
|
||||||
|
**`src/cli_args.rs`** — SnapshotArgs:
|
||||||
|
- [ ] Add `#[arg(long, help = "Shallow overview with children_count on truncated nodes")]` `skeleton: bool`
|
||||||
|
|
||||||
|
**`src/dispatch.rs`**:
|
||||||
|
- [ ] Pass `skeleton` through to core SnapshotArgs
|
||||||
|
|
||||||
|
**`src/typed batch path`**:
|
||||||
|
- [ ] Parse `skeleton` from JSON in batch snapshot dispatch
|
||||||
|
|
||||||
|
### Research Insights: Skeleton Mode
|
||||||
|
|
||||||
|
**From performance-oracle:**
|
||||||
|
- At the skeleton truncation boundary, `count_children()` still triggers one Mach IPC call per boundary node. For a depth-3 skeleton with ~50 boundary nodes, that is 5-25ms overhead. Acceptable — the savings (avoiding 450+ deeper IPC calls) vastly outweigh it.
|
||||||
|
|
||||||
|
**From code-simplicity-reviewer:**
|
||||||
|
- `allocate_refs()` currently has 7 parameters (already exceeds the 5-param max from CLAUDE.md). Adding `skeleton: bool` makes 8. **Recommendation: pass `skeleton` via `TreeOptions`** (already done — `opts.skeleton` is available). Do NOT add a separate parameter. Access it from the `opts` struct that's already threaded through.
|
||||||
|
|
||||||
|
**Tests (Phase 2):**
|
||||||
|
- [ ] Skeleton of mock tree with depth 5: output has max depth 3, truncated nodes have `children_count`, no `children` array
|
||||||
|
- [ ] Named container at boundary gets a ref; anonymous wrapper at boundary does not
|
||||||
|
- [ ] `interactive_only` does NOT prune truncated nodes with `children_count`
|
||||||
|
- [ ] `--skeleton --max-depth 2` produces depth-2 output
|
||||||
|
- [ ] `--skeleton --compact` composes: compact collapses within skeleton depth
|
||||||
|
- [ ] Skeleton ref_count includes both interactive refs and drill-down target refs
|
||||||
|
- [ ] Golden fixture: skeleton of standard test tree
|
||||||
|
- [ ] `count_children()` does not materialize AXElement wrappers (unit test in macos crate)
|
||||||
|
|
||||||
|
#### Phase 3: Drill-Down Mode
|
||||||
|
|
||||||
|
Implement `--root @ref` with refmap merging.
|
||||||
|
|
||||||
|
**`crates/macos/src/adapter.rs`** — MacOSAdapter:
|
||||||
|
- [ ] Implement `get_subtree(&self, handle: &NativeHandle, opts: &TreeOptions) -> Result<AccessibilityNode, AdapterError>`
|
||||||
|
- [ ] Extract AXElement from handle: `ManuallyDrop::new(AXElement(handle.as_raw() as AXUIElementRef))` (existing pattern from `get_live_value`)
|
||||||
|
- [ ] Call `build_subtree(&el, 0, opts.max_depth, opts.include_bounds, &mut FxHashSet::default())` — fresh ancestor set
|
||||||
|
- [ ] Delegate to same `build_subtree()` as `get_tree()` — prevents behavior divergence
|
||||||
|
|
||||||
|
**`crates/core/src/snapshot_ref.rs`** — New file (~80-100 LOC):
|
||||||
|
- [ ] `pub(crate) fn run_from_ref(adapter: &dyn PlatformAdapter, opts: &TreeOptions, root_ref: &str) -> Result<RefRootResult, AppError>`
|
||||||
|
- [ ] Step 1: `resolve_ref(root_ref, adapter)` → `(entry, handle)`. On failure → `STALE_REF` with suggestion "Run 'snapshot --skeleton' to refresh"
|
||||||
|
- [ ] Step 2: Derive app context from `entry.pid` and `entry.source_app`
|
||||||
|
- [ ] Step 3: `adapter.get_subtree(&handle, opts)` → raw subtree
|
||||||
|
- [ ] Step 4: `RefMap::load()` → existing refmap
|
||||||
|
- [ ] Step 5: `existing_refmap.remove_by_root_ref(root_ref)` — scoped invalidation
|
||||||
|
- [ ] Step 6: `allocate_refs(subtree, &mut existing_refmap, opts)` with `root_ref_tag: Some(root_ref)` — counter continues from loaded refmap
|
||||||
|
- [ ] Step 7: `existing_refmap.save()` (includes write-side size check)
|
||||||
|
- [ ] Step 8: Return `RefRootResult { tree, refmap, app_name, window }`
|
||||||
|
- [ ] **Skeleton boundary ref preservation**: @e3 itself (from skeleton, with `root_ref: None`) is NOT removed by scoped invalidation. Only refs where `root_ref == "@e3"` are removed. The agent keeps using @e3 for re-drilling.
|
||||||
|
|
||||||
|
**`crates/core/src/snapshot.rs`**:
|
||||||
|
- [ ] Add `pub mod snapshot_ref;` or re-export from the module
|
||||||
|
- [ ] In existing code, no changes needed — `build()` and `run()` are untouched
|
||||||
|
|
||||||
|
**`crates/core/src/commands/snapshot.rs`** — execute():
|
||||||
|
- [ ] Branch: if `args.root_ref.is_some()` → call `snapshot_ref::run_from_ref()` instead of `run()`
|
||||||
|
- [ ] Format response with `ref_count` (total)
|
||||||
|
- [ ] Derive `app` and `window` from the resolved `RefEntry.source_app` and PID window lookup
|
||||||
|
|
||||||
|
**`src/cli_args.rs`**:
|
||||||
|
- [ ] Add `#[arg(long, value_name = "REF", help = "Start from a previously-discovered ref")]` `root: Option<String>`
|
||||||
|
|
||||||
|
**`src/dispatch.rs`**:
|
||||||
|
- [ ] Pass `root` as `root_ref` through to core SnapshotArgs
|
||||||
|
|
||||||
|
**`src/typed batch path`**:
|
||||||
|
- [ ] Parse `root` from JSON in batch snapshot dispatch: `root_ref: str_field(&args, "root")`
|
||||||
|
|
||||||
|
**Argument validation:**
|
||||||
|
- [ ] `--root` + `--surface` → return `INVALID_ARGS` with message "Cannot use --root and --surface together"
|
||||||
|
- [ ] Validate ref format (`@e{N}`) before attempting resolution
|
||||||
|
|
||||||
|
### Research Insights: Drill-Down Mode
|
||||||
|
|
||||||
|
**From security-sentinel:**
|
||||||
|
- **TOCTOU race (HIGH):** The load → modify → save cycle on the refmap has no file locking. Concurrent `agent-desktop` invocations can silently overwrite each other's refs. The atomic rename prevents corruption but not lost updates. **Mitigation for now:** Document as known limitation. **For Phase 4:** Add advisory `flock()`/`fcntl()` around the load-modify-save cycle.
|
||||||
|
- **PID reuse (MEDIUM):** If an app restarts with a different PID, the old PID could theoretically belong to a different process. The `resolve_element()` function already matches on `(pid, role, name, bounds_hash)` which makes accidental cross-process resolution extremely unlikely. Acceptable risk.
|
||||||
|
- **Write-side size check:** Implemented in Phase 1 (RefMap::save). Prevents the scenario where drill-downs produce a >1MB file that blocks all future operations.
|
||||||
|
|
||||||
|
**From agent-native-reviewer:**
|
||||||
|
- **Batch dispatch parity is critical.** Every new CLI field must have an explicit batch JSON parsing line in `typed batch path`. The plan now includes explicit checklist items for both `skeleton` and `root` parsing.
|
||||||
|
- **Batch sequencing works:** A batch like `[{"command": "snapshot", "skeleton": true}, {"command": "snapshot", "root": "@e3"}]` executes sequentially, and the second command reads the refmap written by the first. This is the existing batch execution model.
|
||||||
|
|
||||||
|
**Tests (Phase 3):**
|
||||||
|
- [ ] Drill-down from a ref: subtree returned with correct depth, refs merge into existing map
|
||||||
|
- [ ] Scoped invalidation: drill into @e3, refs with `root_ref: "@e3"` removed, others preserved
|
||||||
|
- [ ] Skeleton boundary ref (@e3 with `root_ref: None`) preserved after drill-down
|
||||||
|
- [ ] Re-drill same region: old subtree refs replaced, new ones added, counter continues
|
||||||
|
- [ ] Multiple drill-downs accumulate: refs from @e3 and @e7 coexist
|
||||||
|
- [ ] Stale root ref: returns STALE_REF with skeleton suggestion
|
||||||
|
- [ ] `--root @e3 --surface menu` → INVALID_ARGS error
|
||||||
|
- [ ] Empty subtree (root has no children): valid response with ref_count of 0 or 1
|
||||||
|
- [ ] Counter continuity: after skeleton (refs @e1-@e10), drill-down creates @e11+
|
||||||
|
- [ ] Golden fixture: drill-down result merged refmap
|
||||||
|
- [ ] Write-side size check: rapid drill-downs approaching 1MB → error, previous refmap preserved
|
||||||
|
|
||||||
|
#### Phase 4: Polish and Documentation
|
||||||
|
|
||||||
|
- [ ] Update STALE_REF suggestion in `crates/core/src/commands/helpers.rs`: when `STALE_REF` occurs and the ref was from a drill-down (has `root_ref`), suggest "Run 'snapshot --skeleton' to refresh, then re-drill"
|
||||||
|
- [ ] Update skill files: `skills/agent-desktop/references/commands-observation.md` — document `--skeleton`, `--root` flags
|
||||||
|
- [ ] Add golden fixtures for skeleton and drill-down JSON output
|
||||||
|
- [ ] Run full clippy + fmt + test suite
|
||||||
|
|
||||||
|
### Deferred to Follow-Up PRs
|
||||||
|
|
||||||
|
These were cut from scope based on simplicity review:
|
||||||
|
|
||||||
|
- [ ] **`find --root @ref`** — Scoped search within a ref's subtree. Agent can achieve this today with `snapshot --root @e3` then `find`. Follow-up PR.
|
||||||
|
- [ ] **`--skeleton --root @ref` combo** — Skeleton view of a subtree. Useful for very deep regions. Follow-up PR.
|
||||||
|
- [ ] **`epoch` counter on RefMap/RefEntry** — Was deemed redundant with existing per-element staleness detection. Revisit if agents report confusion about ref freshness.
|
||||||
|
- [ ] **Advisory file locking (`flock`)** — Prevents TOCTOU race on refmap during concurrent access. Deferred to Phase 4 daemon.
|
||||||
|
- [ ] **RefMap size eviction policy** — Auto-purge oldest refs when approaching 1MB. For now, agents should periodically run `--skeleton` to reset.
|
||||||
|
|
||||||
|
## Alternative Approaches Considered
|
||||||
|
|
||||||
|
(see brainstorm: `docs/brainstorms/2026-03-10-progressive-skeleton-traversal-brainstorm.md`)
|
||||||
|
|
||||||
|
1. **Semantic Zone Auto-Detection** — Auto-label regions ("sidebar", "toolbar") via role heuristics. Rejected: fragile across apps, platform-dependent role semantics, introduces new concept (zones) alongside refs.
|
||||||
|
|
||||||
|
2. **Query-Driven Traversal** — Skip trees, agents search directly with `find --near "Channels"`. Rejected: requires agents to know what they're looking for, fails for the "orient myself in an unfamiliar app" use case.
|
||||||
|
|
||||||
|
3. **Auto-skeleton default** — Make `--skeleton` the default when `--compact --interactive-only` are both set. Rejected: changes existing behavior, could break agents already using those flags.
|
||||||
|
|
||||||
|
4. **Extend `get_tree()` signature instead of adding `get_subtree()`** — Architecture reviewer suggested adding `Option<&NativeHandle>` to `get_tree()`. Rejected: `NativeHandle` contains raw pointers, would break `TreeOptions`'s `Clone`/`Copy` derives. The additive `get_subtree()` method is cleaner and follows the project's pattern of default `not_supported` implementations.
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
### Interaction Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
snapshot --skeleton → build() → adapter.get_tree() → build_subtree() [skeleton-aware]
|
||||||
|
→ allocate_refs() [skeleton-aware ref assignment]
|
||||||
|
→ refmap.save() [full replace]
|
||||||
|
|
||||||
|
snapshot --root → run_from_ref() → resolve_ref() → adapter.get_subtree()
|
||||||
|
→ RefMap::load() → remove_by_root_ref()
|
||||||
|
→ allocate_refs() [merge into loaded refmap]
|
||||||
|
→ refmap.save() [merged, size-checked]
|
||||||
|
|
||||||
|
get @ref → RefMap::load() → resolve → [transparent, no changes needed]
|
||||||
|
click @ref → RefMap::load() → resolve → [transparent, no changes needed]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Propagation
|
||||||
|
|
||||||
|
- `--root @e3` with stale ref → `resolve_ref()` fails → `STALE_REF` (exit 1) with skeleton suggestion
|
||||||
|
- `--root @e3 --surface menu` → validation in `execute()` → `INVALID_ARGS` (exit 2)
|
||||||
|
- `adapter.get_subtree()` fails (e.g., app crashed mid-traversal) → `ACTION_FAILED` propagated
|
||||||
|
- RefMap load failure during merge → `INTERNAL` error (file permissions, corrupt JSON)
|
||||||
|
- RefMap save exceeds 1MB → `INTERNAL` error with suggestion to run full snapshot to reset
|
||||||
|
|
||||||
|
### State Lifecycle Risks
|
||||||
|
|
||||||
|
- **RefMap is the critical state artifact.** Skeleton replaces entirely (safe). Drill-down merges (partial update). Concurrent access (two agents) can cause lost updates — documented, deferred to Phase 4.
|
||||||
|
- **Unbounded refmap growth:** Multiple drill-downs without a skeleton reset accumulate refs. Write-side size check (1MB) prevents catastrophic failure. Agent best practice: periodically run `--skeleton` to reset.
|
||||||
|
- **TOCTOU race (documented):** The load → modify → save cycle has no file locking. Atomic rename prevents corruption but not lost updates. Phase 4 will add advisory `flock()`.
|
||||||
|
|
||||||
|
### API Surface Parity
|
||||||
|
|
||||||
|
| Interface | Needs update |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `snapshot` CLI command | Yes — `--skeleton`, `--root` flags |
|
||||||
|
| `batch` JSON API | Yes — `skeleton`, `root` fields |
|
||||||
|
| MCP server (Phase 3) | Will inherit via dispatch |
|
||||||
|
| All action commands | No — transparent via merged refmap |
|
||||||
|
| `get`, `is` commands | No — transparent via merged refmap |
|
||||||
|
| `find` CLI command | Deferred — follow-up PR |
|
||||||
|
|
||||||
|
### Integration Test Scenarios
|
||||||
|
|
||||||
|
1. **End-to-end progressive workflow**: `snapshot --skeleton` → `snapshot --root @e3` → `click @e45` → verify action succeeds on element from drill-down
|
||||||
|
2. **Re-drill after UI change**: `snapshot --skeleton` → `snapshot --root @e3` → [UI changes] → `snapshot --root @e3` again → verify stale refs replaced, fresh refs work
|
||||||
|
3. **Cross-region interaction**: `snapshot --skeleton` → `snapshot --root @e3` → `snapshot --root @e7` → `click @e25` (from @e3 drill-down) → verify still works
|
||||||
|
4. **Full snapshot resets**: `snapshot --skeleton` → `snapshot --root @e3` → `snapshot --app Slack -i` (no flags) → verify refmap fully replaced, drill-down refs gone
|
||||||
|
5. **Batch with skeleton + drill-down**: batch JSON with skeleton first, drill-down second → verify sequential execution and refmap merge
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- [ ] `snapshot --skeleton -i` returns max depth 3 tree with `children_count` on truncated nodes
|
||||||
|
- [ ] Named containers at skeleton boundary receive refs as drill-down targets
|
||||||
|
- [ ] `snapshot --root @e3` returns subtree rooted at @e3, merges refs into existing refmap
|
||||||
|
- [ ] Scoped invalidation: re-drilling @e3 removes only @e3-rooted refs, preserves others
|
||||||
|
- [ ] `--root @ref --surface menu` returns `INVALID_ARGS`
|
||||||
|
- [ ] Stale root ref returns `STALE_REF` with skeleton suggestion
|
||||||
|
- [ ] All existing flags compose: `--skeleton --compact --interactive-only`, `--root @e3 --compact --interactive-only --max-depth 5`
|
||||||
|
- [ ] Write-side refmap size check prevents >1MB files
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- [ ] Skeleton snapshot of Slack: < 50 nodes, < 1,000 tokens
|
||||||
|
- [ ] Drill-down of Slack sidebar: < 100 nodes, < 2,000 tokens
|
||||||
|
- [ ] No regression in full snapshot performance (same path when no new flags used)
|
||||||
|
- [ ] Backward compatibility: old refmap files (no root_ref) deserialize with defaults
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
- [ ] `cargo clippy --all-targets -- -D warnings` passes
|
||||||
|
- [ ] `cargo fmt --all -- --check` passes
|
||||||
|
- [ ] `cargo test --lib --workspace` passes (all new + existing tests)
|
||||||
|
- [ ] No file exceeds 400 LOC (snapshot_ref.rs extraction keeps snapshot.rs under limit)
|
||||||
|
- [ ] Zero `unwrap()` in non-test code
|
||||||
|
- [ ] Golden fixtures for skeleton output and merged refmap
|
||||||
|
|
||||||
|
## Dependencies & Prerequisites
|
||||||
|
|
||||||
|
- No new crate dependencies
|
||||||
|
- No platform-specific dependencies beyond what exists
|
||||||
|
- Windows/Linux stubs inherit `get_subtree()` default (`not_supported`) — no stub changes needed
|
||||||
|
|
||||||
|
## Risk Analysis & Mitigation
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| `interactive_only` pruning removes truncated skeleton nodes | High — drill-down targets lost | Guard: only prune if `children_count.is_none()` |
|
||||||
|
| Ref ID collision during merge | High — corrupt refmap | Counter continues from loaded refmap, never resets during merge |
|
||||||
|
| Concurrent drill-down race condition | Medium — lost updates | Documented. Atomic write prevents corruption. flock in Phase 4. |
|
||||||
|
| RefMap exceeds 1MB after many drill-downs | Medium — all refs inaccessible | Write-side size check in save(). Fail early, preserve previous file. |
|
||||||
|
| `children_count` not O(1) on Linux AT-SPI | Low (Phase 3) | Verify during Linux adapter. Fallback: quick recursive count. |
|
||||||
|
| `--skeleton --max-depth 100` defeats purpose | Low — foot-gun | Cap: `min(max_depth, SKELETON_DEFAULT_DEPTH)` |
|
||||||
|
| `snapshot.rs` exceeds 400 LOC | Low — coding standard violation | Extract to `snapshot_ref.rs` |
|
||||||
|
|
||||||
|
## File Change Summary
|
||||||
|
|
||||||
|
| File | Change | LOC Impact |
|
||||||
|
|------|--------|------------|
|
||||||
|
| `crates/core/src/node.rs` | Add `children_count` field | +2 |
|
||||||
|
| `crates/core/src/refs.rs` | Add `root_ref` to RefEntry, `remove_by_root_ref()` + save size check to RefMap | +25 |
|
||||||
|
| `crates/core/src/adapter.rs` | Add fields to TreeOptions, `get_subtree()` to trait | +15 |
|
||||||
|
| `crates/core/src/snapshot.rs` | Skeleton logic in `allocate_refs()`, pruning fix | +20 |
|
||||||
|
| `crates/core/src/snapshot_ref.rs` | **NEW** — `run_from_ref()` drill-down logic | +80-100 |
|
||||||
|
| `crates/core/src/commands/snapshot.rs` | Branch on `root_ref`, skeleton args | +15 |
|
||||||
|
| `crates/macos/src/adapter.rs` | Implement `get_subtree()` | +15 |
|
||||||
|
| `crates/macos/src/tree/builder.rs` | Skeleton-aware truncation | +15 |
|
||||||
|
| `crates/macos/src/tree/element.rs` | `count_children()` function | +15 |
|
||||||
|
| `src/cli_args.rs` | `--skeleton`, `--root` flags | +5 |
|
||||||
|
| `src/dispatch.rs` | Pass new args | +3 |
|
||||||
|
| `src/typed batch path` | Parse new JSON fields | +5 |
|
||||||
|
| **Total** | | **~215-235 new LOC** |
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
### Origin
|
||||||
|
|
||||||
|
- **Brainstorm document:** [docs/brainstorms/2026-03-10-progressive-skeleton-traversal-brainstorm.md](docs/brainstorms/2026-03-10-progressive-skeleton-traversal-brainstorm.md) — Key decisions: skeleton with children_count hint, ref-rooted drill-down with scoped merge, named containers get refs at skeleton boundary, `--skeleton` flag naming, opt-in only.
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
|
||||||
|
- `crates/core/src/snapshot.rs:127-149` — `append_surface_refs()` precedent for refmap merge pattern
|
||||||
|
- `crates/core/src/snapshot.rs:160-215` — `allocate_refs()` where skeleton logic and pruning fix go
|
||||||
|
- `crates/core/src/refs.rs:8-28` — RefEntry and RefMap structs to extend
|
||||||
|
- `crates/core/src/adapter.rs:27-45` — TreeOptions to extend
|
||||||
|
- `crates/core/src/adapter.rs:115-247` — PlatformAdapter trait for new method
|
||||||
|
- `crates/macos/src/adapter.rs:33-56` — `get_tree()` pattern to follow for `get_subtree()`
|
||||||
|
- `crates/macos/src/tree/builder.rs:44-119` — `build_subtree()` to make skeleton-aware
|
||||||
|
- `crates/macos/src/tree/element.rs` — Where `count_children()` goes
|
||||||
|
- `crates/core/src/commands/helpers.rs:11-34` — `resolve_ref()` used by drill-down path
|
||||||
|
|
||||||
|
### Review Agents Applied
|
||||||
|
|
||||||
|
| Agent | Key Finding |
|
||||||
|
|-------|-------------|
|
||||||
|
| architecture-strategist | Keep `get_subtree()` additive; extract `snapshot_ref.rs` for LOC management |
|
||||||
|
| performance-oracle | Add `count_children()` using raw CFArrayGetCount; RefMap I/O negligible |
|
||||||
|
| security-sentinel | TOCTOU race documented, defer flock to Phase 4; write-side size check critical |
|
||||||
|
| pattern-recognition | Rename `drill_down` → `run_from_ref`; TreeOptions at 7-field boundary |
|
||||||
|
| code-simplicity-reviewer | Cut epoch, new_ref_count, mode; defer find --root and skeleton+root combo |
|
||||||
|
| agent-native-reviewer | Explicit batch dispatch parsing for all new fields |
|
||||||
|
| data-integrity-guardian | Write-side size check prevents catastrophic refmap failure |
|
||||||
|
|
||||||
|
### Related Work
|
||||||
|
|
||||||
|
- `docs/brainstorms/2026-03-01-electron-tree-compaction-brainstorm.md` — compact mode (already implemented), composes with skeleton
|
||||||
|
- `docs/brainstorms/2026-02-19-ax-tree-accuracy-speed-brainstorm.md` — tree accuracy insights
|
||||||
1008
docs/plans/2026-04-16-001-fix-ffi-safety-abi-correctness-plan.md
Normal file
1008
docs/plans/2026-04-16-001-fix-ffi-safety-abi-correctness-plan.md
Normal file
File diff suppressed because it is too large
Load diff
1858
docs/plans/2026-04-18-001-feat-phase2-windows-crossplatform-plan.md
Normal file
1858
docs/plans/2026-04-18-001-feat-phase2-windows-crossplatform-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,366 @@
|
||||||
|
---
|
||||||
|
title: "feat: add Playwright-grade desktop reliability"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-06-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: add Playwright-grade desktop reliability
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Build a pragmatic reliability foundation for Agent Desktop by borrowing the parts of Playwright that make automation dependable: late target resolution, strict matching, actionability waits, retrying assertions, useful failure traces, and session-safe refs. The work keeps Agent Desktop's role clear: Playwright remains the browser automation tool; Agent Desktop becomes a reliable native desktop automation tool whose behavioral contract can be shared by macOS, Windows, and Linux adapters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
Agent Desktop already has strong macOS primitives: snapshot-scoped refmaps, rich `RefEntry` identity data, path and bounds based stale-ref recovery, semantic AX action chains, and policy-gated physical fallback. The remaining reliability gap is at the public contract layer. A ref can still behave like a concrete handle that is resolved once and acted on immediately, while Playwright's most reliable surface keeps a target as intent until action time, checks that the target is unique and actionable, and leaves enough trace data to debug failures.
|
||||||
|
|
||||||
|
This plan strengthens that contract without building a test runner, a daemon, a visual trace viewer, or a custom selector language. It adds the smallest cross-platform foundation needed for future Windows and Linux adapters to inherit the same semantics instead of inventing platform-local behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Baseline and Success Criteria
|
||||||
|
|
||||||
|
Before implementation, capture a small baseline of representative flaky desktop workflows. The baseline should include the command sequence, expected behavior, observed failure, and which acceptance example or conformance test will prove the failure is fixed. If no production incident is available for a category, use a fixture-backed reproduction rather than inventing scope.
|
||||||
|
|
||||||
|
Minimum success for the first shipping slice is:
|
||||||
|
|
||||||
|
- A moved-but-unique ref resolves late and acts safely without a manual refresh.
|
||||||
|
- A duplicated target fails as ambiguous without mutating the desktop.
|
||||||
|
- A disabled or non-actionable target can be waited on with a structured timeout reason.
|
||||||
|
- Existing one-shot `get` and `is` behavior remains cheap and non-retrying.
|
||||||
|
- Existing semantic macOS click and text flows continue to pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Target Resolution
|
||||||
|
|
||||||
|
- R1. Ref-consuming commands must resolve through a late-bound target contract that can re-find an element from snapshot identity, source surface, path, and semantic fields before acting.
|
||||||
|
- R2. Single-target actions must be strict by default: zero matches is stale or missing, multiple matches is ambiguous, and only exactly one candidate proceeds.
|
||||||
|
- R3. Stale-ref handling must remain fail-closed. Automatic recovery is allowed only when the resolver finds one high-confidence candidate in the same scoped app/window/surface.
|
||||||
|
|
||||||
|
### Action Reliability
|
||||||
|
|
||||||
|
- R4. Ref actions must run a shared actionability preflight before mutation: resolved uniquely, evaluated for visibility, enabledness, and stability when native evidence is available, allowed to proceed on unknown checks only when core policy says the action remains safe, and policy-compatible.
|
||||||
|
- R5. Actionability failures must return structured reasons that agents can use for recovery, not only generic `ACTION_FAILED`.
|
||||||
|
- R6. Existing headless-first policy must stay intact: semantic AX/UIA/AT-SPI actions remain primary, and focus or cursor movement happens only through explicit policy paths.
|
||||||
|
|
||||||
|
### Waiting, Assertions, and Diagnostics
|
||||||
|
|
||||||
|
- R7. `wait` must support retrying state and actionability predicates so agents can wait for "enabled", "visible", "value changed", "text present", or "actionable" without manual polling loops.
|
||||||
|
- R8. One-shot `get` and `is` commands must remain available and cheap; retrying behavior belongs in `wait` predicates rather than a full test-runner abstraction.
|
||||||
|
- R9. Optional trace output must capture resolver attempts, actionability checks, action-chain steps, post-state, and failure context in a machine-readable artifact.
|
||||||
|
|
||||||
|
### Interface and Platform Parity
|
||||||
|
|
||||||
|
- R10. Current CLI and FFI ref-consuming action surfaces must use the same core ref, resolver, actionability, and error semantics so reliability does not depend on the calling interface. Future MCP work must inherit that core path when MCP is implemented.
|
||||||
|
- R11. Concurrent agents must be able to avoid global latest-snapshot collisions through an explicit session-scoped ref namespace while default no-session behavior stays unchanged.
|
||||||
|
- R12. The work must define a reusable adapter conformance harness for strict resolution, stale-ref recovery, actionability, retrying waits, and session isolation. Windows and Linux adapters must pass that harness before claiming parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **Keep refs, add locator semantics behind them:** Do not replace `@eN` refs or introduce a new user-facing selector DSL. Extend the core ref pipeline so a ref carries enough locator-like identity for strict late resolution.
|
||||||
|
- **Strictness is the default:** If a recovery search finds multiple matching candidates, the command fails with a first-class ambiguity error and candidate summaries. Guessing is worse than a retryable failure because desktop actions can mutate real user state.
|
||||||
|
- **Actionability is shared core policy, with adapter evidence only where needed:** Core owns the vocabulary, result shape, and per-action gate rules. The first pass should build the report from the resolved target, policy, live node fields, bounds, and existing adapter methods. Add new adapter trait surface only when a named check cannot be expressed through existing methods.
|
||||||
|
- **Extend `wait`, do not build an `expect` framework yet:** Playwright-style retrying assertions are valuable, but a standalone test runner would be YAGNI. Add predicate modes to `wait` and leave `get` / `is` as one-shot commands.
|
||||||
|
- **Trace JSONL before trace viewer:** Start with structured trace events written to a caller-provided path or failure-retained default. A UI viewer, zip bundle, or screencast can wait until real trace usage proves the need.
|
||||||
|
- **Session namespace before daemon:** Session-scoped refs solve multi-agent contamination without committing to a future persistent daemon. The default CLI behavior can remain compatible while MCP and advanced callers opt into session IDs.
|
||||||
|
- **Conformance before adapter expansion:** The plan does not implement Windows or Linux automation. It defines an adapter-facing harness and capability matrix those adapters must satisfy later so platform work does not diverge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Snapshot["snapshot builds tree + RefMap"] --> Entry["RefEntry + locator identity"]
|
||||||
|
Entry --> Resolve["strict late resolver"]
|
||||||
|
Resolve -->|0 matches| Stale["STALE_REF / missing"]
|
||||||
|
Resolve -->|many matches| Ambiguous["AMBIGUOUS_TARGET"]
|
||||||
|
Resolve -->|1 match| Actionable["actionability preflight"]
|
||||||
|
Actionable -->|not ready| Waitable["structured waitable reason"]
|
||||||
|
Actionable -->|ready| Execute["adapter action chain"]
|
||||||
|
Execute --> Post["post-state + optional trace event"]
|
||||||
|
Wait["wait predicates"] --> Resolve
|
||||||
|
Trace["trace sink"] -. records .-> Resolve
|
||||||
|
Trace -. records .-> Actionable
|
||||||
|
Trace -. records .-> Execute
|
||||||
|
```
|
||||||
|
|
||||||
|
The resolver remains ref-rooted and snapshot-aware. The new behavior is that ref actions no longer treat the loaded `RefEntry` as enough evidence by itself. They resolve the entry strictly, classify failure, run actionability, then execute through the existing adapter action path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### Included
|
||||||
|
|
||||||
|
- Core resolver contract and strict matching result types.
|
||||||
|
- macOS implementation of the new resolver and actionability checks using existing AX data.
|
||||||
|
- Retry-capable `wait` predicate modes.
|
||||||
|
- Trace event sink sufficient for debugging resolver/actionability failures.
|
||||||
|
- Session-scoped ref storage namespace.
|
||||||
|
- FFI parity for `RefEntry` fields that CLI resolution already uses.
|
||||||
|
- Shared conformance tests and documentation updates.
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
|
||||||
|
- Full MCP server implementation.
|
||||||
|
- MCP parity acceptance gates, once MCP exists, requiring MCP commands to use the same core resolver/session path.
|
||||||
|
- Persistent daemon, warm caches, and event-driven invalidation.
|
||||||
|
- Visual trace viewer, screencast recording, and trace zip bundles.
|
||||||
|
- OCR or vision fallback for inaccessible custom-rendered UIs.
|
||||||
|
- New public selector language beyond existing `find` inputs and refs.
|
||||||
|
- Windows and Linux adapter implementation beyond conformance expectations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
Ship this in slices so the foundation proves value before wider surface area is added:
|
||||||
|
|
||||||
|
1. **Slice 1: CLI/macOS reliability core.** Implement U1-U3 first: strict late resolution, shared actionability, and retrying wait predicates. This is the minimum shippable reliability improvement.
|
||||||
|
2. **Slice 2: diagnostics and session isolation.** Implement U4 only when Slice 1 exposes failures that need trace artifacts, or when current callers need concurrent no-`--snapshot` isolation. Session IDs must be explicit; no daemon or global process context.
|
||||||
|
3. **Slice 3: interface and platform hardening.** Implement U5 when FFI consumers need ref-consuming mutations to match CLI behavior. Implement U6 before Windows/Linux adapter work claims parity.
|
||||||
|
|
||||||
|
Do not start a later slice just because it is listed here. Each slice must either protect correctness already introduced by earlier units or answer a current consumer need.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Examples
|
||||||
|
|
||||||
|
- AE1. Given a ref whose element moved within the same source window, when a click command runs, then the resolver re-finds the unique matching element and the action proceeds.
|
||||||
|
- AE2. Given two matching "Save" buttons in the same window, when a click command targets a stale ref whose identity now matches both, then the command fails with an ambiguity error and does not click either.
|
||||||
|
- AE3. Given a disabled button, when `wait` asks for it to become actionable, then the command retries until it becomes enabled or returns a timeout with the last actionability reason.
|
||||||
|
- AE4. Given two agents using different session IDs, when each omits `--snapshot`, then each loads its own latest snapshot and cannot act on the other's latest refs.
|
||||||
|
- AE5. Given a failed action with trace enabled, when the command exits, then the trace records resolver attempts, candidate count, actionability result, action-chain steps attempted, and final error.
|
||||||
|
- AE6. Given the same macOS window is discovered through focused, main, and `AXWindows` roots, when strict resolution counts candidates, then native identity deduplication prevents a false ambiguity result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Strict Late Resolution Contract
|
||||||
|
|
||||||
|
- **Goal:** Add a core target-resolution contract that preserves ref ergonomics while making strict late resolution explicit.
|
||||||
|
- **Requirements:** R1, R2, R3, R10
|
||||||
|
- **Dependencies:** None
|
||||||
|
- **Files:**
|
||||||
|
- `crates/core/src/refs.rs`
|
||||||
|
- `crates/core/src/resolved_element.rs`
|
||||||
|
- `crates/core/src/error.rs`
|
||||||
|
- `crates/core/src/commands/helpers.rs`
|
||||||
|
- `crates/macos/src/tree/resolve.rs`
|
||||||
|
- `crates/macos/src/tree/resolve_identity.rs`
|
||||||
|
- `crates/macos/src/tree/resolve_bounds.rs`
|
||||||
|
- `crates/macos/src/tree/resolve_tests.rs`
|
||||||
|
- `crates/core/src/commands/helpers_tests.rs`
|
||||||
|
- **Approach:** Introduce a small core resolution result model: unique target, no target, ambiguous target, and timed out. Add a dedicated ambiguity error code so agents can distinguish "refresh needed" from "target is no longer unique." Reuse the existing `RefEntry` fields rather than creating a new selector object unless implementation needs a tiny helper struct to group matching inputs. macOS keeps its current path fast path and broad search fallback, deduplicates candidates by native element identity, and returns candidate classification instead of first-success-only semantics.
|
||||||
|
- **Patterns to follow:** Existing scoped-root search in `crates/macos/src/tree/resolve.rs`; identity matching in `crates/macos/src/tree/resolve_identity.rs`; stale-ref error shape in `crates/core/src/error.rs`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- A valid ref with matching path and identity resolves to exactly one handle.
|
||||||
|
- A ref whose path no longer resolves but whose identity uniquely matches elsewhere in the same source window resolves successfully.
|
||||||
|
- A ref whose identity matches multiple candidates returns an ambiguity classification and no action is executed.
|
||||||
|
- A ref found through duplicate macOS roots is deduplicated to one native candidate before strict count classification.
|
||||||
|
- A syntactically invalid ref remains `INVALID_ARGS`, not stale or ambiguous.
|
||||||
|
- A valid ref with no candidates returns `STALE_REF` with the existing refresh suggestion.
|
||||||
|
- **Verification:** Ref-consuming commands can distinguish stale, ambiguous, timeout, and unique resolution without changing their public JSON envelope.
|
||||||
|
|
||||||
|
### U2. Shared Actionability Preflight
|
||||||
|
|
||||||
|
- **Goal:** Add a small actionability layer before ref actions mutate the desktop.
|
||||||
|
- **Requirements:** R4, R5, R6
|
||||||
|
- **Dependencies:** U1
|
||||||
|
- **Files:**
|
||||||
|
- `crates/core/src/action.rs`
|
||||||
|
- `crates/core/src/actionability.rs`
|
||||||
|
- `crates/core/src/commands/helpers.rs`
|
||||||
|
- `crates/macos/src/actions/mod.rs`
|
||||||
|
- `crates/macos/src/actions/actionability.rs`
|
||||||
|
- `crates/macos/src/actions/dispatch.rs`
|
||||||
|
- `crates/macos/src/actions/chain.rs`
|
||||||
|
- `crates/macos/src/actions/chain_steps_tests.rs`
|
||||||
|
- `crates/core/src/commands/ref_policy_tests.rs`
|
||||||
|
- **Approach:** Define an `ActionabilityReport` in core with a compact set of checks: unique target, visible or unknown, enabled or unknown, stable or unknown, editable when text input is requested, and policy allowed. Build the first implementation from the resolved target, policy, live state, bounds, available actions, and existing adapter calls. Keep `force` out of the first implementation unless a current command needs it; explicit physical/focus policy already handles most escape hatches.
|
||||||
|
- **Actionability gate matrix:**
|
||||||
|
|
||||||
|
| Action group | Required evidence | Unknowns allowed | Blocking states |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Click/select/toggle | Unique target, policy allowed, semantic action available or physical policy allowed | Visibility/stability unknown when a semantic native action is available | Disabled false, zero-size bounds when bounds exist, policy would require forbidden focus/cursor movement |
|
||||||
|
| Text input/set value | Unique target, policy allowed, settable value or editable text evidence | Visibility unknown for headless set-value | Non-editable false, missing settable value when keyboard fallback is policy-denied |
|
||||||
|
| Scroll | Unique target or window, policy allowed, scroll action or allowed physical fallback | Enabled/stability unknown | No scroll action and physical fallback denied |
|
||||||
|
| Keyboard/app press | App/window target, policy allowed, focus policy satisfied | Element visibility when the command is app-scoped | Required focus cannot be established under policy |
|
||||||
|
|
||||||
|
- **Patterns to follow:** `InteractionPolicy` in `crates/core/src/action.rs`; post-state verification in `crates/macos/src/actions/post_state.rs`; chain timeout handling in `crates/macos/src/actions/chain.rs`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- A disabled element reports `enabled=false` and a structured actionability failure before action dispatch.
|
||||||
|
- An element with zero-size bounds reports not visible when bounds are available.
|
||||||
|
- A text command against a non-editable target fails actionability before keyboard or clipboard fallback.
|
||||||
|
- A headless command that would require cursor movement remains policy denied.
|
||||||
|
- A command with unknown visibility but valid semantic AX action can proceed when no stronger evidence exists.
|
||||||
|
- **Verification:** Existing semantic click and text paths still work, but failures include actionability reasons before falling back to generic action failure.
|
||||||
|
|
||||||
|
### U3. Retrying Wait Predicates
|
||||||
|
|
||||||
|
- **Goal:** Make Playwright-style retrying assertions available through `wait` without building a separate test framework.
|
||||||
|
- **Requirements:** R7, R8
|
||||||
|
- **Dependencies:** U1, U2
|
||||||
|
- **Files:**
|
||||||
|
- `src/cli_args_system.rs`
|
||||||
|
- `src/dispatch.rs`
|
||||||
|
- `crates/core/src/commands/wait.rs`
|
||||||
|
- `crates/core/src/commands/wait_tests.rs`
|
||||||
|
- `crates/core/src/search_text.rs`
|
||||||
|
- `src/batch.rs`
|
||||||
|
- **Approach:** Extend `wait` with predicate modes for ref state, ref actionability, live value/text, and match count. Keep `get` and `is` one-shot so simple reads stay cheap and predictable. Predicate results should include elapsed time and the last observed value or actionability reason. Do not add a generic expression language.
|
||||||
|
- **Wait predicate contract:**
|
||||||
|
|
||||||
|
| Predicate | Required input | Snapshot behavior | Timeout output |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `exists` | ref or find query | Fixed `--snapshot` stays fixed; omitted snapshot may refresh through existing throttle | last candidate count and last resolver error |
|
||||||
|
| `state` | ref plus state name/value | Re-resolves the ref on each poll | last observed state map and actionability reason if related |
|
||||||
|
| `actionable` | ref plus optional action kind | Re-runs strict resolution and U2 gate on each poll | last failed check and policy reason |
|
||||||
|
| `value` | ref plus equals/contains operand | Re-resolves and reads live value on each poll | last observed value |
|
||||||
|
| `text` | text or query operand | Uses existing text search refresh behavior | last match count and matched surface when present |
|
||||||
|
| `match-count` | find query plus count relation | Uses the same fixed/latest snapshot rule as `exists` | last observed count |
|
||||||
|
|
||||||
|
Invalid predicate/input combinations return `INVALID_ARGS`, not timeout.
|
||||||
|
- **Patterns to follow:** Existing `wait_for_element`, `wait_for_text`, and `LatestRefCache` in `crates/core/src/commands/wait.rs`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Waiting for an element to become enabled polls live state and succeeds when the state changes.
|
||||||
|
- Waiting for actionability reports the last failed check on timeout.
|
||||||
|
- Waiting for text still saves a fresh snapshot when it finds a match.
|
||||||
|
- Waiting against a fixed `--snapshot` does not silently switch to a newer snapshot.
|
||||||
|
- Waiting without `--snapshot` refreshes latest snapshot metadata only at the existing throttle interval.
|
||||||
|
- **Verification:** Agents can replace manual sleep-and-poll loops with a single structured `wait` call for state and actionability.
|
||||||
|
|
||||||
|
### U4. Trace Events and Session-Scoped RefStore
|
||||||
|
|
||||||
|
- **Goal:** Add the smallest useful diagnostics and isolation layer for concurrent agent runs.
|
||||||
|
- **Requirements:** R9, R11
|
||||||
|
- **Dependencies:** U1, U2
|
||||||
|
- **Files:**
|
||||||
|
- `src/cli.rs`
|
||||||
|
- `src/cli_args.rs`
|
||||||
|
- `src/batch.rs`
|
||||||
|
- `src/dispatch.rs`
|
||||||
|
- `src/main.rs`
|
||||||
|
- `crates/core/src/trace.rs`
|
||||||
|
- `crates/core/src/refs_store.rs`
|
||||||
|
- `crates/core/src/refs_lock.rs`
|
||||||
|
- `crates/core/src/commands/helpers.rs`
|
||||||
|
- `crates/core/src/commands/snapshot.rs`
|
||||||
|
- `crates/core/src/snapshot_ref.rs`
|
||||||
|
- `crates/core/src/commands/wait.rs`
|
||||||
|
- `crates/core/src/commands/status.rs`
|
||||||
|
- `crates/core/src/refs_tests.rs`
|
||||||
|
- `crates/core/src/commands/snapshot_tests.rs`
|
||||||
|
- **Approach:** Add opt-in trace writing as structured JSONL events, not a viewer. Add a session namespace to `RefStore` so callers can isolate `latest_snapshot_id` and snapshot directories by session. Preserve current default behavior when no session is provided.
|
||||||
|
- **Session contract:** Add a global `--session <id>` CLI option and equivalent batch field. Validate IDs as short filesystem-safe tokens. Map storage to `~/.agent-desktop/sessions/{session_id}/snapshots/...` while preserving the current default path for no-session commands. Thread the session through snapshot creation, drill-down, helper resolution, wait, status, and every omitted-`--snapshot` latest lookup. Do not use process-global session state.
|
||||||
|
- **Patterns to follow:** Private file writes and lock handling in `crates/core/src/refs.rs` and `crates/core/src/refs_store.rs`; response envelope handling in `src/main.rs`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Two session IDs can each save and load independent latest snapshots.
|
||||||
|
- Omitting a session ID preserves the current default latest-snapshot path.
|
||||||
|
- A failed ambiguous resolution writes trace events for candidate count and final error when tracing is enabled.
|
||||||
|
- Trace output never appears in stdout JSON envelopes.
|
||||||
|
- Trace writing failure does not turn a successful desktop action into a failed action unless the caller explicitly requested strict trace output.
|
||||||
|
- **Verification:** Concurrent agent workflows have an isolation mechanism, and failed actions can be diagnosed from trace events without rerunning with verbose stderr.
|
||||||
|
- **Delivered:** Added `CommandContext`, global `--session`, batch item `session`, session-scoped `RefStore`, opt-in `--trace`, `--trace-strict`, trace events on snapshot/ref/action paths, and tests for session isolation and trace behavior.
|
||||||
|
|
||||||
|
### U5. FFI Ref-Action Parity
|
||||||
|
|
||||||
|
- **Goal:** Bring FFI ref resolution and ref-consuming mutations up to the same reliability standard as the CLI.
|
||||||
|
- **Requirements:** R10
|
||||||
|
- **Dependencies:** U1, U2
|
||||||
|
- **Files:**
|
||||||
|
- `crates/ffi/src/types/ref_entry.rs`
|
||||||
|
- `crates/ffi/src/actions/resolve.rs`
|
||||||
|
- `crates/ffi/src/actions/execute.rs`
|
||||||
|
- `crates/ffi/src/types/action.rs`
|
||||||
|
- `crates/ffi/src/types/action_result.rs`
|
||||||
|
- `crates/ffi/src/actions/resolve_tests.rs`
|
||||||
|
- `crates/ffi/tests/c_abi_actions.rs`
|
||||||
|
- `crates/ffi/tests/c_abi_layout.rs`
|
||||||
|
- `crates/ffi/include/agent_desktop.h`
|
||||||
|
- `scripts/update-ffi-header.sh`
|
||||||
|
- `skills/agent-desktop-ffi/SKILL.md`
|
||||||
|
- **Approach:** Fill the FFI `AdRefEntry` conversion gap for the fields CLI resolution already uses: value, description, bounds, source app/window/title, surface, root ref, path, and path mode. Do not require `states` for resolution parity unless the CLI resolver actually consumes them; if state data becomes necessary for actionability, make that dependency explicit in U2. Add a ref-action entrypoint or shared command wrapper that accepts ref identity plus action/policy and runs strict resolution, actionability, optional trace context, and execution in one path. Keep the existing native-handle execute API documented as low-level if it remains.
|
||||||
|
- **Patterns to follow:** FFI string conversion in `crates/ffi/src/convert/string.rs`; ABI layout tests in `crates/ffi/tests/c_abi_layout.rs`; existing header update script.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- FFI resolution with value and description can match a target that name-only matching cannot.
|
||||||
|
- FFI resolution with bounds hash rejects a candidate with the wrong bounds.
|
||||||
|
- Invalid UTF-8 in optional identity fields returns `INVALID_ARGS`.
|
||||||
|
- FFI ref-consuming actions run the same preflight as CLI ref-consuming actions.
|
||||||
|
- The low-level native-handle action path, if retained, is documented as bypassing ref reliability semantics.
|
||||||
|
- C ABI layout tests reflect any struct changes.
|
||||||
|
- Header drift check catches stale `agent_desktop.h` after type changes.
|
||||||
|
- **Verification:** CLI and FFI consumers no longer get materially different stale-ref or actionability behavior for the same target identity.
|
||||||
|
- **Delivered:** Expanded `AdRefEntry` to carry the full ref identity envelope, switched FFI resolution to strict resolution, added `ad_execute_ref_action_with_policy`, updated the committed C header, and added ABI/conversion/actionability tests.
|
||||||
|
|
||||||
|
### U6. Cross-Platform Conformance and Documentation
|
||||||
|
|
||||||
|
- **Goal:** Lock the reliability contract before Windows and Linux adapters implement their platform-specific details.
|
||||||
|
- **Requirements:** R12
|
||||||
|
- **Dependencies:** U1, U2, U3, U4, U5
|
||||||
|
- **Files:**
|
||||||
|
- `crates/core/src/refs_test_support.rs`
|
||||||
|
- `crates/core/src/commands/ref_policy_tests.rs`
|
||||||
|
- `crates/core/src/commands/wait_tests.rs`
|
||||||
|
- `tests/conformance/README.md`
|
||||||
|
- `tests/fixtures/README.md`
|
||||||
|
- `tests/fixtures/skeleton-tree.json`
|
||||||
|
- `README.md`
|
||||||
|
- `skills/agent-desktop/SKILL.md`
|
||||||
|
- `skills/agent-desktop/references/workflows.md`
|
||||||
|
- `docs/phases.md`
|
||||||
|
- `docs/solutions/best-practices/playwright-grade-desktop-reliability-2026-06-02.md`
|
||||||
|
- **Approach:** Add core and fixture-level conformance tests for resolver strictness, actionability reason shapes, retrying wait predicates, trace event shape, and session isolation. Define an adapter conformance harness that each real platform adapter can opt into with adapter-provided fixtures. Publish a small capability matrix for required, optional, unsupported, and unknown checks. Update docs to describe refs as snapshot-scoped handles backed by strict late resolution, not stable durable element handles.
|
||||||
|
- **Patterns to follow:** Existing solution docs under `docs/solutions/best-practices/`; existing fixture documentation in `tests/fixtures/README.md`; skill reference organization under `skills/agent-desktop/references/`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Mock adapter fixtures prove zero, one, and many candidate resolution outcomes.
|
||||||
|
- Mock adapter fixtures prove actionability timeout includes the last failed reason.
|
||||||
|
- Fixture snapshots cover a moved-but-unique target and an ambiguous duplicate target.
|
||||||
|
- Adapter conformance fixtures prove required checks can pass on a real adapter before that adapter claims parity.
|
||||||
|
- Documentation examples show stale, ambiguous, and waitable recovery paths.
|
||||||
|
- Windows and Linux stub adapters continue returning supported structured errors until implemented.
|
||||||
|
- **Verification:** A future Windows or Linux adapter has explicit behavioral tests to satisfy before claiming parity.
|
||||||
|
- **Delivered:** Added `tests/conformance/README.md`, a reliability solution note, README reliability contract docs, and cross-platform expectations for stale, ambiguous, actionable, session, trace, and FFI behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
- **Risk: Overbuilding a selector framework.** Mitigation: keep the first pass ref-backed and internal; no new public selector DSL unless future usage proves it.
|
||||||
|
- **Risk: Ambiguity errors feel worse than best-effort clicking.** Mitigation: include candidate summaries and recovery suggestions. For desktop mutation, fail-closed is the safer default.
|
||||||
|
- **Risk: Actionability checks become platform-inconsistent.** Mitigation: core defines the report vocabulary; adapters report supported, unsupported, or unknown checks explicitly.
|
||||||
|
- **Risk: Trace output bloats normal CLI usage.** Mitigation: trace is opt-in and never mixed into stdout envelopes.
|
||||||
|
- **Risk: Session namespaces complicate simple scripts.** Mitigation: default behavior remains unchanged when no session is specified.
|
||||||
|
- **Risk: FFI struct changes affect consumers.** Mitigation: keep changes tied to existing FFI ABI layout tests and header drift checks; document the reliability rationale.
|
||||||
|
- **Risk: Later slices expand before the core path proves value.** Mitigation: U4-U6 are gated by the Execution Strategy; U1-U3 must ship and validate the core behavior first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation and Operational Notes
|
||||||
|
|
||||||
|
- Update README and skill docs to explain three target states: fresh ref, stale ref, and ambiguous target.
|
||||||
|
- Document coordinate and physical commands as lower-confidence fallbacks compared with accessibility-backed refs.
|
||||||
|
- Add a solution note after implementation so future adapter work understands why strict late resolution and actionability are core semantics, not macOS-only details.
|
||||||
|
- Keep Playwright comparisons in docs conceptual. Avoid implying Agent Desktop automates browsers or replaces Playwright.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources and Research
|
||||||
|
|
||||||
|
- Playwright docs: locators, strictness, actionability, auto-waiting, retrying assertions, trace viewer, browser contexts, codegen, and CLI behavior.
|
||||||
|
- Installed Playwright CLI observed locally as `Version 1.58.0`; command surface included `open`, `codegen`, `install`, `screenshot`, `pdf`, and `show-trace`.
|
||||||
|
- Current Agent Desktop ref and resolver code: `crates/core/src/refs.rs`, `crates/core/src/refs_store.rs`, `crates/core/src/commands/helpers.rs`, `crates/macos/src/tree/resolve.rs`, `crates/macos/src/tree/resolve_identity.rs`, and `crates/macos/src/tree/resolve_bounds.rs`.
|
||||||
|
- Current action reliability code: `crates/core/src/action.rs`, `crates/macos/src/actions/chain.rs`, `crates/macos/src/actions/dispatch.rs`, `crates/macos/src/actions/post_state.rs`, and `crates/macos/src/actions/chain_web_steps.rs`.
|
||||||
|
- Current one-shot and wait behavior: `crates/core/src/commands/get.rs`, `crates/core/src/commands/is_check.rs`, and `crates/core/src/commands/wait.rs`.
|
||||||
|
- FFI parity gap: `crates/ffi/src/actions/resolve.rs`.
|
||||||
|
- Cross-platform adapter seam: `crates/core/src/adapter.rs`, `crates/windows/src/adapter.rs`, and `crates/linux/src/adapter.rs`.
|
||||||
478
docs/plans/2026-06-24-001-feat-ffi-completion-plan.md
Normal file
478
docs/plans/2026-06-24-001-feat-ffi-completion-plan.md
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
---
|
||||||
|
title: "feat: Complete the FFI surface and cross-platform parity contract"
|
||||||
|
status: active
|
||||||
|
type: feat
|
||||||
|
date: 2026-06-24
|
||||||
|
depth: deep
|
||||||
|
origin: docs/phases.md (P2-O16, ad_abi_version §613/§632)
|
||||||
|
decisions:
|
||||||
|
- Codegen migration included, sequenced last (after entrypoints are proven)
|
||||||
|
- First external consumer = Python (ctypes) smoke harness in CI
|
||||||
|
- Cross-platform = contract + gates only; no Windows/Linux adapter implementation
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Complete the FFI surface and cross-platform parity contract
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`crates/ffi` already ships a real C ABI — a cdylib plus a committed cbindgen header (`crates/ffi/include/agent_desktop.h`) exposing ~60 `ad_*` functions over the core engine. This plan closes the remaining gaps so the FFI is *fully done* on macOS and *ready to light up* on Windows/Linux with zero new FFI code:
|
||||||
|
|
||||||
|
1. A load-time **ABI-version handshake** (`ad_abi_version`, `ad_init`) so a consumer detects a header/dylib mismatch instead of corrupting memory.
|
||||||
|
2. The missing **command-backed entrypoints** — `ad_snapshot` (full refmap → `@e` refs), `ad_execute_by_ref`, `ad_wait`, `ad_version`, `ad_status`.
|
||||||
|
3. **`ad_set_log_callback`** forwarding `tracing` output to a consumer callback.
|
||||||
|
4. A **Python ctypes smoke harness** in CI — the first real external consumer.
|
||||||
|
5. A **`build.rs` codegen migration** that replaces the hand-written command-backed wrappers with one generated `ad_<name>` per command, sharing its file-scan with a CI exhaustiveness guard so CLI↔FFI parity becomes automatic.
|
||||||
|
|
||||||
|
It deliberately does **not** implement the Windows or Linux adapters. Instead it bakes the parity *contract* — a CI header-drift gate, per-target `release-ffi` builds, and verified `PLATFORM_NOT_SUPPORTED` passthrough — so those adapters expose the same `ad_*` surface for free when they land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
The FFI is the in-process path for non-Rust hosts (Python agents, Swift apps, Go services) to drive desktop automation without spawning the CLI or parsing JSON over a pipe. Today it has three classes of gap:
|
||||||
|
|
||||||
|
- **Safety:** no runtime way for a consumer to check that the dylib it loaded matches the header it compiled against. The only guard is comparing `ad_*_size()` against the hand-written `AD_*_SIZE` macros — partial and easy to skip.
|
||||||
|
- **Completeness:** the ref-based observe→act loop that defines the CLI (snapshot → `@e5` ref → action) is not reachable in one call. `ad_get_tree` returns a tree with **no refs** (`ref_id` is always null); there is no `ad_snapshot`, no `ad_execute_by_ref`, no `ad_wait`. There is no `ad_version`/`ad_status`, and `dlopen` consumers cannot see debug output.
|
||||||
|
- **Maintainability + proof:** the ~60 wrappers are hand-maintained, so CLI/FFI parity is enforced by review, not by construction (drift risk). And nothing outside the repo consumes the ABI — it is built and layout-tested but unproven from another language.
|
||||||
|
|
||||||
|
This plan resolves all three while keeping core untouched in spirit: the FFI calls `core` through the `PlatformAdapter` trait, so cross-platform parity is an architectural property, not new per-platform code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
- ABI-version handshake (`ad_abi_version`, `ad_init`, `AD_ABI_VERSION_MAJOR`).
|
||||||
|
- New command-backed entrypoints: `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_version`, `ad_status`.
|
||||||
|
- `ad_adapter_create_with_session` constructor (session plumbing for refmap persistence — KTD5) and a `stub-adapter` cargo feature for CI/passthrough testing (KTD10).
|
||||||
|
- `ad_set_log_callback` + a `tracing_subscriber` layer.
|
||||||
|
- Python ctypes smoke harness wired into CI.
|
||||||
|
- `build.rs` codegen for the command-backed family + a CI exhaustiveness guard + a header-drift gate.
|
||||||
|
- Cross-platform parity gates: header-drift CI check, per-target `release-ffi` build verification, `PLATFORM_NOT_SUPPORTED` passthrough tests.
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
- Codegen of the **typed-struct, adapter-direct family** (`ad_find`, `ad_get`, `ad_get_tree`, `ad_execute_action`, `ad_resolve_element`). These need a per-parameter marshaling refactor; this plan leaves them hand-written and only codegens the command-backed family (see KTD2).
|
||||||
|
- A Swift native-host example (Python is the CI consumer this round).
|
||||||
|
- Progressive-traversal args (`--skeleton`, `--root @ref`) on `ad_snapshot` — the first cut exposes the full-window snapshot only; the skeleton/drill-down parity is a fast-follow.
|
||||||
|
- ✅ **DONE (commit `9bf4731` on `chore/ffi-header-toolchain`/PR #67):** Restored the 3 ABI header doc comments lost in the cbindgen regen — added `///` docs on the Rust source (`error.rs` `ad_last_error_details` privacy note + `AdResult` forward-compat note; `actions/execute.rs` behavioral descriptions for the `ad_execute_action*` family) and regenerated the header. Done directly on the integration branch (not a separate post-merge PR) since the fold already required the branch to be live.
|
||||||
|
- ✅ **DONE (commit `2aebce2`):** DRY — folded `wait.rs`'s local `app_error_to_adapter_error` into the shared `commands::app_error_to_adapter`; one canonical `AppError→AdapterError` conversion across every ffi command.
|
||||||
|
- **Won't-fix (noted):** `AdWaitArgs::count` is `usize`; on a hypothetical 32-bit target it would shift the `AD_WAIT_ARGS_SIZE=112` layout pin. Only 64-bit targets are supported and the per-platform `const` assert catches it at compile time — revisit only if 32-bit ever ships.
|
||||||
|
|
||||||
|
### Out of scope (different product phase)
|
||||||
|
- **Windows adapter implementation** — Phase 2.
|
||||||
|
- **Linux adapter implementation** — Phase 3.
|
||||||
|
- Any new `Action`/`ErrorCode` variants or new CLI commands (those arrive with the cross-platform phases).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **R1** — A consumer can detect header/dylib incompatibility at load time via `ad_abi_version()` + `ad_init(expected_major)`, before any adapter call. *(origin: docs/phases.md §613)*
|
||||||
|
- **R2** — `ad_snapshot` produces the CLI-format snapshot envelope with `@e` refs and persists the refmap, so a consumer can drive the ref-based observe→act loop.
|
||||||
|
- **R3** — `ad_execute_by_ref` drives a ref action through the **full strict-resolution ladder** (refmap load → strict resolve → `STALE_REF`/`AMBIGUOUS_TARGET` → live actionability → dispatch → handle release) with **CLI-parity policy** (headless default).
|
||||||
|
- **R4** — `ad_version`, `ad_status`, and `ad_wait` expose CLI-equivalent behavior over the ABI.
|
||||||
|
- **R5** — `ad_set_log_callback` forwards `tracing` output to a consumer-supplied callback, thread-safely, without writing to stdout, and never failing a mutation on a trace error.
|
||||||
|
- **R6** — A Python ctypes harness loads the dylib, validates `ad_abi_version` and every `ad_*_size()` against the header, drives the new entrypoints, and runs as a CI gate.
|
||||||
|
- **R7** — The command-backed wrappers are generated by `build.rs` from the per-file command set, with a CI exhaustiveness guard that fails when a command file has no FFI wrapper, and per-command `InteractionPolicy` preserved.
|
||||||
|
- **R8** — The FFI is cross-platform-ready: a CI header-drift gate, per-target `release-ffi` builds, and verified `PLATFORM_NOT_SUPPORTED` passthrough — so Windows/Linux adapters expose the same `ad_*` surface with zero new FFI code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **KTD1 — Expose the ABI version as a runtime getter *and* a cbindgen-emitted constant.** `ad_abi_version() -> u32` is the only mechanism that works for `dlopen` consumers (the caller compiled against its own header copy and can detect a mismatch only at runtime) — confirmed standard by SQLite/libgit2/Botan. Pair it with `AD_ABI_VERSION_MAJOR` emitted *by cbindgen* from a `pub const AD_ABI_VERSION_MAJOR: u32` via `[const] allow_static_const` (→ `static const uint32_t …`), or an `after_includes` `#define` if a preprocessor `#if` form is wanted — **not** hand-maintained. (`[defines]` maps `cfg`→`#ifdef` and does not emit const values.) `ad_init(expected_major)` failing closed is stronger than the field norm (SQLite asserts, libgit2 leaves it to callers) and right for embedding in agents.
|
||||||
|
- **KTD2 — Codegen targets only the command-backed JSON-returning family (Family B).** These commands return `Result<Value, AppError>`, but their call sites are **not** uniform: `version::execute()` takes no args/adapter, `status::execute_with_report_with_context(adapter, &report, &ctx)` needs a precomputed `PermissionReport`, and the standard form is `execute(args, adapter, &ctx)`. So the generator emits a **per-command call site** (not a single `execute` fn-pointer table), sharing only the output convention (KTD9) and the policy table (KTD6). The typed-struct adapter-direct family (Family A) needs bespoke marshaling and stays hand-written.
|
||||||
|
- **KTD3 — Introduce a minimal `CommandDescriptor` for Family B; the codegen and the exhaustiveness guard share one command universe** so generator and guard cannot diverge. The universe is the set of `pub mod` command declarations in `crates/core/src/commands/mod.rs` (cross-checked against the `Commands` enum / dispatch arms) — **not** a `commands/*.rs` glob, which would pull in helper/sub-modules (`helpers.rs`, `wait_mode.rs`, `point_resolve.rs`, `*_tests.rs`) and emit phantom wrappers. No runtime registry, no `inventory`/`linkme` (link-GC unreliable for cdylib per docs/phases.md §631).
|
||||||
|
- **KTD4 — Generated FFI source is committed at a fixed path** (mirroring the committed-header contract), with a CI regenerate-and-diff drift check — not `$OUT_DIR`-only, whose hash-randomized path makes drift checks unreliable. *(learning: deterministic-build-artifact-marker.)*
|
||||||
|
- **KTD5 — `ad_snapshot` uses a default `CommandContext`; session is opt-in.** `AdAdapter` gains an optional `session_id`; a `NULL`/absent session means the sessionless default context. This is the minimum needed to persist the refmap.
|
||||||
|
- **KTD6 — Per-command `InteractionPolicy` stays per-command.** The generator must read a per-command policy table (`type_text` → `focus_fallback`, everything else → `headless`); it must never centrally pick a default. FFI headless default mirrors the CLI. *(learnings: keep-ffi-action-policy-aligned-with-cli, preserve-command-policy-semantics.)*
|
||||||
|
- **KTD7 — The log callback is thread-safe, install-once, best-effort.** `tracing` events fire from arbitrary threads. Install the subscriber once via `OnceLock`; store the swappable callback pointer in an `AtomicPtr` (lock-free, reentrancy-safe) wrapped in a `Send + Sync` newtype — not a `Mutex` (Mutex only if the install allocates under the lock). The pointer's ABI is `unsafe extern "C"` (not `C-unwind`) so a foreign unwind aborts rather than corrupts Rust state. Invocations are best-effort; a trace failure never fails the originating command. *(external: libgit2/Botan install-once; Rust `OnceLock`/`AtomicPtr` idiom.)*
|
||||||
|
- **KTD8 — Envelope-version discipline.** `ad_abi_version`/`ad_version` are additive (no `ENVELOPE_VERSION` bump). Only bump `ENVELOPE_VERSION` (with a `BREAKING CHANGE:` footer) if `ad_status` alters always-present top-level fields. Tests assert through the `ENVELOPE_VERSION` constant, never a string literal. *(learning: envelope-version-bump-contract.)*
|
||||||
|
- **KTD9 — Command-backed entrypoints emit the full CLI envelope.** `commands::{name}::execute(...)` returns only the *data payload*; the `{version, ok, command, data}` envelope is applied by `output::Response::ok(command, data)` (the binary's `finish()` path, `pub` in `crates/core/src/output.rs`). Every command-backed `ad_*` must build the `Response` via `Response::ok`/`Response::err` and serialize *that*, not the raw `Value` — otherwise FFI output diverges from the CLI (e.g. `version` would ship `{version,target,os}` instead of the enveloped form U2's test asserts).
|
||||||
|
- **KTD10 — A `stub-adapter` cargo feature** swaps `build_adapter()` for a not-supported adapter, so the Python CI harness (U9) and the passthrough tests (U10) can exercise the `PLATFORM_NOT_SUPPORTED` path on a macOS runner without AX permission and without a real Windows/Linux adapter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
### ABI-version handshake (load-time)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as C / Python consumer
|
||||||
|
participant L as libagent_desktop_ffi
|
||||||
|
C->>L: dlopen()
|
||||||
|
C->>L: ad_abi_version()
|
||||||
|
L-->>C: u32 (packed major)
|
||||||
|
C->>C: compare to AD_ABI_VERSION_MAJOR (from header)
|
||||||
|
alt incompatible
|
||||||
|
C->>C: refuse to proceed (no adapter calls)
|
||||||
|
else compatible
|
||||||
|
C->>L: ad_init(expected_major)
|
||||||
|
L-->>C: AD_RESULT_OK (or ErrInvalidArgs on mismatch)
|
||||||
|
C->>L: ad_adapter_create() → ... → ad_adapter_destroy()
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two-family wrapper split (governs the codegen boundary)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph A["Family A — typed-struct, adapter-direct (stays hand-written)"]
|
||||||
|
A1["ad_find / ad_get / ad_get_tree<br/>ad_execute_action / ad_resolve_element"]
|
||||||
|
A1 --> AM["bespoke C-struct ↔ Rust marshaling per parameter"]
|
||||||
|
end
|
||||||
|
subgraph B["Family B — command-backed, JSON-returning (codegen target)"]
|
||||||
|
B1["ad_snapshot / ad_version / ad_status<br/>ad_wait / ad_execute_by_ref"]
|
||||||
|
B1 --> BM["uniform: guard_non_null → trap_panic →<br/>commands::name::execute(args, adapter, ctx) →<br/>serialize Value → string_to_c(out)"]
|
||||||
|
BM --> CG["build.rs walks commands/*.rs →<br/>emits one ad_name per command-backed command"]
|
||||||
|
CG --> G["CI exhaustiveness guard shares the same file-scan"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The codegen and the guard read the **same** `crates/core/src/commands/*.rs` set, so a command file that lacks a wrapper fails CI (R7). Family A is explicitly excluded from the walk by a per-command descriptor opt-in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/ffi/
|
||||||
|
├── src/
|
||||||
|
│ ├── abi_version.rs # NEW ad_abi_version, ad_init, AD_ABI_VERSION_MAJOR
|
||||||
|
│ ├── commands/ # NEW command-backed entrypoints (hand-written first, then generated)
|
||||||
|
│ │ ├── snapshot.rs
|
||||||
|
│ │ ├── version.rs
|
||||||
|
│ │ ├── status.rs
|
||||||
|
│ │ ├── execute_by_ref.rs
|
||||||
|
│ │ └── wait.rs
|
||||||
|
│ ├── types/wait.rs # NEW (U7) AdWaitArgs repr(C) struct
|
||||||
|
│ ├── log_callback.rs # NEW ad_set_log_callback + tracing layer
|
||||||
|
│ ├── descriptor.rs # NEW CommandDescriptor + per-command policy table (codegen phase)
|
||||||
|
│ └── generated/ffi_commands.rs # NEW (codegen phase) committed generated wrappers
|
||||||
|
├── build.rs # MODIFIED add codegen step (codegen phase)
|
||||||
|
├── include/agent_desktop.h # MODIFIED via scripts/update-ffi-header.sh
|
||||||
|
└── tests/
|
||||||
|
└── c_abi_*.rs # MODIFIED size + lifecycle + parity tests for new surface
|
||||||
|
tests/ffi-python/ # NEW ctypes smoke harness
|
||||||
|
.github/workflows/ci.yml # MODIFIED header-drift gate + python harness job
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delivery & PR Strategy
|
||||||
|
|
||||||
|
**One PR per unit, against `main`, organized into 5 dependency waves. Parallel *within* a wave, serial *across* waves.** Not every unit depends on the others — the independent ones build and review concurrently; a dependent unit waits only for *its* specific dependency to **merge**, never for the whole plan. (Stacking is rejected — it adds rebase cascades for no benefit on a dependency chain.)
|
||||||
|
|
||||||
|
**Dependency waves** (derived from each unit's `Dependencies`):
|
||||||
|
|
||||||
|
| Wave | Units (run in parallel) | Unblocked when |
|
||||||
|
|------|-------------------------|----------------|
|
||||||
|
| W1 | U1, U2, U3, U8 | immediately — 4 concurrent worktrees |
|
||||||
|
| W2 | U4, U5, U7 | U3 merged |
|
||||||
|
| W3 | U6 | U4 merged |
|
||||||
|
| W4 | U9, U10 | U9: U1+U2+U4 merged · U10: all entrypoints (U1–U8) merged |
|
||||||
|
| W5 | U11 | U9 + all command-backed entrypoints merged |
|
||||||
|
|
||||||
|
Each unit is its own PR/worktree (smallest reviewable diff, max parallelism). Trivial independent units in the *same* wave MAY be bundled into one PR, never across waves. **U8 and U11 always ship alone** (highest miss-risk).
|
||||||
|
|
||||||
|
**Per-unit pipeline — local review gate BEFORE the remote PR:**
|
||||||
|
|
||||||
|
1. **Worktree:** `git worktree add ../ad-ffi-u<N> -b feat/ffi-u<N>-<slug> main` (off the *latest merged* `main`). `ce-worktree` automates this.
|
||||||
|
2. **Build:** the builder agent (`ce-work`) implements the unit in that worktree and commits locally. **No push.**
|
||||||
|
3. **Review (separate agent):** a reviewer audits the worktree diff (`git diff main...HEAD`) — e.g. `ce-code-review mode:agent` (reports findings, does not push).
|
||||||
|
4. **Fix:** builder or you apply fixes in the same worktree.
|
||||||
|
5. **Your visibility:** the worktree is a real local checkout — `cd ../ad-ffi-u<N>`, read the diff, run it, fix anything. Nothing is remote yet.
|
||||||
|
6. **Promote:** when satisfied → `git push -u origin <branch>` → `gh pr create --base main`.
|
||||||
|
7. **Merge:** CI runs on the remote PR → green → squash-merge → `git worktree remove ../ad-ffi-u<N>` + delete branch.
|
||||||
|
|
||||||
|
**How parallel + reviewable coexist:** within a wave, each unit runs steps 1–7 in its **own worktree at the same time** — you review N worktrees concurrently and they merge independently as each goes green. When a wave's units are all merged, the next wave branches off the updated `main`. So the build is parallel within a wave, dependency order is honored across waves, **nothing merges unreviewed, and nothing is built against unmerged code.**
|
||||||
|
|
||||||
|
**Per-PR gate (before step 6):** `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --workspace`, `cargo test -p agent-desktop-ffi` all green; regenerate + commit the header if any `ad_*`/`repr(C)` changed; conventional-commit title; no AI attribution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
> Phases group the units; dependency order is explicit per unit. The codegen migration (U11) is intentionally last so every wrapper is proven by hand before being mechanized. **Delivery is one PR per unit across 5 dependency waves (parallel within a wave) — see Delivery & PR Strategy.**
|
||||||
|
|
||||||
|
### Progress (as of 2026-06-24)
|
||||||
|
|
||||||
|
**Phase A — COMPLETE as 9 stacked PRs against `main` (none merged; review-fix pass in flight).**
|
||||||
|
|
||||||
|
| Unit | PR | Branch | Base | CI |
|
||||||
|
|------|----|--------|------|----|
|
||||||
|
| Foundation (KTD1 self-maintaining header guards + cbindgen pin) | #67 | `chore/ffi-header-toolchain` | `main` | ✅ green |
|
||||||
|
| U1 ABI handshake | #68 | `feat/ffi-abi-handshake` | #67 | ✅ green |
|
||||||
|
| U2 `ad_version` | #69 | `feat/ffi-version` | #67 | ✅ green |
|
||||||
|
| U3 session context | #70 | `feat/ffi-session-context` | #67 | ✅ green |
|
||||||
|
| U8 `ad_set_log_callback` | #71 | `feat/ffi-log-callback` | #67 | ✅ green |
|
||||||
|
| U4 `ad_snapshot` | #72 | `feat/ffi-snapshot` | #70 | ✅ green |
|
||||||
|
| U5 `ad_status` | #73 | `feat/ffi-status` | #70 | ✅ green |
|
||||||
|
| U7 `ad_wait` | #74 | `feat/ffi-wait` | #70 | ✅ green |
|
||||||
|
| U6 `ad_execute_by_ref` | #75 | `feat/ffi-execute-by-ref` | #72 | ✅ green |
|
||||||
|
|
||||||
|
- Each unit built in its own git worktree, full CI-mirrored local gate run per unit before push.
|
||||||
|
- `/ce-code-review` pass landed on every branch (1st round); a 2nd per-worktree review-fix swarm is running (triage → validate → fix → re-gate → force-push).
|
||||||
|
- **Merge order (dependency waves):** #67 → {#68,#69,#70,#71} → {#72,#73,#74} → #75. Restack siblings after each merge (shared `lib.rs`/header/`Cargo.toml`).
|
||||||
|
|
||||||
|
**Phase B (U9 Python smoke, U10 parity gates) — NOT STARTED.**
|
||||||
|
**Phase C (U11 codegen migration) — NOT STARTED.**
|
||||||
|
|
||||||
|
### Phase A — ABI safety + entrypoints
|
||||||
|
|
||||||
|
*Delivery: units span waves W1–W3 — per-unit PRs, parallel within each wave (W1: U1/U2/U3/U8 · W2: U4/U5/U7 · W3: U6). See Delivery & PR Strategy.*
|
||||||
|
|
||||||
|
### U1. ABI-version handshake
|
||||||
|
|
||||||
|
**Goal:** Consumers can detect a header/dylib mismatch at load.
|
||||||
|
**Requirements:** R1.
|
||||||
|
**Dependencies:** none.
|
||||||
|
**Files:** `crates/ffi/src/abi_version.rs` (new), `crates/ffi/src/lib.rs` (module decl), `crates/ffi/cbindgen.toml` (add `[const] allow_static_const`), `crates/ffi/include/agent_desktop.h` (regenerate), `crates/ffi/tests/c_abi_lifecycle.rs`, `crates/ffi/tests/c_header_compile.rs`.
|
||||||
|
**Approach:** `ad_abi_version() -> u32` returns a packed major version (start at `1`). `ad_init(expected_major: u32) -> AdResult` returns `Ok` when compatible, `ErrInvalidArgs` + `set_last_error` otherwise. Emit `AD_ABI_VERSION_MAJOR` from a `pub const` via cbindgen `[const] allow_static_const` (KTD1) — not hand-written — so the header and the getter share one source. No struct → no size guard; document the version-bump rule in a header comment.
|
||||||
|
**Patterns to follow:** existing `ad_*_size()` runtime-getter pattern; `set_last_error_static` for the mismatch message; `trap_panic` wrapper.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `ad_abi_version()` returns the current major (`== AD_ABI_VERSION_MAJOR`).
|
||||||
|
- `ad_init(current_major)` → `AD_RESULT_OK`.
|
||||||
|
- `ad_init(current_major + 1)` and `ad_init(0)` → `ErrInvalidArgs`, and `ad_last_error_message()` is non-null.
|
||||||
|
- `c_header_compile.rs` still compiles with the new `#define`.
|
||||||
|
**Verification:** A consumer reading `ad_abi_version()` before any adapter call can branch on compatibility; mismatched-major `ad_init` fails closed.
|
||||||
|
|
||||||
|
### U2. `ad_version` entrypoint (establishes the JSON-string output pattern)
|
||||||
|
|
||||||
|
**Goal:** Expose `version` over the ABI; lock the command-backed output convention reused by U4–U7.
|
||||||
|
**Requirements:** R4.
|
||||||
|
**Dependencies:** none.
|
||||||
|
**Files:** `crates/ffi/src/commands/version.rs` (new), `crates/ffi/src/lib.rs`, header, `crates/ffi/tests/c_abi_lifecycle.rs`.
|
||||||
|
**Approach:** `ad_version(out: *mut *mut c_char) -> AdResult`. `guard_non_null!(out)`, `trap_panic`, call `commands::version::execute()`, **wrap the payload via `output::Response::ok("version", value)` and serialize the `Response`** (KTD9) — not the raw `Value` — then `string_to_c` → `*out`. No adapter, no context. On error: zero `*out`, `set_last_error`, return code. This `Response::ok` wrapping is the shared output convention U4/U5/U7 reuse. Document `ad_free_string(*out)` ownership.
|
||||||
|
**Patterns to follow:** `crates/ffi/src/convert/string.rs` (`string_to_c`, 1 MB cap); the entry template from research (`guard_non_null!` outside `trap_panic`).
|
||||||
|
**Test scenarios:**
|
||||||
|
- `ad_version(&out)` → `OK`; `out` parses as JSON with `data.version`/`data.target`/`data.os`.
|
||||||
|
- `ad_version(NULL)` → `ErrInvalidArgs`, no write.
|
||||||
|
- After success, `ad_free_string(out)` then `ad_last_error_code()` is unchanged (success doesn't clear prior errno per the documented lifetime).
|
||||||
|
**Verification:** `out` matches `agent-desktop version` JSON byte-for-byte (envelope `version` from `ENVELOPE_VERSION`).
|
||||||
|
|
||||||
|
### U3. `CommandContext` + session plumbing on `AdAdapter`
|
||||||
|
|
||||||
|
**Goal:** Give context-taking commands (`snapshot`/`status`/`wait`) a `CommandContext`, with opt-in session for refmap persistence.
|
||||||
|
**Requirements:** R2, R4 (enabler).
|
||||||
|
**Dependencies:** none.
|
||||||
|
**Files:** `crates/ffi/src/adapter.rs` (add `session_id: Option<String>` to `AdAdapter`; optional `ad_adapter_create_with_session(session: *const c_char)` or a setter), `crates/core/src/context.rs` (confirm minimal constructor), `crates/ffi/tests/c_abi_lifecycle.rs`.
|
||||||
|
**Approach:** Add `session_id: Option<String>` to `AdAdapter` (default `None`). Add a **separate constructor** `ad_adapter_create_with_session(session: *const c_char)` — not a setter, which would introduce mutable state that can race with an in-flight `ad_snapshot`. Tri-state decode the session (`NULL` = sessionless, `""` distinct, invalid UTF-8 → `ErrInvalidArgs`). Build a `CommandContext` at each call boundary from `session_id` via `CommandContext::new(...)` (confirm the exact arg list at `crates/core/src/context.rs:16` — session, trace path, trace-strict). Keep `ad_adapter_create()` working unchanged (sessionless).
|
||||||
|
**Patterns to follow:** tri-state `try_c_to_string` C-string decode (learning: identity-fingerprint §FFI rule); existing `AdAdapter`/`build_adapter()` in `crates/ffi/src/adapter.rs`.
|
||||||
|
**Execution note:** Confirm `CommandContext` minimal construction against `crates/core/src/context.rs:16` before wiring U4/U5/U7.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `ad_adapter_create()` yields a sessionless adapter; context has `session_id() == None`.
|
||||||
|
- session-string constructor with `"agent-a"` → context `session_id() == Some("agent-a")`.
|
||||||
|
- invalid-UTF-8 session bytes → `ErrInvalidArgs`, no adapter leaked.
|
||||||
|
**Verification:** `ad_snapshot`/`ad_status` can construct a valid `CommandContext`; sessionless default path works for the smoke harness.
|
||||||
|
|
||||||
|
### U4. `ad_snapshot` — full refmap pipeline
|
||||||
|
|
||||||
|
**Goal:** One call yields the CLI-format snapshot with `@e` refs and a persisted refmap.
|
||||||
|
**Requirements:** R2.
|
||||||
|
**Dependencies:** U3.
|
||||||
|
**Files:** `crates/ffi/src/commands/snapshot.rs` (new), `crates/ffi/src/lib.rs`, header, `crates/ffi/tests/c_abi_lifecycle.rs`.
|
||||||
|
**Approach:** `ad_snapshot(adapter, args..., out: *mut *mut c_char) -> AdResult`. `require_main_thread()`, `guard_non_null!`, `trap_panic`. Call `crates/core/src/snapshot.rs::run_with_context()` with a `CommandContext` from `AdAdapter.session_id` (U3) so `RefStore::for_session(...).save_new_snapshot(...)` writes `~/.agent-desktop/snapshots/{id}/refmap.json`. Serialize the full envelope (`{version, ok, command:"snapshot", data:{app, ref_count, tree, ...}}`) to `*out`. Decide the minimal arg surface (app/surface/max_depth/interactive_only/compact) — flat scalars or a small size-pinned `AdSnapshotArgs` struct.
|
||||||
|
**Patterns to follow:** `run_with_context` (NOT `transform_tree`, which is `ad_get_tree`'s ref-less path); `convert/string.rs`; struct-size pinning (3 layers) if a struct is introduced.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `ad_snapshot` against `MockAdapter` → `OK`; `out` has `data.ref_count >= 1` and tree nodes carry `@e` refs.
|
||||||
|
- A follow-up `ad_execute_by_ref` (U6) resolves a ref from this snapshot (integration).
|
||||||
|
- `NULL` adapter / `NULL` out → `ErrInvalidArgs`, no write.
|
||||||
|
- `PLATFORM_NOT_SUPPORTED` passthrough: stub adapter → envelope `code == "PLATFORM_NOT_SUPPORTED"`.
|
||||||
|
- Covers refmap persistence: the sessionless default context still persists the refmap and the envelope carries a `snapshot_id` (the refmap is saved regardless of whether a session is set).
|
||||||
|
**Verification:** Output matches `agent-desktop snapshot` JSON shape; refmap file exists and is loadable by a subsequent ref action.
|
||||||
|
|
||||||
|
### U5. `ad_status` entrypoint
|
||||||
|
|
||||||
|
**Goal:** Expose adapter health + permission state over the ABI.
|
||||||
|
**Requirements:** R4.
|
||||||
|
**Dependencies:** U3.
|
||||||
|
**Files:** `crates/ffi/src/commands/status.rs` (new), `crates/ffi/src/lib.rs`, header, `crates/ffi/tests/c_abi_lifecycle.rs`.
|
||||||
|
**Approach:** `ad_status(adapter, out) -> AdResult`. Call `adapter.inner.permission_report()` inline, then `commands::status::execute_with_report_with_context(&*adapter.inner, &report, &ctx)`. Serialize to `*out`. Per KTD8, if the status envelope's always-present top-level fields differ from the CLI status shape, bump `ENVELOPE_VERSION` with a `BREAKING CHANGE:` footer; otherwise no bump.
|
||||||
|
**Patterns to follow:** `crates/core/src/commands/status.rs:11`; envelope-version assertion via `ENVELOPE_VERSION`.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `ad_status` against `MockAdapter` → `OK`; `out` has the status fields and envelope `version == ENVELOPE_VERSION`.
|
||||||
|
- `NULL` adapter/out → `ErrInvalidArgs`.
|
||||||
|
- Stub adapter permission nuance: document + assert that `ad_check_permissions` returns `ErrPermDenied (-1)` (not `-8`) on stubs, while other entrypoints return `-8`.
|
||||||
|
**Verification:** Matches `agent-desktop status`; envelope version asserted through the constant.
|
||||||
|
|
||||||
|
### U6. `ad_execute_by_ref` — strict-resolution ref action
|
||||||
|
|
||||||
|
**Goal:** Drive a ref action (`@e5` + action) with full CLI parity.
|
||||||
|
**Requirements:** R3.
|
||||||
|
**Dependencies:** U4 (needs a refmap to resolve against).
|
||||||
|
**Files:** `crates/ffi/src/commands/execute_by_ref.rs` (new), `crates/ffi/src/lib.rs`, header, `crates/ffi/tests/c_abi_lifecycle.rs`, `crates/ffi/tests/c_abi_actions.rs`.
|
||||||
|
**Approach:** `ad_execute_by_ref(adapter, ref_id: *const c_char, action: *const AdAction, out) -> AdResult`. Tri-state decode `ref_id`, then: (1) load `RefStore::for_session(ctx.session_id()).load(...)` to get the `RefEntry`; (2) build the `ActionRequest` with the **action's CLI base policy** (per KTD6: `TypeText` → `focus_fallback`, every other action → `headless`); (3) call `ref_action::execute_entry(adapter, &entry, request)` — the same `pub(crate)` core path the CLI uses (already called from `crates/ffi/src/actions/execute.rs`), which traverses strict resolve → `STALE_REF`/`AMBIGUOUS_TARGET` → live actionability preflight → dispatch → handle release. An explicit `AdPolicyKind` parameter may *elevate* to headed but must never downgrade an action below its CLI base policy. Reuse the existing `AdAction` C struct (already size-pinned).
|
||||||
|
**Patterns to follow:** `crates/ffi/src/actions/` existing ref-action wrappers; learnings keep-ffi-action-policy-aligned-with-cli + playwright-grade-desktop-reliability (the 7-step ladder).
|
||||||
|
**Test scenarios:**
|
||||||
|
- Valid `@e` ref from a U4 snapshot + click action → `OK`; effect observed (integration via MockAdapter scripted resolution).
|
||||||
|
- Stale/removed ref → `ErrStaleRef`.
|
||||||
|
- Ambiguous twins → `ErrAmbiguousTarget` with candidate summaries in `details`.
|
||||||
|
- `NULL`/invalid-UTF-8 `ref_id` → `ErrInvalidArgs` (null ≠ empty ≠ invalid).
|
||||||
|
- Policy parity: a `TypeText` action with no policy arg defaults to `focus_fallback` (behaving identically to `agent-desktop type`); other actions default to `headless`; an explicit `AD_POLICY_KIND_HEADED` elevates to headed.
|
||||||
|
- Covers AE(ref-action strict resolution).
|
||||||
|
**Verification:** Behavior matches `agent-desktop click @e5` including fail-closed on stale/ambiguous and headless-by-default.
|
||||||
|
|
||||||
|
### U7. `ad_wait` entrypoint
|
||||||
|
|
||||||
|
**Goal:** Expose `wait` (predicates + async appearance) over the ABI.
|
||||||
|
**Requirements:** R4.
|
||||||
|
**Dependencies:** U3.
|
||||||
|
**Files:** `crates/ffi/src/commands/wait.rs` (new), `crates/ffi/src/types/wait.rs` (new C struct), `crates/ffi/src/lib.rs`, header, `crates/ffi/tests/c_abi_layout.rs`, `crates/ffi/tests/c_abi_lifecycle.rs`.
|
||||||
|
**Approach:** Define a flat `AdWaitArgs` `repr(C)` struct mirroring `WaitModeArgs` (7) + `WaitPredicateArgs` + `timeout_ms` + `app` (~14 fields fully flattened; `Option` modeled as nullable pointers / sentinel). Apply the **3-layer size pinning** (Rust `const` assert + header `_Static_assert` + `c_abi_layout.rs` test) and an `ad_wait_args_size()` getter (mandatory for ctypes). Decode + call `commands::wait::execute(...)`. Heaviest marshaling — do last in this phase. **Document that `ad_wait` blocks the calling thread up to `timeout_ms`** (it sleeps internally); consumers must not call it on a thread they need responsive — on macOS the main-thread requirement compounds this (R-F).
|
||||||
|
**Patterns to follow:** `crates/ffi/src/types/action.rs` size-guard reference; learning ffi-repr-c-struct-size-pinning.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `wait` with a `ms` mode → `OK` after the delay.
|
||||||
|
- predicate `actionable`/`visible`/`value` → resolves true against a scripted MockAdapter.
|
||||||
|
- timeout with unmet predicate → `ErrTimeout`, `details` carries last observed state.
|
||||||
|
- `AdWaitArgs` size/alignment/field-offset assertions pass; `ad_wait_args_size()` equals `sizeof`.
|
||||||
|
- `NULL` args/out → `ErrInvalidArgs`.
|
||||||
|
**Verification:** Matches `agent-desktop wait`; struct layout pinned at all three layers.
|
||||||
|
|
||||||
|
### U8. `ad_set_log_callback` + tracing layer
|
||||||
|
|
||||||
|
**Goal:** `dlopen` consumers can capture debug output.
|
||||||
|
**Requirements:** R5.
|
||||||
|
**Dependencies:** none (independent; can land anytime in Phase A).
|
||||||
|
**Files:** `crates/ffi/src/log_callback.rs` (new), `crates/ffi/src/lib.rs`, `crates/ffi/Cargo.toml` (add `tracing-subscriber` dep), `crates/core/src/trace.rs` (extract shared redaction fn), header, `crates/ffi/tests/c_abi_lifecycle.rs`.
|
||||||
|
**Approach:** `ad_set_log_callback(cb: Option<extern "C" fn(level: i32, msg: *const c_char)>) -> AdResult`. Add `tracing-subscriber` to `crates/ffi/Cargo.toml` (only `tracing` is transitively present). Store the swappable callback pointer in an `AtomicPtr` behind a `Send + Sync` newtype (KTD7) — `tracing` fires from arbitrary threads. **Install the subscriber exactly once** (`OnceLock`/`Once` on first registration): `set_global_default` is per-process, so subsequent calls only swap the guarded pointer, never re-install. **Redaction:** `sanitize_trace_value` is wired to the file writer, not a subscriber — extract its key-based redaction into a shared `crates/core/src/trace.rs` fn and apply it in the layer's `on_event` before formatting, so the `SENSITIVE_KEYS` fields never reach the callback. Never write to stdout; a callback/trace failure never fails a command. Passing `NULL` unregisters (swaps the pointer to `None`).
|
||||||
|
**Patterns to follow:** `crates/core/src/trace.rs` subscriber hookup; redaction rules (don't leak secrets to the callback — reuse trace sanitization).
|
||||||
|
**Execution note:** Resolve the thread/subscriber model against `trace.rs` before fixing the callback signature.
|
||||||
|
**Test scenarios:**
|
||||||
|
- Register a callback; a subsequent failing call delivers at least one event with a level + non-null message.
|
||||||
|
- Callback fired from a non-caller thread does not panic across the boundary (spawn a thread that emits a tracing event).
|
||||||
|
- `NULL` unregisters; no further callbacks.
|
||||||
|
- Re-registering a callback (and `NULL` then re-register) swaps the pointer without re-installing the subscriber or erroring.
|
||||||
|
- Secret-bearing fields (the `SENSITIVE_KEYS` set) are redacted in the **callback output** — asserted against the actual delivered message, not the trace file.
|
||||||
|
**Verification:** A consumer sees structured debug output; no stdout pollution; mutations still succeed when the callback errors.
|
||||||
|
|
||||||
|
### Phase B — Proof + cross-platform parity gates
|
||||||
|
|
||||||
|
*Delivery: wave W4 — U9 and U10 in parallel, off `main` after the entrypoints (U1–U8) merge.*
|
||||||
|
|
||||||
|
### U9. Python ctypes smoke harness (first external consumer)
|
||||||
|
|
||||||
|
**Goal:** Prove the ABI works from a non-Rust host and gate it in CI.
|
||||||
|
**Requirements:** R6.
|
||||||
|
**Dependencies:** U1, U2, U4 (minimum: version + abi + snapshot).
|
||||||
|
**Files:** `tests/ffi-python/smoke.py` (new), `tests/ffi-python/README.md` (new), `.github/workflows/ci.yml` (new job).
|
||||||
|
**Approach:** `ctypes.CDLL` loads the `release-ffi` dylib. **First call** is `ad_abi_version()`, asserted `== AD_ABI_VERSION_MAJOR`. Validate each `ad_*_size()` against the header sizes, then drive the **AX-independent** surface (`ad_version`, sizes, the handshake) — these need no permission and form the always-green CI gate. For the adapter path, build with the `stub-adapter` feature (KTD10) whose `build_adapter()` returns a not-supported adapter; the harness then drives `ad_adapter_create` → `ad_snapshot` and asserts a clean `PLATFORM_NOT_SUPPORTED` envelope. **Do not** assert `OK` from `ad_snapshot` on a CI runner without AX permission — the real adapter returns `PERM_DENIED (-1)` there; the real-adapter happy path is covered by the local E2E harness, not this CI gate. Declare `restype`/`argtypes` for every symbol. CI builds `--profile release-ffi -p agent-desktop-ffi` (plus `--features stub-adapter` for the adapter leg).
|
||||||
|
**Patterns to follow:** `crates/ffi/tests/common/mod.rs` extern declarations → ctypes equivalents (`c_int`, `c_char_p`, `c_void_p`); learning ffi-repr-c-struct-size-pinning (ctypes must validate sizes at import).
|
||||||
|
**Test scenarios:**
|
||||||
|
- Library loads; `ad_abi_version()` matches header major.
|
||||||
|
- Every `ad_*_size()` equals the header's `AD_*_SIZE`.
|
||||||
|
- `ad_version` returns parseable JSON with `data.version`.
|
||||||
|
- With `--features stub-adapter`, `ad_adapter_create` → `ad_snapshot` → `ad_free_string` → `ad_adapter_destroy` returns a `PLATFORM_NOT_SUPPORTED` envelope — no crash, no leak. (Real-adapter `OK` is exercised by the E2E harness, not this CI gate.)
|
||||||
|
- Missing-symbol / wrong-arity call surfaces a clear Python error (guards header/binary drift).
|
||||||
|
**Verification:** CI job is green and fails loudly if a symbol, size, or return contract drifts.
|
||||||
|
|
||||||
|
### U10. Cross-platform parity gates
|
||||||
|
|
||||||
|
**Goal:** Make the FFI ready for Windows/Linux adapters with zero new FFI code, enforced by CI.
|
||||||
|
**Requirements:** R8.
|
||||||
|
**Dependencies:** U1–U8 (gates cover the full surface).
|
||||||
|
**Files:** `.github/workflows/ci.yml` (header-drift gate), `scripts/update-ffi-header.sh` (reused), `crates/ffi/tests/c_abi_lifecycle.rs` (passthrough tests), `crates/ffi/include/agent_desktop.h` (doc the permission nuance).
|
||||||
|
**Approach:**
|
||||||
|
- **Header-drift gate:** a CI step installs a pinned cbindgen (e.g. `0.29.x`) and runs `cbindgen crates/ffi --config crates/ffi/cbindgen.toml --output crates/ffi/include/agent_desktop.h --verify` — cbindgen's native `--verify` exits non-zero when the committed header would differ (cleaner than regen + `git diff`). Pin the cbindgen version so a generator upgrade can't false-positive.
|
||||||
|
- **Panic-trap guard:** run the FFI integration tests (or a dedicated step) under `--profile release-ffi`, or assert that `release-ffi` keeps `panic = "unwind"` — otherwise a flip to `panic = "abort"` silently defeats every `catch_unwind` trap in the shipped dylib, and the default `test` profile wouldn't catch it.
|
||||||
|
- **Per-target builds:** confirm `release.yml`'s `build-ffi` matrix already covers macOS×2 + Linux×2 + Windows×1; add a smoke `cargo build --profile release-ffi` per target if not gated on PR.
|
||||||
|
- **Passthrough tests:** for every new entrypoint, call it against a `not_supported()` adapter path and assert the JSON envelope carries `"code": "PLATFORM_NOT_SUPPORTED"` with a non-empty `suggestion`. Document the `ad_check_permissions` → `ErrPermDenied (-1)` exception (stub `permission_report()` returns `Denied`, not absent).
|
||||||
|
**Patterns to follow:** learning playwright-grade-desktop-reliability (core owns contract, adapters supply evidence); `error_code_to_result` mapping in `crates/ffi/src/error.rs`.
|
||||||
|
**Test scenarios:**
|
||||||
|
- Header-drift gate fails on an intentionally stale header (verified once locally), passes when regenerated.
|
||||||
|
- Each new `ad_*` entrypoint against a stub/not-supported adapter → `PLATFORM_NOT_SUPPORTED` envelope (except `ad_check_permissions` → documented `-1`).
|
||||||
|
- `release-ffi` builds on all five targets.
|
||||||
|
**Verification:** The same `ad_*` surface compiles and returns structured not-supported errors on non-macOS today; when a real adapter lands, the surface lights up unchanged.
|
||||||
|
|
||||||
|
### Phase C — Codegen migration (last)
|
||||||
|
|
||||||
|
*Delivery: wave W5 — U11 alone, off `main` after W4 merges.*
|
||||||
|
|
||||||
|
### U11. `build.rs` codegen for the command-backed family + exhaustiveness guard
|
||||||
|
|
||||||
|
**Goal:** Replace the hand-written command-backed wrappers (U2/U4/U5/U6/U7) with generated ones so CLI↔FFI parity is automatic.
|
||||||
|
**Requirements:** R7.
|
||||||
|
**Dependencies:** U2, U4, U5, U6, U7 (must be proven by hand first), U9 (harness proves equivalence post-migration).
|
||||||
|
**Files:** `crates/ffi/src/descriptor.rs` (new `CommandDescriptor` + per-command policy table), `crates/ffi/build.rs` (codegen step), `crates/ffi/src/generated/ffi_commands.rs` (new, committed), `crates/ffi/tests/codegen_exhaustiveness.rs` (new guard), `.github/workflows/ci.yml` (codegen drift gate), `crates/ffi/include/agent_desktop.h` (preserve hand-written `#define`/`_Static_assert` block).
|
||||||
|
**Approach:** Introduce a minimal `CommandDescriptor` (name, arg-decode, **per-command call-site template**, `policy`) for the command-backed family — *not* a single `execute` fn pointer, since the call sites differ (KTD2: `version` no-arg, `status` precomputes `permission_report`, standard `execute(args,adapter,ctx)`). The generator emits, per command, the matching call site + the KTD9 `Response::ok` wrapping + the KTD6 policy, into a **committed** `generated/ffi_commands.rs` (KTD4), in a deterministic (alphabetical) emit order. The command universe is the `commands/mod.rs` pub-mod set (KTD3), never a `*.rs` glob. Preserve the hand-written header augmentation by splitting the header into a cbindgen section and a manual `#define`/`_Static_assert` section the script concatenates. Add a CI step that reruns codegen and diffs the committed output. Add `codegen_exhaustiveness.rs`: it shares the same `mod.rs`-derived universe and fails when a command-backed module has no generated wrapper, and pins each command→policy mapping.
|
||||||
|
**Patterns to follow:** learnings exhaustiveness-guards-over-catch-alls (machine-derived command universe + per-case pins), deterministic-build-artifact-marker (committed output, stable drift path), preserve-command-policy-semantics (no central policy).
|
||||||
|
**Execution note:** Characterize first — assert the hand-written wrappers' outputs are equivalent before and after migration. Because `snapshot_id`/timestamps are non-deterministic (`new_snapshot_id()` = time+random+counter), the harness **masks volatile fields** (snapshot_id, any timestamp) to a sentinel before diffing; equivalence holds on the masked form.
|
||||||
|
**Test scenarios:**
|
||||||
|
- Generated `ad_<name>` output equals the hand-written wrapper (volatile fields masked) for `version`/`snapshot`/`status` (characterization).
|
||||||
|
- The `mod.rs`-derived universe excludes helper modules (`helpers`, `wait_mode`, `point_resolve`) — no phantom `ad_*` wrapper is generated for them.
|
||||||
|
- Exhaustiveness guard fails when a new command file is added without a descriptor/wrapper.
|
||||||
|
- Policy pins: `type`-family → `focus_fallback`; all others → `headless`.
|
||||||
|
- Codegen drift gate fails on a stale committed `generated/ffi_commands.rs`.
|
||||||
|
- The hand-written `AD_*_SIZE`/`_Static_assert` header block survives regeneration.
|
||||||
|
**Verification:** Adding a new command-backed CLI command auto-produces its `ad_<name>` (or fails CI), with no hand-edited wrapper and no policy drift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Platform Extension (the Windows/Linux question)
|
||||||
|
|
||||||
|
**Answer: yes — by construction, with zero new FFI code.** The FFI calls `core`, which dispatches through the `PlatformAdapter` trait. The Windows and Linux crates today carry an empty `impl PlatformAdapter for {Platform}Adapter {}`, so all ~25 trait methods inherit `Err(AdapterError::not_supported(...))` → `ErrorCode::PlatformNotSupported` → `AdResult::ErrPlatformNotSupported (-8)` at the boundary. Every `ad_*` call therefore already returns a structured not-supported error on those platforms. When Phase 2 (Windows) and Phase 3 (Linux) implement the trait, the **same** `ad_*` surface lights up — no per-platform FFI wrappers (docs/phases.md §1034 confirms Phase 3 adds none; §993 notes a Windows FFI cdylib already ships).
|
||||||
|
|
||||||
|
This plan makes that future safe rather than assumed:
|
||||||
|
- **U10 header-drift gate** keeps the one committed header correct across all targets.
|
||||||
|
- **U10 per-target `release-ffi` builds** prove the cdylib compiles for macOS/Linux/Windows every release.
|
||||||
|
- **U10 passthrough tests** prove the not-supported envelope is correct *now*, before any adapter exists.
|
||||||
|
- **One nuance to document:** `ad_check_permissions` returns `ErrPermDenied (-1)` on stub platforms (the default `permission_report()` returns `Denied`, not absent), while every other entrypoint returns `-8`. Cross-platform callers should treat both as "unavailable here."
|
||||||
|
- **One caveat (R-G):** passthrough is automatic for adapter-method calls, but `ad_snapshot`'s `RefStore` persistence uses `std::fs` + advisory locking, whose semantics differ on Windows. That path is validated when the Windows adapter lands — it is not covered by `not_supported()` passthrough.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **R-A — Codegen wipes the hand-written header block.** The `AD_*_SIZE`/`_Static_assert` macros are manual augmentation cbindgen doesn't emit; naive regeneration loses them. *Mitigation:* KTD4 committed output + the U11 split-or-emit strategy + a test that asserts the block survives.
|
||||||
|
- **R-B — Generator flattens per-command policy.** A catch-all default would silently break `type`'s `focus_fallback`. *Mitigation:* KTD6 explicit policy table + U11 per-case policy pins (learnings).
|
||||||
|
- **R-C — Log-callback cross-thread unsafety.** `tracing` fires off-thread; a naive global pointer is a data race / use-after-free. *Mitigation:* KTD7 guarded pointer, best-effort, `NULL` unregister; resolve against `trace.rs` first.
|
||||||
|
- **R-D — `ad_snapshot` refmap persistence.** `run_with_context` saves the refmap unconditionally via `RefStore::for_session(ctx.session_id())` — the **sessionless default context still persists** it under the default namespace (KTD5). The only failure mode is calling a tree path that bypasses `run_with_context`. *Mitigation:* U3 (context plumbing) before U4; never bypass `run_with_context`.
|
||||||
|
- **R-E — CI lacks cbindgen.** The drift gate needs cbindgen on the runner. *Mitigation:* U10 installs it; if unavailable, gate degrades to the existing `c_header_compile.rs` (catches missing decls, not stale ones) and the risk is noted.
|
||||||
|
- **R-F — `ad_wait` blocks the calling thread.** It sleeps up to `timeout_ms`; a consumer calling it on a UI/main thread freezes that thread, and macOS's main-thread requirement compounds it. *Mitigation:* U7 documents that callers must run `ad_wait` off any thread they need responsive.
|
||||||
|
- **R-G — `RefStore` file-locking is not cross-platform-uniform.** `ad_snapshot`'s refmap persistence uses `std::fs` + advisory locking; Windows semantics differ (NTFS vs POSIX). The "zero new FFI code" claim holds for adapter-method passthrough but **not** automatically for `RefStore` paths. *Mitigation:* validate `RefStore` on Windows as part of the Phase 2 adapter work; flagged here so it isn't assumed solved.
|
||||||
|
- **Dependency:** `CommandContext::new` arg list (`crates/core/src/context.rs:16`) confirmed present; exact minimal construction verified in U3 before U4/U5/U7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- **docs/phases.md** — P2-O16 (FFI registry migration + parity expansion, §687), `ad_abi_version` gap (§613, §632), no-`inventory` decision (§631), Phase 3 adds no FFI wrappers (§1034), Windows FFI cdylib already ships (§993).
|
||||||
|
- **Repo research (this session):** no command registry (hand-match in `src/dispatch/mod.rs`); two wrapper families; header regenerated by `scripts/update-ffi-header.sh` (not in build graph), no CI drift check; `ad_get_tree` is ref-less, `snapshot.rs::run_with_context` is the refmap path; `ad_abi_version` best as a runtime fn; stub passthrough automatic (`-8`) with the `ad_check_permissions` `-1` nuance; `release.yml build-ffi` covers 5 targets.
|
||||||
|
- **Institutional learnings (`docs/solutions/best-practices/`):** ffi-repr-c-struct-size-pinning (3-layer pinning + mandatory `ad_*_size()` for ctypes), keep-ffi-action-policy-aligned-with-cli (headless default, CI parity gate), preserve-command-policy-semantics (no central policy in shared dispatch), exhaustiveness-guards-over-catch-alls (machine-derived command universe + per-case pins), envelope-version-bump-contract (`ENVELOPE_VERSION` discipline), identity-fingerprint (tri-state C-string decode), playwright-grade-desktop-reliability (strict-resolution ladder + core-owns-contract), deterministic-build-artifact-marker (committed codegen output for stable drift checks).
|
||||||
|
- **External research (cbindgen docs via Context7 + industry prior art, 2024–2026):** validated the core FFI choices against current practice and named exemplars — **all confirmed**, with refinements folded in (cbindgen-emitted `AD_ABI_VERSION_MAJOR` per KTD1; `cbindgen --verify` drift gate; `OnceLock`/`AtomicPtr` callback per KTD7; the release-ffi panic-trap guard in U10). Exemplars: SQLite / libgit2 / Botan (version macro + runtime getter — `ad_init` fail-closed is *stronger* than their norm); libgit2 / Botan / wgpu-native (opaque handle + create/destroy + paired free); libgit2 / pact_ffi / ffi_helpers / Botan (thread-local errno last-error); libsodium (`*_size()` getters + ctypes size checks); cbindgen `--verify` (committed-header drift gate). Sources: cbindgen 0.29 docs, Rust Nomicon/Reference (panic, `C-unwind`), Effective Rust Item 34, pact_ffi, ffi_helpers, and the libgit2 / SQLite / Botan / libsodium / wgpu-native API references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Builder Notes (for ce-work)
|
||||||
|
|
||||||
|
High-signal execution guidance not fully captured in the units. **The repo is the source of truth — verify against current code; line numbers here drift.**
|
||||||
|
|
||||||
|
**Gates — every unit passes before "done":**
|
||||||
|
- `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings` (zero warnings), `cargo test --workspace`.
|
||||||
|
- FFI also: `cargo test -p agent-desktop-ffi` — the `c_abi_layout.rs` / `c_abi_lifecycle.rs` / `c_header_compile.rs` tests MUST stay green; extend them, never break ABI layout.
|
||||||
|
- Match each unit's test scenarios to real evidence (diff + passing test); do **not** call a unit done from the diff alone.
|
||||||
|
|
||||||
|
**FFI gotchas that will bite:**
|
||||||
|
- Build/run the cdylib with `--profile release-ffi` (panic=unwind). Default `release` is panic=abort → silently breaks every `catch_unwind`/`trap_panic` (U10 panic-trap guard).
|
||||||
|
- After touching ANY exported `ad_*` symbol or `repr(C)` struct: run `scripts/update-ffi-header.sh`, commit the regenerated `crates/ffi/include/agent_desktop.h` (committed contract; cbindgen is NOT in the build graph).
|
||||||
|
- New adapter-touching entrypoint: call `require_main_thread()` before any `adapter.inner.*` (macOS); put `guard_non_null!` OUTSIDE the `trap_panic` closure; zero out-params on error; document `ad_free_*` ownership.
|
||||||
|
- Every new `repr(C)` struct: 3-layer pin — Rust `const` assert + `c_abi_layout.rs` test + `ad_*_size()` getter (ctypes consumer needs the getter). Mirror `crates/ffi/src/types/action.rs`.
|
||||||
|
|
||||||
|
**Verified facts — do NOT re-derive or assume:**
|
||||||
|
- `version::execute()` (and the other command fns) return the DATA payload only — wrap via `output::Response::ok(command, data)` (KTD9) or FFI output won't match the CLI envelope.
|
||||||
|
- `status` → `execute_with_report_with_context(adapter, &report, &ctx)` (precompute `adapter.inner.permission_report()`).
|
||||||
|
- `type_text` base policy is `focus_fallback`, not headless (U6 / KTD6).
|
||||||
|
- Command universe = the `pub mod` set in `crates/core/src/commands/mod.rs`, NOT a `commands/*.rs` glob (helper/`wait_*`/`*_tests` files exist there).
|
||||||
|
- `crates/ffi/Cargo.toml` lacks `tracing-subscriber` — add `tracing-subscriber.workspace = true` for U8.
|
||||||
|
- `build_adapter()` always builds the real macOS adapter → CI needs the `stub-adapter` feature (KTD10); never assert `OK` from `ad_snapshot` on a permission-less runner (it returns `PERM_DENIED -1`, not `-8`).
|
||||||
|
|
||||||
|
**Confirm-before-coding (verify against code first):**
|
||||||
|
- `CommandContext::new` exact arg list — `crates/core/src/context.rs`.
|
||||||
|
- `ad_set_log_callback` subscriber/thread model — `crates/core/src/trace.rs` (extract the shared redaction fn there).
|
||||||
|
|
||||||
|
**Sequencing is strict:** U3 → U4/U5/U7; U4 → U6. **Codegen (U11) is LAST** — hand-write, prove, and characterize the entrypoints (with volatile-field masking) before mechanizing; do not start U11 early.
|
||||||
|
|
||||||
|
**Repo conventions:** 400 LOC/file hard limit; one command/struct per file; zero `unwrap()` in non-test code; no inline comments (only `///`); conventional commits, **no `Co-Authored-By` / AI attribution**; the pre-commit hook runs fmt/clippy/test — don't bypass it.
|
||||||
|
|
||||||
|
**This plan stays local — never `git add` / commit anything under `docs/plans/`.**
|
||||||
1496
docs/plans/2026-06-28-macos-core-hardening-validated.json
Normal file
1496
docs/plans/2026-06-28-macos-core-hardening-validated.json
Normal file
File diff suppressed because it is too large
Load diff
262
docs/plans/2026-06-29-001-feat-wait-for-selector-flag-plan.md
Normal file
262
docs/plans/2026-06-29-001-feat-wait-for-selector-flag-plan.md
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
---
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
execution: code
|
||||||
|
type: feat
|
||||||
|
product_contract_source: ce-plan-bootstrap
|
||||||
|
origin: https://github.com/lahfir/agent-desktop/issues/84
|
||||||
|
created: 2026-06-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: add `--wait-for` selector polling flags
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add global `--wait-for` / `--wait-for-gone` (with `-w` short for `--wait-for`) and `--wait-timeout` flags that poll the accessibility tree until an element matching a compact selector appears (or disappears), then return the resulting snapshot. This collapses the agent-side "snapshot → check → sleep → retry" loop into a single invocation, and lets action commands confirm a post-action UI change in one call (`click @e5 -w ":Saved!"`).
|
||||||
|
|
||||||
|
The feature is **pure CLI orchestration over existing engines** — it reuses `find`'s matcher and `wait`'s poll loop. No new platform/AX code, no CSS parser, no new adapter methods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
Today an agent that needs to wait for a UI state change (modal appears, spinner disappears, button becomes available) must hand-roll a polling loop: `snapshot`, inspect JSON, `sleep`, retry. That burns tokens, adds latency, and is re-implemented in every agent workflow.
|
||||||
|
|
||||||
|
The **engine** to avoid this already shipped in 0.4.4 as the `wait` command (`wait --text/--window/--element … --timeout`), which polls with 200ms→1s backoff and returns a `kind: "wait_timeout"` error envelope on expiry. What's missing is **ergonomics**: a single-string selector and a flag that fuses the wait onto the command that produces the snapshot, so `click; wait; snapshot` becomes `click … -w "…"`. Issue #84 asks for exactly this, and explicitly frames it as small CLI-only work.
|
||||||
|
|
||||||
|
The issue's `button:contains('Submit')` spelling is **illustrative, not a contract** — its own "Key design points" say "Reuses existing selector/filter infrastructure," which is `find`'s `--role/--name/--text/--value` + `node_matches`. We honor the latter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **R1.** A global `--wait-for <SELECTOR>` flag (short `-w`) blocks until an element matching `<SELECTOR>` is present in the polled tree, then returns the snapshot.
|
||||||
|
- **R2.** A global `--wait-for-gone <SELECTOR>` flag blocks until **no** element matches `<SELECTOR>`, then returns the snapshot (covers "spinner disappears").
|
||||||
|
- **R3.** A global `--wait-timeout <MS>` flag bounds the poll (default 30000ms, matching `wait`). On expiry the command exits 1 with a `kind: "wait_timeout"`, `predicate: "selector"` error envelope, consistent with `wait`.
|
||||||
|
- **R4.** `<SELECTOR>` is a compact `role:text` string parsed into a `FindQuery` and matched by the **same** `node_matches` used by `find`. Forms: `"button:Submit"` (role+text), `"button"` (role only), `":Saved!"` (text only).
|
||||||
|
- **R5.** `snapshot --wait-for …` polls the snapshot's own scope (`--app`/`--window-id`/`--surface`/depth) and returns the matched snapshot.
|
||||||
|
- **R6.** Ref-resolving action commands (`click`, `double-click`, `triple-click`, `right-click`, `clear`, `focus`, `toggle`, `check`, `uncheck`, `expand`, `collapse`, `scroll-to`, `type`, `set-value`, `select`, `scroll`) accept `--wait-for`/`--wait-for-gone`: perform the action, then poll **the app the acted-on ref belongs to** (`entry.source_app`), returning the post-action snapshot with the original action result preserved.
|
||||||
|
- **R7.** `--wait-for` and `--wait-for-gone` are mutually exclusive (clap `conflicts_with`, exit 2); `--root` and `--wait-for`/`--wait-for-gone` are mutually exclusive on `snapshot` (ref-rooted drill-down has no poll semantics).
|
||||||
|
- **R8.** Passing `--wait-for`/`--wait-for-gone` to a command that does **not** support it (anything other than `snapshot` and the R6 action commands) is an `INVALID_ARGS` error, not a silent no-op. The supporting set is a single `&[&str]` checked at dispatch time.
|
||||||
|
- **R9.** Batch mode does not honor an outer `--wait-for`; batch items run independently and never inherit the CLI-level selector.
|
||||||
|
- **R10.** No new platform-adapter methods; `agent-desktop-core` remains free of platform crates (CI `cargo tree` gate stays green).
|
||||||
|
|
||||||
|
**Success criteria:** an agent replaces a multi-call poll loop with one `snapshot -w "<selector>"`; a `click @e5 -w ":Saved!"` returns only after "Saved!" is visible; a timeout returns exit 1 with a discriminable envelope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
**KTD1 — Compact `role:text` selector, not a DSL.** Parse on the first `:` into role (left) and text (right); each side is **trimmed and an empty side becomes `None`** (unconstrained on that axis) — never `Some("")`. This matters: `search_text::contains` runs `haystack.as_bytes().windows(needle.len())`, which **panics on a zero-length needle**, so `"button:"`, `":"`, and `" : "` must collapse the empty side to `None` rather than threading `Some("")` into `node_matches`. The result is a `FindQuery` fed to `node_matches`. This keeps the single-string ergonomics the issue shows while adding ~5 lines of parsing instead of a CSS grammar that would become a permanent public contract. Role is normalized via `roles::normalize_role_query`; text via `search_text::normalize` (the exact path `find` uses), so selector semantics equal `find` semantics by construction. `node_matches`'s `text` axis already searches name/value/description (`search_text::node_contains`), so `window:Settings` matches a node whose role is `window` and whose text contains "Settings"; matching by *window title* specifically is out of scope (use `wait --window` for that).
|
||||||
|
|
||||||
|
**KTD2 — One matcher, promoted out of `find`.** `FindQuery` and `node_matches` are currently private in `commands/find.rs`. Promote them (plus the `role:text` parser) into a new `commands/query.rs`; `find` imports them. This is the anti-redundancy move: the wait path does not get a parallel matcher — both call sites share one. Behavior of `find` is unchanged (pure move + re-export).
|
||||||
|
|
||||||
|
**KTD3 — One poll helper, reusing the `wait` loop shape.** New `commands/wait_selector.rs` mirrors `wait_for_text`: `snapshot::build` (cheap, no persist) in a 200ms→1s backoff loop, run `node_matches` over the tree, persist the refmap and return the snapshot envelope only on match. Presence vs. absence is one boolean (`gone`). **Retryable poll set is wider than `wait_for_text`'s** — `Timeout`, `ElementNotFound`, **`AppNotFound`, and `WindowNotFound`** are all swallowed and surfaced as `last_error` on timeout. This is the correctness fix for the common post-action case where the action closes/navigates the target (`click @submit -w ":Saved!"` dismisses the dialog): without `AppNotFound`/`WindowNotFound` in the retryable set, the poll would propagate a raw `WINDOW_NOT_FOUND` instead of the promised `wait_timeout` envelope. The empty/match-everything selector guard lives **here** (before the loop) so both call sites (snapshot self-apply and post-action) reject it identically.
|
||||||
|
|
||||||
|
**KTD4 — Global flags carried in `CommandContext`, applied at two scoped call sites, both keyed to the *correct* app.** The flags are defined once as clap globals (like `--headed`) and threaded into `CommandContext` (new `WaitSelector` config, set via a `with_wait_selector(...)` builder mirroring `with_headed`). Two thin application points, both delegating to the single `wait_selector` helper:
|
||||||
|
- `snapshot::execute` self-applies when a selector is set, scoped to its own `--app`/opts (correct ordering: wait *is* the snapshot; no wasted eager build).
|
||||||
|
- The **ref-action path in `helpers.rs`** applies it after the action, scoped to **`entry.source_app`** — the app the resolved ref belongs to — embedding the action result under `data.after_action`.
|
||||||
|
|
||||||
|
**Why not a binary post-dispatch hook (rejected).** A hook in `run_with_adapter` only sees the action's JSON result, not the ref's pid/app. In the default **headless** mode AXPress does not foreground the target app, so the frontmost app is usually the *terminal*, not the acted-on app — a focused-app poll would read the wrong tree and time out on the default code path. The ref-action path already holds the `RefEntry` (`source_app`), so scoping there is both correct and the natural place that covers the ref-resolving commands R6 enumerates. This is layering, not duplication — the loop, matcher, and persistence live solely in the helper.
|
||||||
|
|
||||||
|
**Wiring reaches all 16 R6 commands via the two `helpers.rs` entrypoints** (verified against the codebase): 11 commands (`click`, `double-click`, `triple-click`, `clear`, `focus`, `toggle`, `check`, `uncheck`, `expand`, `collapse`, `scroll-to`) go through `execute_ref_action_with_context`; 5 (`type`, `set-value`, `select`, `scroll`, **`right-click`**) go through `execute_ref_action_result_with_context` and then serialize themselves. `right-click` is the only one that builds a custom JSON body (its menu probe), so its wait must run **after** the probe (action → menu probe → wait). `execute_by_ref` (the FFI bridge) also routes through `execute_ref_action_with_context` but is unaffected: the FFI `CommandContext` never carries a `WaitSelector`, so `apply_post_action_wait` early-returns.
|
||||||
|
|
||||||
|
**KTD5 — Timeout returns the `wait` error envelope **plus** the last snapshot's id.** The issue says "return the last snapshot on timeout," but the established `wait` convention is a `TIMEOUT` error (`kind: "wait_timeout"`) carrying a compact `last_error`, and `finish()` maps `Err` → exit 1. We keep the error-envelope convention (consistency, no bespoke success-on-timeout path) **but honor the issue's debuggability intent**: on timeout, persist the last successfully-built tree and include its `snapshot_id` in `details` alongside `last_error`, so the agent can inspect the final tree state without racing a fresh `snapshot`. For post-action timeouts the original action result is also embedded under `details.after_action` so the caller learns the action ran.
|
||||||
|
|
||||||
|
**KTD6 — CLI-only; FFI parity deferred.** The flags live in the binary/CLI + core helper. The FFI cdylib does not gain a `--wait-for` surface in this PR (the issue scopes this as "purely a CLI orchestration feature"). The `wait_selector` core helper is reusable by a future `ad_wait_for` entrypoint; noted under Deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
```
|
||||||
|
--wait-for / --wait-for-gone / --wait-timeout (clap globals)
|
||||||
|
|
|
||||||
|
CommandContext (WaitSelector)
|
||||||
|
|
|
||||||
|
+-----------------------+------------------------+
|
||||||
|
| |
|
||||||
|
snapshot::execute helpers.rs ref-action path
|
||||||
|
(selector set?) (after action, selector set?)
|
||||||
|
| |
|
||||||
|
| scope = --app/opts | action runs first -> ActionResult
|
||||||
|
| | scope = entry.source_app (correct in headless)
|
||||||
|
+----------------------+ +---------------------+
|
||||||
|
| |
|
||||||
|
commands/wait_selector::execute
|
||||||
|
|
|
||||||
|
loop: snapshot::build -> node_matches (present|absent)
|
||||||
|
match -> persist refmap, return snapshot envelope
|
||||||
|
timeout -> wait_timeout::selector(...) (exit 1)
|
||||||
|
|
|
||||||
|
commands/query.rs
|
||||||
|
FindQuery + node_matches + parse_selector
|
||||||
|
(shared with find.rs)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Extract shared selector matcher into `query.rs`
|
||||||
|
|
||||||
|
**Goal:** Single matcher shared by `find` and the wait path; add the `role:text` parser.
|
||||||
|
**Requirements:** R4, KTD1, KTD2.
|
||||||
|
**Dependencies:** none.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/commands/query.rs` (new) — move `FindQuery`, `node_matches`, `count_matches`'s predicate use; add `pub fn parse_selector(&str) -> FindQuery`.
|
||||||
|
- `crates/core/src/commands/find.rs` — delete the moved items, import from `query`.
|
||||||
|
- `crates/core/src/commands/mod.rs` — register `query`.
|
||||||
|
- `crates/core/src/commands/query_tests.rs` (new).
|
||||||
|
**Approach:** Pure refactor + addition. `FindQuery::from_args` stays in `find` (it depends on `FindArgs`); the reusable pieces are `FindQuery` the struct, `node_matches`, and the new `parse_selector`. `parse_selector` splits once on `:`; **trims each side; an empty-after-trim side becomes `None`, never `Some("")`** (a `Some("")` text would reach `search_text::contains` and panic on `windows(0)` — see KTD1); role through `roles::normalize_role_query`, text through `search_text::normalize`. Also add `pub fn tree_has_match(&AccessibilityNode, &FindQuery) -> bool` here (short-circuiting presence check) so `wait_selector` shares the exact predicate. `find`'s public behavior must not change.
|
||||||
|
**Patterns to follow:** existing `FindQuery`/`node_matches` in `find.rs`; `roles::normalize_role_query` and `search_text::normalize/contains/node_contains`.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `parse_selector("button:Submit")` → role normalized to `button`, text normalized to `submit`.
|
||||||
|
- `parse_selector("button")` → role set, text `None`.
|
||||||
|
- `parse_selector(":Saved!")` → role `None`, text set.
|
||||||
|
- `parse_selector("")`, `parse_selector(":")`, `parse_selector(" : ")` → **both `None`** (match-everything; rejected by `wait_selector`, see U2).
|
||||||
|
- `parse_selector("button:")` → role `button`, text **`None`** (not `Some("")`).
|
||||||
|
- `parse_selector(" button : Submit ")` → trimmed to role `button`, text `submit`.
|
||||||
|
- `parse_selector("textfield:a:b")` → splits on first colon only; text is `a:b`.
|
||||||
|
- A `FindQuery { text: None }` never causes `search_text::contains` to be called with an empty needle (guards the `windows(0)` panic).
|
||||||
|
- `node_matches`/`tree_has_match` over a fixture tree returns identical results before/after the move (regression).
|
||||||
|
**Verification:** `find` tests pass unchanged; new parser tests pass; `cargo tree -p agent-desktop-core` shows no platform crates.
|
||||||
|
|
||||||
|
### U2. `wait_selector` poll helper
|
||||||
|
|
||||||
|
**Goal:** The reusable poll-until-present/absent engine returning a snapshot envelope.
|
||||||
|
**Requirements:** R1, R2, R3, R5, KTD3, KTD5.
|
||||||
|
**Dependencies:** U1.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/commands/wait_selector.rs` (new).
|
||||||
|
- `crates/core/src/commands/wait_timeout.rs` — add `pub(crate) fn selector(selector: &str, gone: bool, timeout_ms, last_error, last_snapshot_id: Option<String>) -> Result<Value, AppError>` (`predicate: "selector"`, `kind: "wait_timeout"`, includes `snapshot_id` in details when a tree was built).
|
||||||
|
- `crates/core/src/commands/mod.rs` — register `wait_selector`.
|
||||||
|
- `crates/core/src/commands/wait_selector_tests.rs` (new).
|
||||||
|
**Approach:** `execute(WaitSelectorInput { query, gone, app, opts, timeout_ms }, adapter, context)`. **First, reject a match-everything query** (both axes `None`) with `INVALID_ARGS` — this single guard covers both call sites (snapshot self-apply and post-action). Loop mirrors `wait_for_text`: `snapshot::build(adapter, &opts, app, None)`; `present = query::tree_has_match(&tree, &query)`; `matched = if gone { !present } else { present }`. On match: `RefStore::for_session(...).save_new_snapshot(&result.refmap)`, return a JSON object with **the same field shape `snapshot::execute` emits** (`app`, `window`, `ref_count`, `snapshot_id`, `tree`) plus `elapsed_ms` and `matched_selector`. `snapshot::format_result` is private — either promote it to `pub(crate)` and reuse it, or replicate the shape (the field set is the contract). Swallow the retryable set (`Timeout`, `ElementNotFound`, `AppNotFound`, `WindowNotFound`) into `last_error`, **and on each successful build remember the built tree's persisted id as `last_snapshot_id`**. On deadline call `wait_timeout::selector(... , last_error, last_snapshot_id)`. Reuse the 200ms→1s doubling backoff verbatim.
|
||||||
|
**Patterns to follow:** `wait::wait_for_text` (loop, backoff, retryable-error handling, persist-on-match, `last_error`); `snapshot::format_result` (envelope shape — promote to `pub(crate)` if reused).
|
||||||
|
**Test scenarios** (MockAdapter; deterministic trees):
|
||||||
|
- Match-everything query (`FindQuery` both `None`) → `Err` `INVALID_ARGS` before any poll.
|
||||||
|
- Element present on first poll → returns snapshot envelope with `matched_selector`, `elapsed_ms`, a persisted `snapshot_id`.
|
||||||
|
- Element absent then present on Nth poll → returns once present (mock whose tree changes across calls).
|
||||||
|
- `gone=true`, element present then absent → returns when absent.
|
||||||
|
- `gone=true`, element never present → returns immediately (already gone).
|
||||||
|
- Never matches within timeout → `Err` `code == TIMEOUT`, details `kind=="wait_timeout"`, `predicate=="selector"`, `last_error` populated, **`snapshot_id` of the last built tree present in details**.
|
||||||
|
- Each retryable error (`ElementNotFound`, `AppNotFound`, `WindowNotFound`) mid-poll is swallowed, not surfaced, until timeout — covers the "action closed the window" case.
|
||||||
|
- Persisted snapshot is loadable by the returned `snapshot_id` (refs usable by a follow-up action).
|
||||||
|
**Verification:** all unit tests pass; timeout envelope is JSON-discriminable from a `chain_deadline` timeout.
|
||||||
|
|
||||||
|
### U3. Global flags + `CommandContext` wiring + snapshot self-apply
|
||||||
|
|
||||||
|
**Goal:** Define the flags once, carry them, reject them on unsupported commands, and make `snapshot` honor them in its own scope.
|
||||||
|
**Requirements:** R1, R2, R3, R5, R7, R8, R9, KTD4.
|
||||||
|
**Dependencies:** U2.
|
||||||
|
**Files:**
|
||||||
|
- `src/cli/mod.rs` — add three `global = true` args: `--wait-for`/`-w` (`Option<String>`, `conflicts_with = "wait_for_gone"`), `--wait-for-gone` (`Option<String>`), `--wait-timeout` (`u64`, default 30000). The `--wait-for`/`--wait-for-gone` pair is mutually exclusive via clap `conflicts_with` (exit 2).
|
||||||
|
- `crates/core/src/context.rs` — add `WaitSelector { query_raw: String, gone: bool, timeout_ms: u64 }` carried in `CommandContext`; `with_wait_selector(Option<WaitSelector>)` builder mirroring `with_headed`; accessor. **Update `for_batch_item` to set `wait_selector: None`** — the struct literal is exhaustive, so this is a required compile fix and the correct semantics (R9: batch items never inherit the outer selector).
|
||||||
|
- `src/main.rs` — build `WaitSelector` from the parsed globals and attach via `with_wait_selector`. **Before dispatch, when a selector is set, reject it with `INVALID_ARGS` unless the command is `snapshot` or one of the R6 action commands** (R8) — a single `const WAIT_SUPPORTED: &[&str]` checked against `cmd.name()`. This closes the `find -w …` silent-no-op footgun.
|
||||||
|
- `src/cli_args/mod.rs` (`SnapshotArgs`) / `src/cli/mod.rs` — make `--root` and `--wait-for`/`--wait-for-gone` mutually exclusive (R7); since the wait flags are global, enforce this in `snapshot::execute` (or `main`) as an `INVALID_ARGS` check rather than clap (a global cannot `conflicts_with` a local arg cleanly).
|
||||||
|
- `crates/core/src/commands/snapshot.rs` — when `context` carries a wait selector **and `root_ref` is `None`**, delegate to `wait_selector::execute` with snapshot's `opts` + `app`; `parse_selector` the raw string. If `root_ref` **and** a selector are both set → `INVALID_ARGS`.
|
||||||
|
- `crates/core/src/commands/snapshot_tests.rs` — selector-path tests.
|
||||||
|
**Approach:** Flags are global so they attach to every subcommand with one definition (no per-command duplication; avoids a clap conflict between a global `-w` and a local one). `snapshot::execute` checks the selector branch **after** the existing `root_ref` early-return guard so the two never silently co-apply; the explicit `INVALID_ARGS` makes the combination loud. `--wait-timeout` default 30000 matches `wait`.
|
||||||
|
**Patterns to follow:** existing globals (`--headed`, `--session`) in `src/cli/mod.rs`; `CommandContext::with_headed` and `for_batch_item` in `crates/core/src/context.rs`; `cmd.name()` dispatch in `src/main.rs`.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `snapshot -w "button:Submit"` on a tree containing it → returns matched snapshot (MockAdapter).
|
||||||
|
- `snapshot --wait-for-gone "progressindicator"` when absent → returns immediately.
|
||||||
|
- `--wait-for` + `--wait-for-gone` together → clap parse error, exit 2 (`conflicts_with`).
|
||||||
|
- `find -w "button:Submit"` → `INVALID_ARGS` (unsupported command, R8), not a silent find result.
|
||||||
|
- `snapshot --root @e5 -w ":button"` → `INVALID_ARGS` (R7), exercised in `snapshot_tests`.
|
||||||
|
- `snapshot --app Foo -w "…"` polls scope `Foo` (assert app passed through).
|
||||||
|
- Batch item does not inherit an outer `-w` (assert `for_batch_item` context has `wait_selector: None`).
|
||||||
|
- snapshot with no wait flags → identical output to today (regression).
|
||||||
|
- clap: `-w` resolves to `--wait-for`; `--wait-timeout 5000` parses.
|
||||||
|
**Verification:** `cargo test -p agent-desktop` (binary contract tests) + core snapshot tests pass; `--help` shows the three flags as global.
|
||||||
|
|
||||||
|
### U4. Post-action wait in the ref-action path (`helpers.rs`)
|
||||||
|
|
||||||
|
**Goal:** Ref-resolving action commands perform their action, then wait — scoped to the acted-on ref's app — returning the post-action snapshot with the action result preserved.
|
||||||
|
**Requirements:** R6, KTD4, KTD5.
|
||||||
|
**Dependencies:** U2, U3.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/commands/helpers.rs` — add `pub(crate) fn apply_post_action_wait(result: Value, app: Option<&str>, context: &CommandContext) -> Result<Value, AppError>`: returns `result` unchanged when context carries no selector; otherwise runs `wait_selector::execute` scoped to `app` (which owns the match-everything guard, U2) and returns the snapshot envelope with `result` embedded under `after_action`. On timeout it surfaces the `wait_timeout` error with the action result in `details.after_action`.
|
||||||
|
- `crates/core/src/commands/helpers.rs` — in `execute_ref_action_with_context`, stop discarding `_entry`: keep it and pass `entry.source_app.as_deref()` to `apply_post_action_wait` before returning. **This covers the 11 simple ref actions** (`click`, `double-click`, `triple-click`, `clear`, `focus`, `toggle`, `check`, `uncheck`, `expand`, `collapse`, `scroll-to`) in one edit.
|
||||||
|
- `crates/core/src/commands/{type_text,set_value,select,scroll}.rs` — each receives `(entry, result)` from `execute_ref_action_result_with_context` and ends with `Ok(serde_json::to_value(result)?)` (they do **not** build custom JSON). Wrap that return: `helpers::apply_post_action_wait(serde_json::to_value(result)?, entry.source_app.as_deref(), context)` (4 sites).
|
||||||
|
- `crates/core/src/commands/right_click.rs` — the **one** command that *does* build a custom JSON body (its menu probe). Call `apply_post_action_wait` **after** the menu-probe JSON is assembled, so ordering is action → menu probe → wait (5th bespoke site).
|
||||||
|
- `crates/core/src/commands/helpers_ref_action_tests.rs` (**modify — file already exists**) — add post-action merge, scope, timeout, and right-click-ordering tests.
|
||||||
|
**Approach:** One shared wrapper, **six call sites** (1 helper edit covering 11 commands + 5 bespoke: type/set-value/select/scroll/right-click) — every site already holds the `RefEntry`, so scoping by `entry.source_app` is a field read, not a pid→name lookup. Total coverage = the 16 R6 commands. `execute_by_ref` (FFI bridge) also routes through `execute_ref_action_with_context` but is behaviorally unaffected: the FFI `CommandContext` never carries a `WaitSelector`, so `apply_post_action_wait` early-returns. Snapshot is untouched here (it self-applied in U3), so there is no double-wait. Scoping by `entry.source_app` is the correctness fix over a focused-app hook: in headless mode the acted-on app is generally not frontmost.
|
||||||
|
**Patterns to follow:** `execute_ref_action_with_context`/`execute_ref_action_result_with_context` in `helpers.rs`; the custom-response assembly in `right_click.rs`; envelope shape in `commands/snapshot::format_result`.
|
||||||
|
**Test scenarios** (MockAdapter):
|
||||||
|
- `click @e5 -w ":Saved!"` where the mock tree gains "Saved!" after the action → envelope contains the snapshot **and** `after_action` with the click result.
|
||||||
|
- Post-action wait is scoped to `entry.source_app`, not the focused app (assert the app handed to `wait_selector` equals the ref's source app even when a different app is "focused" in the mock).
|
||||||
|
- Post-action selector never matches → `Err` `code==TIMEOUT`, `details.kind=="wait_timeout"`, `details.after_action` carries the action result, `details.snapshot_id` present.
|
||||||
|
- Action that closes the source app/window mid-wait → times out with the `wait_timeout` envelope (AppNotFound/WindowNotFound swallowed, U2), not a raw `WINDOW_NOT_FOUND`.
|
||||||
|
- An action command with no wait flag → result returned byte-for-byte unchanged (regression).
|
||||||
|
- `right-click @e -w ":menuitem"` → wait runs after the menu probe; `after_action` carries the right-click body including its `menu` field.
|
||||||
|
- A simple ref action (`toggle`), a bespoke serialize-only action (`type`), and `right-click` all merge correctly (covers all three wiring paths).
|
||||||
|
**Verification:** `cargo test --lib -p agent-desktop-core` ref-action tests pass; `cargo tree -p agent-desktop-core` clean; real smoke deferred to E2E (U5).
|
||||||
|
|
||||||
|
### U5. Docs, help text, and E2E coverage
|
||||||
|
|
||||||
|
**Goal:** Document the flag and prove it against a real app.
|
||||||
|
**Requirements:** R1–R6 (user-facing surface).
|
||||||
|
**Dependencies:** U3, U4.
|
||||||
|
**Files:**
|
||||||
|
- `skills/agent-desktop/references/commands-interaction.md` and the observation reference — document `--wait-for`/`--wait-for-gone`/`--wait-timeout`, the `role:text` selector, that post-action waits poll the **acted-on ref's app** (`entry.source_app`), the timeout envelope (with `snapshot_id` of the last tree), the unsupported-command `INVALID_ARGS` (R8), the `--root` exclusion (R7), and that batch does not honor an outer `-w` (R9). Note the canonical pattern for unsupported commands like `find`: `snapshot -w "<sel>"` then `find`.
|
||||||
|
- `src/cli/help_after.txt` — add a `--wait-for` usage example if examples live there.
|
||||||
|
- `tests/e2e/run.sh` + fixture interactions — one appearance wait and one disappearance wait against the SwiftUI fixture, verified by observation (a control that appears/disappears on action).
|
||||||
|
**Approach:** Documentation-only + E2E. Keep help text terse; the skills reference carries the detail. E2E asserts by independent observation per the harness contract, not the command's own `ok`.
|
||||||
|
**Patterns to follow:** existing `--force` documentation added in 0.4.4; `tests/e2e/README.md` conventions.
|
||||||
|
**Test scenarios:** `Test expectation: none` for docs. E2E: (a) trigger a control that reveals a "Saved!" label, assert `-w ":Saved!"` returns only after it appears; (b) trigger a spinner that clears, assert `--wait-for-gone` returns after it's gone; (c) timeout case returns exit 1 with the `wait_timeout` envelope.
|
||||||
|
**Verification:** `bash tests/e2e/run.sh` green (headless + `--headed`); skills reference renders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
**In scope:** the three global flags, the `role:text` selector, the shared matcher extraction, the poll helper (incl. window-close-tolerant retryable set and timeout `snapshot_id`), snapshot self-apply, post-action wait for all 16 R6 commands, the unsupported-command `INVALID_ARGS` guard (R8), the `--root` exclusion (R7), batch isolation (R9), docs + E2E.
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
- **FFI `ad_wait_for` entrypoint** — the core `wait_selector` helper is reusable; a cdylib surface is a separate, additive PR (KTD6).
|
||||||
|
- **Richer selector axes** (`name:`, `value:`, `state:` predicates, multiple constraints in one string) — only `role:text` ships now; the matcher already supports name/value, so a future grammar can expand without re-architecting.
|
||||||
|
- **`--wait-for` on non-ref commands** (e.g. `launch Foo -w "button:Login"`, `find`) — rejected with `INVALID_ARGS` this PR (R8); the workaround is two calls (`launch Foo; snapshot --app Foo -w "button:Login"`). Extending support to `find`/`launch` is additive follow-up.
|
||||||
|
- **`--wait-for` in batch mode** — batch items run independently and ignore an outer `-w` (R9). Per-item wait selectors in the batch JSON schema are deferred.
|
||||||
|
- **Polling-interval flag** — backoff is fixed (200ms→1s) to match `wait`; expose only if measured need arises.
|
||||||
|
- **`--wait-for-gone` stabilization guard** — see Risks; an optional "require one present observation before accepting absent" guard is deferred unless flakiness is observed.
|
||||||
|
|
||||||
|
### Out of scope (not this product)
|
||||||
|
- CSS / `:contains()` selector grammar (KTD1).
|
||||||
|
- A success-on-timeout return path that diverges from the `wait` envelope (KTD5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **R: post-action wait reads the wrong app's tree (headless default).** Resolved by design: scope is `entry.source_app` (the resolved ref's app), not the focused app — a binary post-dispatch hook was rejected for exactly this reason (KTD4/U4). Test asserts the ref's app is used even when a different app is focused.
|
||||||
|
- **R: post-action wait errors instead of timing out when the action closes the window.** Resolved: `AppNotFound`/`WindowNotFound` are in the retryable poll set (KTD3/U2), so a window-closing `click @submit -w ":Saved!"` times out with the `wait_timeout` envelope (action result preserved) rather than a raw `WINDOW_NOT_FOUND`.
|
||||||
|
- **R: empty-needle panic in the matcher.** Resolved: `parse_selector` collapses an empty axis to `None` (KTD1/U1) so `search_text::contains` is never called with a zero-length needle (`windows(0)` panic); U1 tests cover `"button:"`, `":"`, `" : "`.
|
||||||
|
- **R: `--wait-for-gone` false-positive on a transient empty/partial tree** (first poll fires before the action's AX notification propagates, reads no match, returns "gone"). Confidence moderate. Mitigation this PR: documented limitation + the standard 200ms first-interval gives the tree time to populate; a "require one present observation before accepting absent" guard is deferred (Scope Boundaries) pending observed flakiness.
|
||||||
|
- **R: `find` regression from the matcher move.** Mitigation: U1 is a pure move with `node_matches`/`tree_has_match` parity tests; `find`'s existing suite must pass unchanged before U2 starts.
|
||||||
|
- **R: 30s default `--wait-timeout` is long for a blocking post-action call** in an agent pipeline. Mitigation: matches `wait` for consistency; agents set `--wait-timeout` explicitly for tight loops. Documented in U5.
|
||||||
|
- **R: global `-w` short-flag collision.** Verified clear — only `-v` (global) and `-i` (snapshot) exist today.
|
||||||
|
- **Dependency:** none external; reuses `snapshot::build`, `RefStore`, `node_matches`/`tree_has_match`, `wait_timeout`, and `RefEntry.source_app`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- All five units landed; `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --lib --workspace`, `cargo test -p agent-desktop`, and `cargo tree -p agent-desktop-core` (no platform crates) green.
|
||||||
|
- `snapshot -w "<role:text>"`, `--wait-for-gone`, and post-action `click @e -w "…"` work against the E2E fixture in headless and `--headed` mode.
|
||||||
|
- Timeout exits 1 with a `kind:"wait_timeout"`, `predicate:"selector"` envelope carrying the last tree's `snapshot_id` and `last_error`; post-action timeout preserves the action result in `details.after_action`.
|
||||||
|
- All 16 R6 commands honor `--wait-for` (verified: 11 via `execute_ref_action_with_context`, 5 bespoke incl. `right-click`); `find`/`launch`/other unsupported commands return `INVALID_ARGS` (R8); batch ignores an outer `-w` (R9).
|
||||||
|
- `--wait-for` + `--wait-for-gone` together → clap parse error (exit 2); `--root` + `--wait-for` → `INVALID_ARGS` (R7); a match-everything selector → `INVALID_ARGS`.
|
||||||
|
- Skills reference and `--help` document the flags; issue #84 referenced in the commit/PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- Issue #84 (origin) — proposed shape and "why small" framing.
|
||||||
|
- `crates/core/src/commands/wait.rs` (`wait_for_text`) — poll loop, backoff, persist-on-match, `last_error` pattern reused by `wait_selector`.
|
||||||
|
- `crates/core/src/commands/find.rs` (`FindQuery`, `node_matches`) — the matcher promoted to `query.rs`.
|
||||||
|
- `crates/core/src/commands/snapshot.rs` (`build`, `format_result`) — cheap build + envelope shape.
|
||||||
|
- `crates/core/src/commands/wait_timeout.rs` — `kind:"wait_timeout"` envelope convention (KTD5).
|
||||||
|
- `src/cli/mod.rs`, `src/main.rs` — global-flag mechanism (`--headed`) and the single post-dispatch seam (`run_with_adapter`/`finish`).
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
---
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
execution: code
|
||||||
|
type: refactor
|
||||||
|
product_contract_source: ce-plan-bootstrap
|
||||||
|
created: 2026-06-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# refactor: session-first trace architecture
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make the **session** the first-class container that owns its trace, instead of threading a `--trace <path>` flag through every command. A session created by `session start` records tracing automatically — each process appends to its own segment file under the session directory — and flag/env/pointer activation lets an agent set the session once and never repeat a flag. This closes the footgun where an agent that forgets `--trace` on one command silently loses that command's trace, lights up structured tracing for FFI consumers that opt in, and makes concurrent multi-agent sessions safe by convention enforced with precedence order and a start-time guardrail. The trace **viewer** is a deliberate follow-up plan; this plan builds only the architecture it will sit on.
|
||||||
|
|
||||||
|
Trace-on is gated by the session **manifest** (`session.json`, `trace: on`), not by the mere presence of a `--session` id — so existing `--session` callers and FFI embedders who never run `session start` see today's behavior unchanged and never get surprise files on disk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
Sessions and snapshots are already a coherent, lock-protected, hierarchical model on disk (`~/.agent-desktop/sessions/{id}/` with `refstore.lock`, `latest_snapshot_id`, `snapshots/{snapshot_id}/refmap.json`). Tracing is the one piece that does not belong to the session: it is selected per-invocation by `--trace <path>` (`src/cli/mod.rs`, built once in `crates/core/src/context.rs::CommandContext::new`). Three consequences follow, all evidence-backed:
|
||||||
|
|
||||||
|
1. **Silent trace holes.** A command run without `--trace` emits nothing and returns `Ok(())` — no warning. A multi-command run only produces a continuous trace because the agent re-passes the identical path every time. One dropped flag = an invisible gap.
|
||||||
|
2. **FFI has no structured trace.** `crates/ffi/src/adapter.rs` builds its `CommandContext` with `trace_path: None`, so the entire structured event catalog is CLI-only; Python/Swift/Go/Node consumers get only unstructured log-callback lines.
|
||||||
|
3. **Shared traces corrupt.** `crates/core/src/trace.rs::write_event` does two separate syscalls (JSON body, then newline) with no buffering and no cross-process lock. Two agents appending to one trace file interleave into invalid JSONL. This bug exists today and becomes the common case the moment a session owns a shared trace.
|
||||||
|
|
||||||
|
Two more gaps block a session-first model: there is **no session lifecycle** (sessions are created implicitly and never cleaned — directories leak, and `discover_snapshot_base` slows as they accumulate), and the **512-snapshot prune** would silently delete snapshots a long session's trace *references* (the trace log itself is unaffected — see KTD5 for the precise boundary).
|
||||||
|
|
||||||
|
Non-goals for this plan: the trace viewer, per-step screenshot/tree artifacts, swapping the lock implementation, and deleting the legacy migration shim (see Scope Boundaries).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **R1.** When a session **whose manifest has `trace: on`** is active and no explicit `--trace` is given, tracing records automatically to the session directory. No per-command flag is required. A bare `--session <id>` with no manifest selects the snapshot namespace only (today's behavior) and does **not** trace.
|
||||||
|
- **R2.** Each OS process writes its own trace **segment** (`<session>/trace/<pid>-<proc_start_ts>.jsonl`), computed once per process; concurrent processes write to distinct files and never interleave. Each event line carries a per-process monotonic `seq` so a future reader can tie-break equal `ts_ms`. (Merging segments into a timeline is a reader/viewer concern, deferred.)
|
||||||
|
- **R3.** Explicit `--trace <path>` still works and overrides the session sink, writing a single file (back-compatible for CI/one-off use). Every event is written with one `write_all` of a fully-buffered line (atomic append).
|
||||||
|
- **R4.** The active session is resolved once, in precedence order: explicit `--session` > `AGENT_DESKTOP_SESSION` env var > `current_session` pointer file > none. The pointer is written **only** by `session start`.
|
||||||
|
- **R5.** FFI consumers get the same structured, session-bound trace as the CLI when they use a `trace: on` session — with no per-call flag and no ABI change (the session setter already exists). Setting a session id purely for snapshot scoping does **not** turn on disk writes.
|
||||||
|
- **R6.** A session lifecycle exists: `session start [--name] [--no-trace]`, `session end [id]`, `session list`, `session gc`. `start` creates the session dir + `trace/`, writes a `session.json` manifest (`trace: on` unless `--no-trace`) and the `current_session` pointer, and prints the id; `end` seals the manifest and clears the pointer; `list` reports the manifest fields (id/name/created/ended); `gc` removes ended and provably-stale sessions.
|
||||||
|
- **R7.** The trace **log** is never removed by snapshot pruning (segments live outside `snapshots/`). A referenced `snapshot_id` may still be pruned in a long session — full tree replay for pruned state is a viewer concern (KTD5).
|
||||||
|
- **R8.** Shared-session contract: a session is a shared container; every agent acts on the `snapshot_id` returned by its own snapshot call. Implicit "latest" is a single-agent convenience, not a multi-agent guarantee.
|
||||||
|
- **R8a.** Independent-session contract: concurrent independent sessions isolate per-process via `AGENT_DESKTOP_SESSION`, never via the global `current_session` pointer (KTD3a). `session start` guards the pointer against silently clobbering a still-live session (KTD3b).
|
||||||
|
- **R9.** Trace redaction, file-permission hardening (`0600`/`O_NOFOLLOW`, `0700` dirs), and the per-file oversize guard are preserved for the session sink; the manifest `--name` is validated/scrubbed before persistence.
|
||||||
|
- **R10.** `session gc` will not reap a session with a **live writer** — liveness is checked (active `refstore.lock` pid or recent `trace/` mtime), not creation-age alone. Directory removal uses the same symlink-safe pattern as snapshot prune.
|
||||||
|
- **R11.** Activating a session relocates the **snapshot/ref namespace** as well as the trace (a session owns both) — this is intentional and documented; explicit `--snapshot <id>` still resolves cross-session, so only implicit "latest" is affected across a `session start` boundary.
|
||||||
|
- **R12.** No new platform-adapter methods; `agent-desktop-core` stays free of platform crates (CI `cargo tree` gate stays green). Standing constraint, verified by the DoD gate.
|
||||||
|
|
||||||
|
**Success criteria:** an agent runs `session start` (or sets `AGENT_DESKTOP_SESSION`), issues a sequence of commands with no `--trace`, and finds complete, uncorrupted, per-line `ts_ms`+`seq`-ordered segments under the session — even with two agents sharing it — then `session gc` reclaims it without disturbing a live session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
**KTD1 — The session owns the trace; `--trace` becomes an override.** Tracing is bracketed by the session lifecycle, mirroring Playwright (`context.tracing.start/stop`), CDP (`Tracing.start/end`), and Appium (session-scoped artifacts). Activation follows the kubectl/ssh-agent convention (set once via env/pointer, per-command override still available) — the correct fit for a one-process-per-command CLI that keeps state on the filesystem rather than in a daemon.
|
||||||
|
|
||||||
|
**KTD1a — Trace-on is gated by the manifest, not by the session id.** Auto-trace requires `session.json` with `trace: on` (written by `session start`; `--no-trace` sets it off). This is the keystone decision: it keeps a bare `--session <id>` a pure namespace selector (today's behavior, no surprise files), wires `--no-trace` through one flag read, and prevents FFI embedders who set a session id for snapshot scoping from getting unexpected data-at-rest. `--trace <path>` still forces tracing regardless of manifest.
|
||||||
|
|
||||||
|
**KTD1b — A session owns snapshots *and* trace (activation relocates both).** `CommandContext.session_id()` already routes `RefStore::for_session()` — where refmaps/`latest_snapshot_id` live — so widening activation (env/pointer) moves the snapshot namespace too, not just the trace. This is intentional and consistent with "a session is the container." Consequence, documented and tested: across a `session start` boundary, implicit "latest" points at the new session; a snapshot taken before is reached only by explicit `--snapshot <id>` (which still resolves cross-session via `discover_snapshot_base`). Without this decision named, the coupling would surface as a mysterious `STALE_REF`.
|
||||||
|
|
||||||
|
**KTD2 — Per-process trace segments, not one shared file.** Each OS process writes `<session>/trace/<pid>-<proc_start_ts>.jsonl`. Lock-free and multi-agent-safe: the two-syscall interleaving bug cannot occur because no two processes share a file. The `(pid, proc_start_ts)` pair is memoized **once per OS process** in a `OnceLock` — so a long-lived FFI host that constructs many `CommandContext`s still writes to one segment (not one per call), and pid reuse across time yields distinct filenames. Each line also carries a per-process monotonic `seq`. The single-file `--trace` override gets the build-line-then-one-`write_all` atomicity fix (R3). Readers must tolerate a truncated final line (crash/OOM/NFS mid-write) by skip-and-warn — a foundation guarantee the viewer relies on.
|
||||||
|
|
||||||
|
**KTD3 — Opt-in per run (a run = a session), stated honestly.** The chosen answer to "the agent forgets `--trace`" is to make the session the trace boundary and start it once per run (`session start`), matching the user's "make each run a session" framing and the Playwright/CI norm. This **reduces** the footgun from once-per-command to once-per-run; it does **not eliminate** it — forgetting `session start` still yields no trace. The rejected alternative, ambient default-on (trace always, zero setup), would fully eliminate it but change default behavior for every existing/CI caller and always write files; it is offered as a deferred, electable option. To make the residual *observable* rather than silent, `status` reports whether a session is active and tracing (no per-command stderr noise).
|
||||||
|
|
||||||
|
**KTD3a — The `current_session` pointer is a single-active-session convenience; concurrent independent sessions use the env var.** The pointer (`~/.agent-desktop/current_session`) is one global file written only by `session start`. It is **not** per-process. Concurrent independent sessions each set `AGENT_DESKTOP_SESSION` (per-process isolation, precedence above the pointer); the pointer is only for a single active session.
|
||||||
|
|
||||||
|
**KTD3b — `session start` guards the pointer against silent clobbering.** Because two agents each calling `session start` would clobber the pointer and cross-contaminate, `start` refuses (with `--force`) when the existing pointer references a **still-live** session (reusing `RefStoreLock`'s pid-liveness). This turns the highest-risk silent-contamination case into a loud failure, so "safe by construction" is honestly "safe by precedence + a start-time guardrail," not a bare convention.
|
||||||
|
|
||||||
|
**KTD4 — Shared container, explicit `snapshot_id` for multi-agent; no per-agent latest.** The `latest_snapshot_id` pointer is a single-agent convenience. The multi-agent contract is: read the shared pool, act on the id your own snapshot returned. No per-agent latest machinery.
|
||||||
|
|
||||||
|
**KTD5 — Trace *log* is decoupled from the working-snapshot cache; referenced-state fidelity is a viewer concern.** Segments live at `<session>/trace/`, never under `snapshots/`, so the 512-cap prune cannot delete the trace. The trace's own per-event data (identity, actionability report, activation-chain steps, post-state) is self-contained. It does **not** guarantee that a `snapshot_id` referenced by an old event still resolves to its full tree — that state can be pruned. Copying per-step refmaps into the trace for full time-travel is deferred to the viewer with a recorded decision (**copy**, Playwright-style).
|
||||||
|
|
||||||
|
**KTD6 — A small `session` core module owns the on-disk contract.** Manifest (`session.json`), the `current_session` pointer, activation resolution, liveness/gc, and list live in one core module; the CLI `session` subcommands are thin wrappers. Reuses `write_private_file` hardening. `gc` uses the snapshot-prune symlink-safe removal pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
Trace sink selection in `CommandContext::new`:
|
||||||
|
|
||||||
|
```
|
||||||
|
explicit --trace <path>? ──yes─▶ single file at <path> (buffered, one write_all)
|
||||||
|
│ no
|
||||||
|
▼
|
||||||
|
active session has manifest trace:on? ──yes─▶ segment <session>/trace/<pid>-<procTs>.jsonl
|
||||||
|
│ no (dir pre-created by `session start`;
|
||||||
|
▼ sink opens lazily on first event)
|
||||||
|
no trace (writer: None) (bare --session, or no session → today's behavior)
|
||||||
|
```
|
||||||
|
|
||||||
|
Active-session resolution (once, at process start; drives BOTH snapshot namespace and trace):
|
||||||
|
|
||||||
|
```
|
||||||
|
--session <id> ─▶ AGENT_DESKTOP_SESSION ─▶ current_session pointer ─▶ none
|
||||||
|
```
|
||||||
|
|
||||||
|
Session directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.agent-desktop/
|
||||||
|
├── current_session # pointer, written ONLY by `session start` (clobber-guarded)
|
||||||
|
└── sessions/run-42/
|
||||||
|
├── session.json # id, name(validated), created_at, ended_at?, trace: on|off
|
||||||
|
├── refstore.lock # (unchanged) — also the gc liveness signal
|
||||||
|
├── snapshots/<id>/refmap.json # 512-cap prune applies HERE only
|
||||||
|
└── trace/ # pre-created by `session start`; NEVER pruned
|
||||||
|
├── 1837-2291.jsonl # agent A (pid+procTs, memoized once/process)
|
||||||
|
└── 1904-2295.jsonl # agent B
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Expose the session directory from `RefStore`
|
||||||
|
|
||||||
|
**Goal:** Give trace wiring and the session module a way to locate a session's directory.
|
||||||
|
**Requirements:** R1, R2.
|
||||||
|
**Dependencies:** none.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/refs_store.rs` — add `pub(crate) fn base_dir(&self) -> &Path` and `pub(crate) fn trace_dir(&self) -> PathBuf` (`base_dir/trace`). `base_dir` is currently a private field with no accessor.
|
||||||
|
- `crates/core/src/refs_store_tests.rs` — accessor tests.
|
||||||
|
**Approach:** Pure additive path accessors, no filesystem side effects (no dir creation). `trace_dir()` on the default-root store is defined for symmetry but is unreachable under the sink-selection logic (no session ⇒ no trace).
|
||||||
|
**Patterns to follow:** existing private-path helpers (`snapshots_dir`, `lock_path`).
|
||||||
|
**Test scenarios:**
|
||||||
|
- `for_session(Some("run-42")).trace_dir()` returns `.../sessions/run-42/trace`.
|
||||||
|
- Accessors create no directories (assert no side effects).
|
||||||
|
**Verification:** `cargo tree -p agent-desktop-core` clean; store tests pass.
|
||||||
|
|
||||||
|
### U2. Segment trace writer: per-process filename, lazy open, atomic line, `seq`
|
||||||
|
|
||||||
|
**Goal:** Let `TraceConfig` write a per-process segment in a directory, opened lazily, with atomic lines and a tie-break counter.
|
||||||
|
**Requirements:** R2, R3, R9.
|
||||||
|
**Dependencies:** none.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/trace.rs` — add a `TraceSink` enum (`File(path)` | `SegmentDir(dir)`). Memoize `(pid, proc_start_ts)` in a process-wide `OnceLock` so all `TraceConfig`s in one process resolve to the same segment filename. **Defer the file open until first `write_event`** (store the path/dir at construction; open on first emit) so no empty segment is created for `version`/`skills`/help invocations. In the segment branch, **create the parent `trace/` dir** (recursive, `0700`) at first open if absent — the current `open_trace_file` does not `mkdir` the parent. Fix `write_event` to serialize the full line (event + `ts_ms` + per-process monotonic `seq` + redacted fields + `session_id`) into a buffer and issue one `write_all`. Preserve `0600`/`O_NOFOLLOW`/oversize hardening and `sanitize_trace_value` for both sink kinds.
|
||||||
|
- `crates/core/src/trace_tests.rs` — segment/atomicity/seq/lazy-open tests.
|
||||||
|
**Approach:** Redaction and `session_id` injection are unchanged (run in `write_event`). `--trace-strict` semantics preserved. The `seq` is a per-`OnceLock` `AtomicU64`.
|
||||||
|
**Patterns to follow:** current `open_trace_file`/`write_event`; the `Arc<Mutex<File>>` in-process guard stays for batch sharing.
|
||||||
|
**Test scenarios:**
|
||||||
|
- Two `TraceConfig`s constructed in the same process → same segment filename (OnceLock memoization).
|
||||||
|
- A fresh session dir with no `trace/` → first `write_event` creates `trace/` and a non-empty segment (the silent-hole regression).
|
||||||
|
- A `version`/no-op invocation writes **no** segment file (lazy open).
|
||||||
|
- One `write_event` → exactly one well-formed JSONL line; body and newline never split.
|
||||||
|
- `seq` increments per event within a process; present on every line.
|
||||||
|
- Redaction, `session_id`, permission + oversize guards identical to single-file path.
|
||||||
|
- Reader tolerance: a manually-truncated final line does not make the segment unparseable line-by-line.
|
||||||
|
**Verification:** trace unit tests pass; each segment is valid JSONL line-by-line.
|
||||||
|
|
||||||
|
### U3. Manifest-gated auto-trace in `CommandContext::new` + snapshot coupling + batch
|
||||||
|
|
||||||
|
**Goal:** Wire the session sink, gated by the manifest; handle batch session overrides; document/test the snapshot coupling.
|
||||||
|
**Requirements:** R1, R3, R9, R11.
|
||||||
|
**Dependencies:** U1, U2, U5.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/context.rs` — in `CommandContext::new`, select the sink: explicit `trace_path` → single file; else if the active session's manifest has `trace: on` → `RefStore::for_session(session_id)?.trace_dir()` segment sink; else → no trace. In `for_batch_item`, if the item overrides to a **different** session while tracing, re-derive the sink for that session (not just swap `session_id`), or reject the override — never write an item's events into the parent session's segment.
|
||||||
|
- `crates/core/src/context_tests.rs` — sink-selection + batch + coupling tests. If `context.rs` approaches the 400-LOC limit, extract inline tests to `context_tests.rs` first.
|
||||||
|
**Approach:** The manifest read goes through the U5 module. `RefStore::for_session` here is a path computation (no writes). Segment opens lazily (U2).
|
||||||
|
**Patterns to follow:** existing `CommandContext::new`; `with_headed`/`with_wait_selector`.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `trace: on` session, no `--trace` → events in `<session>/trace/<pid>-*.jsonl`.
|
||||||
|
- Bare `--session foo` with **no** manifest → namespace selected, **no** trace (existing-caller regression).
|
||||||
|
- `session start --no-trace` session → no trace; snapshots still namespaced to it.
|
||||||
|
- `--trace <path>` overrides regardless of manifest.
|
||||||
|
- **Coupling:** a snapshot taken before `session start`, then `click @ref` after with implicit latest → resolves against the new session (documents R11); the same ref with explicit `--snapshot <old-id>` still resolves (cross-session).
|
||||||
|
- Batch item overriding to a different session does not write into the parent's segment.
|
||||||
|
**Verification:** `cargo test -p agent-desktop` + core context tests pass.
|
||||||
|
|
||||||
|
### U4. FFI: session-bound structured trace (verification + opt-out)
|
||||||
|
|
||||||
|
**Goal:** FFI consumers get the trace via a `trace: on` session; setting a session id alone does not write files.
|
||||||
|
**Requirements:** R5.
|
||||||
|
**Dependencies:** U3.
|
||||||
|
**Files:**
|
||||||
|
- `crates/ffi/src/adapter.rs` — `ad_adapter_create_with_session` already validates + stores `session_id`, and `command_context()` already passes it to `CommandContext::new`; with U3's manifest gate this yields trace only for a `trace: on` session. **No ABI change** — verification + a smoke test. Confirm an FFI consumer can create a `trace: on` session (via the CLI or a documented call) and that a plain session-id set stays trace-off.
|
||||||
|
- `crates/ffi/` C-ABI tests — smoke: FFI call under a `trace: on` session writes a segment; under a plain session writes none.
|
||||||
|
**Approach:** Behavior falls out of U3 + KTD1a; scope is verification + tests. Because the OnceLock memoizes per process (U2), a long-lived FFI host writes one segment, not one per call.
|
||||||
|
**Patterns to follow:** existing FFI context construction; header/drift tests.
|
||||||
|
**Test scenarios:**
|
||||||
|
- FFI call under a `trace: on` session → one segment for the process, structured events.
|
||||||
|
- FFI call with a plain (no-manifest / `--no-trace`) session → no trace files.
|
||||||
|
- Header/codegen drift gates green (no ABI change).
|
||||||
|
**Verification:** `cargo test -p agent-desktop-ffi --tests`; drift gates green.
|
||||||
|
|
||||||
|
### U5. `session` core module: manifest, pointer, resolution, liveness, gc
|
||||||
|
|
||||||
|
**Goal:** One core module owning the session on-disk contract, activation resolution, and safe gc.
|
||||||
|
**Requirements:** R4, R6, R8, R8a, R9, R10.
|
||||||
|
**Dependencies:** U1.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/session/mod.rs` (new) — `session.json` manifest (id, validated `name`, created_at, ended_at?, `trace: on|off`); `current_session` pointer read/write; `resolve_active_session(explicit, env) -> Option<String>` (flag > env > pointer > none, env above pointer); `is_live(session)` (active `refstore.lock` pid via `RefStoreLock`'s liveness, or recent `trace/` mtime); `list()` (manifest fields only); `gc()` (remove ended + provably-stale-and-not-live; symlink-safe removal mirroring `refs_store_prune`). Validate/scrub `name` before persistence (it bypasses trace redaction).
|
||||||
|
- `crates/core/src/session/session_tests.rs` (new).
|
||||||
|
- `crates/core/src/lib.rs` — register `session` (pub boundary).
|
||||||
|
**Approach:** Resolution is a pure function. `gc` never removes a live or pointer-referenced session. `list` is manifest-only (no subtree walk) to stay within R6's scope.
|
||||||
|
**Patterns to follow:** `refs_store.rs` path/private-file conventions; `RefStoreLock` pid-liveness; `refs_store_prune` symlink-safe removal; `validate_session_id`.
|
||||||
|
**Test scenarios:**
|
||||||
|
- Precedence: explicit > env > pointer > none; env beats a *different* pointer.
|
||||||
|
- Pointer absent → none (bare command → default behavior).
|
||||||
|
- Manifest round-trips with/without `ended_at`; `--name` with a control char is scrubbed/rejected.
|
||||||
|
- `gc` removes ended + stale; **leaves a session with a live `refstore.lock` pid**; leaves a session with recent `trace/` mtime; never removes the pointer-referenced session; refuses to follow a symlinked session dir.
|
||||||
|
- `list` reports manifest fields without walking `snapshots/`.
|
||||||
|
**Verification:** core session tests pass; `cargo tree` clean.
|
||||||
|
|
||||||
|
### U6. `session` CLI commands (`start` / `end` / `list` / `gc`)
|
||||||
|
|
||||||
|
**Goal:** The user-facing lifecycle, with the clobber guard and trace-dir pre-create.
|
||||||
|
**Requirements:** R6, R8a, R9.
|
||||||
|
**Dependencies:** U5.
|
||||||
|
**Files:**
|
||||||
|
- `crates/core/src/commands/session.rs` (new) — `execute()` over the four subactions. `start` creates the session dir + `trace/`, writes the manifest (`trace: on` unless `--no-trace`) and the pointer, prints the id; **refuses to clobber a live pointer without `--force`** (KTD3b). `end` seals + clears the pointer. `list`/`gc` render U5 results.
|
||||||
|
- `crates/core/src/commands/mod.rs` — register.
|
||||||
|
- `src/cli/mod.rs` + `src/cli_args/` — `Session` subcommand: `start [--name] [--no-trace] [--force]`, `end [id]`, `list`, `gc [--older-than] [--ended]`.
|
||||||
|
- `src/dispatch/mod.rs` — dispatch arm.
|
||||||
|
- `src/cli/contract_tests.rs` — CLI contract.
|
||||||
|
**Approach:** Follows the Extensibility Pattern (new command file + cli variant + dispatch arm). `start` pre-creating `trace/` removes any first-write dir race.
|
||||||
|
**Patterns to follow:** a multi-action command (`skills`) + dispatch registration.
|
||||||
|
**Test scenarios:**
|
||||||
|
- `session start` creates dir + `trace/` + manifest + pointer, prints a valid id; a subsequent bare command traces.
|
||||||
|
- `session start` over a **live** pointer without `--force` → refused (loud); with `--force` → overrides.
|
||||||
|
- `session start --no-trace` → session exists, bare commands do not trace.
|
||||||
|
- `session end` clears the pointer; subsequent bare command no longer traces/attaches.
|
||||||
|
- `--session X` explicit overrides an active pointer.
|
||||||
|
- Envelope/exit-code contract per subaction.
|
||||||
|
**Verification:** `cargo test -p agent-desktop` pass; `--help` shows `session`.
|
||||||
|
|
||||||
|
### U7. Activation wiring, retention guard, and docs
|
||||||
|
|
||||||
|
**Goal:** Resolve the active session once in the binary, guarantee trace survives pruning, and document the model.
|
||||||
|
**Requirements:** R4, R7, R8, R8a, R11.
|
||||||
|
**Dependencies:** U3, U5, U6.
|
||||||
|
**Files:**
|
||||||
|
- `src/main.rs` — resolve the active session once via `session::resolve_active_session(cli.session, env)` and thread the resolved id into `CommandContext::new` (batch inherits; not re-resolved per item).
|
||||||
|
- `crates/core/src/refs_store_prune.rs` — a test asserting prune scans only `snapshots/` and never `trace/` (prune already scopes to `snapshots/`; this pins it).
|
||||||
|
- `skills/agent-desktop/references/*.md`, `src/cli/help_after.txt`, `CLAUDE.md` — document: session owns trace **and** snapshots (R11 coupling + the explicit-`--snapshot` escape); manifest-gated trace; activation precedence + `AGENT_DESKTOP_SESSION`; per-process segments + `seq`; the shared-vs-independent contracts (R8/R8a); the clobber guard; that trace is opt-in per run and `status` shows tracing state.
|
||||||
|
**Approach:** Activation resolution lives at the binary edge so batch inherits one resolved id. The prune guard is a test, not new logic.
|
||||||
|
**Patterns to follow:** `--headed` global-flag threading; existing prune tests.
|
||||||
|
**Test scenarios:**
|
||||||
|
- A session past the 512-snapshot cap keeps every `trace/*.jsonl`.
|
||||||
|
- `AGENT_DESKTOP_SESSION` set + no `--session` → attaches to that session (resolved in `main`).
|
||||||
|
- `--session` explicit overrides env and pointer.
|
||||||
|
- Docs updated: `Test expectation: none`.
|
||||||
|
**Verification:** prune retention test passes; full gate set green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
**In scope:** manifest-gated session-owned trace, per-process segments (memoized filename, `seq`, lazy open, atomic line, reader-tolerance), `--trace` override, activation (flag/env/pointer), snapshot-coupling documentation, FFI opt-in trace, `session start/end/list/gc` with clobber guard + gc liveness, retention guard, docs.
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
- **Trace viewer** — the timeline/tree/activation-chain UI that reads a session's segments and **merges them by `ts_ms`+`seq`** (same-ms ties, cross-process clock skew, truncated-final-line tolerance are the viewer's problems). Separate plan; this plan is its foundation.
|
||||||
|
- **Per-step artifacts for replay** — `screenshot_id`/`tree_snapshot_id` per action + copying per-step refmaps into the trace (recorded decision: **copy**, Playwright-style). With the viewer.
|
||||||
|
- **Ambient default-on tracing** — a bounded, auto-gc'd default-session trace that eliminates the forget-`session start` case entirely; electable later if opt-in proves insufficient (KTD3).
|
||||||
|
- **Session-level trace budget** — a total-bytes cap across a session's segments (per-file 64MB still applies); add if unbounded growth between `gc` runs proves a problem.
|
||||||
|
- **Session zip/bundle export**, and **legacy `last_refmap.json` shim removal** (low-risk, separable).
|
||||||
|
|
||||||
|
### Out of scope (not this product)
|
||||||
|
- **Swapping `RefStoreLock` for `flock`** — `flock` is unreliable over NFS and `~/.agent-desktop` can be a network home; the PID+token lock may exist for that portability. Its own PR if ever revisited.
|
||||||
|
- **Cross-OS-user session isolation / ownership tokens** — sessions are per-OS-user (`0700` home); management commands are trusted within that user. Multi-tenant shared-`$HOME` hardening is a separate concern.
|
||||||
|
- **A session daemon/server** — the filesystem-session model is the stateless-CLI equivalent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **CLI contract:** new `session` subcommand; new sticky behavior *after* `session start`. Bare-command and bare-`--session` behavior for non-adopters is unchanged (KTD1a). `--trace`/`--trace-strict` preserved.
|
||||||
|
- **Snapshot resolution:** activation relocates the snapshot namespace, not just trace (R11/KTD1b) — documented + tested; explicit `--snapshot` resolves cross-session.
|
||||||
|
- **FFI:** structured tracing activates only for `trace: on` sessions (no surprise data-at-rest); no ABI change.
|
||||||
|
- **Batch:** the resolved session id flows through `for_batch_item`; a per-item session override re-derives its own sink.
|
||||||
|
- **Two-PR seam:** U1–U4 (manifest-gated session trace + FFI) is the shippable core that kills the footgun; U5–U7 (lifecycle + gc + docs) can land as a second PR. `ce-work` may split there; the plan stays one document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **R: activation silently relocates snapshot state** across a `session start` boundary. Mitigated: named as intentional (KTD1b/R11), documented, and covered by the U3 coupling test; explicit `--snapshot` still resolves.
|
||||||
|
- **R: existing `--session` / FFI callers get surprise trace files.** Mitigated by KTD1a — trace-on requires a manifest, which only `session start` writes.
|
||||||
|
- **R: FFI segment fragmentation** (one segment per call in a long process). Mitigated by the per-process `OnceLock` filename memoization (U2).
|
||||||
|
- **R: silent trace hole from a missing `trace/` dir.** Mitigated: `session start` pre-creates it and U2's segment branch recursively creates it on first open; regression test.
|
||||||
|
- **R: `current_session` pointer clobbering** cross-contaminates two agents. Mitigated by KTD3b (`session start` refuses a live pointer without `--force`) + env precedence (KTD3a) + the resolver test.
|
||||||
|
- **R: `gc` reaps a live env-bound session.** Mitigated by R10 liveness (active lock pid / recent `trace/` mtime), not creation-age alone.
|
||||||
|
- **R: opt-in-per-run does not eliminate the forget case.** Acknowledged (KTD3); `status` surfaces tracing state; ambient-default deferred as electable.
|
||||||
|
- **R: crash/NFS truncates the final segment line.** Mitigated: one `write_all` per event + a stated reader skip-and-warn tolerance (KTD2).
|
||||||
|
- **R: files near the 400-LOC limit** (`context.rs` ~368, `trace.rs` ~322, `refs_store_tests.rs` ~369). Mitigated: extract inline tests to sibling files before adding production code (U2/U3).
|
||||||
|
- **Sequencing note:** this is core/CLI/FFI plumbing that does not block or compete with the Phase 2 Windows/Linux adapter work (different surface, parallelizable).
|
||||||
|
- **Dependency:** none external; reuses `RefStore`, `RefStoreLock` (liveness), `TraceConfig`, `write_private_file`, `validate_session_id`, `refs_store_prune`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- A `trace: on` session ⇒ trace records automatically to per-process segments (memoized filename, `seq`, lazy open, one `write_all`); `--trace` overrides to a single atomic file; a bare `--session`, `--no-trace` session, or no session ⇒ no trace.
|
||||||
|
- Activation resolves flag > env > pointer > none; pointer written only by `session start`; `session start` refuses a live-pointer clobber without `--force`.
|
||||||
|
- Activating a session relocates snapshot namespace too (documented + tested); explicit `--snapshot` resolves cross-session.
|
||||||
|
- FFI under a `trace: on` session produces one segment per process; a plain session writes nothing; drift gates green.
|
||||||
|
- `session start/end/list/gc` work; `gc` never reaps a live or pointer-referenced session and is symlink-safe; `list` is manifest-only.
|
||||||
|
- A session past the 512-snapshot cap retains all trace segments.
|
||||||
|
- Contracts documented (shared vs independent sessions; snapshot coupling; opt-in tracing).
|
||||||
|
- Gates: `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --lib --workspace`, `cargo test -p agent-desktop`, `cargo test -p agent-desktop-ffi --tests`, `cargo tree -p agent-desktop-core` (no platform crates), and E2E green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- Investigation (this session): session/snapshot store map, trace subsystem map, over-engineering/debt audit, external-pattern research, and a six-persona doc review — evidence for every problem and decision.
|
||||||
|
- Current code: `crates/core/src/{refs_store.rs, refs_store_prune.rs, refs_lock.rs, trace.rs, context.rs, snapshot.rs, commands/helpers.rs, commands/batch.rs}`, `crates/ffi/src/adapter.rs`, `src/cli/mod.rs`, `src/main.rs`.
|
||||||
|
- External patterns (load-bearing): Playwright `context.tracing.start/stop`; Appium/WebDriver session lifecycle; CDP `Tracing.start/end` + Target/session; kubectl/`docker context`/ssh-agent set-once active-context. These shaped KTD1/KTD1a (session-bounded, manifest-gated trace), KTD2 (segments), and the activation model (R4).
|
||||||
|
- Debt note: a `ponytail:`/TODO/FIXME harvest found **zero** in-source markers — deferrals are undocumented, not absent.
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
---
|
||||||
|
title: Trace Viewer and Replay Artifacts - Plan
|
||||||
|
type: feat
|
||||||
|
date: 2026-07-01
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
product_contract_source: ce-plan-bootstrap
|
||||||
|
execution: code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Trace Viewer and Replay Artifacts - Plan
|
||||||
|
|
||||||
|
## Goal Capsule
|
||||||
|
|
||||||
|
- **Objective:** Build the trace read-and-replay layer on the session-first foundation: deterministic merged timelines (R1–R5), a versioned and tolerant format contract (R6–R7), replay-complete event enrichment (R8–R9), opt-in screenshot and refmap artifacts (R10–R13), a single-file HTML viewer (R14–R17), and additive, backward-compatible contract safety (R18–R19).
|
||||||
|
- **Authority:** This plan takes precedence over general repo conventions where the two conflict; repo conventions take precedence over implementer judgment; `CLAUDE.md` rules (the 400 LOC cap, zero `unwrap()`, no inline comments, core/platform isolation) remain non-negotiable regardless of what any unit implies.
|
||||||
|
- **Execution profile:** Units U1 and U5 are test-first — their enumerated test scenarios are written as failing tests before the implementation they specify. Every unit lands with its full enumerated test list; no scenario is dropped as a shortcut.
|
||||||
|
- **Stop conditions:** Any change that would require a new `PlatformAdapter` trait method, a new `ErrorCode` variant, an `ENVELOPE_VERSION` bump, or a new external dependency is out of contract (KTD9). Hitting one mid-implementation means stop and surface, not improvise around it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Add the trace read-and-replay layer that the session-first trace architecture (`docs/plans/2026-06-30-001-refactor-session-first-trace-architecture-plan.md`) deferred. `trace show` merges every segment of a session into one deterministic timeline, returned as a bounded JSON envelope agents can consume without exhausting their context window. `trace export` renders that timeline into a single self-contained HTML file — embedded JSON, embedded screenshots, an inline viewer — that opens from `file://` with no server and no network calls.
|
||||||
|
|
||||||
|
Two enrichments make replay complete. Every command now emits a `command.start`/`command.end` boundary pair regardless of how it fails, and an opt-in `artifacts: full` session mode captures pre- and post-action screenshots and refmap copies around every ref action. The format itself carries a per-segment `trace.meta` header and an additive-only evolution rule, so a schema-0 trace from v0.4.6 and a future schema-N trace both read cleanly through the same reader.
|
||||||
|
|
||||||
|
This plan touches no `PlatformAdapter` trait method, adds no error code, and does not bump `ENVELOPE_VERSION`. Both new commands are additive JSON on top of the existing envelope, wired through CLI, batch, and FFI via the same core command functions.
|
||||||
|
|
||||||
|
### Problem Frame
|
||||||
|
|
||||||
|
The session-first foundation gives every process a per-segment, lock-free trace sink, but nothing reads it back. Today's event catalog is thin and write-path-only: `ref.resolve.start/entry/ok/error` (`crates/core/src/commands/helpers.rs`), `actionability.check.start/ok/error` plus `action.dispatch.start/ok` (`crates/core/src/ref_action.rs` — `action.dispatch.ok` already carries the full `ActionResult`, including `post_state` and the activation-chain `steps: Vec<ActionStep>`), `input.focus_app` (`crates/core/src/commands/point_resolve.rs`), and `snapshot.root.saved` (`crates/core/src/snapshot_ref.rs`).
|
||||||
|
|
||||||
|
There is no `command.start`/`command.end` pair, so a command that fails before ref resolution — a bad argument, a policy denial, a missing session — leaves zero trace of the attempt and no duration. The main snapshot path never emits `snapshot.saved`, so a trace cannot even enumerate which snapshots a session produced. Segments carry no self-description: nothing marks their schema or the binary version that wrote them, so a future format change has no way to warn a reader instead of silently misparsing.
|
||||||
|
|
||||||
|
No per-step visual state is captured, so a human reviewing a trace sees JSON event names with no picture of what was on screen. And nothing merges segments into a timeline at all — not a reader, not a viewer, not even a sort-and-concatenate script. A session's trace directory is, today, a pile of unrelated JSONL files.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
**Timeline reading**
|
||||||
|
|
||||||
|
- R1. `trace show` merges every segment of a session into one deterministic timeline and returns it in the standard JSON envelope, honoring session activation precedence (flag > `AGENT_DESKTOP_SESSION` > `current_session` pointer) and requiring no accessibility or screen-recording permission.
|
||||||
|
- R2. Merge order is ascending `ts_ms`; equal `ts_ms` breaks ties by `(writer pid, in-file position)`. Within one segment, file order (the `seq` order) is never violated even when `ts_ms` regresses from a wall-clock adjustment — per-process causality is authoritative, and cross-process same-millisecond order is best-effort but deterministic. Output is independent of segment discovery order. Cross-process ordering is guaranteed only for same-millisecond ties; a wall-clock step large enough to separate causally-ordered events in different processes by more than a tie is an undefended, documented best-effort boundary.
|
||||||
|
- R3. Reader tolerance is a hard contract: a truncated final line from a mid-write crash is skipped and counted; a corrupt line mid-file is skipped and counted; non-object JSON lines are skipped and counted; files in `trace/` that don't match the `<pid>-<procStartTs>.jsonl` segment naming (or `*.tmp`) are ignored with a warning; a line longer than 8MiB is rejected and counted; a segment file that cannot be opened, or whose name resolves to a symlink, is skipped with a counted warning (segment opens use `O_NOFOLLOW`, mirroring the refmap store's open discipline); a `command.start` with no matching `command.end` (a killed process defeats the `Drop` guard) is surfaced as a counted warning and rendered as an open, incomplete group rather than dropped. The reader never errors on malformed content — malformed content degrades to counted warnings in the response. Warnings are machine-readable: each entry carries a closed kind (`foreign_file`, `unreadable_segment`, `symlinked_segment`, `schema_unknown`, `unpaired_command`) plus a human message, so agents branch on kind, not free text.
|
||||||
|
- R4. Each merged event is annotated with provenance: `writer_pid` and `segment` (the filename stem). The existing `pid` field on events like `ref.resolve.entry` — the target app's pid — is never overwritten.
|
||||||
|
- R5. Output is bounded for agent context windows: `--limit N` returns the last N events of the merged timeline (default 500; `--limit 0` means all); the response carries `total_events`, `returned_events`, and `truncated: true|false`. `--event <prefix>` filters by event-name prefix before the limit is applied. The tail slice is positional and may split a `command.start`/`command.end` pair; the envelope's `truncated` flag plus the `unpaired_command` warning kind mark the cut, and the viewer renders the affected group as open-incomplete rather than pretending completeness.
|
||||||
|
|
||||||
|
**Format contract**
|
||||||
|
|
||||||
|
- R6. Every segment opens with a `trace.meta` header event: `schema: 1`, binary version, `os`, `pid`, `proc_start_ms`, `session_id`. Segments without one — every trace from v0.4.6 and earlier — are read as schema 0, fully supported. A schema greater than the reader's known maximum produces a warning and a best-effort parse, never an error.
|
||||||
|
- R7. Trace format evolution is additive-only: new event types and new optional fields may appear, but existing field meanings never change. Readers ignore unknown event types and unknown fields, passing them through to output verbatim.
|
||||||
|
|
||||||
|
**Replay completeness (event enrichment)**
|
||||||
|
|
||||||
|
- R8. Every command run through the binary dispatch — CLI and each batch item — and through the five generated FFI command entrypoints (execute-by-ref, snapshot, status, version, wait) emits `command.start` `{command}` and `command.end` `{command, ok, duration_ms}`, plus `code` and `message` on failure. A command that fails preflight, policy, or resolution still yields its boundary pair. If a command panics or is interrupted after start, a guard emits `command.end` with `ok: false, code: INTERNAL` on unwind where recoverable. Hand-written FFI command surfaces stay uninstrumented at the command-boundary level in this plan (see R-G); per-action events still fire for FFI ref actions routed through the shared ref-action seam.
|
||||||
|
- R9. Snapshot creation is traced: the main snapshot path and wait-produced snapshots emit `snapshot.saved` `{snapshot_id, ref_count, app when known}`; the existing `snapshot.root.saved` on the drill-down path is unchanged, per the additive-only rule.
|
||||||
|
|
||||||
|
**Replay artifacts (opt-in)**
|
||||||
|
|
||||||
|
- R10. `session start --screenshots` records `artifacts: full` in the session manifest (default `events`). An old binary reading a new manifest ignores the field — `SessionManifest` carries no `deny_unknown_fields`. A new binary reading an old manifest defaults to `events` via `#[serde(default)]`. `status` surfaces the artifacts mode for the active session.
|
||||||
|
- R11. When tracing is active and `artifacts: full`, every ref action captures a pre-action and post-action screenshot of the acted-on app (`ScreenshotTarget::Window(entry.pid)`, PNG as returned by the existing adapter method), written under `<session>/trace/screens/` with process-collision-free names, as `0600` files in a symlink-guarded `0700` directory. A per-process budget of 128MiB and 200 captures bounds each process's contribution; a session driven by many separate invocations accumulates one allowance per process, so the session-level footprint is not bounded by this mechanism (see System-Wide Impact). Capture failure, budget exhaustion, missing screen-recording permission, or an adapter without screenshot support (the Windows and Linux stubs) skips the capture with a machine-readable reason — it never fails or slows the action beyond the capture attempt itself.
|
||||||
|
- R12. Each ref action with artifacts enabled emits `action.artifacts` `{ref, screenshot_pre, screenshot_post, skipped reasons when applicable}`, with paths relative to the session's trace directory.
|
||||||
|
- R13. When `artifacts: full`, every refmap save — a new snapshot, a drill-down re-save, a wait-produced snapshot — also copies the refmap JSON to `<session>/trace/refmaps/<snapshot_id>.json`. The copy is idempotent (first-write-wins, atomic tmp-then-rename) and shares a 64MiB per-process budget with skip-and-count on exhaustion. Snapshot pruning (the 512 cap) can therefore never break replay for a step whose refmap was already copied; a refmap that was budget-skipped and later pruned is gone, and the reader and viewer surface a placeholder plus a count for it, mirroring the screenshot embed-budget handling (R16). Refmap copies are intentionally unredacted, matching Playwright's full-fidelity replay — this is exactly why artifacts are opt-in.
|
||||||
|
|
||||||
|
**Human-viewable export**
|
||||||
|
|
||||||
|
- R14. `trace export [--out <path>] [--limit N]` writes one self-contained HTML file, defaulting to `--limit 5000` (ten times `trace show`'s tail default, serving the comprehensive human artifact) with `--limit 0` embedding the full timeline — no network fetches, no external files, works from `file://` — with the merged timeline embedded as JSON, screenshots embedded as base64 data URIs, and a viewer UI with inline CSS and JS. The default output path is `trace-<session_id>.html` in the current directory.
|
||||||
|
- R15. Export is XSS-safe against trace-controlled content — app window titles, element names, and error messages are all attacker-influenceable. Trace JSON is embedded in a `<script type="application/json">` block with `<`, `>`, `&`, U+2028, and U+2029 escaped as `\uXXXX` in the serialized JSON. The viewer JS renders all data via `textContent`/DOM text nodes, never `innerHTML` with data. Screenshot data URIs are validated against the base64 charset before assignment.
|
||||||
|
- R16. Export is bounded: total embedded screenshot bytes cap at 100MiB (beyond the cap: a placeholder plus a count in the response); total serialized JSON is guarded at 200MiB (beyond the guard: `INVALID_ARGS` with a suggestion to use `--limit`). Export output is byte-deterministic for identical inputs — no timestamps or randomness are injected.
|
||||||
|
- R17. Redacted event fields — written as `{"redacted": true}` by the foundation — render distinctly in the viewer (for example `⟨redacted⟩`), never as raw JSON noise.
|
||||||
|
|
||||||
|
**Contract safety**
|
||||||
|
|
||||||
|
- R18. Both commands are additive: no existing envelope shape changes, `ENVELOPE_VERSION` does not bump (per `docs/solutions/best-practices/envelope-version-bump-contract-2026-05-13.md`), and no new error codes are introduced — a missing session or trace maps to `INVALID_ARGS` with a suggestion, mirroring `session end`. The FFI error-code ABI and header asserts stay untouched.
|
||||||
|
- R19. `trace show` and `trace export` work over FFI through the same core command functions — the generated FFI entrypoints extend through the `crates/ffi/build.rs` generator, never by hand-editing `@generated` files — and both are available in batch mode.
|
||||||
|
|
||||||
|
### Scope Boundaries
|
||||||
|
|
||||||
|
**Deferred to follow-up work:**
|
||||||
|
|
||||||
|
- A served or interactive viewer app (web server, TUI) — the no-GUI identity holds; revisit only on demand.
|
||||||
|
- Zip or portable bundle export — the single HTML file is the shareable artifact.
|
||||||
|
- Video or screencast recording — per-action stills suffice.
|
||||||
|
- JPEG or scaled screenshot encoding — needs adapter API changes (the macOS implementation is PNG-only today, `crates/macos/src/system/screenshot.rs`); the byte budget caps volume instead. Revisit if budgets prove tight in practice.
|
||||||
|
- Per-action full accessibility-tree capture — `post_state` and the activation-chain `steps` already in `action.dispatch.ok`, together with screenshots and refmap copies, reconstruct the step without a second tree walk, and a per-action tree capture would visibly slow every action on desktop AX.
|
||||||
|
- Screenshots for non-ref physical input commands (`mouse-click --xy`, a bare `press`) — there is no resolved target pid at a shared seam; capture stays scoped to ref actions in v1.
|
||||||
|
- Live tailing (`trace show --follow`).
|
||||||
|
|
||||||
|
**Outside this product's identity:** embedding an LLM analysis of traces; a GUI mode in the binary.
|
||||||
|
|
||||||
|
### Acceptance Examples
|
||||||
|
|
||||||
|
- AE1. Given a segment whose final line is cut mid-JSON with no trailing newline, when `trace show` runs, then the response succeeds, the event is absent, and the segment's `skipped_lines` is 1.
|
||||||
|
- AE2. Given two segments where process A wrote seq 5 at ts 1000 and seq 6 at ts 999 (a clock regression) and process B wrote at ts 999, when merged, then A's seq 5 still precedes A's seq 6, and B's ts-999 event orders relative to A's by the deterministic `(ts, pid, position)` rule.
|
||||||
|
- AE3. Given a session traced by a hypothetical newer binary writing `schema: 2` meta plus unknown event types and fields, when `trace show` runs, then events pass through verbatim, a schema warning is present, and the exit code is 0.
|
||||||
|
- AE4. Given a v0.4.6-era trace directory with no `trace.meta` lines, when `trace show` or `trace export` runs, then the full timeline is produced with no warnings about missing meta — schema 0 is first-class.
|
||||||
|
- AE5. Given `artifacts: full` and a fixture app whose window title is `<script>alert(1)</script><img src=x onerror=alert(2)>`, when `trace export` runs and the file is opened, then no script executes and the title renders as literal text.
|
||||||
|
- AE6. Given the 200-capture budget is exhausted mid-session, when a further click runs, then the click succeeds, `action.artifacts` carries `skipped: "budget"`, and `command.end.ok` is true.
|
||||||
|
- AE7. Given screen-recording permission is denied, or the platform adapter returns not-supported, when a ref action runs with `artifacts: full`, then the action behaves exactly as with `artifacts: events` except for the `action.artifacts` skip reason.
|
||||||
|
- AE8. Given no active session — no flag, env var, or pointer — when `trace show` runs, then `INVALID_ARGS` returns with a suggestion naming `session start` or `--session`.
|
||||||
|
- AE9. Given the same session read twice, with segments listed by the OS in different orders, when `trace export` runs twice, then the two HTML files are byte-identical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Contract
|
||||||
|
|
||||||
|
### Key Technical Decisions
|
||||||
|
|
||||||
|
- KTD1 — Viewer is the CLI merged timeline plus a single-file HTML export; no server, no GUI mode. `trace show` is the agent surface: envelope JSON bounded by `--limit` tail semantics and a `truncated` flag, so agents never blow their context window. `trace export` is the human surface: one static HTML file, openable anywhere, attachable to an issue. Playwright ships a web-app viewer, but this repo's identity is machine-facing — no GUI, no TUI — so the shareable artifact here is a file, not a program. Playwright 1.59's `npx playwright trace` (`actions --grep`, `action <n>`, `snapshot <n>`) is a stdout-only, no-browser trace reader built for CI and agent loops, and validates `trace show`'s list/filter shape as proven prior art.
|
||||||
|
- KTD2 — Merge is a k-way heap merge keyed on `(ts_ms, pid, file-position)`; per-segment file order is inviolable. Same-machine processes share a clock, so "skew" reduces to non-monotonic wall time; `seq`/file order preserves in-process causality, and the `(pid, position)` tie-break makes cross-process ties deterministic rather than pretending microsecond truth. Segments sort by filename before merging, which makes the result independent of segment discovery order (R2, AE9).
|
||||||
|
- KTD3 — Reader tolerance is a hard contract, not best-effort politeness (R3): every malformed shape degrades to a counted warning. The foundation plan's KTD2 promised that readers must tolerate a truncated final line; this plan turns the full tolerance matrix — truncated tail, corrupt line, foreign file, oversized line, non-object line — into an explicit, tested guarantee, because multi-process append-only files will exhibit all of these in real crashes.
|
||||||
|
- KTD4 — Format versioning is a `trace.meta` header event per segment plus additive-only evolution (R6/R7). An event, rather than a filename suffix or a sidecar file, keeps the format single-channel, survives file copies, costs one line, and lets v0.4.6 traces remain schema 0 with no migration. The reader accepts an unknown schema with a warning — forward-lenient, because traces are diagnostic artifacts and refusing to read one is strictly worse than a best-effort parse. Playwright has no trace-format version contract at all: compatibility is empirically "mostly forward," a 1.32 viewer once rendered old traces as a silent blank panel, and the request for a version-mismatch warning (playwright#21898) is still open. `trace.meta`'s schema field and its warn-not-fail behavior is the direct fix for a gap Playwright users have paid for.
|
||||||
|
- KTD5 — Command boundary events go through a `CommandContext::command_scope` guard wired at the binary's `dispatch::dispatch` — which covers CLI and every batch item, since batch items reuse the same dispatch with their own child contexts — and at the FFI generated entrypoints (a generator template change in `crates/ffi/build.rs`). One core implementation, two thin integration points; a `Drop` safety net emits a failure `command.end` if a scope is abandoned. `command.start` carries only the command name — the command's own events carry the specifics, and including arguments would drag sensitive payloads through redaction for no replay value.
|
||||||
|
- KTD6 — Screenshots are opt-in via the manifest's `artifacts: full` (set by `session start --screenshots`), captured only around ref actions at the single shared seam `ref_action::execute_resolved` — pre-capture after the actionability check (before activation — the activation chain, including scroll-into-view, runs inside `execute_action`), post-capture after execution on both the success and failure paths — targeting `ScreenshotTarget::Window(entry.pid)`, the acted-on app. Window-scoped beats full-screen: smaller PNGs (the adapter is PNG-only, and no new image dependency is allowed), less privacy exposure, and capture resolves through the platform's pid-to-window heuristic — on macOS the largest visible window for the pid, not the frontmost as the trait doc-comment stale-claims (U5 corrects the comment) — so smaller same-pid surfaces (menus, sheets, popovers) and the non-largest windows of multi-window apps can be missed (R-C). Cross-app side effects are a documented v1 miss. Budgets of 128MiB and 200 captures per process, enforced by process-local atomic counters, bound volume; every skip carries a reason in `action.artifacts` (R11/R12). Capture is best-effort by construction — an action must never fail because a screenshot did.
|
||||||
|
- KTD7 — Refmap copies happen at snapshot-save time, not action time. Copying `snapshots/<id>/refmap.json` to `trace/refmaps/<id>.json` when a refmap is saved is naturally idempotent, catches every snapshot an action could later reference, and decouples replay from the 512-snapshot prune — the foundation plan's recorded "copy, Playwright-style" decision, now executed. Copies are raw and unredacted; element names are the replay value, the same way Playwright copies the full DOM, which is exactly why `artifacts: full` is opt-in and the sensitivity is documented (R13). Playwright itself ships no content redaction despite multi-year demand — a request for password-protected traces (playwright#28934) was closed "not planned," and only screenshot masking exists — so agent-desktop's write-time field redaction is already ahead of that prior art; this plan keeps redaction intact for `events` mode and documents the opt-in, unredacted artifacts explicitly rather than following Playwright's precedent of no redaction at all. A per-action full accessibility-tree capture would also visibly slow every action on desktop AX, so this plan skips that: `post_state` plus the activation-chain `steps` already in `action.dispatch.ok`, together with screenshots and refmap copies, reconstruct the step without a second tree walk.
|
||||||
|
- KTD8 — HTML export embeds data as a JSON `<script type="application/json">` island with `<`-style escaping, and the viewer renders exclusively via `textContent` — the Lighthouse report-generator pattern, which sanitizes embedded JSON by escaping `<` to its `\uXXXX` code point (plus U+2028 and U+2029) so `</script>` becomes unrepresentable inside the payload. This makes XSS structurally impossible rather than sanitization-dependent (R15). Viewer assets are three `include_str!` files (HTML, CSS, JS), following the `commands/skills.rs` embedding precedent, with zero new dependencies (core already carries `serde_json` and `base64`). Playwright's own HTML report claims to be "one file" — a base64-zip inside a `<script id="playwrightReportBase64">` block — yet breaks when opened via `file://`, because its embedded viewer leans on service-worker and virtual-filesystem fetch interception, which refuses to register on a `file:` origin; Playwright ships a companion server (`show-report` on localhost:9323) to work around it. That failure is the decisive evidence for this plan's shape: resolving everything at generation time — an inline JSON island and inline base64 images, no runtime fetch or service-worker indirection — is the only single-file design that works from `file://`.
|
||||||
|
- KTD9 — Zero new error codes, zero envelope-version bump, zero `PlatformAdapter` trait changes. A missing session or trace maps to `INVALID_ARGS` with a suggestion, mirroring `session end`; the new commands are additive data, per the envelope-bump solution doc; capture reuses the existing `screenshot(ScreenshotTarget) -> ImageBuffer` adapter method with its default `not_supported` on stub platforms. This keeps the FFI ABI (error discriminant pins, header asserts) and `adapter.rs` (397 of 400 LOC) untouched.
|
||||||
|
- KTD10 — LOC-cap pre-splits are named up front. `crates/core/src/trace.rs` (389 of 400 LOC) sheds its sanitizer into `crates/core/src/trace_sanitize.rs` (re-exported; the FFI `log_callback` depends on `sanitize_trace_value`) before gaining `trace.meta` emission. `src/dispatch/mod.rs` (394 of 400 LOC) extracts the session and trace match arms into a sibling dispatch module before gaining the trace arm. New reader and artifact code lives in new modules (`crates/core/src/trace_read/`, `crates/core/src/trace_artifacts.rs`) rather than being squeezed into capped files.
|
||||||
|
- KTD11 — `trace` joins the permissionless policy class: the `Commands::Version | Skills | Session => None` arm in `src/command_policy/mod.rs` gains a `Trace(_)` case. Reading or exporting traces must work on a machine with zero permissions granted — CI, or post-hoc analysis of a copied session directory.
|
||||||
|
|
||||||
|
### High-Level Technical Design
|
||||||
|
|
||||||
|
The write path fans out across concurrent processes into per-process segments, plus artifacts alongside them; the reader discovers, tolerantly parses, and merges those segments back into one timeline that both new commands consume.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
P["N processes: CLI, batch, FFI"] --> SEG["Segment pid-ts.jsonl"]
|
||||||
|
SEG --> SCR["screens/*.png"]
|
||||||
|
SEG --> RFM["refmaps/id.json"]
|
||||||
|
SEG --> RD["trace_read: parse + merge"]
|
||||||
|
SCR --> RD
|
||||||
|
RFM --> RD
|
||||||
|
RD --> SHOW["trace show (JSON envelope)"]
|
||||||
|
RD --> EXP["trace export (single HTML)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
A single ref action with `artifacts: full` shows the capture path end to end; the refmap-copy path runs independently on every snapshot save.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Cmd as Command
|
||||||
|
participant RA as ref_action
|
||||||
|
participant Cap as Capture
|
||||||
|
participant Ad as Adapter
|
||||||
|
participant Tr as Trace
|
||||||
|
|
||||||
|
Cmd->>RA: resolve ref
|
||||||
|
RA->>RA: actionability check
|
||||||
|
RA->>Cap: screenshot PRE
|
||||||
|
Cap-->>RA: PNG or skip reason
|
||||||
|
RA->>Ad: execute_action
|
||||||
|
Ad-->>RA: post_state, steps
|
||||||
|
RA->>Cap: screenshot POST
|
||||||
|
Cap-->>RA: PNG or skip reason
|
||||||
|
RA->>Tr: action.dispatch.ok
|
||||||
|
RA->>Tr: action.artifacts
|
||||||
|
|
||||||
|
Note over Cmd,Tr: separately, on snapshot save
|
||||||
|
Cmd->>Tr: write refmap
|
||||||
|
Cmd->>Tr: copy to trace/refmaps (idempotent)
|
||||||
|
```
|
||||||
|
|
||||||
|
The write side stays lock-free: per-process segment files, process-local atomic budget counters, and first-write-wins tmp-then-rename copies need no cross-process coordination. The read side is pure `std` file reading inside `agent-desktop-core`, with no platform crate involvement, so `trace show` and `trace export` work identically on Windows and Linux the day those adapters land. U1–U3 (reader, `trace show`, enrichment) can land and prove the agent surface ahead of U4–U6; U2 and U3 both edit the dispatch module, so they land serially in that order.
|
||||||
|
|
||||||
|
### System-Wide Impact
|
||||||
|
|
||||||
|
- FFI consumers gain command boundary events and two new generated entrypoints. The ABI addition is purely additive: the header regenerates via the existing script, and no error code changes.
|
||||||
|
- Windows and Linux (Phase 2): the reader, `trace show`, and `trace export` are pure core, so they work on day one. Capture degrades to a reasoned skip through the adapter's default `not_supported`, proven by unit U5's stub-adapter test.
|
||||||
|
- Privacy posture changes under `artifacts: full`: raw pixels and unredacted refmap copies live under the session directory (`0600`/`0700`) and flow into exports. Documentation must say plainly that an exported HTML file should be treated like a screenshot of the screen. Event-field redaction is unchanged for `events` mode. One documented exception: `command.end`'s `message` is free text from the failing command's error string, not subject to key-based redaction, so caller-supplied predicate text (a wait selector, a window title) can surface verbatim in events-mode traces and their exports.
|
||||||
|
- Trace disk figures (64MiB per segment, 128MiB screenshots, 64MiB refmap copies) are per-process ceilings, not session bounds: every new process gets a fresh allowance, so an active long-running session's total footprint is unbounded across many invocations. Periodic session rotation — end the session and start a new one, then `session gc` — is the current mitigation; session-wide accounting is deliberately out of scope (lock-free write side).
|
||||||
|
|
||||||
|
### Risks & Dependencies
|
||||||
|
|
||||||
|
- R-A. `dispatch/mod.rs` and `trace.rs` are already at the 400-LOC cap. The named pre-splits (KTD10) are the first task inside U2 and U3 respectively; the splits are mechanical — moving match arms and the sanitizer to sibling files, not rewriting logic.
|
||||||
|
- R-B. PNG-only screenshots on a retina display can exhaust the 128MiB capture budget in long sessions. Budgets are fixed constants, so the consequence is skipped captures with reasons, never a failed action; JPEG or scaled encoding is deferred as an adapter change. Trace and single-file-report ecosystems run large by default — Playwright traces commonly land 10-50MB and often exceed 100MB with no built-in cap (playwright#8263, playwright#29218), and Allure's single-file mode inflates roughly 50% via base64 with a practical ~500MB browser ceiling — which is why this plan sets explicit, enforced budgets (128MiB/200 captures for capture, 100MiB/200MiB for export) rather than leaving either unbounded.
|
||||||
|
- R-C. `Window(pid)` capture can miss the acted-on surface two ways: cross-app effects (a system dialog, another process's window), and same-app misses — the macOS resolution picks the largest visible window for the pid, so a smaller menu, sheet, or popover, or the non-largest window of a multi-window app, may not be captured. Both are documented v1 boundaries (Scope Boundaries); U5 asserts the resolution behavior as a stated, tested contract and corrects the trait doc-comment's stale "frontmost" claim.
|
||||||
|
- R-D. The viewer JS must stay under 400 lines. Feature scope for U6 is pinned in its Approach; growth pressure routes to the deferred served-viewer follow-up, not into this file.
|
||||||
|
- R-E. Multiple `trace.meta` lines can appear in a shared explicit `--trace` file, since each writer opens with its own meta line. The reader rule is pinned by U1's test scenario 17: the first meta line wins, later ones pass through as regular events.
|
||||||
|
- R-F. The e2e hostile-window-title case depends on the fixture app's ability to set a window title containing markup. A fallback is documented in U7; unit-level AE5 coverage remains the guarantee regardless.
|
||||||
|
- R-G. Command-boundary events over FFI cover only the five generated entrypoints; the hand-written FFI command files (apps, windows, notifications, observation, screenshot, input, surfaces) stay uninstrumented at that level. Follow-up work; per-action dispatch events still cover FFI ref actions.
|
||||||
|
- R-H. `trace show` and `trace export` output can contain content the reader did not generate — a copied or tampered trace directory is a first-class use case (KTD11) — and R7 passes unknown fields through verbatim. Sanitizing trace-derived content against prompt injection remains the calling agent's responsibility, consistent with the repo's Non-Goals; the reader's job is tolerance and provenance, not trust.
|
||||||
|
|
||||||
|
### Sources & Research
|
||||||
|
|
||||||
|
- Foundation plan `docs/plans/2026-06-30-001-refactor-session-first-trace-architecture-plan.md` — its own R2 (per-process segments with a per-line `seq`), R7 (the trace log survives snapshot pruning), KTD2 (per-process segments), and KTD5 (the trace log decoupled from the snapshot cache) define the merge and copy contracts this plan executes; its "Deferred to Follow-Up Work" section is this plan's scope.
|
||||||
|
- `docs/solutions/best-practices/playwright-grade-desktop-reliability-2026-06-02.md` — the reliability contract touching trace output and FFI parity; read before implementing U3 and U5.
|
||||||
|
- `docs/solutions/best-practices/envelope-version-bump-contract-2026-05-13.md` — grounds KTD9's no-bump call.
|
||||||
|
- Code anchors: `crates/core/src/ref_action.rs` (the `execute_resolved` seam; `action.dispatch.ok` already carries `post_state` and `steps`), `crates/core/src/trace.rs` (`WriterState`, the 64MiB cap, segment naming), `crates/core/src/refs_store.rs` (the 512-snapshot prune cap, the 1MiB refmap cap, snapshot paths), `crates/core/src/session/manifest.rs` (serde compatibility posture), `src/command_policy/mod.rs` (the permissionless arm), `src/cli_args/session.rs` and `src/batch/mod.rs` (wiring mirrors for the new `trace` surface), `crates/core/src/commands/skills.rs` (the `include_str!` embedding precedent), `crates/macos/src/system/screenshot.rs` (evidence the adapter is PNG-only today), `crates/ffi/build.rs` and `crates/ffi/src/commands/generated.rs` (the FFI codegen this plan extends).
|
||||||
|
- External prior art — Playwright and the single-file-report ecosystem:
|
||||||
|
- Playwright's HTML report claims to be "one file" (a base64-zip inside a `<script id="playwrightReportBase64">` block) but breaks when opened via `file://`, because its embedded viewer depends on service-worker and virtual-filesystem fetch interception that refuses to register on a `file:` origin — Playwright ships a companion `show-report` server on localhost:9323 to work around it. Shapes KTD8.
|
||||||
|
- Playwright has no trace-format version contract: compatibility is empirically "mostly forward," a 1.32 viewer once rendered old traces as a silent blank panel, and the request to warn on a version mismatch (playwright#21898) is still open. Shapes KTD4.
|
||||||
|
- Playwright 1.59 added `npx playwright trace` (`actions --grep`, `action <n>`, `snapshot <n>`), a stdout-only, no-browser trace reader built for CI and agent loops. Validates KTD1's `trace show` shape as proven prior art.
|
||||||
|
- Playwright ships no content redaction despite multi-year demand — a request for password-protected traces (playwright#28934) was closed "not planned," and only screenshot masking exists. Shapes KTD7's decision to keep redaction intact for `events` mode while documenting the opt-in, unredacted artifacts explicitly.
|
||||||
|
- Trace sizes in the wild run 10-50MB typical and 100MB+ common with no built-in caps (playwright#8263, playwright#29218); Playwright screencast frames are JPEG-scaled to fit 800×800; Allure's single-file mode reports roughly 50% base64 inflation and a practical ~500MB browser ceiling. Calibrates R16's budgets and the R-B risk mitigation.
|
||||||
|
- Lighthouse's report generator sanitizes embedded JSON by escaping `<` (plus U+2028 and U+2029) to `\uXXXX` code points so `</script>` is unrepresentable inside the payload. This is the precise mechanism R15 mandates and KTD8 adopts.
|
||||||
|
- Sources: playwright.dev/docs/api/class-tracing; playwright.dev trace-viewer documentation; deepwiki.com/microsoft/playwright (trace.zip and HTML report internals); playwright.dev/docs/test-reporters; github.com/microsoft/playwright issues #21898, #28934, #19992, #29218, #8263; github.com/GoogleChrome/lighthouse `report-generator.js`; the allure-framework discussion #2854; the pytest-html user guide (`self-contained-html` limits).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Trace reader engine (`trace_read`)
|
||||||
|
|
||||||
|
- **Goal:** A deterministic, tolerant segment discovery and merge engine, as a pure-core module returning a typed result — events as `serde_json::Value` plus per-segment stats and warnings.
|
||||||
|
- **Requirements:** R1 (engine half), R2, R3, R4, R6 (read side), R7.
|
||||||
|
- **Dependencies:** None.
|
||||||
|
- **Files:** NEW `crates/core/src/trace_read/mod.rs` (public API `read_merged(store_trace_dir, ReadOptions) -> MergedTrace`); NEW `crates/core/src/trace_read/segment.rs` (filename parsing for `<pid>-<ts>`, a tolerant line iterator, per-segment stats); NEW `crates/core/src/trace_read/merge.rs` (k-way merge, provenance annotation, event-prefix filter, tail limit); sibling tests `segment_tests.rs` and `merge_tests.rs`; register the module in `crates/core/src/lib.rs`.
|
||||||
|
- **Approach:** Read lines with `BufRead`, checking explicitly for a final-line trailing newline to detect truncation; guard any line over 8MiB; sort segments by filename, then merge them through a `BinaryHeap` keyed on `(ts_ms, pid, position)`; a missing `ts_ms` sorts as 0 for schema-0 tolerance; `writer_pid`/`segment` annotations are added without clobbering existing event fields; schema is read from a leading `trace.meta` line when present, else defaults to 0; a schema above the reader's known maximum produces a warning string rather than an error. Segment files open with `O_NOFOLLOW` (symlink refusal) mirroring `open_refstore_file`; an unreadable or symlinked segment degrades to a counted warning, never an error.
|
||||||
|
- **Execution note:** Test-first. Encode R2 and R3 as failing tests before writing the merge implementation.
|
||||||
|
- **Patterns to follow:** Sibling test files via the `#[path]` attribute pattern, consistent with how `agent-desktop-core` splits large modules from their tests elsewhere.
|
||||||
|
- **Test scenarios:**
|
||||||
|
1. Two segments interleave strictly by `ts_ms`.
|
||||||
|
2. A same-millisecond tie across processes orders by `(pid, position)` and is stable across runs.
|
||||||
|
3. An in-process `ts_ms` regression keeps `seq` order (AE2).
|
||||||
|
4. Discovery-order independence: the same result with the segment list reversed, fed via renamed copies.
|
||||||
|
5. A truncated final line — no trailing newline, cut mid-JSON — yields `skipped_lines: 1` with remaining events intact (AE1).
|
||||||
|
6. A corrupt middle line is skipped; subsequent lines still parse.
|
||||||
|
7. A non-object JSON line (`[1,2]`, `"str"`) is skipped.
|
||||||
|
8. An empty file and an empty trace directory yield an empty timeline with zero warnings; a missing trace directory yields a typed error for the command layer to map.
|
||||||
|
9. A foreign file (`notes.txt`) and a `123-9.jsonl.tmp` file in `trace/` are ignored — the foreign file with a warning, the `.tmp` file silently.
|
||||||
|
10. An oversized line over 8MiB is counted, not loaded.
|
||||||
|
11. A `trace.meta` line with `schema: 1` parses into segment stats; an absent meta line reads as schema 0 with no warning (AE4).
|
||||||
|
12. A `schema: 2` meta line produces a warning while events still return (AE3).
|
||||||
|
13. Unknown event types and unknown fields pass through verbatim (R7).
|
||||||
|
14. The `writer_pid` annotation does not clobber an existing `pid` field on `ref.resolve.entry`-shaped events (R4).
|
||||||
|
15. The event-prefix filter (`action.`) combines with tail `--limit` semantics: the last N events after filtering, with `total_events` vs `returned_events` and the `truncated` flag both correct.
|
||||||
|
16. Filename parsing: a valid `4242-1719900000000.jsonl` is accepted; `abc-1.jsonl` and `1.jsonl` are rejected as foreign.
|
||||||
|
17. Multiple `trace.meta` lines mid-file pass through as regular events; only the first line counts toward the segment's schema (supports a shared explicit `--trace` file opened by more than one writer; see R-E).
|
||||||
|
18. A segment-named symlink inside `trace/` is skipped with a `symlinked_segment` warning; its target is never read.
|
||||||
|
19. A permission-denied segment file is skipped with an `unreadable_segment` warning; remaining segments still merge.
|
||||||
|
- **Verification:** `cargo test --lib -p agent-desktop-core` green; every new file under 400 LOC; `cargo tree -p agent-desktop-core` unchanged (zero platform imports).
|
||||||
|
|
||||||
|
### U2. `trace show` command and full CLI, batch, and FFI wiring
|
||||||
|
|
||||||
|
- **Goal:** Expose the reader as `agent-desktop trace show` everywhere other commands exist.
|
||||||
|
- **Requirements:** R1, R5, R18, R19, KTD11.
|
||||||
|
- **Dependencies:** U1.
|
||||||
|
- **Files:** NEW `crates/core/src/commands/trace.rs` (core `TraceAction::Show { limit, event }`, resolving `RefStore::for_session(context.session_id()).trace_dir()` and mapping a missing session or directory to `INVALID_ARGS` with a suggestion) plus sibling `trace_tests.rs`; NEW `src/cli_args/trace.rs` (clap `TraceArgs`/`TraceAction::Show(...)`); `src/cli/mod.rs` (the new variant and its name mapping); `src/dispatch/mod.rs` plus a new sibling extracted arm module (the KTD10 split moves the Session and Trace arms out, keeping dispatch under 400 LOC); `src/command_policy/mod.rs` (add `Trace(_)` to the permissionless `None` arm and to the ref-policy exemption arm); `src/batch/mod.rs` (a `"trace"` parse path with a `deny_unknown_fields` `BatchTraceArgs`); `crates/ffi/build.rs` (a descriptor for `trace show`, regenerated via `scripts/update-ffi-header.sh` if the surface changes); `src/cli/contract_tests.rs` plus a regenerated `src/cli/help_after.txt` golden; `src/tests/conformance.rs`; an FFI conformance test file enumerating commands.
|
||||||
|
- **Approach:** The envelope's `data` shape is `{session_id, segments: [{segment, pid, schema, event_count, skipped_lines}], total_events, returned_events, truncated, warnings: [], events: [...]}`, with empty `warnings` omitted per the serialization rules. The command works with a bare `--session <id>`: a manifest-less session still has a trace directory only if it was traced, and an absent directory returns the `INVALID_ARGS` guidance. `segments[].event_count` is an integer; per-segment event bodies are never duplicated — only the top-level `events` array (post-filter, post-limit) carries payloads. `warnings` entries are `{kind, message}` objects with the closed kind set from R3.
|
||||||
|
- **Patterns to follow:** `src/cli_args/session.rs` for the clap argument shape; the existing `session` batch-args handling for `deny_unknown_fields` parsing.
|
||||||
|
- **Test scenarios:**
|
||||||
|
1. Happy path: a seeded session directory (real segment fixtures via a test helper) produces an envelope whose shape is asserted field by field.
|
||||||
|
2. No active session anywhere returns `INVALID_ARGS` with a suggestion (AE8).
|
||||||
|
3. A session that exists but was never traced (no trace directory) returns `INVALID_ARGS` suggesting `session start`, not a crash.
|
||||||
|
4. Session resolution precedence: explicit `--session` beats the env var, which beats the pointer (reusing the `HomeGuard` test pattern).
|
||||||
|
5. `--limit`/`--event` flow through to the reader; the default of 500 is documented in help text and asserted in tests.
|
||||||
|
6. Policy: `trace show` passes preflight with every permission denied (a `command_policy` unit test).
|
||||||
|
7. Batch: `{"command":"trace","args":{"action":"show"}}` runs; an unknown batch arg field is rejected via `deny_unknown_fields`.
|
||||||
|
8. CLI contract: `trace` appears in the command list; the help golden is regenerated; argument-parse tests cover `--limit 0` and `--event action.`.
|
||||||
|
9. FFI conformance: `trace show` is callable and returns an envelope, mirroring existing FFI command tests.
|
||||||
|
10. A committed golden fixture — a mini-session with two segments, one containing a truncated line — produces stable merged JSON in `tests/fixtures/`; one of the two segments carries no `trace.meta` line, so the same committed fixture doubles as the schema-0/v0.4.6 compatibility proof the Definition of Done names.
|
||||||
|
11. A tail window that cuts a `command.start`/`command.end` pair yields `truncated: true` plus an `unpaired_command` warning, with the surviving member still present in `events`.
|
||||||
|
- **Verification:** The full binary contract suite and FFI tests are green; `help_after.txt` is regenerated deliberately, with the diff reviewed rather than accidental.
|
||||||
|
|
||||||
|
### U3. Event enrichment: command boundaries, snapshot linkage, segment meta
|
||||||
|
|
||||||
|
- **Goal:** A trace alone reconstructs every command: boundaries, duration, outcome, and the snapshot ids it touched.
|
||||||
|
- **Requirements:** R6 (write side), R8, R9.
|
||||||
|
- **Dependencies:** None — runs in parallel with U1. U2's `trace show` output benefits once U3 lands, but does not require it first.
|
||||||
|
- **Files:** The KTD10 split happens first: NEW `crates/core/src/trace_sanitize.rs` (moves `sanitize_trace_value` and its key-token helpers out of `trace.rs`, with a `pub use` in `lib.rs` for the FFI `log_callback` consumer). Then: `crates/core/src/trace.rs` (emits `trace.meta` as the first line of every new segment, including explicit `--trace` files, so the reader treats both sink kinds identically); `crates/core/src/context.rs` (a `command_scope(name) -> CommandScope` guard — an eager start event, a `complete(&Result)` call emitting the end event with `duration_ms` via `Instant`, and a `Drop` that emits an `INTERNAL` end if not completed) plus `context_tests.rs`; `src/dispatch/mod.rs` (wraps the dispatch body in the scope, covering CLI and batch items from one place); `crates/ffi/build.rs` (generated entrypoints open and complete the scope); `crates/core/src/commands/snapshot.rs` (emits `snapshot.saved` after save, including `ref_count` and `app` when known); the other `save_new_snapshot` call sites (`snapshot_ref.rs` already emits `root.saved`; the wait paths gain `snapshot.saved` too) plus their tests.
|
||||||
|
- **Approach:** Events flow through the existing redaction pipeline unchanged — the message field passes, sensitive keys redact, already proven by the current context tests. `command.start` carries only `{command}` (KTD5). In batch, the outer `batch` command gets its own scope, and each item gets its own through the shared dispatch path, so nesting is reconstructable from `seq` order. Over FFI, boundary events cover the five generated entrypoints only; hand-written FFI surfaces are a documented follow-up (R-G).
|
||||||
|
- **Patterns to follow:** The existing `write_event`/redaction pipeline already used by `ref.resolve.*` and `actionability.*` events; `snapshot_ref.rs`'s existing `snapshot.root.saved` emission as the template for the new `snapshot.saved` call.
|
||||||
|
- **Test scenarios:**
|
||||||
|
1. Scope happy path: a start/end pair with `duration_ms` present and sane, `ok: true`.
|
||||||
|
2. A failing command's end event carries `ok: false` plus `code` and `message` (for example, a forced `INVALID_ARGS`).
|
||||||
|
3. A drop without `complete()` emits an `INTERNAL` end exactly once — no double-emit when the scope completes normally.
|
||||||
|
4. A no-sink context makes the scope a no-op: no error, no file.
|
||||||
|
5. `trace.meta` is the first line of a fresh segment, with schema 1, `pid`, and `session_id` present; an explicit `--trace` file also opens with meta. This adds test scenario 17 to U1: multiple meta lines mid-file are passthrough events, and only the first counts as the segment's schema.
|
||||||
|
6. A batch of 3 items produces 1 outer plus 3 inner start/end pairs, with distinct commands and correct nesting by `seq`.
|
||||||
|
7. The snapshot command emits `snapshot.saved` carrying the id the envelope returned; the wait-with-snapshot path emits it too.
|
||||||
|
8. FFI: a generated entrypoint produces a start/end pair in the session segment, extending the existing FFI trace verification test.
|
||||||
|
9. Redaction: an end message containing a window title passes through as a diagnostic message under documented semantics; sensitive keys in any future fields still redact, and the sanitizer's own unit tests survive the file move unchanged.
|
||||||
|
- **Verification:** Core, binary, and FFI suites are green; `trace.rs` and `dispatch/mod.rs` are both under 400 LOC after their splits; the generated FFI file is regenerated via the script, never hand-edited.
|
||||||
|
|
||||||
|
### U4. Artifacts mode: manifest, `session start --screenshots`, and status
|
||||||
|
|
||||||
|
- **Goal:** One opt-in knob recorded in the manifest, surfaced everywhere session state is visible.
|
||||||
|
- **Requirements:** R10.
|
||||||
|
- **Dependencies:** None.
|
||||||
|
- **Files:** `crates/core/src/session/manifest.rs` (`#[serde(default)] artifacts: ArtifactsMode` enum with `Full` and `#[default] Events`, plus an `artifacts_full()` helper that respects `ended_at`); `crates/core/src/commands/session.rs` (`Start` gains a `screenshots: bool` mapped to `ArtifactsMode`); `crates/core/src/session/mod.rs` (`StartSessionOptions` gains the field); `src/cli_args/session.rs` (a `--screenshots` flag with help text warning about sensitivity); `src/batch/mod.rs` (`BatchSessionArgs` gains the field); `src/dispatch/mod.rs` (arm plumbing); `crates/core/src/context.rs` (resolves the artifacts mode at construction alongside trace gating, from one manifest read that returns both); `crates/core/src/commands/status.rs` (surfaces `artifacts` for the active session); tests in `session_tests.rs`, `status_tests.rs`, `batch/tests.rs`, plus CLI contract tests and a help golden update.
|
||||||
|
- **Approach:** `--screenshots` implies nothing about tracing on its own — `--no-trace --screenshots` together is `INVALID_ARGS`, since artifacts require tracing, validated at session start with a clear message.
|
||||||
|
- **Patterns to follow:** `artifacts_full()` mirrors the existing `trace_enabled()` end-of-session check; `--screenshots` follows the same boolean-flag-to-manifest-field wiring already used for `trace: on`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
1. `session start --screenshots` produces a manifest with `"artifacts":"full"`; without the flag, the field still serializes explicitly as `"events"` (always-serialize, for simpler golden fixtures).
|
||||||
|
2. An old manifest JSON with no `artifacts` key deserializes to `Events` (backward compatible).
|
||||||
|
3. A manifest with an unknown future key still parses — no `deny_unknown_fields` regression.
|
||||||
|
4. `--no-trace --screenshots` together returns `INVALID_ARGS` with a suggestion.
|
||||||
|
5. An ended session reports `artifacts_full()` as false after `session end`, mirroring `trace_enabled`.
|
||||||
|
6. `status` shows the artifacts mode; an absent session omits the field.
|
||||||
|
7. Batch `session start` accepts `screenshots: true`; an unknown field is still rejected.
|
||||||
|
8. CLI: the flag parses correctly, and the help golden is updated.
|
||||||
|
- **Verification:** Core and binary suites are green; a v0.4.6-era session directory round-trips through `session list` untouched.
|
||||||
|
|
||||||
|
### U5. Capture pipeline: screenshots and refmap copies (`trace_artifacts`)
|
||||||
|
|
||||||
|
- **Goal:** The opt-in artifacts are actually captured, budgeted, and linked from events.
|
||||||
|
- **Requirements:** R11, R12, R13.
|
||||||
|
- **Dependencies:** U4 (mode). U1 only for shared naming conventions — a soft dependency, not a hard ordering requirement.
|
||||||
|
- **Files:** NEW `crates/core/src/trace_artifacts.rs` (hardens `screens/` and `refmaps/` by calling `ensure_trace_dir` — promoted to `pub(crate)` — rather than re-deriving the `0700`/symlink-guard logic; process-local atomic budgets and a capture sequence counter; `capture_action_screenshot(context, adapter, pid, phase) -> ArtifactOutcome` and `copy_refmap_if_full(context, store, snapshot_id)`, both writing through the existing `write_private_file` primitive — which already carries `0600`, `O_NOFOLLOW`, and atomic tmp-then-rename — with a pre-existence check supplying first-write-wins idempotency; no new file-write logic) plus `trace_artifacts_tests.rs`; `crates/core/src/ref_action.rs` (pre/post capture around execution, plus the `action.artifacts` event; stays under 400 LOC — currently 135); refmap-copy calls at every `save_new_snapshot`/`save_existing_snapshot` command seam (`crates/core/src/commands/snapshot.rs`, `snapshot_ref.rs`, and the wait snapshot-save paths — grep for the call sites); `crates/core/src/refs.rs` only if a raw-bytes read helper turns out to be needed; `crates/core/src/adapter.rs` (doc-comment correction only: `ScreenshotTarget::Window` documents largest-visible-window resolution, not "frontmost").
|
||||||
|
- **Approach:** Capture runs only when `context.trace_enabled() && context.artifacts_full()`. Screenshots go through the existing `adapter.screenshot(Window(entry.pid))`; post-capture fires on the action's success and failure paths alike (`inspect_err`-symmetric, mirroring `check_actionability_with_trace` in the same file) — a failed action is when the screenshot matters most. Every failure path returns a reason string (`"budget"`, `"count_budget"`, `"adapter: <code>"`) that lands in `action.artifacts.skipped`; artifact writes never propagate errors into the action result. The mock adapter gains a screenshot stub returning a small fixed PNG.
|
||||||
|
- **Execution note:** Test-first for budget and skip semantics — they are the contract.
|
||||||
|
- **Patterns to follow:** `ensure_trace_dir` (promoted to `pub(crate)`) for directory hardening and `write_private_file` for every artifact write — reuse the proven primitives, do not re-derive them; the existing `serialize_with_size_check` for refmap reserialization rather than a raw-bytes copy; `check_actionability_with_trace`'s `inspect_err` symmetry for failure-path capture.
|
||||||
|
- **Test scenarios:**
|
||||||
|
1. With artifacts full and the mock adapter, pre and post PNGs exist on disk (magic bytes `\x89PNG`), as `0600` files, named with pid, process-start timestamp, sequence, and phase; `action.artifacts` paths resolve relative to the trace directory.
|
||||||
|
2. With artifacts at the default `events` mode, zero files and zero `action.artifacts` events are produced, and the action result is identical to today.
|
||||||
|
3. Trace off with artifacts full still captures nothing — the trace gate wins.
|
||||||
|
4. An adapter screenshot error still lets the action succeed, with skip reason `adapter: ...` (AE7).
|
||||||
|
5. Byte budget exhaustion (a tiny test budget via a `cfg(test)` constructor) skips with reason `"budget"`; counters never wrap, using saturating arithmetic.
|
||||||
|
6. Count budget exhaustion skips with reason `"count_budget"` (AE6).
|
||||||
|
7. A symlinked `screens/` directory makes capture refuse, with a skip reason, never writing through the symlink.
|
||||||
|
8. Refmap copy: a snapshot save with artifacts full produces `trace/refmaps/<id>.json` byte-equal to the source refmap; a second save of the same id produces a single copy (idempotent); a concurrent first-write race between two tmp files leaves exactly one winner and no error.
|
||||||
|
9. Refmap copy budget exhaustion skips and counts; pruning the source snapshot afterward leaves the copy intact, so replay survives prune (the R13 guarantee).
|
||||||
|
10. On an adapter with the default `not_supported` screenshot method (the Windows and Linux stubs), capture cleanly skips — proving cross-platform safety from day one.
|
||||||
|
11. Two threads capturing through the same process counters produce distinct filenames, since the capture sequence is atomic.
|
||||||
|
12. A failing ref action (`execute_action` returns an error) still produces the post-action screenshot and an `action.artifacts` event when artifacts are full.
|
||||||
|
13. Against a multi-window mock, capture resolves per the platform heuristic (largest visible window for the pid) — asserted as the stated contract from R-C.
|
||||||
|
- **Verification:** Core suite is green; no adapter trait change (`adapter.rs` diff-free); capture adds zero overhead when the mode is `events` (asserted by no directory creation).
|
||||||
|
|
||||||
|
### U6. `trace export`: single-file HTML viewer
|
||||||
|
|
||||||
|
- **Goal:** The human-facing artifact: timeline, screenshots, and a detail pane in one XSS-safe static file.
|
||||||
|
- **Requirements:** R14, R15, R16, R17, R19 (export over FFI and batch).
|
||||||
|
- **Dependencies:** U1, U2 (the command file and its wiring already exist).
|
||||||
|
- **Files:** NEW `crates/core/src/trace_read/html.rs` (the export builder: merged timeline to an embedded JSON island with `<` escaping; screenshot files to base64 data URIs under the 100MiB embed budget, each resolved path canonicalized and contained within the session trace directory before any read — a path that escapes (traversal, absolute, or symlink) degrades to the placeholder-plus-count path, never an embedded read; the 200MiB JSON guard; deterministic output) plus `html_tests.rs`; NEW assets `crates/core/src/trace_read/viewer.html`, `viewer.css`, `viewer.js` (each under 400 lines, composed via `include_str!`); `crates/core/src/commands/trace.rs` (an `Export` action taking `--out` and `--limit`); the corresponding wiring increments in `src/cli_args/trace.rs`, the batch parser, the FFI descriptor, CLI contract tests, and the help golden.
|
||||||
|
- **Approach:** Viewer scope is fixed — resist feature creep. A chronological event list uses `command.start` rows as group headers with duration badges and ok/error coloring; a click opens a detail pane with pretty JSON rendered via `textContent`; `action.artifacts` rows show pre/post thumbnails that expand to full size on click; a text filter matches on event name; redacted fields render as `⟨redacted⟩`; a warnings banner surfaces skipped lines, schema warnings, and embed skips; zero network requests; vanilla JS only. The event list renders an explicit empty-state message both for an empty timeline and for a filter matching zero events. A skipped or missing screenshot renders a distinct non-image "screenshot unavailable" placeholder following R17's redacted-field pattern — never a broken `img`. A pruned, budget-skipped refmap renders the same placeholder-plus-count shape (R13). Command status pairs the ok/error coloring with a non-color glyph or text label, so status never relies on color alone. A command group whose start or end fell outside the tail window, or whose writer died before `command.end`, renders as an open, incomplete group labeled as such (R3/R5). An explicit `--limit 0` export is not virtualized and may render slowly for very large sessions — an accepted, documented tradeoff mirroring the JPEG deferral.
|
||||||
|
- **Patterns to follow:** The `commands/skills.rs` `include_str!` asset-embedding precedent for the three viewer asset files; the existing trace-file hygiene of refusing to write through a symlink, applied to `--out`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
1. Export writes exactly one file; the response `data` reports path, event count, screenshots embedded, and byte size.
|
||||||
|
2. The output contains no `src="http`, no `<link href`, and no other external reference (structural assertions).
|
||||||
|
3. Hostile strings — the AE5 payloads in a window title, an element name, and an error message — appear only `<`-escaped inside the JSON island; a raw `<script>alert` is absent outside it.
|
||||||
|
4. The JSON island round-trips: extracting the island text and parsing it with `serde_json::from_str` reproduces the timeline, proving the escaping didn't corrupt it.
|
||||||
|
5. Screenshots embed as `data:image/png;base64,` with a valid base64 charset; a screenshot file missing from disk produces a placeholder entry plus a count, not an error.
|
||||||
|
6. Embed budget: an oversized screenshot set skips the later screenshots, counts them in `screenshots_skipped`, and the export still succeeds.
|
||||||
|
7. The 200MiB JSON guard returns `INVALID_ARGS` with a `--limit` suggestion.
|
||||||
|
8. Determinism: two exports of the same session are byte-identical (AE9).
|
||||||
|
9. The default output path is `trace-<session>.html`; an explicit `--out` is honored; the write is plain create-or-truncate but refuses to follow a symlink, matching existing trace-file hygiene.
|
||||||
|
10. A redacted field renders its flag correctly: the island JSON keeps `{"redacted": true}` intact, and the viewer maps it — the test asserts the data is intact.
|
||||||
|
11. Export with `--limit` embeds only the tail, with a truncated marker in the island's metadata.
|
||||||
|
12. A screenshot path containing traversal (`../`) or an absolute path, or resolving through a symlink, yields the placeholder plus a count — the target file's contents never appear in the HTML (companion to scenario 5).
|
||||||
|
13. An export whose filter or timeline yields zero events renders the explicit empty-state message.
|
||||||
|
14. An unpaired `command.start` renders as an open-incomplete group, not a blank or broken one.
|
||||||
|
- **Verification:** Core suite is green; asset files are each under 400 lines; the e2e-produced file is opened manually once during implementation as a human smoke test, with automated structural assertions thereafter.
|
||||||
|
|
||||||
|
### U7. E2E scenario and docs
|
||||||
|
|
||||||
|
- **Goal:** End-to-end proof by independent observation, with every doc surface telling the truth.
|
||||||
|
- **Requirements:** Closes the loop on R1–R17; updates the documented command count from 55 to 56 — `trace` counts as one command with two actions, consistent with how `session` (four actions: start/end/list/gc) is already counted as one command.
|
||||||
|
- **Dependencies:** U1–U6.
|
||||||
|
- **Files:** `tests/e2e/run.sh` gains a scenario: `session start --screenshots`, snapshot the fixture app, click and type via refs, then `trace show` asserts the command pairs, `action.artifacts`, `snapshot.saved`, and that the artifact files exist with PNG magic bytes; then `trace export` gets structural HTML assertions, including the hostile-title fixture case if the fixture app can set a window title containing markup (if not, the unit-level AE5 coverage suffices, and the gap is noted); `skills/agent-desktop/SKILL.md` (the command count in two places, plus Quick Reference rows for `trace show`, `trace export`, and `session start --screenshots`); `skills/agent-desktop/references/commands-system.md` (the full trace command reference: flags, envelope fields, tolerance semantics, and an artifact-sensitivity warning); `CONCEPTS.md` (the Coordination section gains "Trace Timeline", "Trace Schema", and "Replay Artifacts" entries; "Trace Segment" is updated for `trace.meta`); `README.md` (a trace viewer section with the one-line flow: `session start --screenshots`, do the work, `trace export`). Every doc stays consistent with the `artifacts: full|events` naming from the Planning Contract.
|
||||||
|
- **Test scenarios:** The e2e assertions are the scenarios. Per the repo's observation rule, no step trusts a command's own `ok: true` — the PNGs are stat'd, the HTML is grepped, and the JSON envelope is parsed independently.
|
||||||
|
- **Verification:** `bash tests/e2e/run.sh` is green in both the headless and `--headed` legs (release build plus AX permission); `agent-desktop skills get` output reflects the new docs (skills are `include_str!`'d, so a rebuild is required); the binary size check still passes under 15MB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Contract
|
||||||
|
|
||||||
|
| Gate | Command | Applies |
|
||||||
|
|---|---|---|
|
||||||
|
| Format | `cargo fmt --all -- --check` | all units |
|
||||||
|
| Lint | `cargo clippy --all-targets -- -D warnings` | all units |
|
||||||
|
| Core tests | `cargo test --lib -p agent-desktop-core` | U1, U2, U3, U4, U5, U6 |
|
||||||
|
| Full unit suite | `cargo test --lib --workspace` | all units |
|
||||||
|
| Binary contract | `cargo test -p agent-desktop` | U2, U3, U4, U6 |
|
||||||
|
| FFI | `cargo test -p agent-desktop-ffi --tests` | U2, U3, U6 |
|
||||||
|
| Core isolation | `cargo tree -p agent-desktop-core` contains no platform crates | U1, U2, U3, U4, U5, U6 |
|
||||||
|
| Size | release binary under 15MB (CI gate) | U6, U7 (embedded assets and skills) |
|
||||||
|
| E2E | `bash tests/e2e/run.sh` (release build, AX permission) | U7 (and any unit changing action behavior) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- All 7 units land with their enumerated test scenarios implemented in full, not a subset; every suite in the Verification Contract is green.
|
||||||
|
- Every new or changed file is under 400 LOC; no inline comments; zero `unwrap()` outside tests; only `lib.rs` re-exports.
|
||||||
|
- `trace show` and `trace export` are proven over CLI, batch, and FFI; the permissionless preflight is proven by test.
|
||||||
|
- A v0.4.6-era session trace reads cleanly, proven by a committed schema-0 fixture.
|
||||||
|
- Docs (skills, `CONCEPTS.md`, `README.md`) are updated and the command count is consistent; the help golden is regenerated deliberately, not accidentally.
|
||||||
|
- No abandoned experimental code remains in the final diff.
|
||||||
|
- The e2e scenario passes in both headless and `--headed` mode.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,512 @@
|
||||||
|
---
|
||||||
|
title: Playwright-Grade Foundation Contract - Plan
|
||||||
|
type: feat
|
||||||
|
date: 2026-07-03
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
product_contract_source: ce-plan-bootstrap
|
||||||
|
execution: code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Playwright-Grade Foundation Contract - Plan
|
||||||
|
|
||||||
|
## Goal Capsule
|
||||||
|
|
||||||
|
- **Objective:** Land the Tier-1 FOUNDATION-NOW contract from the Playwright-grade gap analysis in `crates/core` — plus the three live correctness defects — so the Windows (UIA) and Linux (AT-SPI2) adapters implement a settled reliability contract instead of redesigning it.
|
||||||
|
- **Authority hierarchy:** repo `CLAUDE.md` invariants (400 LOC/file, no inline comments, zero `unwrap()` outside tests, core never imports platform crates, conventional commits, additive `not_supported()` trait defaults) override this plan; this plan overrides implementer improvisation; `docs/solutions/` learnings cited per unit are binding constraints.
|
||||||
|
- **Execution profile:** one feature branch (`feat/foundation-playwright-grade-contract`), one conventional commit per implementation unit, dependency order per the Unit Index. All gates in the Verification Contract green before any unit is considered done.
|
||||||
|
- **Stop conditions:** stop and surface — do not guess — if (a) a unit requires core to import a platform crate, (b) a wire-contract change goes beyond additive optional fields anywhere other than U8/U14's documented changes, (c) the U0 restructure cannot preserve `&dyn PlatformAdapter` call sites unchanged, or (d) any file cannot stay under 400 LOC without violating the one-command-per-file rule.
|
||||||
|
- **Tail ownership:** the implementer owns fixture updates, FFI header regeneration via `scripts/update-ffi-header.sh`, and doc touch-ups (`CLAUDE.md` error-code list, `docs/phases.md` pulled-forward notes) inside the unit that causes them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
agent-desktop's core contract has three foundational gaps that cap it below best-in-class desktop automation: element identity is snapshot-bound rather than re-resolvable, actionability is checked once instead of awaited, and the role/state/naming vocabulary is too thin for a second adapter to implement compatibly. This plan fixes the three live correctness defects and lands the eighteen Tier-1 foundation items (two of which — display enumeration and the automation-permission wiring — are themselves the fixes behind defect requirements R2/R3) as core contract — new `PlatformAdapter` capability methods (all defaulting to `not_supported()`), new core types, and an enforced vocabulary — with macOS implementing each where it has immediate value. Windows and Linux then inherit reliability by implementing settled shapes.
|
||||||
|
|
||||||
|
### Problem Frame
|
||||||
|
|
||||||
|
A 15-agent gap analysis (verified against the repo at `52705af`) found: `is --property visible` is unconditionally true because it checks a `"hidden"` state token no code produces, and against snapshot-time state rather than live evidence; `PermissionReport.automation` is hardcoded `NotRequired` while `close_app`'s osascript fallback depends on the Automation TCC gate; `ScreenshotTarget::Screen(usize)` is dead code with no CLI reachability and an index-ignoring macOS impl. Beyond defects: refs go stale with no re-resolving locator alternative, `check_live` runs exactly once per action (`crates/core/src/ref_action.rs`), no occlusion hit-test exists anywhere, the states vocabulary has 5 producer tokens against the 17 both UIA and AT-SPI2 support natively, accessible-name computation is a private macOS function core never sees, three window operations re-resolve by `(pid, title)` even when an unambiguous id was supplied, and the trait has no process-liveness, display-enumeration, launch-environment, session-affinity, or event-baseline concepts. Building Phase 2/3 adapters against this trait would force each to invent incompatible answers — the exact failure the dependency-inversion architecture exists to prevent.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
Live correctness defects:
|
||||||
|
|
||||||
|
- R1. `is --property visible` reports real visibility: live bounds evidence plus canonical `hidden`/`offscreen` state tokens; an off-screen or hidden element reports `false`.
|
||||||
|
- R2. Display capture is a complete, honest contract: displays are enumerable, `--screen N` targets a real display, an out-of-range index returns `INVALID_ARGS` naming the available displays, and captures report their `scale_factor` so point↔pixel math (Retina 2×, mixed-scale multi-monitor) is computable from the result rather than guessed.
|
||||||
|
- R3. `permissions`/`status` report the Automation permission truthfully, without triggering a TCC prompt; osascript-backed paths reclassify authorization failures to `PERM_DENIED` with a recovery suggestion.
|
||||||
|
|
||||||
|
Identity spine:
|
||||||
|
|
||||||
|
- R4. Elements carry the native automation id (`AXIdentifier` / UIA `AutomationId`) when present, and ref re-identification prioritizes it above name/value/description text.
|
||||||
|
- R5. Window-scoped operations treat `WindowInfo.id` as the primary key; title is fallback evidence only. Two same-titled windows never cause an id-addressed operation to act on the wrong one.
|
||||||
|
- R6. A serializable `LocatorQuery` (role, name, description, native id, exactness, state predicates, containment filters, ordinal selection) resolves against the live tree through one adapter method, with the existing 0/1/N strict-resolution classification applied to its results.
|
||||||
|
|
||||||
|
Actionability:
|
||||||
|
|
||||||
|
- R7. Every ref-addressed action auto-waits for actionability by default under a single bounded budget (CLI default 5000 ms, `--timeout-ms 0` restores single-shot), retrying transient states and propagating permanent errors immediately; budget expiry returns `TIMEOUT` with the last actionability report and a `kind` discriminant.
|
||||||
|
- R8. Actions on occluded elements are detected before dispatch where the platform can hit-test; unavailable evidence reports `unknown`, never a false failure.
|
||||||
|
- R9. Element-targeted actions scroll the target into view (best-effort) before the visibility check, uniformly in core rather than per-platform-chain.
|
||||||
|
|
||||||
|
Cross-platform contract:
|
||||||
|
|
||||||
|
- R10. Every new adapter capability defaults to `Err(not_supported())`; macOS-only surface concepts (`Sheet`, `Popover`) are ratified explicitly via `supported_surfaces()` introspection rather than silently assumed portable.
|
||||||
|
- R11. Role and state tokens come from canonical core vocabulary modules; conformance tests fail any adapter emitting a token outside the vocabulary. The `is` property set and its evidence sourcing move onto that vocabulary.
|
||||||
|
- R12. Accessible name/description computation is a core-owned algorithm over adapter-supplied `NameEvidence`; adapters never invent their own precedence.
|
||||||
|
- R19. Action steps carry a typed delivery tier (`SemanticApi` vs `PhysicalSynthetic`) and a verified flag, so callers can programmatically ask "was this delivered semantically and independently confirmed?"
|
||||||
|
|
||||||
|
Lifecycle and environment:
|
||||||
|
|
||||||
|
- R13. Process liveness is classifiable (`Running`/`Exited`/`Crashed`/`Unresponsive`); persistent AX unresponsiveness surfaces as a new `APP_UNRESPONSIVE` error code, and ref/app resolution errors carry process state in `details`.
|
||||||
|
- R14. `launch_app` accepts arguments, environment variables, working directory, and an attach-vs-fail-if-running policy.
|
||||||
|
- R15. A session-affinity lifecycle hook (`open_session` returning an `AdapterSession`) exists on the trait — defaulted, uncalled by the CLI today — so Windows COM-MTA and Linux D-Bus connection state have a landing zone before Phase 2 starts.
|
||||||
|
|
||||||
|
Signals and input vocabulary:
|
||||||
|
|
||||||
|
- R16. UI-event detection works by baseline diff: snapshot observable signals, act, diff — surfaced as `wait --event <kind>` (window opened/closed, app launched/terminated, focus changed, surface appeared) without requiring known titles.
|
||||||
|
- R17. Clipboard content is typed (`Text`/`Image`/`FileUrls`) at the trait and CLI, not string-only.
|
||||||
|
- R18. Mouse events accept modifier chords, and a wheel-delta primitive exists as a first-class command.
|
||||||
|
|
||||||
|
### Acceptance Examples
|
||||||
|
|
||||||
|
- AE1. **Covers R1.** Given a window with a zero-sized or `AXHidden` element that holds a ref, when `is --ref @e5 --property visible` runs, then `result` is `false` (today: unconditionally `true`).
|
||||||
|
- AE2. **Covers R7.** Given a button that becomes enabled 800 ms after a dialog opens, when `click --ref @e3` runs with defaults, then the click succeeds without an explicit `wait` call; with `--timeout-ms 0` it fails immediately with the actionability report.
|
||||||
|
- AE3. **Covers R7.** Given a permanently disabled button, when `click --ref @e3 --timeout-ms 2000` runs, then the command fails at ~2 s with `TIMEOUT`, `details.kind = "actionability_timeout"`, and the last per-check report.
|
||||||
|
- AE4. **Covers R5.** Given two windows titled "Untitled" in one app, when a window operation targets the second window's id, then the operation acts on that window (today: first title match wins).
|
||||||
|
- AE5. **Covers R3.** Given Automation permission denied for System Events, when `close_app` falls back to osascript, then the error is `PERM_DENIED` with a System Settings suggestion, not a generic failure; and `permissions` reports `automation: denied` without prompting.
|
||||||
|
- AE6. **Covers R16.** Given a click that opens a dialog with unknown title, when `wait --event surface-appeared --app TextEdit` runs after the click, then the event reports the new surface without the caller naming it.
|
||||||
|
- AE7. **Covers R7.** Given a permanently disabled button, when `click --ref @e3` runs with no timeout flag at all, then the command fails at ~5 s (the untouched default) with `TIMEOUT`, `details.kind = "actionability_timeout"`, and the last per-check report — the exact experience a caller gets by doing nothing.
|
||||||
|
|
||||||
|
### Scope Boundaries
|
||||||
|
|
||||||
|
**In scope:** the three live defects; the eighteen Tier-1 items (two of them — `list_displays` and the automation-permission wiring — double as the defect fixes behind R2/R3, which is why the non-defect requirement groups sum to sixteen); the `adapter.rs` capability-trait restructure they force; macOS implementations where each unit names them; FFI parity where existing FFI surface is affected; fixture/doc updates caused by these changes.
|
||||||
|
|
||||||
|
**Deferred to Follow-Up Work** (Tier-2/3 of the gap analysis — not this plan): visual diff/baselines and any image-codec dependency decision; the shared settled-debounce primitive and `stability_check` Fail-arm redesign; tri-state assertion-path resolution (`ProvenAbsent`); unified negatable `is`/`wait` predicate vocabulary; `toMatchAriaSnapshot`-style tree assertions; `level`/`pos_in_set`/`set_size`, `ValueRange`, relations, text-range actions; canonical key-name vocabulary module; `DragFiles` payload vocabulary; TCC/tray/launcher surfaces; tree-exposure-quality signal and Electron AX forcing; default per-invocation session isolation; `LocatorQuery.relative` steps and action-by-locator CLI; persisted cross-invocation `SignalBaseline` state (v1 is in-invocation only) and FFI exposure of the `wait --event` mode; `--env-file`/stdin secret passing for `launch`; FFI exposure of locator queries and new Family-B commands; `subscribe_events` push delivery (daemon-gated); per-rung trace streaming; window-id threading onto the five `WindowOp` commands.
|
||||||
|
|
||||||
|
**Outside this product's identity** (stated non-goals, reaffirmed): no embedded LLM, no GUI/TUI, no browser automation, no macro record/replay (the trace-replay compiler stays rejected — it would also reopen the trace-redaction posture), no daemon in this plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Contract
|
||||||
|
|
||||||
|
### Key Technical Decisions
|
||||||
|
|
||||||
|
- KTD1. **Capability supertraits, forced by the 400-LOC cap.** `crates/core/src/adapter.rs` is at 397/400 LOC with 38 trait methods; this plan adds ~12 methods and ~9 types. Restructure into `crates/core/src/adapter/` — `mod.rs` (`pub trait PlatformAdapter: ObservationOps + ActionOps + InputOps + SystemOps` **plus the blanket `impl<T: ObservationOps + ActionOps + InputOps + SystemOps> PlatformAdapter for T {}` that actually confers the composed trait** — supertraits alone are bounds, not implementations) plus one file per capability trait, mirroring the platform crates' `tree/actions/input/system` foldering. Supertrait methods remain callable on `&dyn PlatformAdapter`, so zero call-site churn; the compiler proves behavior preservation. Supporting types move to their own files per the one-type-per-file convention.
|
||||||
|
- KTD2. **The vocabulary module is the `visible` defect fix, not a point patch.** The root cause is unenforced vocabulary (a consumed token with zero producers, undetected indefinitely). `crates/core/src/state.rs` (17 canonical tokens + `STATE_VOCABULARY`) and `role.rs` (canonical `Role` enum, `from_str` → `Unknown`, never an error) plus conformance helpers close the class. `is --property visible` moves to live evidence: live bounds non-empty AND not `hidden` AND not `offscreen` (`is` currently reads snapshot-time `RefEntry.states` via `state_from_ref_entry` — a second latent defect this fixes). Wire format stays `Vec<String>`; the enum is the vocabulary authority, not a serialization change.
|
||||||
|
- KTD3. **Auto-wait defaults ON at the command layer, OFF at the type layer.** `ActionRequest.timeout_ms: Option<u64>` with serde default `None` preserves every existing constructor and wire payload (single-shot). The CLI ref-action surface defaults `--timeout-ms 5000`; FFI `ad_execute_by_ref` adopts the same 5000 ms default per the FFI/CLI-parity learning, with additive `ad_execute_by_ref_timeout(..., i64)` (−1 default, 0 single-shot) for callers needing opt-out. **Why default-on, and why 5000:** the gap analysis's P0 finding is precisely that retry-until-actionable is opt-in today — an opt-in default would preserve the gap; Playwright's equivalent default is 30 s, which is wrong for an LLM-agent consumer whose per-step latency budget is seconds, so 5000 ms covers the common enable/animation/dialog-settle transitions while capping a hard failure's cost at one step. The consumer-owned observe→decide→act loop is unaffected: the loop decides *what* to do next, the budget only gates *whether this one action's target is ready*, and permanent errors (`PERM_DENIED`, `APP_NOT_FOUND`, `ACTION_NOT_SUPPORTED`, `INVALID_ARGS`, `POLICY_DENIED`) still fail instantly, so callers with their own retry wrappers pay the budget only on genuinely transient/ambiguous states — and can set `--timeout-ms 0` to restore fail-fast wholesale. The poll loop re-runs resolve→`check_live` each tick (100 ms) until Pass or deadline; `STALE_REF`/`AMBIGUOUS_TARGET`/actionability-Fail retry within budget; `PERM_DENIED`/`APP_NOT_FOUND`/`ACTION_NOT_SUPPORTED`/`INVALID_ARGS`/`POLICY_DENIED` propagate immediately (mirrors the wait command's established retryable/permanent split). Budget expiry: `TIMEOUT` + `details.kind = "actionability_timeout"` + last `ActionabilityReport`, joining the existing `"wait_timeout"`/`"chain_deadline"` kind convention. The loop wraps resolve+preflight only — it never chooses `InteractionPolicy` and never skips post-condition verification (policy-preservation learning). The macOS chain's internal `AGENT_DESKTOP_CHAIN_TIMEOUT_MS` (post-dispatch) is untouched; `WAIT_RESOLVE_ATTEMPT` (750 ms) becomes the loop's per-tick resolve budget rather than a separate user-facing concept.
|
||||||
|
- KTD4. **Hit-testing ships as `hit_test(target, point) → HitTestResult`, not raw `element_at_point`.** `HitTestResult { ReachesTarget, InterceptedBy { role, name, bounds }, Unknown }` keeps ancestor-walk identity comparison (macOS `CFEqual` chain walk from `AXUIElementCopyElementAtPosition`) adapter-private, gives core a clean tri-state, and lets AT-SPI2 return `not_supported` honestly. The occluder's `name` rides under the `name` key so trace redaction covers it automatically. Consumed by a new `receives_events` actionability check (Pass/Fail/Unknown) and by ref-targeted pointer actions (`Hover`, `Drag`) which today skip `check_live` entirely; raw coordinate `mouse-*` commands stay raw by design.
|
||||||
|
- KTD5. **Scroll-into-view is core policy, adapter mechanics.** `Action::requires_scroll_into_view()` (element-targeted variants: `Click*`, `SetValue`, `TypeText`, `Toggle`, `Check`/`Uncheck`, `Select`, `Expand`/`Collapse`, `Clear`, `Hover`, `SetFocus`) + `ActionOps::scroll_into_view(&NativeHandle)` defaulting `not_supported`. Called best-effort before the actionability visibility check; failure or `not_supported` degrades to today's behavior, never fails the action. macOS promotes its existing `AXScrollToVisible` usage (7 occurrences, 5 files) into the trait impl.
|
||||||
|
- KTD6. **Accessible naming: core algorithm, adapter evidence, principled precedence.** `crates/core/src/accname.rs` computes name/description from `NameEvidence` with documented precedence (explicit label → labelled-by text → native title → static-role value → aggregated child label → placeholder → description last). macOS's private `resolve_element_name` becomes an evidence supplier. This may change some computed names: golden fixtures update consciously via characterization tests written before the migration (execution note on U11). Accepted consequence, stated plainly: a refmap written by a pre-upgrade binary stores names computed by the old algorithm, so acting on those refs after a mid-session upgrade may surface `STALE_REF` until the caller re-snapshots — bounded (one re-snapshot heals it) and accepted, not eliminated.
|
||||||
|
- KTD7. **`SnapshotSurface` ratified, not generalized.** `Sheet`/`Popover` stay as explicitly macOS-native variants; `SystemOps::supported_surfaces()` (default: `Window, Focused, Menu, Menubar, Alert`; macOS overrides adding `Sheet, Popover`) makes support introspectable via `status`, and requesting an unsupported surface returns `PLATFORM_NOT_SUPPORTED` naming the supported set. Honest vocabulary beats lossy force-fit; Windows/Linux declare what they mean, not what Cocoa meant.
|
||||||
|
- KTD8. **`ProcessState` is honest about platform limits.** `Exited { code: Option<i32> }` because macOS cannot read exit codes of non-child processes (`open -g -a` detaches); `Crashed` stays in the contract for adapters with real evidence (Windows `GetExitCodeProcess`), macOS emits `Running`/`Exited{None}`/`Unresponsive` initially. `Unresponsive` classification comes from a bounded AX probe (`kAXErrorCannotComplete` persisting beyond one retry). New `ErrorCode::AppUnresponsive` (16th code) → `ENVELOPE_VERSION` bumps (the envelope learning: bump for error-code changes callers branch on), asserted through the constant, and the `CLAUDE.md` error-code list updates in the same unit. Naming reconciliation: this supersedes `docs/phases.md`'s planned Phase-2 `AxMessagingTimeout` at the process-classification level (transport-level timeout classification remains a Phase-2 concern).
|
||||||
|
- KTD9. **Automation permission via `AEDeterminePermissionToAutomateTarget` with `askUserIfNeeded=false`** — the only probe that answers without triggering a TCC prompt. Mapping: permitted → Granted, not-permitted (−1743) → Denied, System Events not running → Unknown. The osascript fallback path in `close_app` reclassifies −1743 stderr to `PERM_DENIED` following the proven `map_screencapture_error` convention. No new error code (reuse-existing-codes learning); `docs/phases.md`'s planned `AutomationPermissionDenied` is thereby superseded.
|
||||||
|
- KTD10. **`native_id` (the gap report's name) supersedes `docs/phases.md`'s planned `identifier` field.** macOS reads `kAXIdentifierAttribute` in the existing batch attribute read; auto-generated AppKit identifiers (prefix `_NS`) are treated as absent. Identity priority: `native_id` equality is the strongest match signal (above name/value/description); two present-but-different `native_id`s are a hard non-match. Real-world macOS coverage is unverified (gap report Part VIII) — the field is `Option<String>` and degrades gracefully; its full payoff arrives with UIA `AutomationId`.
|
||||||
|
- KTD11. **`LocatorQuery` v1 ships without `relative` steps and without action-by-locator.** Fields: role, name, description, native_id, exact, state predicates (vocabulary tokens + bool), has/has_not (boxed subqueries), has_text, nth/first/last. `ObservationOps::resolve_query` returns live handles; core owns `classify_query_result` (0 → not-found, 1 → accept, 2+ → ambiguous-with-candidates), reusing the existing strict-resolution outcome contract. Consumer v1 is the `find` command (whose `FindQuery` becomes a `LocatorQuery` subset); action-by-locator and FFI exposure are deferred with the follow-up list.
|
||||||
|
- KTD12. **`SignalBaseline` generalizes the proven `NotificationFingerprint` pattern.** Plain-data baseline + pure `diff_signals` in core (testable with adapter doubles, no daemon); `SystemOps::snapshot_signals(&SignalFilter)` supplies windows/apps/focus cheaply, plus alert/sheet surface presence when the filter names an app (bounded cost). Consumer v1 is `wait --event <kind>` (baseline at wait start, poll-diff at the existing wait cadence) in a new `wait_event.rs`; persisted cross-invocation baselines are deferred. Event payloads carry titles under `title` keys — redaction-safe by construction.
|
||||||
|
- KTD13. **Clipboard trait migrates to typed content; string methods are removed, not wrapped.** `ClipboardContent { Text(String), Image(ImageBuffer), FileUrls(Vec<String>) }`; `get_clipboard_content`/`set_clipboard_content` replace `get_clipboard`/`set_clipboard` (repo-internal trait, all impls updated in one unit). CLI `clipboard-get` gains `--format auto|text|image|file-urls`; image content writes to `--out <path>` (JSON carries path + dimensions, keeping the envelope small). macOS implements all three via `NSPasteboard`, reusing the pasteboard-restore machinery's existing type round-tripping.
|
||||||
|
- KTD14. **`launch_app` signature changes directly to `(&self, id, &LaunchOptions)`.** The trait is repo-internal and pre-1.0; a `launch_app_with_options` shim would be permanent noise. `LaunchOptions { args, env, cwd, timeout_ms, attach_if_running }`; `attach_if_running: true` preserves today's `open -g -a` semantics; `false` fails with a structured error if the app is already running. CLI gains `--arg` (repeatable), `--env KEY=VAL` (repeatable), `--cwd`, `--no-attach`. FFI `ad_launch_app` keeps its current C signature (constructs default `LaunchOptions` internally — zero ABI change). **Env values are secrets by assumption:** no error message, trace event, or `details` object may ever carry a raw env value — a malformed `--env` entry reports its argument position and at most the key name, never the value (per `docs/solutions/conventions/keep-raw-arguments-out-of-trace-reachable-error-messages.md`); env passing via argv is an inherent CLI exposure (`ps`/shell history), documented on the flag, with an `--env-file`/stdin alternative deferred to follow-up.
|
||||||
|
- KTD15. **`open_session` lands as a contract-only hook.** `SystemOps::open_session(&SessionAffinity) → Box<dyn AdapterSession>` (Send + Sync, `close(self)`), default `not_supported`, called by nothing in this plan. Its doc comment names the intended owners: Windows COM-MTA apartment thread, Linux D-Bus connection. This resolves the statelessness tension architecturally: the hook exists from day one, behavior changes only when a persistent host (FFI/daemon) opts in.
|
||||||
|
- KTD16. **`ActionStep` gains `mechanism: Option<StepMechanism>` + `verified: Option<bool>`** (`SemanticApi | PhysicalSynthetic`), serde-skipped when absent — additive for every existing trace/JSON consumer. macOS chain rungs populate mechanism from what each rung already knows (AX action vs CGEvent) and `verified` from the chain's existing effect-verification outcomes. Both fields are shapes, not content — redaction-safe by construction.
|
||||||
|
- KTD17. **Open questions resolved by fiat (documented, revisitable):** AT-SPI2 hit-testing may ship `not_supported` (the contract permits it); Wayland wheel-as-buttons is an adapter translation problem, so the `Wheel` contract uses line-deltas with an explicit X11/Wayland translation note; sandbox/hardened-runtime AX denial and secure-field synthetic-input behavior stay open questions carried to Tier-2 (they inform `TreeQuality`, which is deferred); the image-codec dependency decision is moot in this plan (visual diff deferred).
|
||||||
|
|
||||||
|
### High-Level Technical Design
|
||||||
|
|
||||||
|
Contract layering after this plan — core owns vocabulary, types, and policy; adapters own native evidence; consumers are unchanged in shape:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph CORE["crates/core"]
|
||||||
|
VOCAB["role.rs / state.rs / capability.rs\ncanonical vocabulary + conformance"]
|
||||||
|
TYPES["LocatorQuery / HitTestResult / ProcessState\nDisplayInfo / LaunchOptions / ClipboardContent\nSignalBaseline / SessionAffinity / StepMechanism"]
|
||||||
|
TRAIT["adapter/mod.rs\nPlatformAdapter:\nObservationOps + ActionOps + InputOps + SystemOps"]
|
||||||
|
POLICY["ref_action.rs auto-wait poll loop\nactionability checks (+receives_events)\naccname.rs / classify_query_result / diff_signals"]
|
||||||
|
end
|
||||||
|
MAC["crates/macos\nAX evidence: AXIdentifier, hit-test,\nAXScrollToVisible, NSPasteboard, displays,\nprocess probe, signal snapshot"]
|
||||||
|
WIN["crates/windows (Phase 2)\nimplements settled shapes"]
|
||||||
|
LIN["crates/linux (Phase 3)\nimplements settled shapes"]
|
||||||
|
CLI["src/ CLI + batch"]
|
||||||
|
FFI["crates/ffi C ABI"]
|
||||||
|
VOCAB --> POLICY
|
||||||
|
TYPES --> TRAIT
|
||||||
|
TRAIT --> MAC
|
||||||
|
TRAIT -.-> WIN
|
||||||
|
TRAIT -.-> LIN
|
||||||
|
POLICY --> CLI
|
||||||
|
POLICY --> FFI
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-wait pre-action gate (U8) — the loop wraps resolve+preflight only; dispatch and post-condition verification are untouched:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as command (click --ref @e3)
|
||||||
|
participant L as core poll loop
|
||||||
|
participant A as adapter
|
||||||
|
C->>L: ActionRequest{action, policy, timeout_ms}
|
||||||
|
loop per iteration: 100ms sleep; worst case ~850ms when resolve is slow
|
||||||
|
L->>A: resolve_element_strict_with_timeout(entry, tick)
|
||||||
|
alt permanent error (PERM_DENIED, APP_NOT_FOUND, ...)
|
||||||
|
A-->>C: propagate immediately
|
||||||
|
else STALE_REF / AMBIGUOUS_TARGET
|
||||||
|
A-->>L: retry next tick
|
||||||
|
else resolved
|
||||||
|
L->>A: scroll_into_view (best-effort, if action requires)
|
||||||
|
L->>A: check_live (visibility, stability, enabled, receives_events, ...)
|
||||||
|
alt all Pass
|
||||||
|
L->>A: execute_action (policy + post-verify unchanged)
|
||||||
|
A-->>C: ActionResult
|
||||||
|
else Fail
|
||||||
|
A-->>L: retry next tick
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
L-->>C: TIMEOUT + kind:"actionability_timeout" + last report
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProcessState` classification (U14):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Running: pid alive, AX responsive
|
||||||
|
Running --> Unresponsive: AX probe CannotComplete persists
|
||||||
|
Unresponsive --> Running: probe recovers
|
||||||
|
Running --> Exited: pid gone (code None on macOS)
|
||||||
|
Running --> Crashed: adapter has crash evidence (Win/Linux)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- A1. Scope equals the user-supplied Tier-1 list plus the three defects; Tier-2/3 items referenced by Tier-1 designs (settled-debounce, tri-state resolution) are deferred even where a unit touches adjacent code.
|
||||||
|
- A2. CLI surface additions are in scope exactly where they make a Tier-1 contract observable and testable (`--screen`, `--timeout-ms`, `find` query fields, `wait --event`, clipboard formats, launch flags, `mouse-wheel`, `list-displays`); broader ergonomics are not.
|
||||||
|
- A3. Flipping auto-wait ON by default is the intended behavior change (it is the P0 finding), acceptable pre-1.0 as a `feat:`; failure paths get slower by design, success paths are unchanged.
|
||||||
|
- A4. FFI parity obligations are scoped by the alignment learning: behavior parity for the action path (U8), no new Family-B commands, additive Family-A functions only where a unit names them.
|
||||||
|
- A5. The gap analysis (session artifact, verified against `52705af`) is the requirements source; where scouts found its citations drifted (`FOCUS_CONFIRMATIONS`, `activate.rs`, `resolve_classify.rs::proved_absent`), this plan's citations are the corrected ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### Unit Index
|
||||||
|
|
||||||
|
| U-ID | Title | Key files | Depends on |
|
||||||
|
|---|---|---|---|
|
||||||
|
| U0 | Capability-supertrait restructure | `crates/core/src/adapter/` (new), `crates/macos/src/adapter.rs` | — |
|
||||||
|
| U1 | Role/state vocabulary + live `is visible` fix | `crates/core/src/{role,state}.rs` (new), `commands/is_check.rs` | U0 |
|
||||||
|
| U2 | macOS state-producer expansion | `crates/macos/src/tree/{builder,state_reader}.rs`, `.../post_state.rs` | U1 |
|
||||||
|
| U3 | Displays: `list_displays` + `--screen` + scale factor | `crates/core/src/display_info.rs` (new), `commands/list_displays.rs` (new), `crates/macos/src/system/{screenshot,display}.rs` | U0 |
|
||||||
|
| U4 | Automation permission truthfulness | `crates/macos/src/system/permissions.rs`, `.../app_ops.rs` | — |
|
||||||
|
| U5 | `native_id` end-to-end | `crates/core/src/{refs,node,ref_identity}.rs`, `crates/macos/src/tree/attributes.rs`, `crates/ffi/src/types/ref_entry.rs` | U0 |
|
||||||
|
| U6 | Window identity as primary key | `crates/core/src/adapter/system.rs`, `crates/macos/src/{adapter.rs,system/window_ops.rs,system/app_ops.rs}` | U0 |
|
||||||
|
| U7 | `LocatorQuery` + `resolve_query` + `find` | `crates/core/src/locator.rs` (new), `commands/{query,find}.rs`, `crates/macos/src/tree/query.rs` (new) | U0, U1, U5 |
|
||||||
|
| U8 | Auto-wait pre-action gate | `crates/core/src/{action_request,ref_action}.rs`, `src/cli_args/`, `crates/ffi/src/actions/` | U0 |
|
||||||
|
| U9 | `hit_test` + `receives_events` | `crates/core/src/hit_test.rs` (new), `adapter/observation.rs`, `actionability/*`, `crates/macos/src/tree/hit_test.rs` (new) | U0, U8 |
|
||||||
|
| U10 | `scroll_into_view` contract | `crates/core/src/{action,adapter/actions.rs}`, `crates/macos/src/actions/` | U0, U8 |
|
||||||
|
| U11 | `accname.rs` core naming | `crates/core/src/accname.rs` (new), `crates/macos/src/tree/element.rs` | U0 |
|
||||||
|
| U12 | `SnapshotSurface` ratification | `crates/core/src/adapter/system.rs`, `commands/status.rs` | U0 |
|
||||||
|
| U13 | `ActionStep.mechanism`/`verified` | `crates/core/src/action_step.rs`, `crates/macos/src/actions/chain*.rs`, `crates/ffi/src/types/action_step.rs` | — |
|
||||||
|
| U14 | `ProcessState` + `APP_UNRESPONSIVE` | `crates/core/src/{process_state.rs (new),error.rs}`, `crates/macos/src/system/process.rs` (new) | U0 |
|
||||||
|
| U15 | `LaunchOptions` | `crates/core/src/launch_options.rs` (new), `crates/macos/src/system/app_ops.rs`, `src/cli_args/` | U0 |
|
||||||
|
| U16 | `open_session` affinity hook | `crates/core/src/adapter/system.rs`, `session_affinity.rs` (new) | U0 |
|
||||||
|
| U17 | `SignalBaseline` + `wait --event` | `crates/core/src/{signals.rs (new), commands/wait_event.rs (new)}`, `crates/macos/src/system/signals.rs` (new) | U0 |
|
||||||
|
| U18 | Typed clipboard content | `crates/core/src/clipboard_content.rs` (new), `crates/macos/src/input/clipboard.rs`, `crates/ffi/src/input/clipboard.rs` | U0 |
|
||||||
|
| U19 | Mouse modifiers + wheel | `crates/core/src/action.rs`, `crates/macos/src/input/mouse.rs`, `crates/core/src/commands/mouse_wheel.rs` (new) | U0 |
|
||||||
|
|
||||||
|
**Phase A — headroom + live defects (U0–U4).** Everything else builds on U0's file-budget headroom and U1's vocabulary — except U4 and U13, which are dependency-free and can land any time.
|
||||||
|
|
||||||
|
### U0. Capability-supertrait restructure of the adapter contract
|
||||||
|
|
||||||
|
- **Goal:** Split the 397-LOC `crates/core/src/adapter.rs` into `crates/core/src/adapter/` with `PlatformAdapter: ObservationOps + ActionOps + InputOps + SystemOps`, creating file-budget headroom for the ~12 new methods this plan adds. Zero behavior change.
|
||||||
|
- **Requirements:** enables R4–R18 (every unit that adds adapter surface); enforces the repo 400-LOC invariant. R19 (U13) and R3 (U4) are independent of this restructure.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `crates/core/src/adapter/mod.rs` (new — composed trait + re-exports so `crate::adapter::PlatformAdapter` paths survive), `adapter/observation.rs`, `adapter/actions.rs`, `adapter/input.rs`, `adapter/system.rs` (new — trait per capability, existing 38 methods distributed by domain), supporting types move to `crates/core/src/{screenshot_target,image_buffer,window_filter,live_element}.rs` (new) as needed to keep each file <400; `crates/macos/src/adapter.rs` (impl blocks split per trait, delegating into the existing `tree/actions/input/system` modules); `crates/windows/src/adapter.rs`, `crates/linux/src/adapter.rs` (empty impls become four empty impls each); `src/tests/conformance.rs` and every test double (~55 across 33 files) — each single `impl PlatformAdapter for X {}` block becomes up to four capability-trait impls (only the traits whose methods the double overrides; empty ones where needed), and **no `impl PlatformAdapter` block remains anywhere** — real adapters and doubles alike get the composed trait from the blanket impl, so their existing method overrides move onto the capability traits without conflict.
|
||||||
|
- **Approach:** method distribution mirrors the platform-crate foldering: tree/element reads + resolution + queries → `ObservationOps`; `execute_action`, live reads, bounds, release → `ActionOps`; mouse/key/drag/clipboard → `InputOps`; windows/apps/permissions/screenshot/notifications/surfaces/wait → `SystemOps`. All defaults keep their exact current bodies. **The composing piece is a blanket impl** — supertraits are bounds, not implementations, so `adapter/mod.rs` must define `impl<T: ObservationOps + ActionOps + InputOps + SystemOps> PlatformAdapter for T {}` (legal: all five traits are crate-local, no orphan-rule conflict). That blanket impl is what makes the four-per-trait impl pattern at every adapter and test-double site satisfy `PlatformAdapter` for `&dyn`/`impl PlatformAdapter` call sites — no fifth impl anywhere, and without it every one of the ~58 construction sites fails E0277. `lib.rs` re-exports preserve every public path.
|
||||||
|
- **Patterns to follow:** the macOS crate's `tree/actions/input/system` module split; `node.rs`'s grouped-small-types precedent for the extracted type files.
|
||||||
|
- **Test scenarios:** (1) full workspace test suite passes unchanged — the compiler plus existing tests are the behavior proof; (2) a new conformance test asserts `&dyn PlatformAdapter` exposes every method from all four supertraits (compile-time usage test in `src/tests/conformance.rs`); (3) `cargo tree -p agent-desktop-core` still names no platform crate; (4) every new file <400 LOC (existing CI/file conventions).
|
||||||
|
- **Verification:** all Verification Contract gates green with zero test-expectation edits outside mechanical `impl` splitting.
|
||||||
|
|
||||||
|
### U1. Canonical role/state vocabulary + real `is --property visible`
|
||||||
|
|
||||||
|
- **Goal:** Create the enforced role/state vocabulary and fix the `visible` defect by moving `is` onto live evidence.
|
||||||
|
- **Requirements:** R1, R11.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/state.rs` (new — 5 existing + 12 new token constants, `STATE_VOCABULARY`, `assert_states_in_vocabulary` conformance helper), `crates/core/src/role.rs` (new — `Role` enum with `as_str`/`from_str`/`is_interactive`; `roles.rs`'s `INTERACTIVE_ROLES` and `is_toggleable_role`/`is_expandable_role` rebase onto it, string API preserved), `crates/core/src/commands/is_check.rs` + `is_check_tests.rs`, `crates/core/src/commands/wait_predicate.rs` (its `Visible` arm adopts the same evidence).
|
||||||
|
- **Approach:** `is` resolves the ref and reads live evidence (`get_live_state` + `get_element_bounds`) instead of `state_from_ref_entry`'s snapshot clone; `visible` = bounds present, non-zero, and neither `hidden` nor `offscreen` token; unknown evidence → `applicable: false`-style honest reporting, never a false pass. All token literals in core (`is_check.rs:46-52` match arms, `wait_predicate` checks, capability applicability sets) move to `state::` constants. `IsProperty`/`ElementPredicate` unification stays deferred; both consume the same constants.
|
||||||
|
- **Patterns to follow:** `capability.rs`'s constants + membership-helper shape (the gap analysis names it the repo's best vocabulary precedent); the existing live-read usage in actionability checks.
|
||||||
|
- **Test scenarios:** (1) element with `hidden` state → `visible: false`; (2) zero-sized bounds → `false`; (3) `offscreen` token → `false`; (4) visible element → `true`; (5) live-read `not_supported` → applicability honestly degraded, not `true` (per-test adapter double); (6) conformance: every token consumed anywhere in core is in `STATE_VOCABULARY` (grep-derived guard, exhaustiveness-guard learning); (7) `Role::from_str("bogus")` → `Unknown`, `is_interactive` parity with the current 16-role list; (8) `enabled`/`checked`/`focused`/`expanded` behavior unchanged on live state (regression).
|
||||||
|
- **Verification:** AE1 lands in the e2e harness (`tests/e2e/`, the SwiftUI fixture's home); gates green.
|
||||||
|
|
||||||
|
### U2. macOS state-producer expansion onto the vocabulary
|
||||||
|
|
||||||
|
- **Goal:** One shared macOS state-reader emits the vocabulary tokens with direct AX evidence, replacing the two hand-duplicated producer sites.
|
||||||
|
- **Requirements:** R1, R11.
|
||||||
|
- **Dependencies:** U1.
|
||||||
|
- **Files:** `crates/macos/src/tree/state_reader.rs` (new — single producer), `crates/macos/src/tree/builder.rs` (inline states assembly at 152-176 delegates), `crates/macos/src/actions/post_state.rs` (`element_state_from_attrs`, lines 65-91, delegates), `crates/macos/src/tree/attributes.rs` (batch read gains the new AX attributes — both `AXUIElementCopyMultipleAttributeValues` call sites live in `element.rs:77-82`/`attributes.rs:154-159`), sibling `_tests.rs` files.
|
||||||
|
- **Approach:** existing tokens (`focused, disabled, secure, expanded, checked`) plus new producers with direct AX evidence: `selected` (AXSelected), `hidden` (AXHidden), `busy` (AXElementBusy), `modal` (AXModal), `required` (AXRequired where exposed), `indeterminate` (mixed AXValue on toggle roles), `pressed` (button role + boolean AXValue true), `readonly` (editable role whose value attribute is not settable — reuse the capabilities module's settability read), `offscreen` (computed: live bounds fail to intersect the owning window's bounds). `invalid`/`multiselectable`/`haspopup` stay vocabulary-only on macOS (no clean AX evidence — documented in `state.rs`). New attributes join the existing `AXUIElementCopyMultipleAttributeValues` batch (`attributes.rs:154-159`) — no per-attribute fetches.
|
||||||
|
- **Patterns to follow:** the batch-attribute-read gotcha in `CLAUDE.md`; `state_reader` unification follows the ref-alloc config-struct dedup learning (one shared body, not `_with_X` copies).
|
||||||
|
- **Test scenarios:** (1) conformance: every emitted token ∈ `STATE_VOCABULARY` (uses U1 helper); (2) both call sites produce identical tokens for identical evidence (dedup regression); (3) mixed-state checkbox → `indeterminate` + not `checked`; (4) hidden element → `hidden`; (5) window-clipped element → `offscreen`; (6) macOS integration: fixture app's known controls produce expected token sets; (7) golden snapshot fixtures updated only where new tokens appear (diff reviewed, no token disappears).
|
||||||
|
- **Verification:** `cargo test --lib -p agent-desktop-macos` + fixture integration green; AE1 remains green end-to-end.
|
||||||
|
|
||||||
|
### U3. Display contract: `list_displays`, honest `--screen`, scale factor
|
||||||
|
|
||||||
|
- **Goal:** Complete the dead display-targeting contract end-to-end.
|
||||||
|
- **Requirements:** R2.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/display_info.rs` (new — `DisplayInfo { id, bounds, is_primary, scale }`), `adapter/system.rs` (`list_displays` default `not_supported`), `crates/core/src/image_buffer.rs` (`scale_factor: f64`, serde default 1.0), `crates/core/src/commands/list_displays.rs` + `_tests.rs` (new command), `crates/core/src/commands/screenshot.rs` (validate index), `crates/macos/src/system/display.rs` (new — CoreGraphics active-display enumeration: bounds, main flag, points-vs-pixels scale), `crates/macos/src/system/screenshot.rs` (both `capture_screen` impls pass `-D <n>` to `/usr/sbin/screencapture`; populate `scale_factor`), `src/cli/mod.rs` + `src/cli_args/mod.rs` (`ScreenshotArgs` lives there at lines 154-165 — add `--screen N`; new `list-displays` subcommand args in `system.rs`), `src/dispatch/`; note `resolve_target()` (`commands/screenshot.rs:33-63`) currently never produces `Screen` — the new flag wires it.
|
||||||
|
- **Approach:** deterministic ordering (primary first, then stable display-id order) documented on the trait method; `--screen` out of range → `INVALID_ARGS` with available displays in `details` (count + ids — shapes, not content). `ImageBuffer.scale_factor` rides this unit deliberately: AX bounds are in points while captures are pixel-scaled, and without the factor on the result every consumer's crop/coordinate math silently breaks on Retina and mixed-scale multi-monitor setups — the exact evidence `list_displays` exists to supply (R2); `AdImageBuffer` is opaque, so FFI exposure is one accessor, no ABI pin. Honest framing: `Screen(usize)` was CLI-unreachable dead code, so this is contract completion, not a behavior fix; the gap report's "wrong screenshot" claim is corrected in the unit's commit message.
|
||||||
|
- **Patterns to follow:** `list-windows`/`list-apps` command shape (one command per file, registration checklist in `CLAUDE.md`'s Extensibility Pattern); `map_screencapture_error` for shell-error mapping.
|
||||||
|
- **Test scenarios:** (1) `list-displays` JSON envelope with ≥1 display, primary flagged; (2) `--screen 99` → `INVALID_ARGS` naming available count; (3) `--screen 0` targets primary (integration, macOS); (4) `scale_factor` present and ≥1.0 on captures; (5) adapter double returning 2 displays → command output ordering deterministic; (6) `list_displays` default → `PLATFORM_NOT_SUPPORTED` mapping (stub adapters); (7) FFI: `AdImageBuffer` is deliberately opaque (accessor-only, no `repr(C)` pin) — add an `ad_image_buffer_scale_factor` accessor, no pin sequence fires.
|
||||||
|
- **Verification:** new command passes CLI contract tests (`src/cli/contract_tests.rs`); gates green.
|
||||||
|
|
||||||
|
### U4. Truthful Automation permission
|
||||||
|
|
||||||
|
- **Goal:** `PermissionReport.automation` reflects reality; osascript fallbacks classify TCC denials as `PERM_DENIED`.
|
||||||
|
- **Requirements:** R3.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `crates/macos/src/system/permissions.rs` (add `automation_state()` via `AEDeterminePermissionToAutomateTarget(askUserIfNeeded=false)` against System Events; wire at the two construction sites, lines 63/74), `crates/macos/src/system/app_ops.rs` (`close_app_impl` osascript fallback: reclassify −1743/`errAEEventNotPermitted` stderr → `PERM_DENIED` + System Settings suggestion), notification session paths sharing the osascript pattern, sibling tests.
|
||||||
|
- **Approach:** probe never prompts (that is the point of `askUserIfNeeded=false`); the call requires building an `AEAddressDesc` target descriptor for System Events first (typeApplicationBundleID) — isolate descriptor construction + probe behind one testable fn. System Events not running → `Unknown`, honestly. Core's `PermissionReport` shape is already correct — this is adapter wiring only. Suggestion text mirrors the screen-recording suggestion's shape.
|
||||||
|
- **Patterns to follow:** `map_screencapture_error` (`screenshot.rs:105-129`) — the proven stderr-reclassification convention; reuse-existing-error-code learning (no new code).
|
||||||
|
- **Test scenarios:** (1) report mapping unit tests for permitted/denied/unknown probe outcomes (probe injected via small trait or fn pointer for testability); (2) `close_app` fallback with −1743 stderr → `PERM_DENIED` + suggestion (double-driven); (3) `permissions` command JSON shows real state on macOS (integration, environment-dependent assertions: value ∈ {granted, denied, unknown}, never `not_required`); (4) no TCC prompt during `permissions` (manual verification note, one-time).
|
||||||
|
- **Verification:** AE5 double-driven test green; gates green.
|
||||||
|
|
||||||
|
**Phase B — identity spine (U5–U7).**
|
||||||
|
|
||||||
|
### U5. `native_id` end-to-end
|
||||||
|
|
||||||
|
- **Goal:** Capture the native automation id and make it the strongest re-identification signal.
|
||||||
|
- **Requirements:** R4.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/node.rs` (`AccessibilityNode.native_id: Option<String>`, serde skip-none), `crates/core/src/refs.rs` (`RefEntry` 17th field, serde default — old refmaps deserialize; current struct has 16), `crates/core/src/ref_identity.rs` (+`_tests`), `crates/macos/src/tree/attributes.rs` (batch read `kAXIdentifierAttribute`; `_NS`-prefixed auto-generated ids → `None`), `crates/core/src/ref_alloc.rs` (thread the field), `crates/ffi/src/types/ref_entry.rs` (`AdRefEntry` is a pinned `repr(C)` mirror — add the field via the full 3-layer size-pin sequence: const assert, `ad_ref_entry_size`, `tests/c_abi_layout.rs` literal) + conversion code + header regen via `scripts/update-ffi-header.sh`, snapshot serialization tests + golden fixtures.
|
||||||
|
- **Approach:** identity precedence in `identity_matches`: equal present `native_id`s → strongest positive (with pid+role); differing present ids → hard non-match; absent on either side → existing evidence chain unchanged. `has_meaningful_identity` counts it. Snapshot JSON includes it (agents can address by it later; it is already redaction-relevant? — it is a developer-assigned identifier, a shape not user content; no redaction key needed).
|
||||||
|
- **Patterns to follow:** the existing 14-field `RefEntry` evidence conventions; identity-fingerprint learning ("plumb the handle/fingerprint, fail closed on mismatch").
|
||||||
|
- **Test scenarios:** (1) same id → match survives name/value/bounds drift (the localization-resilience case); (2) different present ids → non-match even with identical name+role; (3) absent ids → behavior identical to today (regression on existing identity test corpus); (4) `_NS:123`-style id filtered to `None`; (5) old refmap JSON without the field loads (serde default); (6) fixture snapshot includes `native_id` where the SwiftUI fixture sets `accessibilityIdentifier`; (7) refmap size guard unaffected (<1MB write-side check); (8) FFI: `AdRefEntry` 3-layer pin updated in lockstep (const assert + runtime accessor + layout-test literal), FFI resolution path carries the field (CLI/FFI evidence parity).
|
||||||
|
- **Verification:** ref-identity unit suite + macOS resolve integration green; FFI header drift gate green after regen; gates green.
|
||||||
|
|
||||||
|
### U6. Window identity as primary key
|
||||||
|
|
||||||
|
- **Goal:** Window-scoped operations resolve by `WindowInfo.id` first; title is fallback evidence only.
|
||||||
|
- **Requirements:** R5.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/adapter/system.rs` (`resolve_window_strict(&self, id: &str)` default `not_supported`), `crates/macos/src/adapter.rs:51` (get_tree default surface), `crates/macos/src/system/window_ops.rs:26` (`window_op`), `crates/macos/src/system/app_ops.rs:90` (`focus_window_impl`) — all three re-resolve id-first via CGWindowList `kCGWindowNumber` match, `(pid, title)` only when the caller supplied no id; `src/tests/conformance.rs` + `tests/conformance/` (new shared contract test), macOS integration test.
|
||||||
|
- **Approach:** core documents the resolution obligation on the trait (`WindowInfo.id` is an opaque platform string — never assume numeric, per the AT-SPI D-Bus-path note in the gap analysis). **Id match alone is not trusted:** macOS `kCGWindowNumber` values are recycled after windows close, so `resolve_window_strict` corroborates the id-matched row against `pid` (always) and `title` (when the caller's `WindowInfo` carries one) and fails closed with `WINDOW_NOT_FOUND` on mismatch — the same optional-fingerprint-verify-before-acting shape `NotificationIdentity` already uses. The three macOS sites keep their title path only as explicit fallback for id-less flows.
|
||||||
|
- **Patterns to follow:** identity-fingerprint learning (window lists are its named example); `ContractAdapter` conformance-test shape.
|
||||||
|
- **Test scenarios:** (1) conformance: two same-titled, different-id windows — operation with id targets the id-matched one (adapter double); (2) id supplied but no longer present → structured `WINDOW_NOT_FOUND`, not silent first-title match; (3) **recycled id: id matches a live window but its pid differs from the caller's `WindowInfo.pid` → `WINDOW_NOT_FOUND` (fail-closed), never a silent action on the impostor window**; (4) title-only flow unchanged (regression); (5) macOS integration: two "Untitled" TextEdit windows, focus/resize by id acts on the right one; (6) `resolve_window_strict` default → `not_supported` on stub adapters.
|
||||||
|
- **Verification:** AE4 integration test green; gates green.
|
||||||
|
|
||||||
|
### U7. `LocatorQuery` + `resolve_query` + `find` integration
|
||||||
|
|
||||||
|
- **Goal:** A serializable, live-resolving element query as core contract, consumed by `find`.
|
||||||
|
- **Requirements:** R6.
|
||||||
|
- **Dependencies:** U0, U1, U5.
|
||||||
|
- **Files:** `crates/core/src/locator.rs` + `_tests.rs` (new — `LocatorQuery`, `StatePredicate`, `classify_query_result`), `crates/core/src/adapter/observation.rs` (`resolve_query(&self, &LocatorQuery, scope: Option<&NativeHandle>) → Vec<NativeHandle>` default `not_supported`), `crates/core/src/commands/query.rs` (`FindQuery` becomes a thin constructor of `LocatorQuery`; selector syntax unchanged), `crates/core/src/commands/find.rs` + `_tests.rs` (new flags: `--exact`, `--state token[=bool]` repeatable, `--has-text`, `--native-id`; existing `--nth/--first/--last/--count` map onto query ordinals), `crates/macos/src/tree/query.rs` (new — live traversal matcher reusing the builder's traversal guards and depth caps), `src/cli_args/`.
|
||||||
|
- **Approach:** matching semantics defined in core and unit-tested against tree fixtures: name/description substring case-insensitive by default, `exact` for equality; `state` predicates evaluate vocabulary tokens against live state; `has`/`has_not` are subtree containment (core-side walk over returned subtrees where the adapter returns candidates); `native_id` exact-match always. `classify_query_result` reuses the strict-resolution outcome contract (0/1/N) and the existing candidate-summary shape — implemented and tested now, wired to actions in Tier-2. `find` keeps snapshot/ref materialization behavior; only its matching backend changes. macOS `resolve_query` walks live AX with the same ancestor-path cycle guard as the snapshot builder.
|
||||||
|
- **Patterns to follow:** `parse_selector`/`validate_selector` conventions; progressive-snapshot learning (malformed input → `INVALID_ARGS`, never `STALE_REF`); the resolver-deadline sharing rule from the reliability learning.
|
||||||
|
- **Test scenarios:** (1) role+name exact vs substring; (2) `--state checked=true` filters live state (double with controllable live reads); (3) `has_text` containment matches the row-with-text case; (4) `has`/`has_not` subqueries; (5) `native_id` match ignores renamed labels; (6) `nth/first/last` ordinal determinism (document order); (7) `classify_query_result`: 0 → not-found shape, 1 → accept, 2+ → ambiguous with ≤10 candidate summaries; (8) invalid state token → `INVALID_ARGS` naming the vocabulary; (9) `find` CLI regression corpus unchanged for existing flags; (10) macOS integration: query against fixture app returns stable matches under UI idle.
|
||||||
|
- **Verification:** `find` conformance suite + macOS integration green; gates green.
|
||||||
|
|
||||||
|
**Phase C — actionability (U8–U10).**
|
||||||
|
|
||||||
|
### U8. Auto-wait pre-action gate, default on
|
||||||
|
|
||||||
|
- **Goal:** Every ref-addressed action retries resolve+actionability under one bounded budget by default; the pre-action timeout fragmentation ends.
|
||||||
|
- **Requirements:** R7.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/action_request.rs` (`timeout_ms: Option<u64>`, serde default), `crates/core/src/ref_action.rs` + `_tests.rs` (the poll loop wraps `check_actionability_with_trace` + resolve inside `execute_resolved`/`execute_entry_with_context` — today `check_live` runs once at `ref_action.rs:66` before dispatch), `crates/core/src/commands/helpers.rs` (shared ref-action arg threading), `src/cli_args/mod.rs` (`RefArgs`, lines 209-219 — used verbatim by 12 ref commands) + `src/cli_args/actions.rs` (the payload-carrying structs that inline their own `ref_id`+`snapshot`: `TypeArgs`, `SetValueArgs`, `SelectArgs`, `ScrollArgs`, plus `HoverArgs`/`DragCliArgs`) — `--timeout-ms` clap default 5000 on each, `crates/core/src/commands/wait_element.rs` (`WAIT_RESOLVE_ATTEMPT` becomes the loop's per-tick resolve budget — one owner), `crates/ffi/src/actions/execute.rs` + `conversion.rs` (default 5000 parity; additive `ad_execute_by_ref_timeout`; `AdAction` pin untouched — timeout rides the new fn's parameter, never the struct), `crates/ffi/include/agent_desktop.h` via `scripts/update-ffi-header.sh`, e2e assertions.
|
||||||
|
- **Execution note:** start with failing core tests for the loop's retry/permanent/deadline matrix before touching dispatch (the policy-preservation learning makes this the highest-blast-radius unit).
|
||||||
|
- **Approach:** per KTD3. Iteration cadence, stated honestly: 100 ms sleep between iterations, and each iteration's resolve gets `remaining.min(WAIT_RESOLVE_ATTEMPT)` (750 ms) — so iterations cost ~100 ms when the element resolves fast but is not yet actionable, and up to ~850 ms when resolve itself is slow (element missing), meaning the 5000 ms default yields ~6 attempts in the slow-resolve regime, not 50. Retryable = `STALE_REF`, `AMBIGUOUS_TARGET`, actionability Fail, `TIMEOUT`-from-resolve; permanent = `PERM_DENIED`, `APP_NOT_FOUND`, `ACTION_NOT_SUPPORTED`, `INVALID_ARGS`, `POLICY_DENIED`. **Transient-ambiguity audit:** if any iteration observed `AMBIGUOUS_TARGET` and a later iteration resolves to one match and acts, the success result carries `details.transient_ambiguity: true` (a shape — redaction-safe) so callers can audit or reject an action whose target identity flickered. Deadline → `TIMEOUT`, `details.kind = "actionability_timeout"`, last `ActionabilityReport` attached. **CLI/batch default equivalence is enforced at the arg-struct layer, not assumed:** clap's `default_value_t` does not participate in serde deserialization, and batch decodes JSON into these same structs — so the new field follows the repo's established paired pattern (`ScrollArgs.direction`, `MouseClickArgs.count`): `#[arg(long = "timeout-ms", default_value_t = 5000)]` + `#[serde(default = "default_timeout_ms")]`, with `0` mapping to `ActionRequest.timeout_ms = None` at request build. Batch entries omitting the field therefore auto-wait at 5000 ms too — part of the same documented breaking change. The loop never selects policy and never bypasses per-command post-condition verification. Tests constructing `ActionRequest` directly stay single-shot (`None`); e2e failure-path cases pass `--timeout-ms 0`.
|
||||||
|
- **Patterns to follow:** wait command's retryable/permanent split and `TIMEOUT` `kind` convention (reliability learning); FFI/CLI policy-alignment learning (mandatory `crates/ffi/src/actions/` review pass).
|
||||||
|
- **Test scenarios:** (1) target actionable on first check → zero added latency, single check (call-count assert on double); (2) actionable on 3rd tick → succeeds, elapsed ≈ 200–400 ms; (3) permanently Fail → `TIMEOUT` at budget with `kind` + last report; (4) `PERM_DENIED` mid-loop → immediate propagation; (5) `STALE_REF` twice then resolves → succeeds; (6) `AMBIGUOUS_TARGET` persists → `TIMEOUT` carrying candidate summaries; (7) `AMBIGUOUS_TARGET` once then a clean single match → succeeds **with `details.transient_ambiguity: true`**; (8) slow-resolve double consuming its full per-attempt budget each call → attempt count within the 5000 ms budget asserted (~6, the honest worst case); (9) `timeout_ms: None` → exactly one check (wire/back-compat regression); (10) `--timeout-ms 0` maps to `None`; (11) batch JSON entry omitting `timeout_ms` deserializes to 5000 (serde-default parity — the clap default never fires for batch); (12) post-condition verification still runs after a waited success (policy-preservation regression); (13) FFI default parity: `ad_execute_by_ref` waits, `ad_execute_by_ref_timeout(0)` is single-shot; (14) envelope: no version bump (additive optional field only); (15) e2e: AE2, AE3, and AE7 against the fixture app in both headless and `--headed`.
|
||||||
|
- **Verification:** e2e suite green including the new AE2/AE3 cases; FFI header drift gate green after regeneration.
|
||||||
|
|
||||||
|
### U9. `hit_test` + `receives_events` occlusion detection
|
||||||
|
|
||||||
|
- **Goal:** Occluded targets are caught before dispatch; ref-targeted pointer actions stop bypassing actionability.
|
||||||
|
- **Requirements:** R8.
|
||||||
|
- **Dependencies:** U0, U8.
|
||||||
|
- **Files:** `crates/core/src/hit_test.rs` (new — `HitTestResult { ReachesTarget, InterceptedBy { role, name, bounds }, Unknown }`), `crates/core/src/adapter/observation.rs` (`hit_test(&self, target: &NativeHandle, point: Point)` default `not_supported`), the actionability check module (+`receives_events` check, Unknown on `not_supported`), `crates/core/src/ref_action.rs` (ref-targeted `Hover`/`Drag` route through `check_live` minus `supported_action`/`editable`), `crates/macos/src/tree/hit_test.rs` (new — `AXUIElementCopyElementAtPosition` + `CFEqual` ancestor walk), sibling tests.
|
||||||
|
- **Approach:** per KTD4. Check point = center of live bounds. Three-way classification: Pass iff hit element ∈ target's subtree (target or descendant); **hit on the target's own ancestor → Unknown, not Fail** — AX hit-testing commonly resolves to a coarser container on composited/custom-drawn views where no distinct child node is hit-testable, and treating that as occlusion would false-Fail working clicks (this is the Risks section's Unknown-on-weird-evidence rule made explicit); Fail with occluder summary (`role` + `name` keys — redaction-safe) is reserved for hits **outside the target's ancestor chain** (true occluders: modals, overlays, sibling panels); probe error or `not_supported` → Unknown (never false failure, per the reliability learning's evidence rule). Raw coordinate `mouse-*` commands unchanged by design (documented).
|
||||||
|
- **Patterns to follow:** existing actionability check structure (per-check Pass/Fail/Unknown with reason); ancestor-path traversal guard from the macOS builder.
|
||||||
|
- **Test scenarios:** (1) unobstructed target → Pass; (2) modal overlay covering center → Fail naming occluder role (out-of-chain hit); (3) hit lands on target's own text child → Pass (descendant rule); (4) hit on target's ancestor → **Unknown, action proceeds** (composited-container case — never a false occlusion Fail); (5) `not_supported` adapter → Unknown, action proceeds; (6) `hover --ref` on disabled/occluded element now fails preflight (previously dispatched blind); (7) occluder name redacted in trace output (`trace_sanitize` regression); (8) macOS integration: sheet over button → click blocked with occluder detail, sheet dismissed → click passes within one auto-wait budget (composes with U8).
|
||||||
|
- **Verification:** actionability suite + macOS integration green; gates green.
|
||||||
|
|
||||||
|
### U10. `scroll_into_view` as core contract
|
||||||
|
|
||||||
|
- **Goal:** Element-targeted actions scroll off-screen targets into view uniformly, best-effort, before the visibility check.
|
||||||
|
- **Requirements:** R9.
|
||||||
|
- **Dependencies:** U0, U8.
|
||||||
|
- **Files:** `crates/core/src/action.rs` (`requires_scroll_into_view()` alongside the existing per-variant policy helpers), `crates/core/src/adapter/actions.rs` (`scroll_into_view(&NativeHandle)` default `not_supported`), `crates/core/src/ref_action.rs` (pre-check call inside the U8 loop), `crates/macos/src/actions/` (promote `ax_helpers::ensure_visible` — the real `AXScrollToVisible` invocation, wired today only through `CLICK_CHAIN`'s `pre_scroll` at `chain.rs:42-46` — into the `ActionOps::scroll_into_view` impl; the chain's `pre_scroll` rung delegates to the same fn), sibling tests.
|
||||||
|
- **Approach:** called once per loop iteration before visibility when the action requires it; any error degrades silently to today's behavior (best-effort, never fails the action); macOS falls back to geometric scroll only where the AX action is unavailable on the container (reuse existing scroll semantics), keeping the fallback inside the adapter per the gesture-capability learning ("the command never decides").
|
||||||
|
- **Patterns to follow:** `Action` policy-helper style (`requires_cursor_policy`-family); chain `pre_scroll` as the extraction source.
|
||||||
|
- **Test scenarios:** (1) `requires_scroll_into_view` truth table over all 21 `Action` variants (exhaustive match — compiler-enforced); (2) core calls it before visibility for `Click`, not for `PressKey` (call-order assert via double); (3) `not_supported` → action proceeds to visibility check unchanged; (4) adapter error → logged step, no failure; (5) macOS integration: off-screen row in a scroll area — `click --ref` succeeds where today it fails visibility; (6) chain regression: existing `CLICK_CHAIN` behavior unchanged for on-screen targets.
|
||||||
|
- **Verification:** e2e scroll-area case green in both modes; gates green.
|
||||||
|
|
||||||
|
**Phase D — naming, surfaces, action reporting (U11–U13).**
|
||||||
|
|
||||||
|
### U11. Core accessible-name computation
|
||||||
|
|
||||||
|
- **Goal:** One core-owned name/description algorithm over adapter-supplied evidence.
|
||||||
|
- **Requirements:** R12.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Execution note:** characterization-first — snapshot the fixture apps' current computed names into tests before migrating, then diff consciously.
|
||||||
|
- **Files:** `crates/core/src/accname.rs` + `_tests.rs` (new — `NameEvidence`, `compute_name`, `compute_description`, documented precedence), `crates/core/src/adapter/observation.rs` (`get_live_name_evidence` default `not_supported`), `crates/macos/src/tree/element.rs` (+`attributes.rs`: `resolve_element_name` becomes the evidence supplier feeding `compute_name`), golden fixtures under `tests/fixtures/`, macOS integration tests.
|
||||||
|
- **Approach:** per KTD6. Precedence: explicit label → labelled-by text → native title → static-role value → aggregated child label → placeholder → description. Evidence fields are all `Option<&str>`; core treats absent evidence as skip-to-next. The live path (`get_live_name_evidence`) serves future retrying name assertions; snapshot-time naming uses the same `compute_name` on evidence gathered during the batch read.
|
||||||
|
- **Patterns to follow:** vocabulary-module shape from U1 (algorithm + conformance in core, evidence at the adapter); UIA/AT-SPI mapping notes recorded as doc comments on `NameEvidence` fields.
|
||||||
|
- **Test scenarios:** (1) precedence table — one test per rung proving the earlier rung wins; (2) all-absent evidence → `None` (ref-ability unaffected elsewhere); (3) child-label aggregation joins in document order; (4) characterization: fixture-app names before == after for the common cases, intentional diffs enumerated and approved in the fixture update; (5) `get_live_name_evidence` default → `not_supported`; (6) macOS: evidence supplier returns raw attributes without applying its own precedence (grep-guard: `resolve_element_name` no longer contains fallback chains).
|
||||||
|
- **Verification:** fixture diffs reviewed and committed with the unit; ref-identity suite unaffected; gates green.
|
||||||
|
|
||||||
|
### U12. `SnapshotSurface` platform-neutrality ratification
|
||||||
|
|
||||||
|
- **Goal:** `Sheet`/`Popover` become explicitly platform-declared rather than silently assumed portable.
|
||||||
|
- **Requirements:** R10.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/adapter/system.rs` (`supported_surfaces() → &'static [SnapshotSurface]`, default `[Window, Focused, Menu, Menubar, Alert]`), `crates/macos/src/adapter.rs` (override adds `Sheet`, `Popover`), `crates/core/src/commands/snapshot.rs` (unsupported requested surface → `PLATFORM_NOT_SUPPORTED` naming the supported set), `crates/core/src/commands/status.rs` (surface list in `status` output), doc comments on the enum ratifying the semantics per variant + UIA/AT-SPI mapping notes.
|
||||||
|
- **Approach:** additive; macOS behavior unchanged. The enum stays `#[non_exhaustive]`; the contract text (which variants are universal, which are platform-native) lives on the enum as `///` docs — the ratification the gap analysis demanded before Phase 2.
|
||||||
|
- **Patterns to follow:** `permission_report`-style introspection surfaced through `status`.
|
||||||
|
- **Test scenarios:** (1) default trait impl excludes `Sheet`/`Popover`; (2) macOS reports all 7; (3) requesting `--surface sheet` against a double without it → `PLATFORM_NOT_SUPPORTED` with supported list in details; (4) `status` JSON includes `supported_surfaces`; (5) envelope: additive field only, no version bump (assert via `ENVELOPE_VERSION`).
|
||||||
|
- **Verification:** snapshot/status contract tests green; gates green.
|
||||||
|
|
||||||
|
### U13. Typed `ActionStep` delivery tier
|
||||||
|
|
||||||
|
- **Goal:** Callers can programmatically distinguish semantic-API delivery from physical synthesis, and whether the effect was verified.
|
||||||
|
- **Requirements:** R19.
|
||||||
|
- **Dependencies:** none (core type + macOS chain population).
|
||||||
|
- **Files:** `crates/core/src/action_step.rs` (+`StepMechanism { SemanticApi, PhysicalSynthetic }`, `mechanism: Option<StepMechanism>`, `verified: Option<bool>`, serde skip-none; note current shape is private `label: String` + `pub outcome: ActionStepOutcome` with `attempted/skipped/succeeded` constructors — extend via builder methods, not field surgery), `crates/macos/src/actions/chain*.rs` (each rung tags its mechanism; verification rungs set `verified` on the step they confirm), `crates/macos/src/actions/dispatch.rs` (direct non-chain paths tag too), `crates/ffi/src/types/action_step.rs` (`AdActionStep` is a pinned `repr(C)` mirror — expose the two fields via the full 3-layer size-pin sequence + header regen; result-shape parity is part of the FFI action path), trace fixture updates.
|
||||||
|
- **Approach:** labels stay (back-compat); the typed fields are additive. Population is mechanical: AX `AXUIElementPerformAction`/`SetAttributeValue` rungs → `SemanticApi`; CGEvent rungs → `PhysicalSynthetic`; the chain's existing effect-verification outcome writes `verified: Some(bool)` — no new verification logic, only surfacing what the chain already computes (policy-preservation learning: verification behavior itself untouched).
|
||||||
|
- **Patterns to follow:** existing `ActionStep{label, outcome}` conventions; trace-redaction learning (both new fields are shapes — enum + bool — safe by construction).
|
||||||
|
- **Test scenarios:** (1) headless click on AX-supporting control → all steps `SemanticApi`, final step `verified: true`; (2) `--headed` physical fallback → the fallback rung tagged `PhysicalSynthetic`; (3) serde: absent fields for legacy steps (round-trip old JSON); (4) trace export includes the fields un-redacted (shape fields, sanitizer regression); (5) chain outcome/step-count regression: no behavior change to rung execution; (6) FFI: `AdActionStep` pin triple updated (const assert + `ad_action_step_size` + layout literal), zeroed-read check extended per the size-pin learning.
|
||||||
|
- **Verification:** action-result serialization suite + trace viewer fixture green; FFI header drift gate green; gates green.
|
||||||
|
|
||||||
|
**Phase E — process, launch, session lifecycle (U14–U16).**
|
||||||
|
|
||||||
|
### U14. `ProcessState`, `APP_UNRESPONSIVE`, enriched errors
|
||||||
|
|
||||||
|
- **Goal:** Process liveness/hang become classifiable evidence; persistent AX unresponsiveness gets its own error code; resolution errors carry process state.
|
||||||
|
- **Requirements:** R13.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/process_state.rs` (new — enum per KTD8), `crates/core/src/adapter/system.rs` (`process_state(&self, pid)` default `not_supported`), `crates/core/src/error.rs` (`AppUnresponsive` 16th variant + code string `APP_UNRESPONSIVE`), `crates/core/src/output.rs` (`retry_token_for_code` arm; envelope version constant bump), `crates/core/src/ref_action.rs` + app-resolution paths (attach `details.process_state` on `STALE_REF`/`APP_NOT_FOUND` when pid known, best-effort), `crates/macos/src/system/process.rs` (new — `kill(pid,0)` liveness + bounded AX responsiveness probe), macOS `ax_helpers` (persisting `kAXErrorCannotComplete` → `AppUnresponsive` classification), `CLAUDE.md` error-code list, `docs/phases.md` naming-reconciliation note, batch/main envelope tests.
|
||||||
|
- **Approach:** per KTD8. Enrichment is additive `details` only and never converts a success to failure; **it runs exactly once, when constructing the terminal caller-visible error — never on an internal auto-wait retry tick** (U8's loop and this enrichment share `ref_action.rs`; the probe must not multiply across ~6–50 iterations of an exhausted budget). Classification threshold: the existing one-retry on `CannotComplete` stays, a second consecutive failure within one command classifies. `ENVELOPE_VERSION` bump per the envelope learning, all assertions via the constant.
|
||||||
|
- **Patterns to follow:** envelope-version learning verbatim; the reliability learning's structured-error conventions (`suggestion` on the new code: "app may be hung; consider close-app --force").
|
||||||
|
- **Test scenarios:** (1) live pid → `Running`; (2) exited pid → `Exited{code: None}`; (3) AX probe timing out twice → `Unresponsive` + `APP_UNRESPONSIVE` from the action path with suggestion; (4) `STALE_REF` against dead pid carries `details.process_state = "exited"`; (5) enrichment failure (probe errors) → original error unchanged, no panic; (6) probe call count is independent of auto-wait tick count (terminal-only enrichment, asserted via probe-counting double); (7) envelope tests updated through `ENVELOPE_VERSION` (main + batch + unit, per the learning); (8) `retry_token_for_code(APP_UNRESPONSIVE)` yields a sensible recovery token; (9) exit-code 2 arg-error path unaffected.
|
||||||
|
- **Verification:** full envelope/conformance suites green after the version bump; gates green.
|
||||||
|
|
||||||
|
### U15. `LaunchOptions`
|
||||||
|
|
||||||
|
- **Goal:** Launching accepts args/env/cwd and an explicit attach-vs-fail policy.
|
||||||
|
- **Requirements:** R14.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/launch_options.rs` (new), `crates/core/src/adapter/system.rs` (`launch_app(&self, id, &LaunchOptions)` — direct signature change per KTD14), `crates/core/src/commands/launch.rs`, `src/cli_args/` (`--arg`, `--env`, `--cwd`, `--no-attach`), `crates/macos/src/system/app_ops.rs` (`open -g -a` gains `--args` pass-through and env/cwd via spawn where `open` cannot carry them — adapter-internal choice), `crates/ffi/src/apps/launch.rs` (internal default construction, C signature unchanged), all launch test doubles.
|
||||||
|
- **Approach:** `attach_if_running: true` default preserves today's semantics exactly; `false` + already-running → structured error naming the running pid. macOS: plain `open -g -a` path when options are empty (zero regression risk); options present → `NSWorkspace`/spawn path. Struct stays ≤5 fields (God-object rule).
|
||||||
|
- **Patterns to follow:** config-struct-over-parameter-sprawl (repo naming conventions); `open -g` background-launch semantics preserved.
|
||||||
|
- **Test scenarios:** (1) empty options → byte-identical launch behavior (double asserts the same adapter call shape); (2) `--env KEY=VAL` parse + invalid form → `INVALID_ARGS` whose `message` and `details` carry position/key-name only — a regression test mirroring the existing `wait_text_timeout_message_omits_raw_text_from_trace_segment` guard asserts the raw value never reaches a trace segment; (3) `--no-attach` with running app → structured failure naming pid; (4) attach default with running app → success (today's behavior); (5) FFI `ad_launch_app` unchanged signature, defaults verified; (6) macOS integration: launch fixture app with a marker arg, assert received (fixture reads argv); (7) trace of a successful `launch` with env set carries no env value anywhere in the segment (sanitizer + message audit).
|
||||||
|
- **Verification:** launch suite + FFI compile + header drift gate green.
|
||||||
|
|
||||||
|
### U16. `open_session` adapter-affinity hook
|
||||||
|
|
||||||
|
- **Goal:** The stateful-adapter landing zone exists before Phase 2 starts; nothing calls it yet.
|
||||||
|
- **Requirements:** R15.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/session_affinity.rs` (new — `SessionAffinity { session_id: Option<String> }` extending the manifest vocabulary, and `AdapterSession: Send + Sync { fn close(self: Box<Self>) → Result<(), AdapterError>; }`), `crates/core/src/adapter/system.rs` (`open_session` default `not_supported` + doc naming the COM-MTA/D-Bus owners), conformance test.
|
||||||
|
- **Approach:** per KTD15 — contract only. The doc comment is the deliverable as much as the signature: it states what state may live inside a session (native connection affinity), what must not (resolved element handles — the RAII learning), and that the CLI path remains stateless.
|
||||||
|
- **Patterns to follow:** the trait's existing default-body convention; `resolve-then-release` RAII boundary documented in the gap analysis's strengths list.
|
||||||
|
- **Test scenarios:** (1) default returns `not_supported`; (2) a test double implementing a session (flag-setting `close`) proves the shape is implementable and object-safe; (3) `Box<dyn AdapterSession>` is `Send + Sync` (compile-time assertion); (4) no CLI/dispatch call sites exist (grep guard test, exhaustiveness-guard pattern).
|
||||||
|
- **Verification:** compile + conformance green; gates green.
|
||||||
|
|
||||||
|
**Phase F — signals and input vocabulary (U17–U19).**
|
||||||
|
|
||||||
|
### U17. `SignalBaseline`, `diff_signals`, `wait --event`
|
||||||
|
|
||||||
|
- **Goal:** Title-agnostic "what appeared/changed" detection as core contract, generalizing the notification fingerprint pattern.
|
||||||
|
- **Requirements:** R16.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/signals.rs` + `_tests.rs` (new — `EventKind { WindowOpened, WindowClosed, AppLaunched, AppTerminated, FocusChangedWindow, SurfaceAppeared{kind}, SurfaceDismissed{kind} }`, `SignalFilter { app, pid }`, `SignalBaseline` plain data, pure `diff_signals`, `UiEvent`), `crates/core/src/adapter/system.rs` (`snapshot_signals` default `not_supported`), `crates/core/src/commands/wait_event.rs` + `_tests.rs` (new — `WaitMode::Event` variant in `wait_mode.rs`, registered in the `WAIT_SUPPORTED` allowlist at `src/main.rs`), `crates/macos/src/system/signals.rs` (new — windows via CGWindowList ids, apps via workspace list, focused window; alert/sheet presence when `filter.app` set), `src/cli_args/system.rs` (`--event <kind>` on wait).
|
||||||
|
- **Approach:** per KTD12. Baseline is counting-map-shaped like `NotificationFingerprint` (survives reordering); diff is pure and double-testable. `wait --event` snapshots at start, polls at the existing wait cadence, returns first matching events; timeout follows the `wait_timeout` kind convention with the baseline summary (counts only — shapes) in details. Event payloads put window/surface titles under `title` keys (redaction-covered). FFI: `ad_wait` keeps its existing modes — `AdWaitArgs` is a pinned `repr(C)` struct, and the Event mode's FFI exposure is explicitly deferred to the same follow-up batch as locator FFI (recorded in Scope Boundaries), so no pin changes fire in this unit.
|
||||||
|
- **Patterns to follow:** `wait_for_notification` baseline/diff loop (`wait.rs:233-329`) as the reference implementation; wait-file splitting convention (`wait.rs` is at 349 LOC — new mode lives in its own file); exhaustiveness-guard learning for the `WAIT_SUPPORTED` registration.
|
||||||
|
- **Test scenarios:** (1) `diff_signals` pure tests: new window id → `WindowOpened`; removed → `WindowClosed`; focus id change → `FocusChangedWindow`; app pid appears/disappears → launched/terminated; sheet appears under app filter → `SurfaceAppeared{Sheet}`; (2) reorder-only baselines → zero events (fingerprint property); (3) duplicate-title windows counted correctly (counting map); (4) `wait --event window-opened` end-to-end with double: appears on 3rd poll → success with event payload; (5) timeout → `TIMEOUT` + `kind:"wait_timeout"` + counts-only details; (6) unknown `--event` value → `INVALID_ARGS` naming kinds; (7) trace: event title redacted (sanitizer regression); (8) macOS integration: open TextEdit document window, `wait --event window-opened --app TextEdit` fires without knowing the title (AE6).
|
||||||
|
- **Verification:** AE6 integration green; gates green.
|
||||||
|
|
||||||
|
### U18. Typed clipboard content
|
||||||
|
|
||||||
|
- **Goal:** Clipboard round-trips text, images, and file lists through one typed contract.
|
||||||
|
- **Requirements:** R17.
|
||||||
|
- **Dependencies:** U0.
|
||||||
|
- **Files:** `crates/core/src/clipboard_content.rs` (new), `crates/core/src/adapter/input.rs` (replace `get_clipboard`/`set_clipboard`/`clear_clipboard`-adjacent string surface with `get_clipboard_content`/`set_clipboard_content`; `clear_clipboard` unchanged), `crates/core/src/commands/{clipboard_get,clipboard_set}.rs`, `src/cli_args/` (`--format`, `--out`, `--image`, `--file-url` repeatable), `crates/macos/src/input/clipboard.rs` (NSPasteboard string/PNG/fileURL read+write, reusing the restore machinery's type round-tripping), `crates/ffi/src/input/clipboard.rs` (existing text fns delegate through `ClipboardContent::Text` — C signatures unchanged), sibling tests.
|
||||||
|
- **Approach:** per KTD13. `clipboard-get --format auto` reports the richest available type; image bytes go to `--out` path (default: temp file under the session dir) written via the `write_private_file` pattern (0600, `O_NOFOLLOW`, atomic rename — clipboard images can hold copied secrets; screenshot's unguarded `std::fs::write` is the wrong precedent for this file class), JSON carries `{type, path, width, height}` — envelope stays small and redaction-safe (paths are shapes). Setting file URLs validates existence up front (`INVALID_ARGS` reporting count + entry indexes only — basenames themselves can be sensitive and `details` reaches traces).
|
||||||
|
- **Patterns to follow:** `refs.rs::write_private_file` for the image temp file; screenshot's `--out` flag ergonomics; pasteboard-restore machinery as the macOS marshaling reference.
|
||||||
|
- **Test scenarios:** (1) text round-trip through the typed API (regression vs old string behavior); (2) image set-then-get round-trips pixel dimensions (macOS integration); (3) file-urls set → Finder-paste-shaped pasteboard content (integration, assert via get); (4) `--format text` on image-only clipboard → structured empty/`NOT_FOUND`-style result, not a panic; (5) FFI text fns behave identically pre/post (parity regression); (6) core command tests with double covering all three variants + `auto` preference order; (7) missing `--file-url` path → `INVALID_ARGS` with count + entry-index details only (no path content); (8) default image temp file lands 0600 under the session dir (mode assertion, Unix).
|
||||||
|
- **Verification:** clipboard suite + FFI parity green; gates green.
|
||||||
|
|
||||||
|
### U19. Mouse modifiers + wheel primitive
|
||||||
|
|
||||||
|
- **Goal:** Chorded clicks and a first-class wheel-delta command.
|
||||||
|
- **Requirements:** R18.
|
||||||
|
- **Dependencies:** U0 (file moves only).
|
||||||
|
- **Files:** `crates/core/src/action.rs` (`MouseEvent.modifiers: Vec<Modifier>` serde-default-empty; `MouseEventKind::Wheel { delta_x: f64, delta_y: f64 }`), `crates/core/src/commands/mouse_wheel.rs` + `_tests.rs` (new command), existing mouse command files (`--modifiers` flag), `src/cli_args/`, `crates/macos/src/input/mouse.rs` (CGEventFlags from the existing `Modifier` mapping; wheel reuses `synthesize_scroll_at` — the `CGEventCreateScrollWheelEvent` call already in this file at lines 282-296 — invoked directly instead of only via `scroll`'s fallback), FFI `crates/ffi/src/input/mouse.rs` (additive fn carrying modifiers as a bitmask param + wheel deltas; `AdMouseEvent` — a pinned `repr(C)` mirror — stays untouched, so its pin triple stands; header regen), doc note on the enum: deltas are wheel lines; X11 button-4/5/6/7 translation is adapter-side (KTD17).
|
||||||
|
- **Approach:** modifiers apply to Down/Up/Click kinds by setting event flags for the synthetic event's duration (restore after — no sticky modifier leakage; mirror `KeyCombo` handling). `Wheel` ignores `button`. `mouse-wheel --dx N --dy N [--at X,Y]` is raw-input tier (no ref, no actionability — documented like the other `mouse-*` commands).
|
||||||
|
- **Patterns to follow:** `MouseEventKind::Click{count}` precedent for kind-level extension; keyboard modifier synthesis for flag handling; one-command-per-file registration checklist.
|
||||||
|
- **Test scenarios:** (1) serde: legacy `MouseEvent` JSON without `modifiers` deserializes (default empty); (2) `--modifiers cmd,shift` parse + unknown modifier → `INVALID_ARGS`; (3) wheel deltas serialize/dispatch with sign conventions documented (positive = up/left or down/right — pick and test one, document on the type); (4) macOS integration: cmd-click in fixture multi-select list selects additively; (5) wheel scroll moves fixture scroll area (integration, both directions); (6) modifier restore: subsequent unmodified click carries no stale flags; (7) FFI additive fn present in regenerated header; `AdMouseEvent`'s existing pin triple unchanged (asserted — the new fn takes params, not a struct change).
|
||||||
|
- **Verification:** input suite + e2e wheel/chord cases green; header drift gate green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Contract
|
||||||
|
|
||||||
|
| Gate | Command | Applies to |
|
||||||
|
|---|---|---|
|
||||||
|
| Format | `cargo fmt --all -- --check` | every unit |
|
||||||
|
| Lint | `cargo clippy --all-targets -- -D warnings` | every unit |
|
||||||
|
| Core/workspace tests | `cargo test --lib --workspace` | every unit |
|
||||||
|
| Binary conformance | `cargo test -p agent-desktop` | every unit |
|
||||||
|
| FFI tests | `cargo test -p agent-desktop-ffi --tests` | U3, U8, U15, U18, U19 (and any unit touching `crates/ffi`) |
|
||||||
|
| Core isolation | `cargo tree -p agent-desktop-core` contains no platform crate names | every unit (CI-enforced) |
|
||||||
|
| FFI header drift | CI `ffi-header-drift` job (`cbindgen --verify` via pinned 0.29.4); regenerate with `scripts/update-ffi-header.sh` | U8, U19 (any header-affecting unit) |
|
||||||
|
| Binary size | release binary <15MB (CI check) | final |
|
||||||
|
| File budget | every touched file <400 LOC (except `@generated`) | every unit |
|
||||||
|
| E2E | `bash tests/e2e/run.sh` (release build + AX permission, headless and `--headed`) | U1, U2, U8, U9, U10, U17, U19 at minimum; full run before merge |
|
||||||
|
| Envelope discipline | version assertions via `ENVELOPE_VERSION` constant only; exactly one bump (U14) across the plan | U12, U14 |
|
||||||
|
|
||||||
|
Behavioral acceptance: AE1–AE7 each map to a named integration/e2e test landed with its owning unit (AE1→U1/U2, AE2/AE3/AE7→U8, AE4→U6, AE5→U4, AE6→U17).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- All 20 units merged in dependency order with one conventional commit each (`feat:`/`fix:` per unit intent; U1/U4 carry `fix:`; **U8 carries a `BREAKING CHANGE:` footer** — the default-on auto-wait silently changes every existing untouched call's timing, which meets the repo's breaking bar and must cut a minor version pre-1.0, not a patch).
|
||||||
|
- Every Verification Contract gate green on the branch tip; full e2e pass in both headless and `--headed` modes.
|
||||||
|
- R1–R19 each traceable to at least one landed test; AE1–AE6 green.
|
||||||
|
- All new `PlatformAdapter` surface defaults to `not_supported` and is exercised by at least one default-behavior test (stub-adapter inheritance proof).
|
||||||
|
- No stray scaffolding: abandoned experiments, dead flags, or superseded helpers removed; `git grep "MockAdapter"` still matches only docs (or docs corrected).
|
||||||
|
- `CLAUDE.md` error-code list includes `APP_UNRESPONSIVE`; `docs/phases.md` reconciliation notes landed (U14); no other doc drift introduced.
|
||||||
|
- Follow-up issues filed (or a follow-up list appended to the gap-analysis notes) for every Deferred item this plan touches adjacent to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **Behavior change (deliberate):** ref-action failure paths slow to their wait budget by default (AE3); success paths unchanged. Batch inherits the same defaults through the shared dispatch path — batch entries may set `timeout_ms` per action.
|
||||||
|
- **Wire compatibility:** all new JSON fields are additive/optional; the single envelope bump is U14's new error code. Old refmaps load (serde defaults).
|
||||||
|
- **FFI ABI:** no existing function-signature changes; additive functions (`ad_execute_by_ref_timeout`, mouse additions) plus exactly two pinned-struct extensions — `AdRefEntry` (U5) and `AdActionStep` (U13) — each executed as the size-pin learning's 4-step sequence; `AdImageBuffer` is opaque so `scale_factor` (U3) is accessor-only. Header regenerated per affected unit via the maintainer script; CI drift gate proves it.
|
||||||
|
- **Phase 2/3 adapters:** implement against settled shapes — the point of the plan. `docs/phases.md`'s overlapping planned items (`identifier`, `AutomationPermissionDenied`, `AxMessagingTimeout`) are superseded by name here (U5, U4, U14).
|
||||||
|
- **Trace/redaction:** every new field audited: shapes (enums, bools, counts, ids) pass; content rides only under `SENSITIVE_KEYS`-covered keys (`name`, `title`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **U8 blast radius (highest).** The poll loop wraps every ref action. Mitigations: type-layer default `None` keeps every existing constructor single-shot; the loop adds behavior only at the CLI/FFI arg layer; policy/post-verification untouched (test 9); e2e in both modes.
|
||||||
|
- **U0 mechanical churn.** ~55 test doubles re-split. Mitigation: compiler-driven, zero-logic edits; land first, alone.
|
||||||
|
- **U11 name drift.** Principled precedence may change computed names. Mitigation: characterization tests first; fixture diffs reviewed consciously. Residual (accepted): pre-upgrade refmaps may return `STALE_REF` after a mid-session binary upgrade until re-snapshot — one snapshot heals it.
|
||||||
|
- **`AXUIElementCopyElementAtPosition` fidelity (U9).** May return unexpected proxies on some apps. Mitigation: Unknown-on-weird-evidence keeps it advisory; integration tests on fixture app only assert the clear cases.
|
||||||
|
- **`AEDeterminePermissionToAutomateTarget` linkage (U4).** Carbon-era API via raw FFI. Mitigation: isolate in `permissions.rs` behind a probe fn; `Unknown` on any linkage/runtime failure.
|
||||||
|
- **Fixture app gaps.** Some integration cases (multi-select list, scroll area, `accessibilityIdentifier`) may need small SwiftUI fixture additions — allowed, they live under `tests/e2e/` fixture sources and follow its README.
|
||||||
|
- **rtk grep interception.** Verbatim code inspection during implementation must use `rtk proxy` or Read (scout-verified footgun).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- Gap analysis (requirements source): `agent-desktop-vs-playwright-gap-analysis.md` — session artifact produced 2026-07-02 by a 15-agent review, code-verified against `52705af`; its Tier-1 list + three defects define this plan's scope. This plan is self-contained: every load-bearing claim was re-verified in-repo by scouts on 2026-07-03.
|
||||||
|
- Scout-verified integration facts: `crates/core/src/adapter.rs` 397 LOC / 38 default-bodied methods; `ErrorCode` 15 variants (`error.rs:9-25`); `RefEntry` 16 fields (`refs.rs:22-50`); `is_check.rs` snapshot-state defect (`state_from_ref_entry`, lines 59-65) + dead `hidden` token (line 47); actionability = 6 ordered checks in `actionability/mod.rs:79-108`, Unknown never blocks, `check_live` runs once from `ref_action.rs:66`; `(pid,title)` sites `crates/macos/src/adapter.rs:51`, `system/window_ops.rs:26`, `system/app_ops.rs:90` (all via `tree::window_element_for`); both `capture_screen` impls shell `/usr/sbin/screencapture` (`screenshot.rs:46,211`); automation hardcoded `NotRequired` at all 5 sites (`permission_report.rs:51,68`, core `adapter.rs:214`, macOS `permissions.rs:63,74`); state producers `tree/builder.rs:152-176` + `actions/post_state.rs::element_state_from_attrs` (65-91); `AXScrollToVisible` at 5 sites, real invocation `ax_helpers::ensure_visible` via `CLICK_CHAIN.pre_scroll` (`chain.rs:42-46`); `ActionStep` = private `label` + `pub outcome` with `attempted/skipped/succeeded` constructors; `WAIT_RESOLVE_ATTEMPT` 750ms (`wait_element.rs:14`); two distinct 30s wait defaults (global `--wait-timeout` `src/cli/mod.rs:89-95`; `wait --timeout` `src/cli_args/system.rs:8`); chain env var + 10s default (`chain.rs:17,216-223`); `NotificationFingerprint` (`wait.rs:284-329`); wheel synthesis `input/mouse.rs::synthesize_scroll_at` (282-296); `RefArgs` (`cli_args/mod.rs:209-219`) used by 12 commands, payload actions inline their own ref fields; FFI: 75 exports, two families, codegen registry `crates/ffi/build.rs:49-68` + `EXPECTED_COMMANDS` drift gate, pinned `repr(C)` mirrors incl. `AdAction`/`AdRefEntry`/`AdActionStep`/`AdMouseEvent`/`AdWaitArgs` (3-layer pins, `tests/c_abi_layout.rs`), `AdImageBuffer` deliberately opaque (accessor-only), header via `scripts/update-ffi-header.sh` (cbindgen 0.29.4, banned from build graph per `deny.toml:21-23`); no `MockAdapter` exists — ~58 per-test doubles across 33 files, conformance via `src/tests/conformance.rs`.
|
||||||
|
- Binding institutional learnings (`docs/solutions/`): playwright-grade reliability contract; keep-FFI-action-policy-aligned-with-CLI; preserve-command-policy-semantics; envelope-version-bump contract; exhaustiveness-guards-over-catch-alls; identity-fingerprint-against-OS-reorder; keep-raw-arguments-out-of-trace-reachable-error-messages; deduplicate-ref-allocator-via-config-struct; macos-gesture-headless-capability; abort-state-guidance-multi-step-physical-input.
|
||||||
|
- External grounding (from the gap analysis, cited there): Playwright locator/auto-wait/assertion semantics; UIA ControlTypes/patterns/`AutomationId`; AT-SPI2 roles/interfaces/states and its hit-test gap.
|
||||||
|
|
||||||
489
docs/plans/2026-07-10-feat-gauntlet-evaluation-framework-plan.md
Normal file
489
docs/plans/2026-07-10-feat-gauntlet-evaluation-framework-plan.md
Normal file
|
|
@ -0,0 +1,489 @@
|
||||||
|
# Gauntlet: An Open Evaluation Framework for Desktop Automation Tools
|
||||||
|
|
||||||
|
**Status:** Proposed (v2 — restructured: standalone open-source framework; agent-desktop is the first driver)
|
||||||
|
**Repo:** `desktop-gauntlet` (separate repository, working name)
|
||||||
|
**First platform pack:** macOS · Windows/Linux packs follow the same contracts
|
||||||
|
**Author:** Lahfir · **Date:** 2026-07-10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this is, in one page
|
||||||
|
|
||||||
|
Gauntlet is a benchmark-and-evaluation harness for desktop automation tools — the
|
||||||
|
CLIs/servers that let agents observe and control real applications. It is **not**
|
||||||
|
tied to agent-desktop: it defines a small driver contract, ships its own fixture
|
||||||
|
apps and task corpus, and scores any tool that implements the contract.
|
||||||
|
agent-desktop is the first driver and the reason it exists.
|
||||||
|
|
||||||
|
The whole framework is one machine with three slots:
|
||||||
|
|
||||||
|
```
|
||||||
|
TASK × POLICY × DRIVER
|
||||||
|
what to do who decides steps which tool executes
|
||||||
|
(corpus) (script | real LLM) (agent-desktop vX | other CLI)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
runner executes task → policy → driver → real app
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
VERIFIER reads real state (files, AX tree, DOM) — never the tool's own ok:true
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
results.json → compare → scoreboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix two slots, vary the third:
|
||||||
|
|
||||||
|
| Vary | Question answered | Product |
|
||||||
|
|---|---|---|
|
||||||
|
| driver **version** (scripted policy) | did v0.4.8 regress vs v0.4.7? | tool reliability + regression engine |
|
||||||
|
| **policy** (model) | which LLM drives the tool best, at what cost, how consistently? | computer-use benchmark |
|
||||||
|
| **tool** | agent-desktop vs any other automation CLI | open leaderboard |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph corpus["Task corpus (versioned data)"]
|
||||||
|
C1[conformance cases]
|
||||||
|
C2[grounding triples]
|
||||||
|
C3[multi-step scenarios]
|
||||||
|
C4[chaos scripts]
|
||||||
|
end
|
||||||
|
subgraph runner["gauntlet runner"]
|
||||||
|
P["policy\n(scripted | llm:claude | llm:gpt | ...)"]
|
||||||
|
D["driver\n(tool under test)"]
|
||||||
|
P --> D
|
||||||
|
end
|
||||||
|
corpus --> runner
|
||||||
|
D -->|CLI subprocess| T["desktop tool\n(agent-desktop vX, ...)"]
|
||||||
|
T -->|OS accessibility| A["fixture apps + real apps"]
|
||||||
|
A -.->|independent read| O["oracles\nfs · AX · CDP · inventory"]
|
||||||
|
O --> V{verifier}
|
||||||
|
V --> R[(results.json)]
|
||||||
|
R --> CMP["gauntlet compare (A vs B)"]
|
||||||
|
R --> REP["scoreboard / release report"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Gauntlet is NOT responsible for:** the tool's own repo hygiene. Lint rules,
|
||||||
|
file-size limits, unit-test runners, mutation testing, JSON-schema snapshot gates —
|
||||||
|
those live in each tool's own CI (agent-desktop's are listed in §11 as a companion
|
||||||
|
work stream, because they matter, but they are not the framework). Gauntlet only
|
||||||
|
measures what it can observe through a tool's public surface. It also never uses
|
||||||
|
LLM judges for scoring (every mature benchmark that started with them ripped them
|
||||||
|
out) and never uses pixel-diff assertions (accessibility is the contract;
|
||||||
|
screenshots are evidence, not oracles).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Why (the problem, from the agent-desktop trenches)
|
||||||
|
|
||||||
|
- agent-desktop's merge gates run ~1,700 mock-based unit tests. Its excellent
|
||||||
|
fail-closed e2e suite requires macOS Accessibility permission, which CI runners
|
||||||
|
don't have — so **zero real-AX tests gate a merge**. Three total-breakage
|
||||||
|
regressions shipped green because of exactly this
|
||||||
|
(`docs/solutions/real-app-tests-are-the-platform-adapter-gate.md`).
|
||||||
|
- Two independent audits couldn't agree which commands have e2e coverage (~31 vs
|
||||||
|
~35 of 58) — coverage isn't tracked mechanically.
|
||||||
|
- One latency assertion exists in the whole system; rich per-phase timing is
|
||||||
|
emitted to traces but never aggregated or gated.
|
||||||
|
- No flake accounting, no repeated-trial scoring, no multi-step task corpus, no
|
||||||
|
general cross-version comparison.
|
||||||
|
|
||||||
|
Generalize the pain: **every desktop automation tool has this problem** — mocks
|
||||||
|
lie, real-AX CI is hard (permissions), and there is no shared benchmark for
|
||||||
|
"is this tool reliable enough for an agent to depend on." That's the gap Gauntlet
|
||||||
|
fills, and why it's worth open-sourcing rather than burying in one repo's tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Repo split
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph OG["desktop-gauntlet (open source)"]
|
||||||
|
RN["runner · results schema · compare · report"]
|
||||||
|
CP["corpus/ (versioned task data)"]
|
||||||
|
FX["fixtures/ (SwiftUI · AppKit · Electron apps)"]
|
||||||
|
DRV["drivers/ (agent-desktop, ...)"]
|
||||||
|
POL["policies/ (scripted, llm-anthropic, llm-openai, ...)"]
|
||||||
|
INF["infra/ (Tart VM image recipe with pre-seeded AX permission)"]
|
||||||
|
end
|
||||||
|
subgraph AD["agent-desktop repo (the tool)"]
|
||||||
|
SRC["tool source + unit tests"]
|
||||||
|
CI2["its own CI: lint · unit · mutation · schema gates (§11)"]
|
||||||
|
end
|
||||||
|
CI2 -->|"invokes gauntlet as a required check"| RN
|
||||||
|
DRV -->|subprocess, JSON| SRC
|
||||||
|
OTHER["any other tool"] -.->|"implements driver contract"| DRV
|
||||||
|
```
|
||||||
|
|
||||||
|
- `desktop-gauntlet` owns everything reusable: runner, schemas, corpus, fixtures,
|
||||||
|
drivers, policies, the VM/permission recipe, the leaderboard.
|
||||||
|
- `agent-desktop` keeps its own repo CI and its local e2e acceptance suite (which
|
||||||
|
seeded many of Gauntlet's ideas), and adds one thin CI job: *run Gauntlet's
|
||||||
|
macOS pack against the freshly built binary; fail the PR on gate breaches.*
|
||||||
|
- Fixtures: the existing `AgentDeskFixture` SwiftUI app migrates into
|
||||||
|
`desktop-gauntlet/fixtures/swiftui/` (it was designed tool-agnostically —
|
||||||
|
"never tuned to make a command pass" — which is exactly a benchmark fixture).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The components
|
||||||
|
|
||||||
|
### 4.1 Driver contract
|
||||||
|
|
||||||
|
A driver adapts one tool to three verbs. For CLI tools it's a manifest plus a
|
||||||
|
subprocess convention (no linking required — any language works):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# drivers/agent-desktop/driver.yaml
|
||||||
|
tool: agent-desktop
|
||||||
|
binary: ${GAUNTLET_TOOL_BINARY}
|
||||||
|
capabilities: [snapshot, click, type, scroll, drag, clipboard, windows, wait, batch]
|
||||||
|
verbs:
|
||||||
|
snapshot: ["snapshot", "--app", "{app}", "-i"] # → JSON on stdout
|
||||||
|
act: ["{action}", "{ref}", "--snapshot", "{snap}"] # → JSON envelope
|
||||||
|
probe: ["get", "{ref}", "--property", "{prop}"] # tool-side reads (weak oracle)
|
||||||
|
contract:
|
||||||
|
error_codes: [STALE_REF, AMBIGUOUS_TARGET, TIMEOUT, PERM_DENIED, ...]
|
||||||
|
envelope: json # every response parseable
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner treats drivers as untrusted subprocesses: private hashed binary copy,
|
||||||
|
isolated HOME/state dir, own process group, absolute watchdog timeout, bounded
|
||||||
|
output capture — inherited from agent-desktop's `run.sh` harness-integrity work.
|
||||||
|
A tool without some capability skips those cases (recorded as `unsupported`, which
|
||||||
|
is itself a scoreboard column — coverage of the action vocabulary is a comparison
|
||||||
|
axis between tools).
|
||||||
|
|
||||||
|
### 4.2 Policies (who decides the next step)
|
||||||
|
|
||||||
|
- **`scripted`** — deterministic step lists from the corpus. No model anywhere.
|
||||||
|
This is the *only* policy used for reliability gates and version comparison.
|
||||||
|
- **`llm:<provider/model>`** — a real model (Claude, GPT, Gemini, local) receives
|
||||||
|
the task instruction and drives the tool through the driver, exactly like a
|
||||||
|
production agent would. Used for the computer-use benchmark tier: same tasks,
|
||||||
|
same tool, different models — or same model, two tool versions ("did the new
|
||||||
|
snapshot format make the model better at the task?"). Results carry cost,
|
||||||
|
tokens, steps, wall time, and pass^k, HAL-style (cost and consistency are
|
||||||
|
first-class columns, accuracy alone is a vanity metric).
|
||||||
|
- **`adversarial`** — a scripted policy that deliberately misuses the tool (stale
|
||||||
|
refs, cross-namespace refs, malformed batches, rapid-fire duplicates, concurrent
|
||||||
|
sessions). The contract under abuse: typed error from the documented set,
|
||||||
|
parseable envelope, no state corruption, bounded exit. This is "any model can
|
||||||
|
use it" made testable: the contract must hold even for the worst caller.
|
||||||
|
|
||||||
|
Verification is identical for all policies — the verifier never knows or cares who
|
||||||
|
decided the steps.
|
||||||
|
|
||||||
|
### 4.3 One case, end to end
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as runner
|
||||||
|
participant P as policy
|
||||||
|
participant D as driver
|
||||||
|
participant App as fixture/real app
|
||||||
|
participant O as oracle (independent)
|
||||||
|
R->>App: reset to pinned initial state
|
||||||
|
loop until done / budget exhausted
|
||||||
|
P->>D: next step (snapshot / click @e12 / type ...)
|
||||||
|
D->>App: execute via OS accessibility
|
||||||
|
D-->>P: JSON envelope (result | typed error)
|
||||||
|
end
|
||||||
|
R->>O: read real state (fs · AX · CDP · AppleScript · inventory hook)
|
||||||
|
O-->>R: observed state
|
||||||
|
R->>R: verdict + invariants (no hang · no crash · no wrong-target · store intact)
|
||||||
|
R->>R: append case → results.json (k trials → pass^k)
|
||||||
|
```
|
||||||
|
|
||||||
|
Oracles come in two strengths, stated honestly: **strong** oracles bypass the tool
|
||||||
|
entirely (file system, AppleScript app state, CDP DOM/AX, fixture inventory hook);
|
||||||
|
**weak** oracles read UI state through the tool itself but against an element
|
||||||
|
disjoint from the action target. Scenario verifiers prefer strong oracles; weak
|
||||||
|
ones are allowed for effect checks and are cross-audited by the fidelity suite.
|
||||||
|
When a verifier is found wrong, we fix the verifier and keep the task (score
|
||||||
|
continuity — the OSWorld-Verified rule), and we log every verifier change with its
|
||||||
|
measured false-negative impact (the WebArena-Verified meta-metric: they found
|
||||||
|
broken checkers in 299/812 tasks; auditing your own verifiers is not optional).
|
||||||
|
|
||||||
|
### 4.4 Fixture apps (ground truth you own)
|
||||||
|
|
||||||
|
Real third-party apps update themselves; fixtures don't — they're the
|
||||||
|
deterministic core, and each one **exports its own ground truth** so snapshots can
|
||||||
|
be scored for completeness (recall/precision), not just spot-checked.
|
||||||
|
|
||||||
|
- **SwiftUI** (exists — migrated): buttons/toggles/sliders/pickers, ambiguous
|
||||||
|
twins, zero-bounds controls, delayed-enable, duplicate-titled windows, sheets/
|
||||||
|
popovers/menus, drag canvas, removable rows. Added: an animated control
|
||||||
|
(stability gate), a churn card (timed subtree mutations at configurable Hz), a
|
||||||
|
static `inventory.json` manifest.
|
||||||
|
- **AppKit-classic** (new, small): NSTableView/NSOutlineView/NSToolbar/NSAlert —
|
||||||
|
what Finder-class apps are actually made of.
|
||||||
|
- **Electron** (new — the dense-tree gauntlet): eight pages, each aimed at a
|
||||||
|
researched failure mode — parameterized dense grid (1k–20k DOM nodes),
|
||||||
|
virtualized list **with a non-virtualized twin of the same data** (off-viewport
|
||||||
|
rows are structurally *absent* from every AX channel, so single-snapshot
|
||||||
|
"completeness" claims are only honest against the rendered inventory; full-data
|
||||||
|
completeness is a scroll-loop scenario), AG Grid with virtualization toggle,
|
||||||
|
Monaco (10k lines, accessibility mode on/off — two different code paths), canvas
|
||||||
|
regions (with/without fallback DOM — must report "no actionable children," not
|
||||||
|
junk), nested cross-origin iframes (tree-stitching races), a live-region ticker
|
||||||
|
(5–20 Hz churn), and a frameless custom-chrome window. Every page ships
|
||||||
|
`window.__gauntletInventory__` and exposes CDP for oracle reads.
|
||||||
|
- **Real-app pack** (nightly, informational): Finder, TextEdit, System Settings,
|
||||||
|
Safari, Chrome, VS Code, Slack — because hostile, non-conformant AX trees only
|
||||||
|
exist in the wild (UFO2 attributed 62% of its failures to control detection on
|
||||||
|
exactly such apps).
|
||||||
|
|
||||||
|
### 4.5 Results schema and comparison
|
||||||
|
|
||||||
|
Every run, any tier, any policy, emits one `gauntlet-result/v1` JSON: binary
|
||||||
|
identity (sha256/version/git), host class, **pinned corpus version**, per-case
|
||||||
|
attempts with wall time, phase spans (resolve/preflight/dispatch/verify), error
|
||||||
|
codes, outcome (`pass|fail|flaky|quarantined|unsupported`), invariant violations,
|
||||||
|
and aggregate metrics. Schema rules: additive-only evolution, hard version pinning
|
||||||
|
(a score is meaningless without the exact corpus that produced it —
|
||||||
|
terminal-bench's rule), committed JSON Schema.
|
||||||
|
|
||||||
|
`gauntlet compare a.json b.json` → per-case outcome transitions, metric deltas
|
||||||
|
with noise margins, phase-span deltas ("resolve p95 +40% on dense trees" is one
|
||||||
|
line). `gauntlet run --binary A --binary B` runs balanced AB/BA pairs against one
|
||||||
|
pinned app state — the methodology already proven in agent-desktop's
|
||||||
|
`electron-live.sh` (interleaved ordering, separate HOMEs, immutable hashes, state
|
||||||
|
gate before/after every pair, paired diffs), promoted from one operation to the
|
||||||
|
whole corpus.
|
||||||
|
|
||||||
|
**Metric definitions (fixed):**
|
||||||
|
- `pass^k` — per case, n trials, c successes: `C(c,k)/C(n,k)` (tau-bench's
|
||||||
|
estimator). The metric that exposes "works once, fails on retry" — models drop
|
||||||
|
from ~50% pass@1 to ~25% pass^8; a *tool* must hold ≥0.99 pass^5 on scenarios
|
||||||
|
and 1.0 on conformance.
|
||||||
|
- `flake_rate` — fail-then-pass is recorded `flaky` forever (Playwright/nextest
|
||||||
|
semantics); trailing budget <0.5%, breach → quarantine lane (cases keep running
|
||||||
|
and reporting, gate nothing, carry an issue URL, expire after 4 weeks).
|
||||||
|
- **Zero-tolerance invariants** — wrong-target actions (effect landed on an
|
||||||
|
element other than the addressed one — fixtures make this observable), crashes,
|
||||||
|
watchdog hangs, state-store corruption, secrets in traces: any occurrence fails
|
||||||
|
the run outright.
|
||||||
|
- `determinism_rate` — identical fixture state, two headless snapshots → identical
|
||||||
|
normalized tree and ref assignment. Must be 1.0.
|
||||||
|
- Fidelity — recall (oracle-interactive elements present with refs — a miss means
|
||||||
|
an agent literally cannot act), precision, hierarchy edge-F1, attribute accuracy
|
||||||
|
(role/name/value/bounds). Goes beyond Screen2AX's structure-only F1.
|
||||||
|
- Latency — nearest-rank p50/p95 per command × fixture class × mode; plus token
|
||||||
|
economy (snapshot bytes / est. tokens per fixture class — an agent-facing cost).
|
||||||
|
- `stale_recovery_rate` — churn → STALE_REF → re-snapshot+retry succeeds ≤1 cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The six suites
|
||||||
|
|
||||||
|
| Suite | Question | Gates? |
|
||||||
|
|---|---|---|
|
||||||
|
| **Conformance** | does every documented command do exactly what it claims, under actionability rules, with typed errors — on real AX? | yes — 100%, per-PR (smoke) / merge (full) |
|
||||||
|
| **Fidelity** | is the snapshot complete and correct vs ground truth? | yes on fixtures (recall = 1.0); informational on real apps |
|
||||||
|
| **Grounding** | given a description, does the tool resolve the *right* element — or correctly refuse? | yes |
|
||||||
|
| **Scenarios** | do multi-step, real-workflow tasks succeed repeatedly (pass^k)? | nightly gate |
|
||||||
|
| **Chaos** | under perturbation, does it degrade into typed errors — never hangs/crashes/wrong clicks? | nightly gate, zero-tolerance invariants |
|
||||||
|
| **Performance** | latency/scaling/cost curves, version over version | thresholds vs pinned baselines |
|
||||||
|
|
||||||
|
Condensed specs (full case inventories live in the corpus itself):
|
||||||
|
|
||||||
|
- **Conformance** — per command × fixture pattern × headless/headed: effect
|
||||||
|
verified by observation exactly-once; per-check actionability cases (disabled →
|
||||||
|
typed timeout with last-report; moving → held until stable; occluded → occluder
|
||||||
|
named); an error-taxonomy case per documented code; determinism and recovery
|
||||||
|
cases. Generalizes agent-desktop's AE1–AE7 to the whole surface. A mechanical
|
||||||
|
coverage matrix (command × fixture × mode) is a committed artifact; empty rows
|
||||||
|
fail. Chromium-specific contract: correct AX enablement (`AXManualAccessibility`
|
||||||
|
for Electron), full-tree mode confirmed, post-mutation settle honored, and the
|
||||||
|
notification-incompleteness rule (Chromium's macOS AX event map is explicitly
|
||||||
|
incomplete — a node deleted with no notification must still vanish from the next
|
||||||
|
snapshot; incremental invalidation may only ever be an optimization over full
|
||||||
|
re-traversal).
|
||||||
|
- **Grounding** — ~150 labeled triples to start (every fixture control + surface
|
||||||
|
elements), scored exact-match, including must-refuse rows (AMBIGUOUS_TARGET on
|
||||||
|
twins; not-found on absent). ScreenSpot's design, plus the refusal classes it
|
||||||
|
lacks.
|
||||||
|
- **Scenarios** — 30–40 declarative YAML tasks v1 (fixture flows, TextEdit
|
||||||
|
save-to-disk with fs oracle, Finder folder ops, Settings toggle with `defaults
|
||||||
|
read` oracle, Electron form-fill with DOM oracle, clipboard round-trips,
|
||||||
|
notification post+dismiss). Scripted policy for gates; the same files run under
|
||||||
|
LLM policies for the benchmark tier.
|
||||||
|
- **Chaos** — injectors: window-move mid-action, focus steal, occluder appears,
|
||||||
|
element removed between preflight and dispatch, app SIGSTOP (→ typed
|
||||||
|
APP_UNRESPONSIVE, never a hang), app SIGKILL, 20 Hz churn storm, dense-tree
|
||||||
|
pressure, permission revoked mid-run, store fault injection, concurrent
|
||||||
|
invocations racing one app.
|
||||||
|
- **Performance** — dense scaling curves (the curve is the artifact — slope
|
||||||
|
regressions get caught even when small-N points pass); per-command wall-clock on
|
||||||
|
real Macs (hyperfine methodology: warmups, no shell, JSON export); phase-span
|
||||||
|
attribution from driver `--profile` output where the tool provides it; strategy
|
||||||
|
matrix — when a tool exposes alternative strategies behind flags, Gauntlet runs
|
||||||
|
the corpus per variant and ranks them. That's the "find the faster method"
|
||||||
|
requirement: land alternatives behind flags, let the harness pick the winner,
|
||||||
|
then make it the default. Calibration note: VoiceOver itself stalls 40–60s on
|
||||||
|
Gmail-class pages — dense-tree budgets are set from measured baselines, not
|
||||||
|
vibes. (Deterministic instruction-count gating of a tool's platform-independent
|
||||||
|
core is a *tool-repo* concern, §11 — Valgrind doesn't run on macOS.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Infrastructure: real AX permission in CI
|
||||||
|
|
||||||
|
The blocker that keeps every tool's real tests local is macOS TCC (Accessibility
|
||||||
|
permission cannot be granted non-interactively through any supported API). The
|
||||||
|
production-proven answer, shipped as `infra/` in the framework repo:
|
||||||
|
|
||||||
|
- **Tart golden VM image** — build on Cirrus Labs' own published recipe (their
|
||||||
|
production image pipeline scripts a SIP-disable inside the VM via recovery-mode
|
||||||
|
automation, then seeds `TCC.db` directly, then snapshots; now free after the
|
||||||
|
OpenAI relicense). Gauntlet's image adds a TCC row for a **fixed staging path**
|
||||||
|
(`/opt/gauntlet/bin/<tool>` — path-keyed grants survive binary changes) plus the
|
||||||
|
fixture apps. `tart clone` per CI job = clean state + permission, in seconds, on
|
||||||
|
any Apple-Silicon self-hosted box.
|
||||||
|
- Fallbacks, documented in order of decreasing solidity: persistent self-hosted
|
||||||
|
Mac / EC2 Mac dedicated host with a one-time manual grant; the user-level
|
||||||
|
`TCC.db` sqlite insert on GitHub-hosted runners (works as of 2026-04, unofficial
|
||||||
|
and revocable — stopgap only).
|
||||||
|
|
||||||
|
Suggested cadence for a tool consuming Gauntlet (agent-desktop's plan):
|
||||||
|
|
||||||
|
| Lane | When | What |
|
||||||
|
|---|---|---|
|
||||||
|
| PR (required) | every push | conformance smoke (headless, ~⅓ cases, 1 trial) on a Tart clone — ≤10 min |
|
||||||
|
| PR (label) | `gauntlet-full` | full conformance + fidelity + grounding |
|
||||||
|
| merge | main | full conformance both modes + scenarios (2 trials) + perf representative set |
|
||||||
|
| nightly | cron | scenarios pass^5 + chaos + perf curves + real-app pack + LLM-policy probe |
|
||||||
|
| release | tag | everything, macOS N/N-1 matrix + published reliability report |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Meta-evaluation: proving the framework catches bugs
|
||||||
|
|
||||||
|
A framework that can't catch known bugs has no business gating anything.
|
||||||
|
|
||||||
|
- **Seeded-defect corpus** (mechanism generic, seeds per tool): `gauntlet
|
||||||
|
seed-defects` reverts a known-bug fix in a worktree, rebuilds, runs the mapped
|
||||||
|
suite, and asserts Gauntlet **fails**. agent-desktop's seeds come from its own
|
||||||
|
history — the mock-concealed regressions, the window-id fix, accessible-name
|
||||||
|
unification, the Instant-overflow panic. Detection rate is tracked per release;
|
||||||
|
target 100%. Every new production bug adds a seed before its fix merges.
|
||||||
|
- **Verifier audit log** — every verifier change records measured false-negative/
|
||||||
|
false-positive impact, published in the release report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. The open-source products
|
||||||
|
|
||||||
|
1. **The conformance suite** — WPT-for-desktop-automation: any tool implements the
|
||||||
|
driver contract and self-certifies against the pinned corpus.
|
||||||
|
2. **The computer-use benchmark** — LLM policies × scenario corpus, with cost,
|
||||||
|
steps, and pass^k as first-class columns; results reproducible from the
|
||||||
|
results.json + pinned corpus + model version.
|
||||||
|
3. **The leaderboard** — terminal-bench mechanics: submissions are PRs of raw
|
||||||
|
per-trial results, a bot validates schema/structure, a human merges; every row
|
||||||
|
hard-pinned to corpus + tool + model versions. No self-reported unverifiable
|
||||||
|
numbers.
|
||||||
|
4. **The reliability report** — regenerated per release of a tool: case counts,
|
||||||
|
pass^k, flake trend, chaos survival, fidelity, perf deltas, seeded-defect
|
||||||
|
detection rate. (Playwright publishes no numbers and has no perf CI at all —
|
||||||
|
this artifact is the differentiator.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Build plan (honest sizing — this is phased work, not one PR)
|
||||||
|
|
||||||
|
| Phase | Deliverable | Rough effort |
|
||||||
|
|---|---|---|
|
||||||
|
| **P0 — Scaffold** | `desktop-gauntlet` repo: runner skeleton, driver contract + agent-desktop driver, results schema + compare, SwiftUI fixture migrated, ~20 conformance cases running locally | 1–2 weeks |
|
||||||
|
| **P1 — Reality gate** | Tart image + self-hosted runner; agent-desktop PR lane goes required; conformance corpus covers all 58 commands; coverage matrix + failure bundles | 1–2 weeks |
|
||||||
|
| **P2 — Chromium depth** | Electron fixture (8 pages), fidelity suite with triangulated oracles (inventory + CDP + ariaSnapshot), dense perf curves, grounding corpus | ~2 weeks |
|
||||||
|
| **P3 — Brutality** | Scenario corpus (30–40 tasks), chaos injectors + invariant watchdog, adversarial policy, pass^k nightly, seeded-defect corpus, flake dashboard (a DuckDB file + static HTML — no SaaS) | ~2 weeks |
|
||||||
|
| **P4 — Publish** | LLM policies (Anthropic/OpenAI/local), leaderboard bot, docs site, first public reliability report, Windows/Linux pack scaffolds | 2+ weeks, ongoing |
|
||||||
|
|
||||||
|
Each phase ships value on its own; P1 alone kills the "merged green, broke real
|
||||||
|
apps" failure mode. Phases get their own implementation specs when started — this
|
||||||
|
document is the architecture, not the work items.
|
||||||
|
|
||||||
|
**Companion stream (agent-desktop repo, independent, days not weeks):** §11.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. What Gauntlet deliberately excludes
|
||||||
|
|
||||||
|
- LLM judges in any gating tier (non-deterministic; the failure mode every
|
||||||
|
"Verified" benchmark overhaul existed to remove).
|
||||||
|
- Pixel-diff assertions (AX is the contract; screenshots are evidence).
|
||||||
|
- The tool's internal test infrastructure (unit tests, mutation, lint — §11).
|
||||||
|
- Hosting third-party model leaderboards before the tool-reliability core is
|
||||||
|
credible (products in §8 ship in order).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Companion stream: agent-desktop's own CI hardening (NOT the framework)
|
||||||
|
|
||||||
|
These came out of the same research and are worth doing regardless of Gauntlet —
|
||||||
|
but they are agent-desktop repo concerns, listed here so they don't get lost and
|
||||||
|
don't get confused with the framework again:
|
||||||
|
|
||||||
|
- **JSON envelope schema gate** — schemars-derived schema for the envelope + all
|
||||||
|
58 payloads, committed, insta-snapshot-gated; golden fixtures validated against
|
||||||
|
it (`jsonschema` crate). Additive-only evolution (Stripe/Zalando rules).
|
||||||
|
- **Mutation testing** — `cargo-mutants --in-diff` per PR (cost scales with the
|
||||||
|
diff); sharded full sweeps weekly. Directly quantifies "tests pass but catch
|
||||||
|
nothing" (~61k non-test LOC ⇒ full runs are hours; shard them).
|
||||||
|
- **nextest** as unit runner — process-per-test isolation for the AX/FFI blast
|
||||||
|
radius; `flaky-result="fail"` in PR profile.
|
||||||
|
- **proptest-state-machine** ref-model suite — `@ref → (pid, role, path,
|
||||||
|
stable_text, bounds_hash)` transitions asserting strict re-identification
|
||||||
|
semantics (the icechunk pattern).
|
||||||
|
- **`--profile` flag** — surface the already-collected `query_stats`/phase spans
|
||||||
|
in the response envelope; Gauntlet's phase attribution consumes it.
|
||||||
|
- **Doc-drift fixes** — CLAUDE.md's false CI claim, the four dead
|
||||||
|
`AGENT_DESKTOP_*_TIMEOUT_MS` env knobs, the stale fixtures README.
|
||||||
|
- Existing gates (400-LOC, clippy, core-isolation, binary size, FFI drift/panic)
|
||||||
|
stay exactly where they are: repo CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Decision log (research verdicts, framework-relevant)
|
||||||
|
|
||||||
|
| Decision | Verdict | Basis |
|
||||||
|
|---|---|---|
|
||||||
|
| Execution-based verifiers only; no LLM judges | ADOPT | OSWorld-Verified, WebArena-Verified (299/812 broken checkers fixed via backend state), AndroidWorld |
|
||||||
|
| Fix evaluators not tasks; semver-pinned corpus | ADOPT | OSWorld-Verified score continuity; terminal-bench pinning |
|
||||||
|
| pass^k (hypergeometric) + pass@1 | ADOPT | tau-bench; computer-use reliability follow-ups |
|
||||||
|
| Flaky as first-class outcome; quarantine with issue URLs | ADOPT | Playwright outcome semantics + fixme convention |
|
||||||
|
| Fixture self-inventory + CDP + ariaSnapshot oracle triangulation | ADOPT | each channel's blind spots documented; CDP tree is an unpruned superset; virtualized rows absent from all AX channels |
|
||||||
|
| Fidelity = recall/precision + edge-F1 + attributes | ADOPT (extended) | Screen2AX is structure-only |
|
||||||
|
| Balanced AB/BA paired comparison | ADOPT | agent-desktop `electron-live.sh` methodology |
|
||||||
|
| Tart golden image for TCC | ADOPT | Cirrus Labs' production recipe; free post-relicense |
|
||||||
|
| Hosted-runner TCC.db hack | STOPGAP | works 2026-04; unofficial, revocable |
|
||||||
|
| Cost/steps/consistency as first-class model-benchmark columns | ADOPT | HAL; "AI Agents That Matter" cost-controlled evaluation |
|
||||||
|
| PR-based leaderboard with bot validation | ADOPT | terminal-bench |
|
||||||
|
| Zero perf story in Playwright ⇒ perf tier is our differentiator | ADOPT | verified: no benchmark code/CI in microsoft/playwright |
|
||||||
|
|
||||||
|
## 13. Source index (primary sources)
|
||||||
|
|
||||||
|
Playwright: playwright.dev docs (actionability, writing-tests, test-retries,
|
||||||
|
aria-snapshots, trace-viewer); microsoft/playwright source (injectedScript.ts,
|
||||||
|
dom.ts/frames.ts/progress.ts, packages/trace/src, tests/, workflows incl.
|
||||||
|
fix-flakes.yml, browser_patches). Benchmarks: OSWorld arXiv:2404.07972 +
|
||||||
|
OSWorld-Verified (xlang.ai); WindowsAgentArena arXiv:2409.08264; macOSWorld
|
||||||
|
arXiv:2506.04135; AndroidWorld arXiv:2405.14573; WebArena + evaluators.py +
|
||||||
|
WebArena-Verified (OpenReview); VisualWebArena arXiv:2401.13649; UFO2
|
||||||
|
arXiv:2504.14603; τ-bench arXiv:2406.12045 + τ²-bench arXiv:2506.07982 (pass^k
|
||||||
|
estimator, code-verified); terminal-bench/Harbor arXiv:2601.11868; ScreenSpot
|
||||||
|
family (arXiv:2401.10935, 2410.23218, 2504.07981); Screen2AX arXiv:2507.16704;
|
||||||
|
HAL arXiv:2510.11977; "AI Agents That Matter" arXiv:2407.01502; computer-use
|
||||||
|
reliability arXiv:2604.17849. Chromium/macOS AX: chromium.googlesource.com
|
||||||
|
accessibility docs (overview, how-a11y-works, offscreen, performance),
|
||||||
|
ax_platform_node_mac.mm event map, Electron a11y docs, CDP Accessibility domain,
|
||||||
|
WebKit AXIsolatedTree, VoiceOver perf threads, Lighthouse DOM-size guidance.
|
||||||
|
Tooling: mutants.rs, nexte.st, insta.rs, schemars/jsonschema, gungraun, hyperfine
|
||||||
|
source, criterion FAQ, rustc-perf, ripgrep tests/, git t/README + chainlint,
|
||||||
|
cargo-test-support. CI/TCC: actions/runner-images issues #1567/#3286/#8162/#8214/
|
||||||
|
#9529, fopina/pyautogui-next#20, Apple PPPC guide, tccutil man page,
|
||||||
|
cirruslabs/macos-image-templates (disable-sip.pkr.hcl, update-tcc-database.sh,
|
||||||
|
monthly.yml), openai/tart discussion #1171, Veertu Anka docs, AWS EC2 Mac docs.
|
||||||
Loading…
Reference in a new issue