Skip to content

Wire Protocol

From 0.3, AmiPilotServer can carry its whole command set over a byte stream as well as ARexx — same verbs, same arguments, same @name manifest locators — so a host machine can drive Amiga GUIs. Two transports carry it: serial.device (0.3) and, from 0.4, TCP via bsdsocket.library. This page is the practical guide; the formal contract is server/WIRE.md in the repository.

Starting it

> Run AmiPilotServer SERIAL
> Run AmiPilotServer TCP TCPPORT=6800
> Run AmiPilotServer SERIAL TCP TCPPORT=6800
> Run AmiPilotServer SERIAL FSROOT=Work:amipilot-staging

SERIAL and TCP are independent — enable either or both. Options (ReadArgs template SERIAL/S,SERDEVICE/K,SERUNIT/K/N,BAUD/K/N,TCP/S, TCPPORT/K/N,FSROOT/K/M,TCPALLOW/K,TCPPASSWORD/K):

Argument Default Meaning
SERIAL off Enable the serial.device transport (the ARexx port is always on)
SERDEVICE serial.device Device driver — name a multi-port card's driver here
SERUNIT 0 Device unit
BAUD 19200 Line rate; both ends must agree. 19200 is a safe floor for a plain 68000; faster CPUs handle more
TCP off Enable the TCP transport (bsdsocket.library)
TCPPORT (required with TCP) Listen port
FSROOT off (file API disabled) Grants a directory to the file API (see File API below). Repeatable — FSROOT=Work:a FSROOT=Work:b grants both. The directory must already exist; it's locked once at startup and held for the server's whole run.
TCPALLOW off (every source accepted) A source-IP/CIDR allowlist for TCP, comma-separated for multiple entries in this one value (TCPALLOW=192.168.1.0/24,10.0.0.5) — not repeatable like FSROOT, see Securing TCP for why.
TCPPASSWORD amipilot (public default) The password the AUTH verb checks, TCP only. See Securing TCP — this is not real security on its own.

The serial line is 8N1 with xon/xoff disabled (responses are binary-safe; software flow control would corrupt them). TCP is listen-mode only today — the server binds and listens, the host connects in, and only one connection is treated as active at a time (a new one replaces the old). If a requested transport can't be opened, the server exits with an error rather than silently running without it.

Under an emulator you usually don't need a real cable or a network bridge for the serial transport: Copperline's [serial] mode = "tcp" (or --serial tcp) bridges the guest's serial port to a host TCP socket (default 127.0.0.1:1234), which is exactly what the host test harness uses. Reaching the TCP transport instead needs the guest's own network reachable from the host — Copperline's [hostsocket] board bridged to a real or virtual adapter, or a real Amiga with TCP/IP on the LAN.

Connecting from a real serial port (host side)

The Copperline-bridge path above is convenience, not the only option — host/amipilot can also connect directly over a real (or virtual) serial port on the host machine, no TCP bridge involved at all. This is what you need for real Amiga hardware over a real cable, or a Copperline config that itself uses a real serial device instead of [serial] mode = "tcp". Requires the optional pyserial dependency (pip install amipilot[serial] once published on PyPI, or pip install -e 'host/[serial]' from a checkout) — everything else about amipilot works with no pyserial installed at all.

from amipilot import Amipilot

client = Amipilot.connect_serial("/dev/tty.usbserial-1420", 19200)

device is OS-specific — /dev/tty.usbserial-* on macOS, /dev/ttyUSB0//dev/ttyS0 on Linux, COM3 on Windows. baud must match whatever AmiPilotServer SERIAL was actually started with on the Amiga side — BAUD in the table above, default 19200 on both ends. This is genuinely the same wire, the same verbs, the same RC semantics as the TCP path; only the transport differs.

Under pytest, the amipilot fixture (see Building and Testing) takes the same config via --amipilot-serial-device/--amipilot-serial-baud (or the amipilot_serial_device ini setting) instead of --amipilot-config — whichever one is set is what the fixture connects with; setting both is a configuration mistake and fails immediately rather than silently picking one. Unlike the Copperline path, this mode doesn't boot or manage any process itself — whatever's on the other end of the cable must already be running AmiPilotServer SERIAL before the test session starts.

Talking to it

Send one command per line (LF-terminated; CRLF is fine). Every command gets exactly one response:

