Skip to content

Commit c3bddcf

Browse files
committed
feat(web): add hover command for hover-gated UI (#1783)
Add a first-class `hover <x y|@ref|selector> [--settle]` verb, admitted on web only, that moves the pointer without pressing via the agent-browser backend (mouse move). It rides the existing targeted-touch pipeline (ref/selector/coordinate resolution, occlusion/off-screen guards, settle observation, response builder, recording) through a new optional Interactor/backend `hover` op that only the web provider implements. Touch platforms have no hover state: capabilities advertise it on web only and iOS/Android/Linux reject it at admission with a --platform web hint; longpress stays the mobile hold-gesture verb. Closes #1783
1 parent 856ff38 commit c3bddcf

58 files changed

Lines changed: 500 additions & 50 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- New `hover <x y|@ref|selector>` command for `--platform web` (#1783). It moves the pointer over the target without pressing, so hover-gated UI — a message row's `...` toolbar, a menu that opens on pointer enter — becomes reachable through agent-device the way it already was through the underlying `agent-browser` backend (`mouse move`). It is a member of the targeted-touch family: same `@ref`/selector/coordinate targeting, occlusion and off-screen guards, and `--settle` (the settled diff carries the revealed controls with fresh refs, e.g. `+ @e4 [button] "Delete"`), but no `--verify`, since hover reveals rather than activates. `hover @ref` publishes as a portable selector line in recorded scripts, and the Node client exposes `interactions.hover`. Hover is a pointer state that touch platforms do not have, so `capabilities` advertises it on web only and iOS/Android/Linux reject it during admission with `UNSUPPORTED_OPERATION` and a hint naming `--platform web`; `longpress` remains the mobile hold-gesture verb.
56
- Internal: session recording is now derived from the script-publication lifecycle instead of being stored beside it. `SessionState.recordSession` is removed; `isRecordingPublication` answers the question from the aggregate (ordinary authoring records only while ARMED, a repair transaction records for its whole lifetime). The stored flag was a second source of truth that handler surfaces set directly, which is how #1533's aborted-recording drift arose — no surface can now arm recording without moving the lifecycle that authorizes it, and the script writer's publication gate is answered entirely by the aggregate. Behavior-preserving: the derivation reproduces what the flag held at every transition.
67
- Fixed: a script recording aborted by a second `open` is no longer published by a later bare `close` (#1533). `open <app> --save-script` followed by a second successful `open` terminates the recording and warns "Script publication was aborted…", and `close --save-script` correctly refuses it with "Retry with plain close; it will tear down the session without writing." But when that second `open` itself carried `--save-script`, the flag re-armed recording behind the terminal status, and a bare `close` then wrote the full session log to disk — publishing a recording the caller had been told was aborted, and breaking the promise the refusal makes. An aborted authoring lifecycle is now terminal by construction: `--save-script` arms nothing on any surface that handles it — the re-open builder, the close finalizer, and the recorded-action ingress — and the script writer refuses the lifecycle from every path that reaches it (bare `close`, teardown, idle-reap, active publication). This also stops an aborted session from paying recording-time costs it can never publish: a re-opened aborted recording no longer keeps the direct iOS selector fast paths for `click` and `get` disabled. Armed recordings, published recordings, and every repair transaction are unaffected.
78
- `agent-device mcp` now serves the stateless MCP `2026-07-28` revision alongside the handshake-based revisions it already spoke, as the spec's "dual-era server". Modern clients probe `server/discover`, which advertises the supported revisions, the tools capability, and server identity; their requests declare a protocol version in `_meta`, and their results carry `resultType: "complete"` plus `_meta["io.modelcontextprotocol/serverInfo"]`. `tools/list` and `server/discover` now return the `ttlMs`/`cacheScope` cache hints, so a client can cache the 55-tool, ~223KB tool list for an hour instead of re-fetching it on every start; the list was already emitted in a deterministic (sorted) order, which is the other half of what makes it cacheable. Each revision is answered on its own wire contract: a request declaring `2025-11-25` or `2025-06-18` through modern framing still gets the legacy result shape, and `initialize` never agrees to `2026-07-28`, which has no handshake to establish. A declared revision this server does not implement is rejected with `UnsupportedProtocolVersionError` (`-32022`) naming the ones it does, rather than being served under a version the client did not ask for, and modern framing that omits its required `protocolVersion`/`clientCapabilities` metadata — or supplies a `clientInfo` that is not a valid `Implementation` — is rejected as invalid params. `initialize` and `ping` were removed in `2026-07-28`, so a modern-framed call to either is answered `-32601` rather than served inside a `resultType: "complete"` envelope. Responses to legacy clients are unchanged byte-for-byte — `initialize` and `ping` are still served, and no cache, `resultType`, or `_meta` field is added to their results. Nothing here affects the CLI, Node, or daemon surfaces: the stdio transport, the tool set, and every tool's input/output schema are untouched.

packages/ad-script/src/internal/script-utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ export function isClickLikeCommand(command: string): command is 'click' | 'press
4444
return command === 'click' || command === 'press';
4545
}
4646

47-
export function isTouchTargetCommand(command: string): command is 'click' | 'press' | 'longpress' {
48-
return isClickLikeCommand(command) || command === 'longpress';
47+
export function isTouchTargetCommand(
48+
command: string,
49+
): command is 'click' | 'press' | 'longpress' | 'hover' {
50+
return isClickLikeCommand(command) || command === 'longpress' || command === 'hover';
4951
}
5052

5153
function isTypingCommand(command: string): command is 'type' | 'fill' {

packages/ad-script/src/internal/script.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,10 +463,11 @@ function parseReplayScriptLine(line: string): SessionAction | null {
463463
return action;
464464
}
465465

466-
// wait @ref [timeout] and longpress @ref [durationMs] flow through this
467-
// generic branch: strip recorded generation pins like the branches above.
466+
// wait @ref [timeout], longpress @ref [durationMs], and hover @ref flow
467+
// through this generic branch: strip recorded generation pins like the
468+
// branches above.
468469
action.positionals =
469-
command === 'wait' || command === 'longpress'
470+
command === 'wait' || command === 'longpress' || command === 'hover'
470471
? args.map((token) => stripRecordedRefGeneration(token))
471472
: args;
472473
return action;

packages/contracts/src/client-gesture.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export type LongPressOptions = DeviceCommandBaseOptions &
6464
durationMs?: number;
6565
};
6666

67+
export type HoverOptions = DeviceCommandBaseOptions &
68+
SelectorSnapshotCommandOptions &
69+
InteractionTarget &
70+
SettleCommandOptions;
71+
6772
export type SwipeOptions = DeviceCommandBaseOptions & {
6873
from: { x: number; y: number };
6974
to: { x: number; y: number };

packages/contracts/src/facades/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type {
4747
FillOptions,
4848
FlingOptions,
4949
FocusOptions,
50+
HoverOptions,
5051
LongPressOptions,
5152
PanOptions,
5253
PinchOptions,

packages/contracts/src/facades/interaction.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export type {
8585
FillCommandResponseData,
8686
FillCommandResult,
8787
FindCommandResponseData,
88+
HoverCommandResponseData,
89+
HoverCommandResult,
8890
InteractionEvidence,
8991
InteractionTarget,
9092
LongPressCommandResponseData,

packages/contracts/src/interaction-guarantees.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
199199
export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPathContract> = {
200200
'runtime-selector': {
201201
description: 'Daemon tree capture, selector chain resolution, guarded coordinate tap.',
202-
commands: ['press', 'click', 'fill', 'longpress'],
202+
commands: ['press', 'click', 'fill', 'longpress', 'hover'],
203203
guarantees: {
204204
...RUNTIME_TREE_SHARED_GUARANTEES,
205205
disambiguation: {
@@ -221,7 +221,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
221221
'runtime-ref': {
222222
description:
223223
'Session snapshot ref lookup, guarded coordinate tap. #1654: when the caller already resolved the node (a mutating `find`), the lookup is replaced by that node and every guarantee below is enforced against it — the guards are unchanged, only the lookup is skipped.',
224-
commands: ['press', 'click', 'fill', 'longpress'],
224+
commands: ['press', 'click', 'fill', 'longpress', 'hover'],
225225
guarantees: {
226226
...RUNTIME_TREE_SHARED_GUARANTEES,
227227
disambiguation: {
@@ -428,7 +428,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
428428
},
429429
coordinate: {
430430
description: 'Raw x/y tap. Semantics are intentionally minimal.',
431-
commands: ['press', 'click', 'fill', 'longpress'],
431+
commands: ['press', 'click', 'fill', 'longpress', 'hover'],
432432
guarantees: {
433433
disambiguation: {
434434
kind: 'inapplicable',

packages/contracts/src/interaction.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,15 @@ export type LongPressCommandResponseData =
371371
| (TouchResponseRef & TouchLongPressExtras)
372372
| (TouchResponseSelector & TouchLongPressExtras);
373373

374+
type TouchHoverExtras = {
375+
gesture: 'hover';
376+
};
377+
378+
export type HoverCommandResponseData =
379+
| (TouchResponsePoint & TouchHoverExtras)
380+
| (TouchResponseRef & TouchHoverExtras)
381+
| (TouchResponseSelector & TouchHoverExtras);
382+
374383
/**
375384
* Internal runtime result for press/click. The daemon response layer turns
376385
* this into `PressCommandResponseData` via `buildInteractionResponseData`.
@@ -408,6 +417,17 @@ export type LongPressCommandResult = ResolvedInteractionTarget & {
408417
settle?: SettleObservation;
409418
};
410419

420+
/**
421+
* Internal runtime result for hover. The daemon response layer turns this
422+
* into `HoverCommandResponseData` via `buildInteractionResponseData`.
423+
*/
424+
export type HoverCommandResult = ResolvedInteractionTarget & {
425+
backendResult?: Record<string, unknown>;
426+
message?: string;
427+
warning?: string;
428+
settle?: SettleObservation;
429+
};
430+
411431
/**
412432
* Daemon response data for the `find` command. Read-only actions (`exists`,
413433
* `wait`, `get_text`, `get_attrs`) may issue a pinnable ref with

packages/contracts/src/interactor-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ export type Interactor = {
195195
tapElementSelector?(selector: ElementSelectorTapOptions): Promise<Record<string, unknown> | void>;
196196
doubleTap(x: number, y: number): Promise<Record<string, unknown> | void>;
197197
longPress(x: number, y: number, durationMs?: number): Promise<Record<string, unknown> | void>;
198+
/**
199+
* Move the pointer to a point without pressing. Only pointer-driven
200+
* platforms (web today) implement it; touch platforms have no hover state
201+
* and leave it undefined, which the `hover` command reports as unsupported.
202+
*/
203+
hover?(x: number, y: number): Promise<Record<string, unknown> | void>;
198204
focus(x: number, y: number): Promise<Record<string, unknown> | void>;
199205
type(text: string, delayMs?: number): Promise<TypeTextBackendResult | void>;
200206
fillElementSelector?(

skills/agent-device/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ agent-device open <app> --foreground
1313

1414
That starts the session and returns the initial interactive snapshot with `@refs`.
1515

16-
Loop: act with `press|click|fill|longpress <target> ... --settle`, `scroll <direction> --settle`, or `back --settle`; continue from the printed diff, verify the named expectation (`wait text "..."`, `is`, `get`, or `find`), then run `agent-device close`.
16+
Loop: act with `press|click|fill|longpress <target> ... --settle`, `hover <target> --settle` (web only, reveals hover-gated UI), `scroll <direction> --settle`, or `back --settle`; continue from the printed diff, verify the named expectation (`wait text "..."`, `is`, `get`, or `find`), then run `agent-device close`.
1717

1818
Copy refs byte-for-byte: `@e12`, `@e12~s4` — keep the `@` and any `~sN`. Prefer current refs, then `id`/`label`/`role` selectors; coordinates are a last resort. If snapshot reports sparse/AX-unavailable, its refs and selectors are invalid: run `agent-device screenshot`, inspect the image, use coordinates, then retry `snapshot -i` after navigating. Otherwise run `snapshot -i` only when the diff lacks the next target.
1919

0 commit comments

Comments
 (0)