Bots click by number: every control on the screen is boxed and numbered, on every computer
A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates
from a picture, and the web tools' description told it they didn't work on its own computer.
- marks.rs: the picture a bot gets has every button, link, field and menu item boxed and
numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click /
typing / scroll / mouse_move take the number (ref) and land on the control's centre.
- Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's
computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's
box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets
no number (topmost window at its centre must be the app's; the Dock's invisible full-screen
window doesn't count). open_app waits until the app is really in front.
- No going round in circles: a step that changed nothing says so; the same step (clicks within
16 px) a third time in a turn comes with a warning, from the fourth time it isn't run.
- The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry
at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP,
renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look
(list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a
minute (files and logins stay); a new box is never made from an older image.
- Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the
per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt
says web work goes through browser_* by number, and never to read browser cookie or history
files.
Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new
image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed
into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on
TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28,
153 unit tests; Linux build + 157 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""The controls on the box's desktop, from its accessibility tree (AT-SPI), as JSON:
|
|
|
|
|
{"elements": [{"role", "name", "value", "x", "y", "w", "h", "active", "flags", "window"}]}
|
|
|
|
|
in screen pixels. Web pages are in it too (Chromium runs with renderer accessibility on).
|
|
|
|
|
AnyBot numbers them on the bot's picture; a click by number lands on the centre.
|
|
|
|
|
|
2026-11-05 17:03:59 +00:00
|
|
|
Adapted from XBot's box/box-a11y (which skips anything not "showing"; Chromium can say that of
|
|
|
|
|
its whole window). Usage: a11y [--limit N] [--budget SECONDS]
|
Bots click by number: every control on the screen is boxed and numbered, on every computer
A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates
from a picture, and the web tools' description told it they didn't work on its own computer.
- marks.rs: the picture a bot gets has every button, link, field and menu item boxed and
numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click /
typing / scroll / mouse_move take the number (ref) and land on the control's centre.
- Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's
computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's
box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets
no number (topmost window at its centre must be the app's; the Dock's invisible full-screen
window doesn't count). open_app waits until the app is really in front.
- No going round in circles: a step that changed nothing says so; the same step (clicks within
16 px) a third time in a turn comes with a warning, from the fourth time it isn't run.
- The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry
at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP,
renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look
(list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a
minute (files and logins stay); a new box is never made from an older image.
- Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the
per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt
says web work goes through browser_* by number, and never to read browser cookie or history
files.
Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new
image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed
into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on
TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28,
153 unit tests; Linux build + 157 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
|
|
|
Exit 2 when AT-SPI itself is unreachable.
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import warnings
|
|
|
|
|
|
|
|
|
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
|
|
|
|
|
|
|
|
# Clicked or typed into (they get a number).
|
|
|
|
|
ACTIVE = {
|
|
|
|
|
"push button", "toggle button", "radio button", "check box", "link", "menu item", "check menu item",
|
|
|
|
|
"radio menu item", "tab", "page tab", "list item", "tree item", "combo box", "spin button", "slider",
|
|
|
|
|
"entry", "password text", "menu",
|
|
|
|
|
}
|
|
|
|
|
# Listed when they carry words (headings, labels, cells...).
|
|
|
|
|
NAMED = {"heading", "label", "static", "text", "image", "table cell", "column header", "row header", "icon",
|
|
|
|
|
"tool tip", "alert", "dialog", "paragraph"}
|
|
|
|
|
# The desktop's own parts are visible in the picture; the list is for the windows worked in.
|
|
|
|
|
SKIP_APPS = {"fluxbox", "a11y", "python3", "xterm-wrapper"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
limit, budget = 400, 3.0
|
|
|
|
|
args = sys.argv[1:]
|
|
|
|
|
for i, a in enumerate(args):
|
|
|
|
|
if a == "--limit":
|
|
|
|
|
limit = int(args[i + 1])
|
|
|
|
|
if a == "--budget":
|
|
|
|
|
budget = float(args[i + 1])
|
|
|
|
|
if not os.environ.get("DBUS_SESSION_BUS_ADDRESS"):
|
|
|
|
|
try:
|
|
|
|
|
for line in open("/tmp/anybot-dbus.env", encoding="utf-8"):
|
|
|
|
|
k, _, v = line.strip().partition("=")
|
|
|
|
|
if k == "DBUS_SESSION_BUS_ADDRESS" and v:
|
|
|
|
|
os.environ[k] = v
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
os.environ.setdefault("DISPLAY", ":1")
|
|
|
|
|
try:
|
|
|
|
|
import gi
|
|
|
|
|
gi.require_version("Atspi", "2.0")
|
|
|
|
|
from gi.repository import Atspi
|
|
|
|
|
Atspi.init()
|
|
|
|
|
desktop = Atspi.get_desktop(0)
|
|
|
|
|
apps = desktop.get_child_count()
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
print(f"AT-SPI unreachable: {exc}", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
sw, sh = 0, 0
|
|
|
|
|
try:
|
|
|
|
|
geo = os.popen("xdotool getdisplaygeometry 2>/dev/null").read().split()
|
|
|
|
|
sw, sh = int(geo[0]), int(geo[1])
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
pass
|
|
|
|
|
deadline = time.monotonic() + budget
|
|
|
|
|
out = []
|
|
|
|
|
|
|
|
|
|
def state(node):
|
|
|
|
|
try:
|
|
|
|
|
return node.get_state_set()
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def extents(node):
|
|
|
|
|
try:
|
|
|
|
|
r = node.get_component_iface().get_extents(Atspi.CoordType.SCREEN)
|
|
|
|
|
return int(r.x), int(r.y), int(r.width), int(r.height)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def onscreen(b):
|
|
|
|
|
x, y, w, h = b
|
|
|
|
|
if w <= 0 or h <= 0 or x + w <= 0 or y + h <= 0:
|
|
|
|
|
return False
|
|
|
|
|
return not (sw and x >= sw) and not (sh and y >= sh)
|
|
|
|
|
|
|
|
|
|
def walk(node, depth, window):
|
|
|
|
|
if time.monotonic() > deadline or len(out) >= limit or depth > 45:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
role = node.get_role_name()
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
return
|
|
|
|
|
st = state(node)
|
2026-11-05 17:03:59 +00:00
|
|
|
showing = bool(st and st.contains(Atspi.StateType.SHOWING))
|
|
|
|
|
visible = bool(st and st.contains(Atspi.StateType.VISIBLE))
|
|
|
|
|
# A page that isn't showing is a tab in the background: none of it is on the screen.
|
|
|
|
|
if role in ("document web", "document frame") and not showing:
|
Bots click by number: every control on the screen is boxed and numbered, on every computer
A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates
from a picture, and the web tools' description told it they didn't work on its own computer.
- marks.rs: the picture a bot gets has every button, link, field and menu item boxed and
numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click /
typing / scroll / mouse_move take the number (ref) and land on the control's centre.
- Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's
computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's
box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets
no number (topmost window at its centre must be the app's; the Dock's invisible full-screen
window doesn't count). open_app waits until the app is really in front.
- No going round in circles: a step that changed nothing says so; the same step (clicks within
16 px) a third time in a turn comes with a warning, from the fourth time it isn't run.
- The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry
at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP,
renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look
(list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a
minute (files and logins stay); a new box is never made from an older image.
- Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the
per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt
says web work goes through browser_* by number, and never to read browser cookie or history
files.
Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new
image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed
into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on
TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28,
153 unit tests; Linux build + 157 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
|
|
|
return
|
2026-11-05 17:03:59 +00:00
|
|
|
# Otherwise "showing" isn't trusted: Chromium can call its whole window not showing (it
|
|
|
|
|
# took itself for minimized under this window manager) while its page is on the screen.
|
|
|
|
|
# Visible with a place on the screen is what counts; containers are walked through.
|
Bots click by number: every control on the screen is boxed and numbered, on every computer
A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates
from a picture, and the web tools' description told it they didn't work on its own computer.
- marks.rs: the picture a bot gets has every button, link, field and menu item boxed and
numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click /
typing / scroll / mouse_move take the number (ref) and land on the control's centre.
- Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's
computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's
box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets
no number (topmost window at its centre must be the app's; the Dock's invisible full-screen
window doesn't count). open_app waits until the app is really in front.
- No going round in circles: a step that changed nothing says so; the same step (clicks within
16 px) a third time in a turn comes with a warning, from the fourth time it isn't run.
- The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry
at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP,
renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look
(list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a
minute (files and logins stay); a new box is never made from an older image.
- Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the
per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt
says web work goes through browser_* by number, and never to read browser cookie or history
files.
Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new
image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed
into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on
TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28,
153 unit tests; Linux build + 157 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
|
|
|
box = extents(node)
|
2026-11-05 17:03:59 +00:00
|
|
|
if depth > 1 and (not visible or box is None or not onscreen(box)):
|
Bots click by number: every control on the screen is boxed and numbered, on every computer
A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates
from a picture, and the web tools' description told it they didn't work on its own computer.
- marks.rs: the picture a bot gets has every button, link, field and menu item boxed and
numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click /
typing / scroll / mouse_move take the number (ref) and land on the control's centre.
- Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's
computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's
box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets
no number (topmost window at its centre must be the app's; the Dock's invisible full-screen
window doesn't count). open_app waits until the app is really in front.
- No going round in circles: a step that changed nothing says so; the same step (clicks within
16 px) a third time in a turn comes with a warning, from the fourth time it isn't run.
- The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry
at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP,
renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look
(list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a
minute (files and logins stay); a new box is never made from an older image.
- Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the
per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt
says web work goes through browser_* by number, and never to read browser cookie or history
files.
Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new
image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed
into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on
TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28,
153 unit tests; Linux build + 157 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
name = (node.get_name() or "").strip()
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
name = ""
|
|
|
|
|
if role in ("frame", "dialog", "window") and name:
|
|
|
|
|
window = name
|
|
|
|
|
editable = bool(st and st.contains(Atspi.StateType.EDITABLE))
|
|
|
|
|
value = ""
|
|
|
|
|
if (role in ("entry", "password text", "text", "spin button", "slider") or editable) and role != "password text":
|
|
|
|
|
try:
|
|
|
|
|
t = node.get_text_iface()
|
|
|
|
|
if t is not None:
|
|
|
|
|
value = (t.get_text(0, min(t.get_character_count(), 200)) or "").strip()
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
pass
|
|
|
|
|
active = role in ACTIVE or (editable and role in ("text", "paragraph", "section", "document web"))
|
|
|
|
|
if depth > 1 and box is not None and (active or (role in NAMED and (name or value))):
|
|
|
|
|
flags = []
|
|
|
|
|
if st is not None:
|
|
|
|
|
for s, word in ((Atspi.StateType.FOCUSED, "focused"), (Atspi.StateType.CHECKED, "checked"),
|
|
|
|
|
(Atspi.StateType.SELECTED, "selected")):
|
|
|
|
|
if st.contains(s):
|
|
|
|
|
flags.append(word)
|
|
|
|
|
if not st.contains(Atspi.StateType.ENABLED):
|
|
|
|
|
flags.append("disabled")
|
|
|
|
|
x, y, w, h = box
|
|
|
|
|
out.append({"role": role, "name": name[:120], "value": value[:200], "x": x, "y": y, "w": w, "h": h,
|
|
|
|
|
"active": active, "flags": flags, "window": window})
|
|
|
|
|
try:
|
|
|
|
|
n = node.get_child_count()
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
return
|
|
|
|
|
for i in range(min(n, 2000)):
|
|
|
|
|
try:
|
|
|
|
|
c = node.get_child_at_index(i)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
continue
|
|
|
|
|
if c is not None:
|
|
|
|
|
walk(c, depth + 1, window)
|
|
|
|
|
|
|
|
|
|
for i in range(apps):
|
|
|
|
|
try:
|
|
|
|
|
app = desktop.get_child_at_index(i)
|
|
|
|
|
name = app.get_name() or ""
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
continue
|
|
|
|
|
if name in SKIP_APPS:
|
|
|
|
|
continue
|
|
|
|
|
walk(app, 0, name)
|
|
|
|
|
json.dump({"elements": out, "truncated": len(out) >= limit or time.monotonic() > deadline}, sys.stdout, ensure_ascii=False)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|