RC <code> <byte-count>

followed by exactly <byte-count> bytes of payload. The codes are the same as the ARexx port's RC values: 0 OK, 5 warning (nothing matched), 10 error (bad command), 15 timeout (an awaited condition or payload — WAITFOR, CLICK ... EXPECT=, FSPUT — never arrived within TIMEOUT=), 20 failure (the action didn't deliver). Parse payloads by the byte count, never by scanning for delimiters — a TREE payload contains newlines.

A quick manual session over the Copperline bridge:

$ nc 127.0.0.1 1234
VERSION
RC 0 217
AMIPILOT 1.0 PROTOCOL 1
STABLE VERSION TREE CLICK TYPE GETTEXT MANIFEST LAUNCH FSLIST FSSTAT FSMKDIR FSDELETE FSGET FSPUT WBLAUNCH MENU MENUPICK DRAG WINDOWMOVE WINDOWSIZE WAITFOR SCREENS SCREENSHOT AUTH MUIREXX WHERE PICK QUIT
GETTEXT GadTools 2
RC 0 10
aminet.net

VERSION is the handshake: a client should send it first and check the PROTOCOL number before anything else. It works over ARexx too, for on-Amiga feature tests.

The host client

The repository's host/ Python package speaks this protocol (amipilot.wire.WireClient) — connect, handshake, command:

from amipilot.wire import WireClient

client = WireClient.connect("127.0.0.1", 1234)
info = client.handshake()          # checks PROTOCOL 1
client.command("TYPE GadTools 2 aminet.net")
reply = client.command("GETTEXT GadTools 2")
assert reply.text == "aminet.net"

The commands themselves are documented in the ARexx Reference — the wire adds no verbs of its own beyond VERSION.

The object API and amipilot dump

Most scripts won't touch WireClient directly — amipilot.client wraps it in a Pythonic API that raises exceptions on non-OK replies instead of requiring you to check RC codes by hand:

from amipilot import Amipilot, NotFound

with Amipilot.connect("127.0.0.1", 1234) as client:
    client.type("GadTools", 2, "aminet.net")
    window = client.tree("GadTools")           # -> Window, gadgets parsed
    try:
        client.click("GadTools", 99)
    except NotFound:
        ...

amipilot dump "<window>" (installed via pip install -e host/) is the host-side half of the inspector: connects, fetches a window's tree, and prints it — either the same text AmiInspect prints (--format text, the default) or quirk-profile-ready # name = <id> suggestions (--format python) to copy into a test.

LAUNCH

From 0.4, a connected session can start its own test subject instead of requiring it pre-staged (e.g. via S:User-Startup):

from amipilot import Amipilot, NotFound

with Amipilot.connect("127.0.0.1", 1234) as client:
    try:
        client.tree("GadTools")
        raise AssertionError("should not be running yet")
    except NotFound:
        pass

    client.launch("SRC:build/fixtures/GTApp", stack=8192)

    # LAUNCH is asynchronous -- poll for the effect, don't assume timing.
    import time
    for _ in range(20):
        try:
            window = client.tree("GadTools")
            break
        except NotFound:
            time.sleep(0.5)

stack sets the new process's stack size in bytes (AmigaDOS's own default is 4000 if omitted — most Intuition/ReAction GUI apps need more). On the wire this is LAUNCH [STACK=<n>] <command-line...>; the command line is Shell syntax, sent verbatim, and must not itself contain a line terminator.

Read this before relying on RC 0: the launch is asynchronous (SystemTagList(), SYS_Asynch) so AmiPilotServer keeps servicing the connection while the launched process runs — necessary for anything that doesn't exit on its own, like a GUI app. That means RC 0 only confirms the new AmigaDOS process could be created (no memory exhaustion, a free process slot) — not that the command was found or that it ran successfully; the shell resolves the command name after LAUNCH's own reply has already gone out, and there's no output capture yet to see that failure. Always assert on the expected effect (a window appearing, as above) rather than trusting the RC alone. A proc-wait verb for real exit-code retrieval is planned but not built yet — see server/README.md.

WBLAUNCH

From 1.0, a connected session can also start a target the way a user actually would — double-clicking its icon — instead of launch()'s Shell-style start:

from amipilot import Amipilot, CommandError

