feat(service,features): the agent's cursor moves on a cloud Browser tab
Merged
DA
4d ago+16434+16434+01234567890123456789012345678901234567890123456789-54-54-01234567890123456789
Part 1 of 2 of the agent's cursor on a cloud Browser tab. Dean chose the cursor only, for cloud tabs, so nothing here touches Mac tabs. This PR gets each cursor move from the agent's actions to a read the panel can follow live. Part 2, stacked on this one, adds the client follow and the overlay.
What changed
The runner (@luke/features/browser-agent-cdp)
runBrowserActions(cdp, actions, { cursor })takes an optionalBrowserCursor<R> = { report(move), watched }.- Click, type and a wheel scroll each call
reportwith the resolved point just before their input is dispatched:- click: the given point, or a ref's point;
- type: the ref's point, or the focused element's middle;
- scroll: the middle of the page.
- When
watchedis true, the input then waitsBROWSER_CURSOR_BEAT(250 ms), so the glide lands with the action. - A caller that passes no cursor sends exactly the same commands as before. That covers the Mac path and
settleTitles. - Fetching a point or the page size never fails the action. If either can't be read, no move is reported and the action runs as before.
The vocabulary
BROWSER_CURSOR_KIND(move | click | type) andBrowserCursorMove { tab, x, y, kind, viewport: { width, height }, at }live inbrowser-agent-actions.ts.
The store
- An additive, idempotent migration adds nullable columns to
agent_browser_tab:cursor_x,cursor_y(double),cursor_kind,cursor_viewport_w,cursor_viewport_h(int), andcursor_at. pointBrowserTab(conversationId, move)is one UPDATE keyed by conversation and target, with no read first. It also moveslast_acted_aton.- A tab that has no row yet (one this same call opened) takes no move. No window can be drawing it yet.
The browser tool
- It passes a cursor that writes each move to the tab's row: at most one write per action.
- Who counts as watching: the beat applies only while the agent's
sandbox_terminal_leasestands. The live view moves that lease on while a panel has one of the agent's tabs open, with an idle bound of about 5 minutes.- It is read once per call. An agent nobody watches is never slowed.
- The rule is coarse: a Terminal tab in use holds the same lease, so it counts as watching too.
- Failed store writes are logged by their kind only. The point and the kind are never logged. They are not page content, and they fall under AGENTS.md's cloud-browser line.
The wire and the held list
- Each
AgentBrowserTabnow carries an optionalcursor, present once every cursor column is written. agentBrowserTabLineSchemaandagentBrowserTabOf(line, now)are now the one place a tab is built. The held list and the live read both go through them, so the two can't disagree.
The live read
GET /api/live/task-browser-tabs?id=&userId=islive.taskBrowserTabsin the contract.- It is one sealed statement,
taskBrowserTabsLiveStatementin@luke/database/models/agent-browser-tabs. - It enforces the same reach rule as the held list:
- a member join on the task's workspace;
taskSeenBy;agentReachableBy;- an inner join to the coding agent.
- Polling, held
afterpolls, and a fallback when sealing fails all come fromsealedParts.
What part 2 needs
- Contract:
hosted.live.taskBrowserTabs, input{ id: taskId, userId }, pathHOSTED_SERVICE_PATH.LIVE_TASK_BROWSER_TABS. - Answer:
taskBrowserTabsLiveAnswerSchema={ url, queries: { tabs: sealedLiveQuerySchema } }(@luke/features/live-wire). - Rows:
taskBrowserTabsLiveRowsSchema={ tabs: AgentBrowserTabLine[] }, where each line is:
{ id, agentId, address, title, openedBy, openedByUserId, driverUserId, heldUntil, createdAt, lastActedAt, cursorX, cursorY, cursorKind, cursorViewportW, cursorViewportH, cursorAt }
Instants are epoch ms, and every column is nullable exceptid,agentId,address,title,openedBy,createdAtandlastActedAt. - Reader:
browserTabsOfLiveRows(rows, now)returnsAgentBrowserTab[], sorted the way the held list is (createdAt, then id). Its result equals the held list's.nextBrowserTabLapse(rows, now)gives the instant to read the rows again. A lapsing takeover changes no row, and a live statement can't read the clock.
- Cursor on a tab:
tab.cursor?: { x, y, kind: "move" | "click" | "type", viewport: { width, height }, at }.xandyare CSS pixels of the page's visible area; scale them byviewportonto the drawn live view.atis epoch ms, when the input was about to land.- Rocking or idling can be judged from
cursor.atorlastActedAt.
- Client follow (out of scope here): the gateway method and event, the
compose-tasksfollow (copyfollowPages), the host-operator and act-port procedure, swappinguseCloudBrowserTabsfrom its 5 s poll to the follow, and the lapse timer.
Tests
All tests are hermetic: PGlite, a fake CDP peer, and TestClock.
- Runner:
- the cursor hears each click, type and wheel point, with the page size, before its input;
- typing where the page can't point to moves no cursor;
- a watched click waits the beat (checked by mutation: the test fails without the sleep);
- the existing exact-command tests pin that runs without a cursor are unchanged.
- Tool:
- a click writes the cursor to its row, and a tab this call opened takes none;
- the beat applies only while the sandbox lease stands.
- Store:
pointBrowserTabwrites only to its own agent's tab. - Wire: the held list carries the cursor once the agent points.
- Live:
- the live rows equal the held list for the starter, a teammate and a reader, through a point, a takeover and its lapse, and an outsider gets nothing;
- the read is sealed for the bearer and the task, a stranger gets not-found, and a poll returns the rows inline;
- the statement is in
live-statements.test.ts, which checks it is immutable and seals through Neon's adapter.
main is merged in after #4374 (browser waits), which also edited the runner. Both changes are kept: the navigation watch runs before the action, and the cursor report and beat run inside it, before the input.
Made with Luke