Agent control channel: live event streams into a running session

Status: phases 1 and 2 IMPLEMENTED, plus frozen (agent-stepped) mode and the multi-session dashboard — see those two sections below; the phase-2 pause/run N line item is what frozen mode became, for both the browser and CLI (run.js --control/--control-stdin + tools/ctl.js + dev-server hub + lib/agent-remote.js + dev-server auto-inject, tests test/test-control-cli.js and test/test-web-agent-remote.js); phase 3 (subscribe streams and record/replay) remains design. The browser connect path is zero-paste for pages the dev-server serves: it injects the connect script itself, so the user hands the agent the tab URL and ctl.js -s <that URL> resolves it to the session. The ?agent hook in index.html is thereby unnecessary for served pages and stays deferred (the file is held by another lane); pages served elsewhere still connect by the pasted import(...).connect() line printed at dev-server startup.

The problem

Every way we drive an emulator session today is decided before the run starts. --input=BATCH:ACTION:ARGS is a schedule written at launch time against batch numbers we have to guess; when the guess is wrong the run "fakes a BLANK" (dropdown sweep), or the level timer expires before the click lands (Chip's Challenge), or --stuck-after ends the run and silently drops every later event. There is no way to look first, then act — which is exactly the loop an agent (or a person at a shell) needs: screenshot → decide → click → screenshot.

The browser has the opposite problem. A live page — the local dev-server page, the deployed berrry.app build, Safari on a real phone — has a running session an agent cannot reach at all. tools/ios-eval.js proved the shape of the answer (the page polls the server for work, evaluates it, posts the result back), but it only exists for the ios-lab pages, not the emulator.

This design gives both hosts the same live control channel:

  1. CLI VMtest/run.js --control accepts a continuous stream of events while the guest runs, and answers observation requests (PNG, state).
  2. Browser — any live page connects to the dev-server with one pasted line (or a ?agent URL param), after which the same commands drive it.

One command vocabulary, one client tool, two transports.

What already exists (and is reused, not duplicated)

Piece Where Role here
--input action vocabulary (click, keydown, drag, dlg-cmd, dump-mem, png, …) test/run.js ~line 966-1350 The command set. Control commands are the same actions without the batch prefix — they fire at the next batch.
Scheduled-input drain point top of the batch loop, test/run.js ~4793 Live commands drain at the same point, through the same per-action code.
Event-loop yield inside the batch loop the vlan-wire pattern, test/run.js:7759if ((batch & 0x3F) === 0) await setImmediate Load-bearing constraint: the batch loop is otherwise synchronous and socket callbacks never fire (same reason SIGTERM never lands). --control turns this periodic yield on unconditionally.
Long-poll eval channel tools/ios-selftest-server.js:131-191 + tools/ios-eval.js The browser transport, generalized: page polls GET, executes, POSTs results; the asking HTTP request is held open so the shell prints the answer.
/api/* routes, CORS-open POST, NDJSON append tools/dev-server.js The hub lives here as /api/agent/*.
Inert-without-param page module lib/phone-diag.js (?diag) The pattern for lib/agent-remote.js (?agent).
--input=B:wait-go + parent {t:'go'} IPC test/run.js:282 Prior art for "hold until told"; superseded by this for interactive use.

Protocol

One JSON command shape on both transports:

{ "id": 7, "action": "click", "args": { "x": 120, "y": 88 } }
{ "id": 8, "action": "png" }
{ "id": 9, "action": "eval", "args": { "code": "renderer.windows.length" } }

Each command gets exactly one reply: { "id": 7, "ok": true, "value": ... } (value is a base64 data URL for png, JSON for everything else; ok:false carries the thrown message). A POST may carry an array of commands — that is the "continuous stream" case (e.g. a mousemove trail for a drag) — and the replies come back as an array in the same order.

Command set

Phase 1 (the minimum that closes the agent loop):

Sharing input with the user

A browser session has a person sitting in front of it, and their hands do not stop when an agent starts driving: the mousemove of a hand resting on the desk edge-scrolled Heroes II's map out from under the agent's path clicks, and every click landed somewhere else than it was aimed. So the page takes input exclusively while an agent drives it.

What engages it is the first input command the agent actually sends (any click/key/mouse/wheel entry through execEntry), never the connect — the dev-server auto-connects every page it serves and people play on those normally most of the time. Once engaged, lib/agent-remote.js's window-capture guard drops trusted mouse, wheel and touch events aimed at the #screen canvas, and window.__agentInputExclusive makes shouldIgnorePageKey() in lib/browser-input.js drop trusted keys (its listeners are registered at page load and cannot be out-captured, so keys are cooperative rather than blocked). The agent's own synthesized events carry isTrusted=false and pass; the ?debug toolbar is never in the guarded target chain, so its controls stay clickable.

The Agent owns input checkbox in the ?debug toolbar shows and steers that state. Unchecking it takes the session back for good — auto-engage is retired for the rest of the session, so the next agent click will not silently take it again. The agent's side of the same switch is node tools/ctl.js -s SESSION user-input on (hand it back) / off (take it); like launch, it refuses a direct CLI target with exit 2, since a headless VM has no user at its canvas.

Phase 2:

Frozen (agent-stepped) mode — IMPLEMENTED

A live controlled session is a running machine, and that is a bad thing to photograph. Between the png an agent looks at and the click it decides on, the guest has run tens of thousands of slices: the menu it aimed at animated away, the dialog closed itself, the timer fired. Frozen mode gives both hosts an explicit schedule: nothing happens unless a step is requested.

What it is: while frozen, host.js's drive loop schedules nothing. No slice runs, no frame is presented, and the guest clock does not move. The picture on the canvas cannot change, so png is byte-identical between commands. Work happens only when the agent asks for a specific amount of it.

How to turn it on:

Where How
URL ?frozen — the page is frozen from its first instruction, so ?app=sol&frozen never even boots until stepped. ?frozen=MS also sets the tick.
?debug toolbar the Frozen checkbox, plus a Step button (shift-click = 100) and a FROZEN step N guest T badge
agent node tools/ctl.js -s ID frozen on / off
CLI Start test/run.js with --control-stdin --frozen; send step N, pause, resume, or JSON equivalents on stdin.

The loop it exists for:

node tools/ctl.js -s ID frozen on
node tools/ctl.js -s ID step 2000          # boot far enough to see something
node tools/ctl.js -s ID png /tmp/a.png     # look — and it will still look like this
node tools/ctl.js -s ID click 231,110      # act (queued, exactly as today)
node tools/ctl.js -s ID step 200           # the guest consumes the click here
node tools/ctl.js -s ID png /tmp/b.png     # look again
node tools/png-diff.js /tmp/a.png /tmp/b.png

The direct CLI equivalent is:

node test/run.js --app=sol --control-stdin --frozen --max-seconds=60
{"id":"boot","cmd":"step 2000"}
{"id":"shot","action":"png","path":"/tmp/sol.png"}
{"id":"click","cmd":"click:231:110"}
{"id":"advance","action":"step","n":200}
{"id":"done","action":"quit"}

While the CLI is frozen, input commands acknowledge when queued; their effects become visible only after a step. png, snapshot, eval, and ping inspect the stopped machine immediately. --max-seconds still measures wall time and wakes a CLI paused past its deadline, so tests remain self-bounded.

click + step is the atomic unit of play. A POST carrying the array [{cmd:"click:231:110"},{action:"step",n:200},{action:"png"}] executes the three in order in one round trip, because lib/agent-remote.js awaits each command before starting the next.

What a step is. In the browser, one step is one iteration of the page's run loop — the unit stepsPerSlice sizes (100,000 x86 steps by default, less under some renderer policies). In the CLI, one step is one existing headless batch of --batch-size x86 steps. step N returns {frozen, ran, steps, ticks, guestMs, tickMs, eip} so the reply says what actually happened rather than what was asked for.

The clock is the part that had to be got right. _guestTickMs derives guest time from the wall (now - wallStartMs). A wall clock that keeps running while nothing executes is worse than a stopped one: every WM_TIMER the app owns is instantly overdue when it resumes, and a timeGetTime-paced animation sees one enormous delta per step — the exact failure mode docs/frame-pacing-census.md describes from the other direction. So a frozen host stops reading the wall and charges tickMs of guest time per executed step (default 16, step N MS or ?frozen=MS changes it). That is the browser's answer to --tick-ms-per-batch, and the same tuning judgement applies: an app that paces off WM_TIMER wants a small tick, an app you are trying to fast-forward wants a large one. lib/batch-clock.js is the CLI's implementation of the same idea over a different unit; the two are deliberately separate objects, and nothing but the idea is shared.

Unfreezing slides wallStartMs forward by the interval the guest did not experience, so the guest never sees a jump — the trick _resumeFromHidden already used for a backgrounded tab.

Implementation is one seam. Both drive loops (cooperative and worker-backed) reach their next slice through WineAssembly._scheduleStep, so frozen mode holds the continuation there instead of posting it, and stepFrozen(n) hands it back exactly n times. Consequences worth knowing:

Live mode is untouched when nothing turns this on: _scheduleStep gains one counter increment and one boolean test.

The dashboard: many sessions at once — IMPLEMENTED

GET /dashboard (dev-server, dashboard.html) is the human half of all this: one tile per emulator, each showing its live canvas, its app, its hub session id and its frozen state. It is for watching — the agents drive through ctl.js, and the only control on a tile beyond freeze/close is copy ctl, which puts that tile's own node tools/ctl.js -s <id> … line on the clipboard.

http://127.0.0.1:8080/dashboard                          empty grid, add tiles by hand
http://127.0.0.1:8080/dashboard?apps=sol,winmine         two tiles, live
http://127.0.0.1:8080/dashboard?apps=sol,winmine&frozen  the same two, agent-stepped

Every tile is an ordinary emulator page in an iframe — same index.html, same boot, its own WineAssembly and its own 512MB memory. That is the design decision, and it is deliberate rather than lazy: the page is singleton-shaped in ways that would each have to be undone to host two guests in one document — one <canvas id="screen">, one window.sharedRenderer, one set of window-level key listeners in lib/browser-input.js, one document.fullscreenElement, one desktop-icon grid. An iframe gives all of that per tile for free.

It also means no new protocol at all. The dev-server injects the agent auto-connect into every index.html it serves, so each tile registers its own hub session and answers ctl.js -s <id> exactly like a full-page session; the dashboard is a viewport, not a router, and nothing is proxied through it. A frame-push route was considered and dropped for the same reason: the tiles are the frames.

Two small supports were added for it, both useful on their own:

The tile labels are refreshed by one 500ms setInterval that reads contentWindow.__agentRemote.session and contentWindow.WineFrozen.status() across the same-origin boundary — it touches no guest, and a frozen tile changes nothing on its own, so it is the only thing on the page that ticks. Chrome throttles that timer to nothing in a background tab, which is correct behaviour for an observer page and is why test/test-web-agent-frozen.js brings the dashboard to the front before reading it.

Transports

CLI VM: a control server inside run.js

node test/run.js --app=sol --control[=PORT] (default 8123, bind 127.0.0.1):

Why a direct server and not "run.js polls the dev-server too": the headless case is the agent's bread and butter and must not require a second process. run.js --control + curl is self-contained. The hub exists only because a browser page cannot listen.

Browser: the dev-server as hub

The page cannot accept connections, so it polls — the proven ios-eval shape, promoted from lab-only to the emulator page and made session-aware:

Connecting a page — copy the link

Three ways in, cheapest first:

  1. The page is served by the dev-server (the normal case): nothing. The server appends a <script type=module> auto-connect to index.html as it serves it (localhost binds only; --no-agent-inject turns it off), so the session is on the hub the moment the page loads. The user's whole handoff is the tab URL: node tools/ctl.js -s 'http://127.0.0.1:8080/?debug' png out.png resolves the link against the hub's session list (sessions record their href; match order exact href → origin+pathname → only-session; ambiguity lists ids and exits 2). This is also the phone path — Safari loading the served page connects itself, no console needed. Injection is never enabled on a wider bind, because the page would need the agent token and serving the token to every viewer is serving control of every session.

  2. Any other page (deployed berrry.app, someone else's tab): paste one line into the console:

    import('http://127.0.0.1:8080/lib/agent-remote.js').then(m => m.connect('http://127.0.0.1:8080'))
    

    The logic stays in the repo file; the snippet never grows. dev-server already serves the repo with CORS headers, and dynamic import() from an https page to http://127.0.0.1 is allowed in Chrome (localhost is a potentially-trustworthy origin). Safari blocks that mixed request — the Safari fallback is path 1 against a locally-served page, or a https:// hub (see Security).

  3. The same line as a bookmarklet, for repeat use.

connect(hub) is exported precisely so the snippet is one call; with no argument it uses the script's own origin.

The client: tools/ctl.js

curl can do everything, but the agent-facing verbs deserve a tool (build-tools-not-scripts):

node tools/ctl.js sessions                      # list live sessions (hub + default CLI port)
node tools/ctl.js [-s ID] click 120,88
node tools/ctl.js [-s ID] type "hello world"
node tools/ctl.js [-s ID] key VK_RETURN         # keydown+keyup pair
node tools/ctl.js [-s ID] frozen on             # stop the world (browser or CLI)
node tools/ctl.js [-s ID] step 400 [16]         # 400 steps of guest work, then stop
node tools/ctl.js [-s ID] png out.png
node tools/ctl.js [-s ID] snapshot              # JSON to stdout
node tools/ctl.js [-s ID] eval 'wineShell.apps.length'
node tools/ctl.js [-s ID] pipe < events.ndjson  # the continuous-stream case
node tools/ctl.js [-s ID] tail                  # phase 2: follow subscribed events

With exactly one live session, -s is optional. -s accepts a bare CLI port (-s :8123), a hub session id, or a page URL copied from the browser tab (-s 'http://127.0.0.1:8080/?debug' — the URL's origin is the hub, since the dev-server serves the page and hosts the hub; an explicit --hub= wins). Exit codes compose in a shell: 0 executed, 1 the command threw guest/page-side, 2 transport failure — same contract as ios-eval.

The agent loop this enables, verbatim:

node tools/ctl.js png /tmp/f1.png      # look
node tools/ctl.js click 231,110        # act
node tools/ctl.js png /tmp/f2.png      # look again
node tools/png-diff.js /tmp/f1.png /tmp/f2.png   # did anything happen?

Timing, clocks, and what "now" means

Security

Same posture as ios-selftest-server — an unauthenticated debugging server the user starts by hand — but this one carries eval, so the defaults tighten:

Failure modes designed for up front

Implementation order

  1. test/run.js --control + tools/ctl.js (direct mode): server, live queue drained beside scheduledInput, forced periodic yield, png / snapshot / eval / input actions. This alone retires the guess-the-batch-number workflow. Test: test/test-control-cli.js — spawn run.js --app=sol --control=PORT, wait for the ready line, snapshot, click a card, png twice, assert png-diff sees the change; bounded by timeout -s KILL.
  2. dev-server hub + lib/agent-remote.js + ?agent: hello/poll/result/ ctl/sessions routes, DOM-event synthesis, ctl.js -s hub mode. Test: test/test-web-agent-remote.js in the existing headless-web harness — load ?agent page against a dev-server, drive a click, assert via eval that the input routed.
  3. Frozen mode + dashboard (done): host.js _scheduleStep seam and the window.WineFrozen page switch, frozen/step on the channel, the ?debug checkbox and badge, dashboard.html + the /dashboard alias. Test: test/test-web-agent-frozen.js — asserts the negative (a ?frozen page retires ZERO steps across a 2.5s sleep), then that step N runs exactly N and stops, that png is byte-identical between commands, that a click alone changes nothing but click+step does, that the checkbox freezes and unfreezes a running session, and that /dashboard boots two emulators that each answer ping and png on their own session.
  4. Streams + record/replay: subscribe, NDJSON event log, tail, browser input recording. Each is independently shippable.