with Amipilot.connect("127.0.0.1", 1234) as client:
    client.wb_launch("SRC:build/fixtures/WBApp")

    client.wb_launch(
        "SRC:build/fixtures/WBApp",
        tooltypes={"PORT": "7777"},
        args=["Work:data/one.txt"],
    )

    try:
        client.wb_launch("SRC:build/fixtures/NoSuchApp")
    except CommandError:
        pass  # RC 10, bad icon path

This is a genuinely different mechanism from launch(), not a cosmetic variant: it hand-builds a real WBStartup/WBArg message and sends it to a real non-CLI CreateNewProc() process, the exact handshake a Workbench-aware program's own startup code (libnix's _WBenchMsg, or any compiler's equivalent) waits for — so tooltype parsing and WBStartup argument handling are code paths the launched program actually exercises, which launch()'s Shell start never touches at all.

icon_path is the tool or project icon's path without the ".info" suffix (icon.library's own convention) — wb_launch() reads the real icon, and for a project icon resolves its own default tool automatically (the project itself becomes a second argument, matching what double-clicking a project icon actually does).

tooltypes, if given, overrides (or adds, if not already present) just the named tooltypes for this one launch — everything else on the real icon is left alone. This needs a real, if surprising, mechanism: there is no in-memory channel to hand a tooltype override to an off-the-shelf binary, since the launched program discovers its own tooltypes by reading its own icon file back off disk. wb_launch() instead writes a scratch copy of the icon (merged tooltypes) to T: — never touching the app's real .info — and points the launch at that instead; it's cleaned up automatically once the launched process exits.

args, if given, are additional fully-qualified paths passed as further project-file arguments (the "multiple files selected, one is a tool" case double-clicking with extended-select icons produces).

Same async honesty as launch(): this raises CommandError as soon as the icon/path itself is rejected (bad icon, unsupported icon type, an unlockable ARG=/TOOLTYPE=-scratch path), ActionFailed if CreateNewProc() itself fails (out of memory, no process slot) — but neither confirms the launched program actually finished starting; assert on its expected effect, same as launch().

Unlike FSPUT, WBLAUNCH carries no binary wire payload, so — unlike that verb — it's fully answerable over ARexx too; there's no wire-only asymmetry here.

SCREENSHOT

From 1.0, a connected session can capture a screen's raw pixels for human viewing/debugging/documentation — [GitHub issue

41](https://github.com/sidick/amipilot/issues/41). This is

inspector tooling, not a locator mechanism: click()/type()/ get_text() stay structural/semantic, this doesn't change that.

from amipilot import Amipilot, NotFound

with Amipilot.connect("127.0.0.1", 1234) as client:
    shot = client.screenshot()                     # frontmost screen
    ilbm_path, png_path = shot.save("/tmp/capture")  # writes both

    window_shot = client.screenshot(window="GadTools")
    print(window_shot.crop)  # (x, y, w, h) within its owning screen

    try:
        client.screenshot(screen="NoSuchScreen")
    except NotFound:
        pass

Same "wire stays simple, host does the rendering" split this project already uses for TREE/amipilot dump (no JSON on the wire, ever): the Amiga side sends raw, uncompressed pixel bytes plus a small header — see server/include/screenshot.h's own header comment for the exact byte layout. All image-format and colour-space work happens host-side, amipilot.screenshot, stdlib only (zlib, no Pillow):

  • shot.to_ilbm() — a real IFF FORM ILBM (BMHD/CMAP/CAMG/ BODY). For a planar capture, a near-direct copy since that's ILBM's own native shape; for a Picasso96 CLUT (palette) capture, a re-plane into a standard 8-bitplane/256-colour ILBM. Raises for a P96 truecolor/hicolor capture — ILBM is fundamentally planar and has no standard way to carry 15-32 bits of true colour; use to_png() or to_rgb888() instead. Viewable with Multiview or any Amiga paint program, and preserves the exact view mode the capture came from.
  • shot.to_png() — indexed-colour (palette) for anything with a palette (planar or P96 CLUT), true-colour (24-bit RGB, no palette) for a P96 truecolor/hicolor capture. Easier for modern tooling (browsers, CI artifact viewers, image diffing) to work with than ILBM, at the cost of real per-pixel unpacking/decoding work host-side — cheap there, which is the whole point of doing it host-side rather than on the 68000.
  • shot.save(path) writes both <path>.iff and <path>.png in one call (raises from to_ilbm() for a P96 truecolor/hicolor capture — call to_png() directly for those).

Picasso96/RTG support is real, optional, and never requiredGitHub issue #44. A Picasso96/CGX screen's BitMap still has Planes[]/BytesPerRow/ Depth fields, but they're not real chip-mem bitplanes; walking them risks reading garbage pointers, the same risk class as the WBPattern/GTYP_CUSTOMGADGET hang found and fixed in issue #36. An earlier attempt to guard against this (BitMap->Flags & BMF_STANDARD) turned out to be simply wrong, not just imperfect — live testing against a real, completely ordinary Copperline Workbench screen showed that flag is never set on Intuition's own screen bitmaps regardless of whether they're planar. The REAL, verified check, from Picasso96API.library's own published SDK interface data (not redistributed here — see server/include/p96_compat.h's own header comment): p96GetBitMapAttr(bm, P96BMA_ISP96), safe to call unlocked on any bitmap. The library is opened OPTIONALLY at server startup — absent, or the target screen isn't genuinely P96-backed, and the classic planar capture runs completely unchanged. When it IS a genuine P96 screen, its native pixel format (CLUT, or one of several truecolor/hicolor RGB byte orders — YUV formats aren't supported, the SDK's own docs mark them hardware-only) is sent raw over the wire and decoded host-side, including the documented PC-suffix byte-swap pitfall (16-bit PC formats are little-endian, non-PC big-endian — never assumed uniform). The P96-active capture path has been verified against real Picasso96 3.6 + uaegfx under Amiberry (both a CLUT and a truecolor screen, each decoding back correctly), and — since Copperline 0.15 added its own RTG support — is now a real, automated make test-target regression check too (run_screenshot_p96_check), skip-safe on machines without an RTG board configured; see server/README.md's own SCREENSHOT section for the fuller story.

With both screen/window omitted, screenshot() captures the frontmost/default public screen; screen alone selects one by DefaultTitle substring; window (optionally narrowed by screen, resolved exactly like click()'s own window pattern) captures that window's OWNING SCREEN in full, with the window's rectangle on shot.crop — there is no separate per-window pixel buffer to grab on classic Intuition (overlapping windows share one screen bitmap), so that's what a "window screenshot" actually is on any windowing system.

Palette precision is 4-bit-per-gun (12-bit RGB, expanded to 8-bit-per- channel for the wire/PNG/ILBM) — the real, V33-era-safe way to read a ColorMap (GetRGB4(), not ColorMap->ColorTable directly, which is an opaque APTR in the real struct, not the plain array it might look like). A real AGA 8-bit-per-gun capture would need GetRGB32() (V39+, above this project's V37 floor) — not attempted here.

Serial transfer time, no compression on the wire (8N1, byte rate ≈ baud/10):

Capture (typical example) Size 9600 baud 19200 baud (default) 38400 baud 57600 baud
320x256, 16 colours (4 planes) ~41 KB ~43 s ~21 s ~11 s ~7 s
320x256, 32 colours (5 planes) ~50 KB ~53 s ~27 s ~13 s ~9 s
640x512 interlaced, 256 colours (8 planes, AGA) ~320 KB ~5.7 min ~2.9 min ~1.4 min ~57 s
Server's own size cap (AMIP_SCREENSHOT_MAX_BYTES) 512 KB ~9.1 min ~4.6 min ~2.3 min ~1.5 min

Serial is genuinely slow for anything beyond a small/low-colour screen — prefer TCP for SCREENSHOT, which isn't baud-limited at all. These numbers exist so a serial-only setup (real hardware without a network card, or a Copperline config without --serial tcp) knows what to expect rather than guessing why a capture appears to hang.

Since a capture can genuinely take anywhere from seconds to several minutes with the client otherwise giving zero feedback, screenshot() (and fs_get(), below) accept an optional on_progress(bytes_so_far, total_bytes) callback, invoked as the payload streams in — amipilot.stderr_progress() is a ready-made callback for the common "print a progress line" case:

from amipilot import stderr_progress

shot = client.screenshot(on_progress=stderr_progress("screenshot"))
# stderr: "screenshot: 51200/524288 bytes (9%)" ... updated in place ...

Pass your own callback instead for a GUI progress bar or structured logging; omit it entirely for today's exact zero-overhead behavior.

File API

From 0.4, a connected session can list/stat/create/delete files and read one back, scoped to directories explicitly granted at startup via one or more FSROOT options (above) — nothing is granted implicitly, and there is no way to grant a root once the server is running:

from amipilot import Amipilot, CommandError, NotFound

with Amipilot.connect("127.0.0.1", 1234) as client:
    for entry in client.fs_list("Work:amipilot-staging"):
        print(entry.name, "dir" if entry.is_dir else "file", entry.size)

    data = client.fs_get("Work:amipilot-staging/results.log")

    client.fs_mkdir("Work:amipilot-staging/run3")
    client.fs_delete("Work:amipilot-staging/run3")

    try:
        client.fs_list("SYS:")          # outside every granted root
    except CommandError:
        pass                             # RC 10, expected

fs_stat() returns the same FsEntry shape as one fs_list() row — name, is_dir, size, prot (the classic four-character rwed string, inverted-bit gotcha already handled), date, comment — for a single path without listing a whole directory.

Containment is checked by lock identity, not string matchingLock()/ParentDir()/SameLock(), walking up from the target to see whether it lands on a granted root. Amiga assigns mean two different path strings can name the same or a nested location, so a prefix check would be both wrong (missing genuine matches) and unsafe (missing genuine escapes). A path outside every granted root raises CommandError (RC 10) naming what is granted; a genuinely missing path raises NotFound (RC 5).

fs_get() returns the file's exact bytes (Reply.payload, not the NUL-terminated .text other verbs use — a file's contents may legitimately contain embedded NULs, and the wire's length-prefixed framing carries them intact). The server caps this at its own small internal buffer (server/src/fs.c's AMIP_FS_BUF_SIZE, currently 16KB) and raises ActionFailed (RC 20) for anything larger — this is a test-staging channel for small fixtures, config, and log files, not a general file transfer mechanism.

fs_put(path, data, *, timeout=30.0) writes a file host-to-Amiga, creating it if it doesn't exist and overwriting it if it does:

    client.fs_put("Work:amipilot-staging/results.log", b"hello\x00world")
    assert client.fs_get("Work:amipilot-staging/results.log") == b"hello\x00world"

This is the wire's first request to carry a raw binary body: on the wire it's FSPUT <path> <byte-count> [TIMEOUT=<n>], the request-line declaring how many raw bytes immediately follow it (no delimiter, no escaping — the same length-prefixed idea the response side already uses, just inverted). fs_put() is wire-only — there is no ARexx equivalent at all, because RexxMsg/ARG0() only ever carries string arguments, so there's genuinely no channel to receive a binary payload over that transport. Same allowlist/containment rules and AMIP_FS_BUF_SIZE cap as fs_get(). timeout (default 30s, longer than most other waits here — a real multi-KB payload over a slow serial link needs it) bounds how long the server waits for the declared payload to fully arrive after the request line; if it doesn't, fs_put() raises Timeout (RC 15), distinct from ActionFailed (RC 20) on a write that fails after a complete payload arrived (e.g. disk full).

From 0.4, a connected session can read a window's menu strip and select an item by its keyboard shortcut:

from amipilot import ActionFailed, Amipilot

with Amipilot.connect("127.0.0.1", 1234) as client:
    strip = client.menu("GadTools")
    project = strip.menus[0]
    print(project.title, [i.text for i in project.items])

    about = strip.find("About")             # look up by label instead
    client.menu_pick("GadTools", about.menu_num, about.item_num)

    try:
        client.menu_pick("GadTools", 0, 2)   # a disabled item, say
    except ActionFailed:
        pass                                  # RC 20, expected

menu() returns a MenuStrip: one Menu per pulldown title, each with a list of MenuItems and — one level deep, matching classic Intuition's own limit — their submenu items. Each MenuItem carries text, checkit/checked (for a checkmark toggle item), enabled, shortcut (the single keyboard character, or None), and menu_num/item_num/sub_num — the same 0-based chain positions Intuition itself reports via IDCMP_MENUPICK's MENUNUM()/ ITEMNUM()/SUBNUM() macros. MenuStrip.find("some label") looks up an item by its text instead of hand-counting positions.

menu_pick() chooses the pick mechanism automatically, per item. For an item with a keyboard shortcut, it activates the window, then strikes the shortcut character with the right-Amiga qualifier held — the same input.device path a human pressing Right-Amiga+key produces; Intuition resolves that combination against the window's own live menu strip, so RC 0 is real evidence the pick reached the app through the genuine menu-shortcut path. For an item with no shortcut, it instead drives a genuine synthesized RMB-down, moves onto the target menu's own title, moves onto the item (auto-opening its one-level submenu if it has one, purely as Intuition's own reaction to pointer position), moves into the submenu if picking a sub-item, then releases (RMB-up) over the target — exactly what Intuition turns into a real IDCMP_MENUPICK, the same "genuinely synthesized input, not a shortcut" path click()/drag() already use. Every box this needs is resolved live off the window's own current menu structure immediately before each move, never cached — menu layout depends on the user's own screen/menu font. Raises ActionFailed (RC 20) if the item is disabled, or — pointer path only — if the window traps the right mouse button (WFLG_RMBTRAP, a real Intuition window flag some apps set): no synthesized RMB-down can ever open that window's menu strip, a permanent limit for that window rather than a transient failure. See server/README.md for the full mechanism, including the (largely undocumented-by-Commodore) submenu geometry this was measured against a real screenshot to confirm.

Screens

From 0.4, a connected session can see every open screen and target a specific one when a window-title pattern is ambiguous:

from amipilot import Amipilot

with Amipilot.connect("127.0.0.1", 1234) as client:
    for screen in client.screens():
        print(screen.title, screen.width, screen.height, screen.frontmost)

    # Two windows both matching "GadTools" on different screens --
    # narrow the search to a specific one:
    window = client.tree("GadTools", screen="Second Screen")

    # Waiting for an asynchronously launched program's own screen to
    # appear, instead of a fixed sleep:
    client.launch("SRC:build/fixtures/SecondScreenApp")
    screen = client.wait_for_screen("Second Screen", timeout=20.0)
    window = client.wait_for_window("GadTools", screen=screen.title)

screens() returns every screen's title (its DefaultTitle — the app's own stable name for the screen, set once at open time — not the live title-bar text, which tracks whichever window is currently active on that screen via that window's own WA_ScreenTitle and so isn't a safe identity to match against), position, size, and whether it's frontmost.

tree(), click(), type(), get_text(), menu(), and menu_pick() all accept an optional screen= keyword that narrows the window search to screens whose title contains it — this is purely for disambiguating two same-titled windows on different screens; window-finding already searched every screen before this existed, it just couldn't tell two matches apart. TREE/MENU's own payload gains a screen="..." field on the window line for the same reason (Window.screen/MenuStrip.screen on the host side), so you can confirm which screen a match actually landed on.

Acting on a window brings its screen forward automatically. click(), type(), and menu_pick() already call ScreenToFront() on the target window's own screen before injecting anything — this predates SCREEN=/SCREENS, and there's no separate "bring this screen to front" verb because none is needed. Read-only calls (tree(), get_text(), menu()) deliberately leave screen order alone.

wait_for_window()/wait_for_screen() poll tree()/screens() until the target appears or timeout elapses (TimeoutError on expiry) — a host-side poll loop, useful when there's no accompanying action to anchor a wait to (e.g. waiting for an externally-launched process's window). For anything tied to an action you just took, see WAITFOR below and click()'s own expect= parameter instead — these poll server-side, in one wire round trip, not from the host.

WAITFOR

From 0.5, a connected session can wait for a condition to become true without a host-side poll loop at all: WAITFOR [SCREEN=<substring>] <condition> [TIMEOUT=<n>] blocks entirely server-side (one wire round trip) until condition becomes true or TIMEOUT= (default 10s) elapses.

from amipilot import Amipilot, Timeout

with Amipilot.connect("127.0.0.1", 1234) as client:
    client.wait_for("window:Settings")       # a matching window appears
    client.wait_for("nowindow:Settings")     # no window matches (fresh re-search each poll)

    try:
        client.wait_for("window:NeverOpens", timeout=2.0)
    except Timeout:
        pass  # RC 15

click() also takes its own expect= parameter ("window:<pattern>" or "nowindow", plus timeout=) to compose an action atomically with a wait for its effect — expect="nowindow" there is checked by POINTER IDENTITY against the exact window click() itself just acted on, not a fresh pattern re-search, which is the more precise "did the window I just clicked actually close" guarantee a standalone wait_for("nowindow:...") can't give (a different, same-titled window could satisfy a pattern re-search without the original ever closing). wait_for_text()/wait_for_text_by_role()/wait_for_text_by_name() cover waiting on a GADGET's text/state instead of a window — a different condition shape (needs a gadget locator) wait_for()'s plain condition string doesn't carry.

wait_for("requester") waits for a genuine Intuition Requester to appear (GitHub issue #52's original "cheap first step"). No pattern argument, and window-attached Requesters only — a system-wide one with no owning window (a disk-swap prompt, say) is a stated, harder problem left open. See userdocs/ARexx-Reference.md's own WAITFOR section for the full detection story, including two genuine surprises found building it: neither window->FirstRequest nor the GTYP_REQGADGET bit BuildSysRequest()'s own 1990s autodoc documents turned out to be set by AutoRequest()/BuildSysRequest()/ EasyRequest() on this project's target OS/ROM when given a real owning window — detection instead relies on the confirmed-live fact that these calls open a genuinely separate window sharing their owner's exact title text.

Once detected, a window-owned requester's own gadgets need no new client method at all: since it's a genuinely separate window sharing its owner's exact title, client.click(window_pattern, gadget_id=1) (the RKRM-documented, fixed GadgetID for the positive/"Yes" choice — 0 for negative/"No") already reaches it through the ordinary click() path. Confirmed live in tests/copperline/requester-test.py.

System-wide requesters (no owning window, e.g. a real disk-swap prompt via BuildSysRequest(NULL, ...)) are detectable AND click-able too now: confirmed live to produce a window titled EXACTLY "System Request" (Intuition's own documented default), which wait_for("requester") now matches, and which client.click("System Request", gadget_id=1) reaches through the exact same mechanism as the window-owned case.

get_text() cannot read a requester's own body text -- confirmed as a permanent limit (2026-08-09): struct Requester->ReqText would be the obvious field, but a live dump of every open window's FirstRequest while a real AutoRequest() was up showed it NULL on both the owning window and the requester's own separate window -- no struct Requester exists anywhere reachable here for this OS/ROM's AutoRequest(), so there is no field for get_text() to query.

This IS a genuine server-side blocking wait, not a host-side workaround: AmiPilotServer's dispatch is single-threaded, so while one WAITFOR/CLICK ... EXPECT= call is polling server-side, no other request on that connection is serviced until it resolves — by design, since TCP already only serves one active client connection at a time (server/README.md's own TCP section). Keep TIMEOUT= values reasonable for this reason.

Window move and resize

From phase 1.0, a connected session can move or resize a whole window via genuine input-device drags — the same synthesized press/move/ release primitive drag() already uses for gadgets, just anchored on the window's own title bar or sizing gadget instead of a gadget's:

from amipilot import Amipilot, ActionFailed

with Amipilot.connect("127.0.0.1", 1234) as client:
    before = client.tree("GadTools")
    print(before.left, before.top, before.width, before.height)

    client.window_move("GadTools", 30, 20)      # relative offset
    client.window_resize("GadTools", 400, 300)  # absolute target size

    after = client.tree("GadTools")
    print(after.left, after.top, after.width, after.height)

    try:
        client.window_resize("SomeFixedSizeWindow", 800, 600)
    except ActionFailed:
        pass  # RC 20 -- window has no sizing gadget (WFLG_SIZEGADGET unset)

window_move(pattern, dx, dy) sends WINDOWMOVE [SCREEN=<s>] <pattern> <dx> <dy>: a real drag of the window's title bar, anchored at its horizontal center vertically centered in the window's own BorderTop strip — a documented, honest heuristic (not an attempt to locate the close/depth/zoom system gadgets' exact pixel extents and dodge them precisely) that clears them for any window wider than roughly 120px. Raises ActionFailed if the window has no drag bar at all (WFLG_DRAGBAR unset).

window_resize(pattern, width, height) sends WINDOWSIZE [SCREEN=<s>] <pattern> <width> <height>: a real drag of the window's sizing gadget from its current bottom-right corner to wherever that corner needs to land to reach the given ABSOLUTE target size. It does not pre-check width/height against the window's own min_width/min_height/max_width/max_height — Intuition clamps the drag exactly as it would a genuine user drag, so confirm the actual resulting size with a follow-up tree() call rather than assuming the exact target was reached, the same "verify the real outcome" precedent drag() already sets. Raises ActionFailed if the window has no sizing gadget at all (WFLG_SIZEGADGET unset).

Both take an optional screen= keyword, same as tree()/click(), and both bring the window/screen forward first, same as every other action call. Neither has a manifest (@name) form — this acts on a whole window, the same scope tree()/menu() already have, neither of which takes one either. There's no separate "get window position/ size" call: tree()'s own result already carries left/top/ width/height, so query before or after a move/resize with a regular tree() call.

PICK

The platform's first genuine interactive element-picker equivalent (issue #65): point at a gadget, get back its exact locator, no batch tree() dump required.

from amipilot import Amipilot, NotFound

with Amipilot.connect("127.0.0.1", 1234) as client:
    result = client.pick()                       # frontmost screen
    print(result.title, result.gadgets)           # gadgets: 0 or 1 entry

    scoped = client.pick(screen="Second Screen")

    try:
        client.pick(screen="NoSuchScreen")
    except NotFound:
        pass

pick() hit-tests the LIVE global pointer position against screen's windows (the frontmost screen if omitted, same convention screenshot()'s own screen parameter uses) and returns the Window containing it. result.gadgets has at most one entry — the gadget under the pointer, if any; an empty list means the pointer is over bare window background, not an error (this can still include a real system gadget, e.g. gadget_id == 0 with class_name == "gadgetclass", for the drag bar/close/depth/size decorations — tree() already reports those the same way). Raises NotFound if no window on the target screen contains the pointer at all, including when screen itself doesn't match any open screen.

This is a single point-in-time snapshot, not a live subscription — call it repeatedly (e.g. in a poll loop) for a "hover and watch it update" experience. AmiInspect PICK (see AmiInspect Reference) is the standing-at-the-machine equivalent, looping locally with no host connection at all.

Built on intuition-model's own coordinate-to-gadget hit test over an already-walked screen model (AmipHitTest()), reusing the same role/label classification TREE/AmiInspect already do — not a new Intuition mechanism. The server corrects for a real, live-confirmed quirk in the live pointer position it reads back internally (roughly 2x the real pixel Y, present on this project's own default Workbench screen configuration); this is handled server-side and needs nothing from the caller — see server/README.md's own PICK section for the full story, including the two real findings (window z-order and pointer-Y scaling) building this turned up.

Securing TCP

AmiPilot's TCP transport is meant for a trusted LAN or a direct machine-to-machine link — never expose it on an open/internet-facing port. This server can run arbitrary shell commands (LAUNCH), read/write files inside a granted FSROOT, and inject GUI input; that's real exposure on any network it's reachable from. Two independent, opt-in, combinable options narrow who can connect and what they can do without one:

  • TCPALLOW=<ip-or-cidr>[,<ip-or-cidr>...] — a source-address allowlist, e.g. TCPALLOW=192.168.1.0/24,10.0.0.5. A single value, comma-separated for more than one entry (not repeatable like FSROOT — AmigaDOS's ReadArgs() only allows one repeatable /M keyword per template, and FSROOT already uses it). With none granted, every source is accepted — today's unchanged default. A rejected connection is closed immediately, with no reply ever sent.
  • TCPPASSWORD=<value> gates a new AUTH <password> verb — TCP only, not ARexx or serial.device. If omitted, defaults to "amipilot", which Amipilot.connect()/connect_with_retry() already send automatically, so TCP keeps working with zero config changes on either side.
from amipilot import Amipilot, CommandError

# Matches a server started with TCPPASSWORD=correct-horse-battery-staple
try:
    client = Amipilot.connect("192.168.1.50", 6800, password="correct-horse-battery-staple")
except CommandError:
    ...  # wrong password

Neither option is real security, and you should say so to anyone relying on this. The default password is public — it's in this open-source repository. There's no TLS, so even a custom password crosses the wire in cleartext, sniffable by anything on the same network segment. There's no rate-limiting or lockout on repeated AUTH guesses. TCPALLOW/TCPPASSWORD raise the bar above "wide open to anyone," the same way a router's default admin password does — nothing more. AmiPilotServer prints a warning to this effect every time TCP is enabled, precisely so this isn't something you only find out by reading documentation.