diff --git a/mcp/Dockerfile b/mcp/Dockerfile
new file mode 100644
index 0000000..542fbcd
--- /dev/null
+++ b/mcp/Dockerfile
@@ -0,0 +1,33 @@
+# Runwave MCP server: lets an agent harness play browser games.
+#
+# The Playwright base image already carries every system library Chromium needs
+# (libnss3, libnspr4, libgbm, the X client libs), which is the whole reason to
+# start from it rather than a plain node image.
+#
+# No recording means no gstreamer, no PulseAudio, and no Xvfb: Chromium runs
+# headless, so this image stays far smaller than the playtest runner's.
+FROM mcr.microsoft.com/playwright:v1.61.1-noble
+
+ENV NODE_ENV=production \
+ RUNWAVE_MCP_WORKSPACE=/var/lib/runwave-mcp
+
+WORKDIR /opt/runwave
+
+# Dependencies are copied first so a source edit does not invalidate the
+# install layer.
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev
+
+COPY runwave ./runwave
+COPY mcp ./mcp
+
+RUN mkdir -p "$RUNWAVE_MCP_WORKSPACE"
+
+# Chromium's sandbox needs privileges a default container does not grant. The
+# base image ships a non-root user with the right setup; use it rather than
+# disabling the sandbox.
+USER pwuser
+
+# stdio transport: the host speaks MCP over stdin/stdout, so nothing is exposed
+# on the network and no port is published.
+ENTRYPOINT ["node", "/opt/runwave/mcp/bin/runwave-mcp.js"]
diff --git a/mcp/README.md b/mcp/README.md
new file mode 100644
index 0000000..4d0e75f
--- /dev/null
+++ b/mcp/README.md
@@ -0,0 +1,131 @@
+# Runwave MCP
+
+An MCP server that lets an agent harness such as Claude Code play a browser game
+directly: look at a frame, send a timed sequence of inputs, look at the next
+frame.
+
+This is the interactive counterpart to the `runwave` CLI. The CLI runs a VLM in a
+loop by itself and produces a recorded video for playtesting. Here the connected
+agent *is* the player, so the OpenRouter agent loop is not used and there is no
+recording — which is also why this needs none of runwave's gstreamer,
+PulseAudio, or Xvfb setup.
+
+## Requirements
+
+- Node 20+
+- Chromium's system libraries. On a bare Linux host:
+ ```sh
+ npx playwright install --with-deps chromium # needs sudo for the libs
+ ```
+ In Docker, use `mcp/Dockerfile`, which starts from the Playwright base image
+ and already has them.
+
+No X server, no audio, no display. Chromium runs headless.
+
+## Run
+
+```sh
+node mcp/bin/runwave-mcp.js
+```
+
+Artifacts (screenshots, per-step JSON) are written under
+`RUNWAVE_MCP_WORKSPACE`, defaulting to a directory in the system temp dir. Set
+it explicitly if you want to keep them:
+
+```sh
+RUNWAVE_MCP_WORKSPACE=./artifacts node mcp/bin/runwave-mcp.js
+```
+
+Register it with Claude Code:
+
+```sh
+claude mcp add runwave -- node /absolute/path/to/mcp/bin/runwave-mcp.js
+```
+
+Or in Docker:
+
+```sh
+docker build -f mcp/Dockerfile -t runwave-mcp .
+claude mcp add runwave -- docker run --rm -i runwave-mcp
+```
+
+## Tools
+
+| Tool | Purpose |
+| --- | --- |
+| `launch_game` | Start a session from a `url`, or a `game_dir` containing `start.sh` plus a `port`. Returns the first frame. |
+| `observe` | Fresh frame and state, no input sent. |
+| `act` | Send a timed input sequence, return the resulting frame. |
+| `zoom` | Full-resolution crop of a region, to read small UI without a full frame. |
+| `capture` | Save a clean, full-resolution, un-annotated screenshot. The deliverable. |
+| `reset_game` | Reload at the launch URL. |
+| `journal` | Text log of what has been tried this session. |
+| `list_sessions` | Running sessions. |
+| `end_game` | Close the browser and stop the game process. |
+
+## How `act` works
+
+An action sequence is timed, not a single keypress. Offsets are milliseconds
+from the start of the sequence and actions may overlap, so one call can express
+"hold right for 900ms and jump at 150ms":
+
+```json
+{
+ "session_id": "game-...",
+ "actions": [
+ { "type": "key", "start": 0, "end": 900, "key": "ArrowRight" },
+ { "type": "key", "start": 150, "end": 230, "key": "Space" }
+ ]
+}
+```
+
+This matters because an agent turn costs seconds. Committing to a move beats
+sending one tap per turn.
+
+Action types: `key`, `click`, `multi_click`, `drag`, `cursor_move`, `view_move`.
+Pointer actions take either `x`/`y` in viewport pixels or an
+`overlay_row`/`overlay_col` grid cell. The full schema is enforced on the tool
+input, so a malformed sequence is rejected before any input is sent.
+
+## Notes on the design
+
+**Frames are downscaled by default.** A 1280x720 PNG is roughly 1200 tokens; at
+half scale it is about 300. Over a long navigation that difference dominates
+everything else. Pass `full_res: true` when detail genuinely matters, or use
+`zoom` on the region you care about — usually cheaper than a full-resolution
+frame.
+
+**One frame per turn.** Interval captures are off. Pass `captures` with explicit
+offsets to see a trajectory within a sequence.
+
+**`act` reports whether the frame changed.** A byte-identical frame almost always
+means the input never reached the game, rather than the game ignoring it. The
+tool says so instead of leaving the agent to guess.
+
+**The grid overlay is off by default.** When enabled it enlarges the PNG with a
+label margin on every side, so pixels read off the image no longer match pixels
+sent back as `x`/`y`. The offset is reported in the response when the grid is on,
+but exact coordinates or grid cells are the better targets.
+
+**Grid cells resolve to the cell centre.** Runwave's playtest path deliberately
+scatters clicks inside a cell to vary footage; that is wrong when aiming at a
+specific target, and it makes a run unreproducible. Playtest behaviour is
+unchanged — this server opts into `markGridSampleMode: 'center'`.
+
+**Calls are serialized per session.** There is one Playwright page and one shared
+step counter, so concurrent calls would interleave keypresses. Subagents may
+share a `session_id` safely.
+
+**Sessions close on shutdown.** Chromium and any spawned game process are
+detached children; `SIGINT`, `SIGTERM`, `SIGHUP`, and an uncaught exception all
+close them. Sessions also close after 30 minutes idle, so a forgotten
+`end_game` does not leak a browser.
+
+## Tests
+
+```sh
+npm run test:mcp
+```
+
+The integration test drives a real headless Chromium against a fixture game and
+skips itself when Chromium cannot launch.
diff --git a/mcp/bin/runwave-mcp.js b/mcp/bin/runwave-mcp.js
new file mode 100644
index 0000000..b814bef
--- /dev/null
+++ b/mcp/bin/runwave-mcp.js
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+'use strict';
+
+const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
+const { createServer } = require('../src/server');
+
+async function main() {
+ const { server, registry, workspace } = createServer();
+ // stdout is the MCP transport, so diagnostics go to stderr only.
+ process.stderr.write(`runwave-mcp workspace: ${workspace}\n`);
+
+ // Chromium and any game process are detached children. Without this a host
+ // shutting the server down orphans the whole tree, leaving browsers running.
+ let shuttingDown = false;
+ const shutdown = async (signal) => {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ process.stderr.write(`runwave-mcp shutting down on ${signal}\n`);
+ await registry.closeAll();
+ await server.close().catch(() => {});
+ process.exit(0);
+ };
+
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
+ process.on(signal, () => { shutdown(signal); });
+ }
+ process.on('uncaughtException', async (error) => {
+ process.stderr.write(`runwave-mcp uncaught: ${error.stack || error.message}\n`);
+ await shutdown('uncaughtException');
+ });
+ process.on('unhandledRejection', (reason) => {
+ process.stderr.write(`runwave-mcp unhandled rejection: ${reason}\n`);
+ });
+
+ await server.connect(new StdioServerTransport());
+}
+
+main().catch((error) => {
+ process.stderr.write(`${error.stack || error.message}\n`);
+ process.exit(1);
+});
diff --git a/mcp/src/config.js b/mcp/src/config.js
new file mode 100644
index 0000000..872aa3b
--- /dev/null
+++ b/mcp/src/config.js
@@ -0,0 +1,56 @@
+'use strict';
+
+const path = require('path');
+
+const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
+
+// Runwave scatters clicks within a cell to vary playtest footage. An agent
+// aiming at a target needs the opposite: the same cell must mean the same pixel
+// so a saved action trace replays identically.
+const CELL_SAMPLE_MODE = 'center';
+
+function normalizeViewport(viewport) {
+ const width = Number(viewport && viewport.width);
+ const height = Number(viewport && viewport.height);
+ return {
+ width: Number.isFinite(width) && width > 0 ? Math.round(width) : DEFAULT_VIEWPORT.width,
+ height: Number.isFinite(height) && height > 0 ? Math.round(height) : DEFAULT_VIEWPORT.height,
+ };
+}
+
+// The daemon hands createSession its raw CLI input, which is why grid-cell
+// actions fail there when no viewport was passed. Building the config
+// explicitly closes that gap: viewport is always present and always numeric.
+function buildSessionConfig(options = {}) {
+ const viewport = normalizeViewport(options.viewport);
+ return {
+ kind: 'web',
+ ...(options.url ? { url: options.url } : {}),
+ ...(options.gameDir ? { gameDir: path.resolve(options.gameDir) } : {}),
+ ...(options.port ? { port: Number(options.port) } : {}),
+ viewport,
+ deviceScaleFactor: 1,
+ // No recording: no gstreamer, no PulseAudio, no headed Chromium.
+ record: false,
+ headless: true,
+ // Overlay is opt-in per call. It enlarges the PNG by a margin per side,
+ // which desyncs image coordinates from input coordinates.
+ gridScreenshots: false,
+ fullPageScreenshots: false,
+ // The agent asks for frames explicitly; interval captures would spend
+ // context on frames nobody requested.
+ autoCaptures: false,
+ markGridSampleMode: CELL_SAMPLE_MODE,
+ ...(options.markGridRows ? { markGridRows: Number(options.markGridRows) } : {}),
+ ...(options.markGridCols ? { markGridCols: Number(options.markGridCols) } : {}),
+ ...(options.stateExpression ? { stateExpression: String(options.stateExpression) } : {}),
+ waitAfterLoad: Number(options.waitAfterLoad ?? 700),
+ };
+}
+
+module.exports = {
+ CELL_SAMPLE_MODE,
+ DEFAULT_VIEWPORT,
+ buildSessionConfig,
+ normalizeViewport,
+};
diff --git a/mcp/src/diff.js b/mcp/src/diff.js
new file mode 100644
index 0000000..b28e8c4
--- /dev/null
+++ b/mcp/src/diff.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const crypto = require('crypto');
+const fs = require('fs');
+
+function hashFile(file) {
+ try {
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+ } catch {
+ return null;
+ }
+}
+
+// "Did anything happen?" is the single most useful signal after an input, and
+// the cheapest: no second screenshot, no pixel walk. A false here usually means
+// the input never reached the game, which is otherwise easy to misread as the
+// game ignoring the move.
+function changedSince(previousFile, nextFile) {
+ if (!previousFile || !nextFile) return null;
+ const before = hashFile(previousFile);
+ const after = hashFile(nextFile);
+ if (!before || !after) return null;
+ return before !== after;
+}
+
+module.exports = {
+ changedSince,
+ hashFile,
+};
diff --git a/mcp/src/frame.js b/mcp/src/frame.js
new file mode 100644
index 0000000..a412532
--- /dev/null
+++ b/mcp/src/frame.js
@@ -0,0 +1,54 @@
+'use strict';
+
+const { drawMarkGridOnScreenshot } = require('../../runwave/controller/src/grid-overlay');
+const { readPng } = require('./image');
+const { frameBlocks, gridNote, stateText, textBlock } = require('./result');
+
+// The overlay writes the PNG larger than the capture by a fixed margin per
+// side. gridLabelStyle is private, so the margin is recovered from the file
+// itself rather than reimplementing the label metrics.
+function overlayMargin(file, viewport) {
+ try {
+ const png = readPng(file);
+ const margin = Math.round((png.width - Number(viewport.width)) / 2);
+ return margin > 0 ? margin : 0;
+ } catch {
+ return 0;
+ }
+}
+
+// Draws the overlay onto an existing capture and reports the margin it added.
+function applyGrid(session, file) {
+ drawMarkGridOnScreenshot(file, session.config);
+ return overlayMargin(file, session.config.viewport);
+}
+
+// Screenshots are taken clean. The grid is drawn only when a caller asks for
+// it, so the deliverable frame is never annotated.
+async function captureFrame(session, { name, grid = false }) {
+ const dir = session.actionDir(name);
+ const file = await session.browser.screenshot(dir, name);
+ if (!grid) return { file, margin: 0 };
+ return { file, margin: applyGrid(session, file) };
+}
+
+// Assembles the per-turn payload: frame, trimmed state, and any coordinate
+// caveat the model needs in order to aim correctly.
+function frameResult({ file, margin, state, scale, fullRes, region, label, extra = [] }) {
+ const { blocks, image } = frameBlocks(file, { scale, fullRes, region, label });
+ const notes = [stateText(state)];
+ const caveat = gridNote(image, margin);
+ if (caveat) notes.push(caveat);
+ for (const note of extra) if (note) notes.push(note);
+ return {
+ content: [...blocks, textBlock(notes.join('\n'))],
+ image,
+ };
+}
+
+module.exports = {
+ applyGrid,
+ captureFrame,
+ frameResult,
+ overlayMargin,
+};
diff --git a/mcp/src/image.js b/mcp/src/image.js
new file mode 100644
index 0000000..5ddfe62
--- /dev/null
+++ b/mcp/src/image.js
@@ -0,0 +1,108 @@
+'use strict';
+
+const fs = require('fs');
+const { PNG } = require('pngjs');
+
+// Screenshots are the dominant context cost for an agent playing a game: a
+// 1280x720 PNG is roughly 1200 tokens. Frames are downscaled by default so a
+// long navigation stays affordable, and only widened on explicit request.
+const DEFAULT_SCALE = 0.5;
+
+function readPng(file) {
+ return PNG.sync.read(fs.readFileSync(file));
+}
+
+function clampScale(scale) {
+ const value = Number(scale);
+ if (!Number.isFinite(value) || value <= 0) return DEFAULT_SCALE;
+ return Math.min(1, value);
+}
+
+// Box filter. Averaging over the source rectangle keeps thin game sprites and
+// small UI text legible, which nearest-neighbour sampling loses.
+function resize(source, scale) {
+ const ratio = clampScale(scale);
+ if (ratio === 1) return source;
+ const width = Math.max(1, Math.round(source.width * ratio));
+ const height = Math.max(1, Math.round(source.height * ratio));
+ const target = new PNG({ width, height });
+
+ for (let y = 0; y < height; y += 1) {
+ const topEdge = Math.floor((y * source.height) / height);
+ const bottomEdge = Math.max(topEdge + 1, Math.floor(((y + 1) * source.height) / height));
+ for (let x = 0; x < width; x += 1) {
+ const leftEdge = Math.floor((x * source.width) / width);
+ const rightEdge = Math.max(leftEdge + 1, Math.floor(((x + 1) * source.width) / width));
+ let red = 0;
+ let green = 0;
+ let blue = 0;
+ let samples = 0;
+ for (let sourceY = topEdge; sourceY < bottomEdge; sourceY += 1) {
+ for (let sourceX = leftEdge; sourceX < rightEdge; sourceX += 1) {
+ const index = (source.width * sourceY + sourceX) << 2;
+ red += source.data[index];
+ green += source.data[index + 1];
+ blue += source.data[index + 2];
+ samples += 1;
+ }
+ }
+ const out = (width * y + x) << 2;
+ target.data[out] = Math.round(red / samples);
+ target.data[out + 1] = Math.round(green / samples);
+ target.data[out + 2] = Math.round(blue / samples);
+ target.data[out + 3] = 255;
+ }
+ }
+ return target;
+}
+
+// Clamped so a model-supplied region can never throw; an out-of-bounds ask
+// yields the nearest valid rectangle instead of failing the turn.
+function crop(source, region) {
+ const x = Math.max(0, Math.min(Math.round(Number(region.x) || 0), source.width - 1));
+ const y = Math.max(0, Math.min(Math.round(Number(region.y) || 0), source.height - 1));
+ const width = Math.max(1, Math.min(Math.round(Number(region.width) || 0), source.width - x));
+ const height = Math.max(1, Math.min(Math.round(Number(region.height) || 0), source.height - y));
+ const target = new PNG({ width, height });
+ PNG.bitblt(source, target, x, y, width, height, 0, 0);
+ return { png: target, region: { x, y, width, height } };
+}
+
+function encode(png) {
+ return PNG.sync.write(png).toString('base64');
+}
+
+// Reads a screenshot off disk and returns an MCP image content block plus the
+// dimensions actually sent, so a caller can map coordinates back if needed.
+function imageBlock(file, options = {}) {
+ let png = readPng(file);
+ let region = null;
+ if (options.region) {
+ const cropped = crop(png, options.region);
+ png = cropped.png;
+ region = cropped.region;
+ }
+ const scale = options.fullRes ? 1 : clampScale(options.scale ?? DEFAULT_SCALE);
+ const sourceWidth = png.width;
+ const sourceHeight = png.height;
+ png = resize(png, scale);
+ return {
+ block: { type: 'image', data: encode(png), mimeType: 'image/png' },
+ width: png.width,
+ height: png.height,
+ sourceWidth,
+ sourceHeight,
+ scale,
+ region,
+ };
+}
+
+module.exports = {
+ DEFAULT_SCALE,
+ clampScale,
+ crop,
+ encode,
+ imageBlock,
+ readPng,
+ resize,
+};
diff --git a/mcp/src/registry.js b/mcp/src/registry.js
new file mode 100644
index 0000000..1e6f74b
--- /dev/null
+++ b/mcp/src/registry.js
@@ -0,0 +1,60 @@
+'use strict';
+
+const { Session, newSessionId } = require('./session');
+
+class SessionRegistry {
+ constructor({ workspace }) {
+ this.workspace = workspace;
+ this.sessions = new Map();
+ }
+
+ async create(options = {}) {
+ const id = options.sessionId ? String(options.sessionId) : newSessionId();
+ if (this.sessions.has(id)) throw new Error(`session ${id} already exists`);
+ const session = new Session({ id, workspace: this.workspace, options });
+ this.sessions.set(id, session);
+ try {
+ await session.start();
+ } catch (error) {
+ this.sessions.delete(id);
+ // A game process or Chromium may already be up even though start threw.
+ await session.close().catch(() => {});
+ throw error;
+ }
+ return session;
+ }
+
+ get(id) {
+ const session = this.sessions.get(String(id));
+ if (!session) {
+ const known = [...this.sessions.keys()];
+ const hint = known.length ? ` known sessions: ${known.join(', ')}` : ' no sessions are running';
+ throw new Error(`unknown session_id "${id}".${hint}`);
+ }
+ return session;
+ }
+
+ async end(id) {
+ const session = this.get(id);
+ const summary = session.summary();
+ await session.close();
+ this.sessions.delete(session.id);
+ return { ...summary, closed: true };
+ }
+
+ list() {
+ return [...this.sessions.values()].map((session) => session.summary());
+ }
+
+ // Chromium and any spawned game process are detached children; without this
+ // a server shutdown orphans the whole tree.
+ async closeAll() {
+ const closing = [...this.sessions.values()].map((session) => session.close().catch(() => {}));
+ this.sessions.clear();
+ await Promise.all(closing);
+ }
+}
+
+module.exports = {
+ SessionRegistry,
+};
diff --git a/mcp/src/result.js b/mcp/src/result.js
new file mode 100644
index 0000000..83faefd
--- /dev/null
+++ b/mcp/src/result.js
@@ -0,0 +1,50 @@
+'use strict';
+
+const { imageBlock } = require('./image');
+const { compactState } = require('./state');
+
+function textBlock(text) {
+ return { type: 'text', text };
+}
+
+function errorResult(error) {
+ return {
+ isError: true,
+ content: [textBlock(String((error && error.message) || error))],
+ };
+}
+
+// Every frame goes back as an image block for the model and a path for tooling
+// that wants the original PNG on disk.
+function frameBlocks(file, options = {}) {
+ const image = imageBlock(file, options);
+ const scaleNote = image.scale === 1
+ ? `${image.width}x${image.height}`
+ : `${image.width}x${image.height}, downscaled ${image.scale}x from ${image.sourceWidth}x${image.sourceHeight}`;
+ const label = options.label ? `${options.label} ` : '';
+ return {
+ blocks: [image.block, textBlock(`${label}frame (${scaleNote})\npath: ${file}`)],
+ image,
+ };
+}
+
+function stateText(raw) {
+ const state = compactState(raw);
+ return Object.keys(state).length ? `state: ${JSON.stringify(state)}` : 'state: {}';
+}
+
+// Coordinate space warning matters: with the grid on, the saved PNG is larger
+// than the viewport by a margin per side, so pixels read off the image do not
+// match pixels sent back as x/y.
+function gridNote(image, margin) {
+ if (!margin) return null;
+ return `grid overlay is on. The image includes a ${margin}px label margin on every side, so image pixel (px, py) is viewport (px - ${margin}, py - ${margin}). Prefer overlay_row/overlay_col targets while the grid is on.`;
+}
+
+module.exports = {
+ errorResult,
+ frameBlocks,
+ gridNote,
+ stateText,
+ textBlock,
+};
diff --git a/mcp/src/schema.js b/mcp/src/schema.js
new file mode 100644
index 0000000..e51803d
--- /dev/null
+++ b/mcp/src/schema.js
@@ -0,0 +1,112 @@
+'use strict';
+
+const { z } = require('zod');
+const { MAX_ACTION_SPAN_MS } = require('../../runwave/protocol/src/action');
+const { DEFAULT_MARK_GRID } = require('../../runwave/protocol/src/mark-grid');
+
+// Spans are pulled from the protocol rather than restated, so the tool contract
+// cannot drift from what the executor actually enforces.
+const span = (type) => (MAX_ACTION_SPAN_MS[type] ? ` Max ${MAX_ACTION_SPAN_MS[type]}ms.` : '');
+
+const cell = z
+ .object({
+ overlay_row: z.number().int().min(0).describe(`Grid row, 0-${DEFAULT_MARK_GRID.rows - 1}.`),
+ overlay_col: z.number().int().min(0).describe(`Grid column, 0-${DEFAULT_MARK_GRID.cols - 1}.`),
+ })
+ .describe('Grid cell target. Resolves to the centre of that cell.');
+
+const point = z.object({
+ x: z.number().optional().describe('Viewport pixel X. Preferred for precise targets.'),
+ y: z.number().optional().describe('Viewport pixel Y.'),
+ overlay_row: z.number().int().min(0).optional(),
+ overlay_col: z.number().int().min(0).optional(),
+});
+
+const start = z.number().min(0).describe('Offset in ms from the start of the sequence.');
+
+const keyAction = z.object({
+ type: z.literal('key'),
+ start,
+ end: z.number().min(0).optional().describe('Release offset in ms. Omit for a ~50ms tap. Hold longer to move further.'),
+ key: z.string().describe('Key name, e.g. ArrowRight, Space, KeyW, Enter. Aliases: left/right/up/down/jump.'),
+});
+
+const clickAction = z.object({
+ type: z.literal('click'),
+ start,
+ end: z.number().min(0).optional().describe(`Hold duration.${span('click')}`),
+ ...point.shape,
+ button: z.enum(['left', 'middle', 'right']).optional(),
+ clickCount: z.number().int().min(1).max(3).optional(),
+});
+
+const multiClickAction = z.object({
+ type: z.literal('multi_click'),
+ start,
+ ...point.shape,
+ cells: z.array(cell).max(4).optional().describe('Up to 4 candidate cells; clicks scatter across them.'),
+ count: z.number().int().min(1).max(20).optional().describe('Number of clicks, default 10.'),
+ intervalMs: z.number().min(20).max(500).optional(),
+ button: z.enum(['left', 'middle', 'right']).optional(),
+});
+
+const dragAction = z.object({
+ type: z.literal('drag'),
+ start,
+ end: z.number().min(0).optional().describe(`Drag duration.${span('drag')}`),
+ from: point.describe('Drag origin.'),
+ to: point.describe('Drag destination.'),
+ button: z.enum(['left', 'middle', 'right']).optional(),
+ mode: z.enum(['mouse', 'html5']).optional().describe('mouse for canvas games; html5 only for native draggable elements.'),
+ steps: z.number().int().min(1).max(80).optional(),
+});
+
+const cursorMoveAction = z.object({
+ type: z.literal('cursor_move'),
+ start,
+ end: z.number().min(0).optional().describe(`Move duration.${span('cursor_move')}`),
+ ...point.shape,
+ steps: z.number().int().min(1).max(80).optional(),
+});
+
+const viewMoveAction = z.object({
+ type: z.literal('view_move'),
+ start,
+ end: z.number().min(0).optional(),
+ dx: z.number().optional().describe('Relative pointer delta X. Positive is right.'),
+ dy: z.number().optional().describe('Relative pointer delta Y. Positive is down.'),
+ steps: z.number().int().min(1).max(80).optional(),
+}).describe('Relative mouse movement for pointer-lock/FPS camera control.');
+
+const action = z
+ .discriminatedUnion('type', [
+ keyAction,
+ clickAction,
+ multiClickAction,
+ dragAction,
+ cursorMoveAction,
+ viewMoveAction,
+ ])
+ .describe('One timed input. Offsets are ms from sequence start; actions may overlap.');
+
+const region = z.object({
+ x: z.number().min(0),
+ y: z.number().min(0),
+ width: z.number().min(1),
+ height: z.number().min(1),
+});
+
+module.exports = {
+ action,
+ cell,
+ clickAction,
+ cursorMoveAction,
+ dragAction,
+ keyAction,
+ multiClickAction,
+ point,
+ region,
+ span,
+ start,
+ viewMoveAction,
+};
diff --git a/mcp/src/server.js b/mcp/src/server.js
new file mode 100644
index 0000000..5ac9ed7
--- /dev/null
+++ b/mcp/src/server.js
@@ -0,0 +1,50 @@
+'use strict';
+
+const os = require('os');
+const path = require('path');
+const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
+const { SessionRegistry } = require('./registry');
+const { registerAct, registerObserve, registerPlayTools } = require('./tools-play');
+const { registerCapture, registerSessionTools, registerZoom } = require('./tools-aux');
+
+const VERSION = '0.1.0';
+
+// An MCP server starts in whatever directory the host happens to use, so the
+// workspace is resolved explicitly instead of from cwd. This is the same trap
+// runwave's paths.js falls into by capturing cwd at require time.
+function resolveWorkspace() {
+ const configured = process.env.RUNWAVE_MCP_WORKSPACE;
+ if (configured) return path.resolve(configured);
+ return path.join(os.tmpdir(), 'runwave-mcp');
+}
+
+function createServer({ workspace = resolveWorkspace() } = {}) {
+ const server = new McpServer(
+ { name: 'runwave', version: VERSION },
+ {
+ instructions: [
+ 'Play browser games by looking at frames and sending timed input sequences.',
+ 'Call launch_game once, then loop act/observe, then end_game.',
+ 'Frames come back downscaled to save context; use zoom to inspect detail and full_res only when you must.',
+ 'Prefer one act call that commits to a move (hold a key for several hundred ms) over many single taps.',
+ 'If a result says the frame did not change, the input did not land: change approach rather than repeating it.',
+ 'Use journal to recall what you have already tried, and capture to save the final screenshot.',
+ ].join(' '),
+ }
+ );
+
+ const registry = new SessionRegistry({ workspace });
+ registerPlayTools(server, registry);
+ registerObserve(server, registry);
+ registerAct(server, registry);
+ registerZoom(server, registry);
+ registerCapture(server, registry);
+ registerSessionTools(server, registry);
+ return { server, registry, workspace };
+}
+
+module.exports = {
+ VERSION,
+ createServer,
+ resolveWorkspace,
+};
diff --git a/mcp/src/session.js b/mcp/src/session.js
new file mode 100644
index 0000000..933cd97
--- /dev/null
+++ b/mcp/src/session.js
@@ -0,0 +1,109 @@
+'use strict';
+
+const path = require('path');
+const { createSession } = require('../../runwave/controller/src/session-factory');
+const { OutputWriter } = require('../../runwave/controller/src/output-writer');
+const { ensureDir, timestamp } = require('../../runwave/controller/src/file-utils');
+const { buildSessionConfig } = require('./config');
+
+const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
+
+class Session {
+ constructor({ id, workspace, options }) {
+ this.id = id;
+ this.config = buildSessionConfig(options);
+ this.paths = {
+ runDir: path.join(workspace, 'sessions', id),
+ outputRoot: path.join(workspace, 'sessions', id, 'output'),
+ };
+ ensureDir(this.paths.runDir);
+ this.output = new OutputWriter(this.paths.outputRoot);
+ this.browser = createSession(this.config, this.paths, null);
+ this.stepIndex = 0;
+ this.turn = 0;
+ this.journal = [];
+ this.closed = false;
+ this.createdAt = Date.now();
+ this.idleTimeoutMs = Number(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS);
+ this.idleTimer = null;
+ // Serializes work per session. One Playwright page, one shared step
+ // counter: concurrent steps would interleave keypresses and collide on
+ // output filenames. Subagents sharing a session_id hit this too.
+ this.queue = Promise.resolve();
+ }
+
+ async start() {
+ await this.browser.start();
+ this.touch();
+ return this;
+ }
+
+ // Every tool call runs through here, so ordering is guaranteed even when
+ // several agents hold the same session id.
+ run(fn) {
+ const result = this.queue.then(() => {
+ if (this.closed) throw new Error(`session ${this.id} has ended`);
+ return fn();
+ });
+ this.queue = result.then(() => undefined, () => undefined);
+ return result;
+ }
+
+ // An agent that forgets to call end_game would otherwise leak Chromium for
+ // the lifetime of the server.
+ touch() {
+ if (this.idleTimer) clearTimeout(this.idleTimer);
+ if (!this.idleTimeoutMs || this.closed) return;
+ this.idleTimer = setTimeout(() => {
+ this.close().catch(() => {});
+ }, this.idleTimeoutMs);
+ if (typeof this.idleTimer.unref === 'function') this.idleTimer.unref();
+ }
+
+ // Append-only text log. This is how an agent re-orients after its context is
+ // compacted, without paying to replay screenshots.
+ note(entry) {
+ this.journal.push({ turn: this.turn, at: Date.now() - this.createdAt, ...entry });
+ return this.journal[this.journal.length - 1];
+ }
+
+ nextStepIndex() {
+ this.stepIndex += 1;
+ this.turn += 1;
+ return this.stepIndex;
+ }
+
+ actionDir(name) {
+ return this.output.actionDir(name);
+ }
+
+ async close() {
+ if (this.closed) return;
+ this.closed = true;
+ if (this.idleTimer) clearTimeout(this.idleTimer);
+ await this.browser.close();
+ }
+
+ summary() {
+ return {
+ session_id: this.id,
+ url: this.browser.launchUrl,
+ viewport: this.config.viewport,
+ turns: this.turn,
+ steps: this.stepIndex,
+ uptime_ms: Date.now() - this.createdAt,
+ run_dir: this.paths.runDir,
+ closed: this.closed,
+ };
+ }
+}
+
+function newSessionId() {
+ return `game-${timestamp()}`;
+}
+
+module.exports = {
+ DEFAULT_IDLE_TIMEOUT_MS,
+ Session,
+ newSessionId,
+};
diff --git a/mcp/src/state.js b/mcp/src/state.js
new file mode 100644
index 0000000..0fa69fa
--- /dev/null
+++ b/mcp/src/state.js
@@ -0,0 +1,46 @@
+'use strict';
+
+// Runwave's raw state carries a full WebGL renderer probe and every canvas on
+// the page. Useful for a playtest report, mostly noise for an agent deciding a
+// next move, and it is paid for on every single turn. Only the fields that
+// change a decision survive.
+function compactState(raw) {
+ const generic = (raw && raw.generic) || raw || {};
+ const canvases = Array.isArray(generic.canvases) ? generic.canvases : [];
+ const state = {};
+ if (generic.title) state.title = generic.title;
+ if (generic.url) state.url = generic.url;
+
+ const active = generic.activeElement;
+ if (active && active.tagName && active.tagName !== 'BODY') {
+ state.focus = [active.tagName, active.id ? `#${active.id}` : ''].filter(Boolean).join('');
+ }
+ if (generic.pointerLockElement && generic.pointerLockElement.tagName) {
+ state.pointer_locked = true;
+ }
+
+ // The largest canvas is almost always the game surface. Its client rect tells
+ // an agent which part of the viewport is actually playable.
+ const surface = canvases
+ .filter((canvas) => canvas && canvas.clientWidth > 0 && canvas.clientHeight > 0)
+ .sort((left, right) => right.clientWidth * right.clientHeight - left.clientWidth * left.clientHeight)[0];
+ if (surface) {
+ state.game_area = {
+ x: Math.round(surface.left ?? surface.x ?? 0),
+ y: Math.round(surface.top ?? surface.y ?? 0),
+ width: Math.round(surface.clientWidth),
+ height: Math.round(surface.clientHeight),
+ };
+ }
+ if (canvases.length > 1) state.canvas_count = canvases.length;
+
+ // A stateExpression is opt-in and game-specific, so whatever it returns is
+ // assumed relevant and passed through intact.
+ if (raw && raw.custom !== undefined) state.custom = raw.custom;
+ if (raw && raw.customError) state.custom_error = String(raw.customError).slice(0, 300);
+ return state;
+}
+
+module.exports = {
+ compactState,
+};
diff --git a/mcp/src/tools-aux.js b/mcp/src/tools-aux.js
new file mode 100644
index 0000000..be1fcf8
--- /dev/null
+++ b/mcp/src/tools-aux.js
@@ -0,0 +1,140 @@
+'use strict';
+
+const { z } = require('zod');
+const { region } = require('./schema');
+const { captureFrame, frameResult } = require('./frame');
+const { errorResult, textBlock } = require('./result');
+
+function registerZoom(server, registry) {
+ server.registerTool('zoom', {
+ title: 'Zoom',
+ description: 'Screenshot a rectangle of the viewport at full resolution. Use this to read small UI or confirm a target before clicking, instead of paying for a full-resolution frame.',
+ inputSchema: {
+ session_id: z.string(),
+ region: region.describe('Viewport rectangle in real pixels.'),
+ },
+ }, async (args) => {
+ try {
+ const session = registry.get(args.session_id);
+ return session.run(async () => {
+ const name = `zoom-${String(session.turn).padStart(3, '0')}`;
+ const { file } = await captureFrame(session, { name, grid: false });
+ const state = await session.browser.state(session.config.stateExpression);
+ const result = frameResult({
+ file, margin: 0, state, fullRes: true, region: args.region,
+ label: `zoom ${args.region.width}x${args.region.height} at (${args.region.x},${args.region.y})`,
+ });
+ session.touch();
+ return result;
+ });
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+}
+
+function registerCapture(server, registry) {
+ server.registerTool('capture', {
+ title: 'Capture deliverable',
+ description: 'Save a clean, full-resolution, un-annotated screenshot to disk and return its path. Use this for the final artifact once the target is reached.',
+ inputSchema: {
+ session_id: z.string(),
+ name: z.string().describe('File label, e.g. "target-reached".'),
+ preview: z.boolean().optional().describe('Also return a downscaled preview to confirm what was saved.'),
+ },
+ }, async (args) => {
+ try {
+ const session = registry.get(args.session_id);
+ return session.run(async () => {
+ const { file } = await captureFrame(session, { name: `capture-${args.name}`, grid: false });
+ session.note({ event: 'capture', name: args.name, path: file });
+ session.touch();
+ const summary = textBlock(`saved ${session.config.viewport.width}x${session.config.viewport.height} clean capture\npath: ${file}`);
+ if (!args.preview) return { content: [summary] };
+ const state = await session.browser.state(session.config.stateExpression);
+ const preview = frameResult({ file, margin: 0, state, label: 'saved' });
+ return { content: [summary, ...preview.content] };
+ });
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+}
+
+function registerSessionTools(server, registry) {
+ server.registerTool('reset_game', {
+ title: 'Reset game',
+ description: 'Reload the game at its launch URL. Use this when stuck or to start a fresh attempt.',
+ inputSchema: { session_id: z.string() },
+ }, async (args) => {
+ try {
+ const session = registry.get(args.session_id);
+ return session.run(async () => {
+ await session.browser.navigate({ url: session.browser.launchUrl });
+ session.stepIndex = 0;
+ session.note({ event: 'reset' });
+ const { file } = await captureFrame(session, { name: `reset-${session.turn}`, grid: false });
+ session.lastFrame = file;
+ const state = await session.browser.state(session.config.stateExpression);
+ const result = frameResult({ file, margin: 0, state, label: 'after reset' });
+ session.touch();
+ return result;
+ });
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+
+ // Cheap way back into context after a compaction: text only, no frames.
+ server.registerTool('journal', {
+ title: 'Journal',
+ description: 'Read the log of what has been tried this session. Use this to re-orient without replaying screenshots.',
+ inputSchema: {
+ session_id: z.string(),
+ limit: z.number().int().positive().max(200).optional().describe('Most recent entries to return. Default 40.'),
+ },
+ }, async (args) => {
+ try {
+ const session = registry.get(args.session_id);
+ const limit = args.limit ?? 40;
+ const entries = session.journal.slice(-limit);
+ const lines = entries.map((entry) => {
+ const seconds = (entry.at / 1000).toFixed(1);
+ const rest = Object.entries(entry)
+ .filter(([key]) => !['turn', 'at', 'event'].includes(key))
+ .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join('+') : value}`)
+ .join(' ');
+ return `[${seconds}s] turn ${entry.turn} ${entry.event}${rest ? ` ${rest}` : ''}`;
+ });
+ const header = `${session.journal.length} entries, showing last ${entries.length}`;
+ return { content: [textBlock([header, ...lines].join('\n'))] };
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+
+ server.registerTool('list_sessions', {
+ title: 'List sessions',
+ description: 'List running game sessions.',
+ inputSchema: {},
+ }, async () => {
+ const sessions = registry.list();
+ if (!sessions.length) return { content: [textBlock('no sessions running')] };
+ return { content: [textBlock(JSON.stringify(sessions, null, 2))] };
+ });
+
+ server.registerTool('end_game', {
+ title: 'End game',
+ description: 'Close the browser and stop the game process. Always call this when finished.',
+ inputSchema: { session_id: z.string() },
+ }, async (args) => {
+ try {
+ const summary = await registry.end(args.session_id);
+ return { content: [textBlock(JSON.stringify(summary, null, 2))] };
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+}
+
+module.exports = { registerCapture, registerSessionTools, registerZoom };
diff --git a/mcp/src/tools-play.js b/mcp/src/tools-play.js
new file mode 100644
index 0000000..511ce0c
--- /dev/null
+++ b/mcp/src/tools-play.js
@@ -0,0 +1,178 @@
+'use strict';
+
+const { z } = require('zod');
+const { runStep } = require('../../runwave/controller/src/step-runner');
+const { action } = require('./schema');
+const { captureFrame, frameResult } = require('./frame');
+const { errorResult } = require('./result');
+const { changedSince } = require('./diff');
+const { applyGrid } = require('./frame');
+
+// Returning several frames from one turn is occasionally worth it to see a
+// trajectory, but each one costs context, so the count is capped.
+const MAX_FRAMES_PER_TURN = 4;
+
+const frameOptions = {
+ full_res: z.boolean().optional().describe('Return the frame at full resolution. Costs ~4x the context of the default.'),
+ grid: z.boolean().optional().describe('Overlay a labelled row/column grid to help aim. Adds a label margin around the image.'),
+};
+
+function registerPlayTools(server, registry) {
+ server.registerTool('launch_game', {
+ title: 'Launch game',
+ description: 'Start a headless browser game session and return the first frame. Provide either url, or game_dir plus port for a directory containing start.sh.',
+ inputSchema: {
+ url: z.string().optional().describe('URL to open, e.g. http://127.0.0.1:3000/'),
+ game_dir: z.string().optional().describe('Directory containing start.sh. Launched with the given port.'),
+ port: z.number().int().positive().optional().describe('Port for game_dir, also used to build the URL.'),
+ viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
+ session_id: z.string().optional().describe('Reuse a specific id. Generated when omitted.'),
+ state_expression: z.string().optional().describe('JS expression evaluated in the page each turn for game-specific state.'),
+ ...frameOptions,
+ },
+ }, async (args) => {
+ try {
+ if (!args.url && !args.game_dir) throw new Error('launch_game requires url or game_dir');
+ const session = await registry.create({
+ url: args.url,
+ gameDir: args.game_dir,
+ port: args.port,
+ viewport: args.viewport,
+ sessionId: args.session_id,
+ stateExpression: args.state_expression,
+ });
+ return session.run(async () => {
+ const { file, margin } = await captureFrame(session, { name: 'launch', grid: args.grid });
+ session.lastFrame = file;
+ session.note({ event: 'launch', url: session.browser.launchUrl });
+ const state = await session.browser.state(session.config.stateExpression);
+ const result = frameResult({
+ file, margin, state, fullRes: args.full_res, label: 'initial',
+ extra: [`session_id: ${session.id}`, `viewport: ${session.config.viewport.width}x${session.config.viewport.height}`],
+ });
+ session.touch();
+ return result;
+ });
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+}
+
+// runStep writes clean captures. The grid, when asked for, is applied afterwards
+// to the frames actually being returned, so the on-disk originals stay usable.
+function actResult({ session, step, args, previousFrame, actionName }) {
+ const captures = Array.isArray(step.captures) ? step.captures : [];
+ if (!captures.length) throw new Error('step produced no frames');
+ const wanted = captures.slice(-MAX_FRAMES_PER_TURN);
+ const finalCapture = wanted[wanted.length - 1];
+ session.lastFrame = finalCapture.path;
+
+ const changed = changedSince(previousFrame, finalCapture.path);
+ session.note({
+ event: 'act',
+ action: actionName,
+ duration_ms: step.duration,
+ inputs: step.actions.map((item) => item.type === 'key' ? item.key : item.type),
+ changed,
+ ...(args.note ? { intent: args.note } : {}),
+ });
+
+ const content = [];
+ for (const capture of wanted.slice(0, -1)) {
+ const frame = frameResult({
+ file: capture.path, margin: 0, state: capture.state,
+ fullRes: args.full_res, label: `t=${capture.at}ms`,
+ });
+ content.push(...frame.content);
+ }
+ const last = frameResult({
+ file: finalCapture.path,
+ margin: args.grid ? applyGrid(session, finalCapture.path) : 0,
+ state: step.endState,
+ fullRes: args.full_res,
+ label: wanted.length > 1 ? `t=${finalCapture.at}ms` : null,
+ extra: [
+ `sequence ran ${step.duration}ms`,
+ changed === false
+ ? 'frame is byte-identical to the previous one: the input probably did not reach the game. Check focus, try a different key, or hold it longer.'
+ : null,
+ ],
+ });
+ session.touch();
+ return { content: [...content, ...last.content] };
+}
+
+function registerObserve(server, registry) {
+ server.registerTool('observe', {
+ title: 'Observe',
+ description: 'Take a fresh screenshot and read game state without sending any input.',
+ inputSchema: {
+ session_id: z.string(),
+ ...frameOptions,
+ },
+ }, async (args) => {
+ try {
+ const session = registry.get(args.session_id);
+ return session.run(async () => {
+ const name = `observe-${String(session.turn).padStart(3, '0')}`;
+ const { file, margin } = await captureFrame(session, { name, grid: args.grid });
+ const state = await session.browser.state(session.config.stateExpression);
+ session.lastFrame = file;
+ const result = frameResult({ file, margin, state, fullRes: args.full_res });
+ session.touch();
+ return result;
+ });
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+}
+
+function registerAct(server, registry) {
+ server.registerTool('act', {
+ title: 'Act',
+ description: [
+ 'Send a timed sequence of inputs, then return the resulting frame.',
+ 'Offsets are milliseconds from the start of the sequence and actions may overlap, so one call can express "hold right for 900ms and jump at 150ms".',
+ 'This is the main way to play: prefer one sequence that commits to a move over many single-input calls.',
+ ].join(' '),
+ inputSchema: {
+ session_id: z.string(),
+ actions: z.array(action).min(1).describe('Inputs to run. Total sequence should stay under 8000ms.'),
+ captures: z.array(z.number().min(0)).max(MAX_FRAMES_PER_TURN).optional()
+ .describe('Offsets in ms to screenshot at. Defaults to the end of the sequence. Each extra frame costs context.'),
+ note: z.string().optional().describe('Short intent for the journal, e.g. "cross bridge east".'),
+ ...frameOptions,
+ },
+ }, async (args) => {
+ try {
+ const session = registry.get(args.session_id);
+ return session.run(async () => {
+ const stepIndex = session.nextStepIndex();
+ const actionName = `act-${String(stepIndex).padStart(3, '0')}`;
+ const previousFrame = session.lastFrame;
+ const step = await runStep({
+ input: {
+ action: 'step',
+ action_name: actionName,
+ actions: args.actions,
+ ...(args.captures ? { captures: args.captures } : {}),
+ autoCaptures: false,
+ },
+ config: session.config,
+ browser: session.browser,
+ outputDir: session.actionDir(actionName),
+ nextStepIndex: stepIndex,
+ actionName,
+ profiler: null,
+ });
+ return actResult({ session, step, args, previousFrame, actionName });
+ });
+ } catch (error) {
+ return errorResult(error);
+ }
+ });
+}
+
+module.exports = { MAX_FRAMES_PER_TURN, frameOptions, registerAct, registerObserve, registerPlayTools };
diff --git a/mcp/test/fixtures/game/index.html b/mcp/test/fixtures/game/index.html
new file mode 100644
index 0000000..acaf3de
--- /dev/null
+++ b/mcp/test/fixtures/game/index.html
@@ -0,0 +1,62 @@
+
+
+
+
+ MCP Test Game
+
+
+
+
+
+
+
diff --git a/mcp/test/integration.test.js b/mcp/test/integration.test.js
new file mode 100644
index 0000000..b34bdf8
--- /dev/null
+++ b/mcp/test/integration.test.js
@@ -0,0 +1,131 @@
+'use strict';
+
+// Drives a real headless Chromium against a real game page through the MCP
+// tool surface. Skipped automatically when Chromium's system libraries are
+// missing, so the suite still runs on a bare machine.
+
+const assert = require('node:assert/strict');
+const fsp = require('node:fs/promises');
+const os = require('node:os');
+const path = require('node:path');
+const test = require('node:test');
+const { pathToFileURL } = require('node:url');
+
+const { chromium } = require('playwright');
+const { SessionRegistry } = require('../src/registry');
+const { captureFrame } = require('../src/frame');
+const { changedSince } = require('../src/diff');
+const { compactState } = require('../src/state');
+const { imageBlock } = require('../src/image');
+const { runStep } = require('../../runwave/controller/src/step-runner');
+
+const GAME_URL = pathToFileURL(path.join(__dirname, 'fixtures', 'game', 'index.html')).href;
+const VIEWPORT = { width: 640, height: 360 };
+
+async function chromiumUsable() {
+ try {
+ const browser = await chromium.launch({ headless: true });
+ await browser.close();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+test('MCP session plays a real browser game', async (t) => {
+ if (!(await chromiumUsable())) {
+ t.skip('chromium cannot launch here; run "npx playwright install-deps chromium"');
+ return;
+ }
+ const workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'runwave-mcp-it-'));
+ const registry = new SessionRegistry({ workspace });
+ t.after(async () => {
+ await registry.closeAll();
+ await fsp.rm(workspace, { recursive: true, force: true });
+ });
+
+ const session = await registry.create({
+ url: GAME_URL,
+ viewport: VIEWPORT,
+ stateExpression: '() => window.gameState',
+ });
+
+ await t.test('launch returns a usable downscaled frame', async () => {
+ const { file } = await captureFrame(session, { name: 'launch', grid: false });
+ session.lastFrame = file;
+ const image = imageBlock(file);
+ assert.equal(image.sourceWidth, VIEWPORT.width, 'capture must match the viewport exactly');
+ assert.equal(image.width, VIEWPORT.width / 2, 'frames are halved by default to save context');
+ assert.ok(image.block.data.length > 100, 'image block must carry real base64 payload');
+ assert.equal(image.block.mimeType, 'image/png');
+ });
+
+ await t.test('state exposes the game canvas and custom state', async () => {
+ const state = compactState(await session.browser.state(session.config.stateExpression));
+ assert.deepEqual(state.game_area, { x: 0, y: 0, width: VIEWPORT.width, height: VIEWPORT.height });
+ assert.equal(state.custom.hits, 0);
+ assert.equal(state.webgl, undefined, 'renderer probe must be stripped from per-turn state');
+ });
+
+ await t.test('a held key moves the player and the frame changes', async () => {
+ const before = await session.browser.state(session.config.stateExpression);
+ const previousFrame = session.lastFrame;
+ const stepIndex = session.nextStepIndex();
+ const actionName = `act-${stepIndex}`;
+ const step = await runStep({
+ input: {
+ action: 'step',
+ action_name: actionName,
+ actions: [{ type: 'key', start: 0, end: 600, key: 'ArrowRight' }],
+ autoCaptures: false,
+ },
+ config: session.config,
+ browser: session.browser,
+ outputDir: session.actionDir(actionName),
+ nextStepIndex: stepIndex,
+ actionName,
+ profiler: null,
+ });
+
+ assert.equal(step.captures.length, 1, 'one frame per turn unless more are requested');
+ const after = step.endState;
+ assert.ok(
+ after.custom.x > before.custom.x + 20,
+ `holding right must move the player: ${before.custom.x} -> ${after.custom.x}`
+ );
+ assert.equal(changedSince(previousFrame, step.captures[0].path), true);
+ session.lastFrame = step.captures[0].path;
+ });
+
+ await t.test('a grid cell click lands on the intended target', async () => {
+ // The target sits at x 480-560, y 140-220 in a 640x360 viewport. On a 16x16
+ // grid that is columns 12-13 and rows 6-9, so cell (7, 12) must hit it.
+ const stepIndex = session.nextStepIndex();
+ const actionName = `act-${stepIndex}`;
+ const step = await runStep({
+ input: {
+ action: 'step',
+ action_name: actionName,
+ actions: [{ type: 'click', start: 50, overlay_row: 7, overlay_col: 12 }],
+ autoCaptures: false,
+ },
+ config: session.config,
+ browser: session.browser,
+ outputDir: session.actionDir(actionName),
+ nextStepIndex: stepIndex,
+ actionName,
+ profiler: null,
+ });
+
+ // Cell centre for (row 7, col 12) on a 16x16 grid over 640x360.
+ const expected = {
+ x: Math.round((12 + 0.5) * (VIEWPORT.width / 16)),
+ y: Math.round((7 + 0.5) * (VIEWPORT.height / 16)),
+ };
+ const click = step.actions.find((item) => item.type === 'click');
+ assert.deepEqual({ x: click.x, y: click.y }, expected, 'cell must resolve to its centre');
+ assert.ok(click.x >= 480 && click.x <= 560 && click.y >= 140 && click.y <= 220, 'centre must fall inside the target');
+ assert.equal(step.endState.custom.hits, 1, 'the click must register on the target');
+ assert.equal(step.endState.custom.lit, true);
+ });
+});
diff --git a/mcp/test/unit.test.js b/mcp/test/unit.test.js
new file mode 100644
index 0000000..721d4da
--- /dev/null
+++ b/mcp/test/unit.test.js
@@ -0,0 +1,143 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const { PNG } = require('pngjs');
+
+const { buildSessionConfig, normalizeViewport } = require('../src/config');
+const { clampScale, crop, resize } = require('../src/image');
+const { compactState } = require('../src/state');
+const { action } = require('../src/schema');
+const { normalizeActions } = require('../../runwave/controller/src/action-normalizer');
+
+function solidPng(width, height, color = [10, 20, 30]) {
+ const png = new PNG({ width, height });
+ for (let i = 0; i < width * height; i += 1) {
+ const idx = i << 2;
+ png.data[idx] = color[0];
+ png.data[idx + 1] = color[1];
+ png.data[idx + 2] = color[2];
+ png.data[idx + 3] = 255;
+ }
+ return png;
+}
+
+test('session config disables recording and grid overlay by default', () => {
+ const config = buildSessionConfig({ url: 'http://127.0.0.1:1/' });
+ assert.equal(config.record, false, 'recording must be off so gstreamer is never required');
+ assert.equal(config.headless, true);
+ assert.equal(config.gridScreenshots, false, 'overlay must be opt-in to keep image and input coordinates aligned');
+ assert.equal(config.autoCaptures, false);
+});
+
+test('session config always carries a numeric viewport so grid cells resolve', () => {
+ // The daemon passes raw CLI input through, which is why cell actions fail
+ // there when no viewport was given. Building the config must close that gap.
+ const config = buildSessionConfig({ url: 'http://127.0.0.1:1/' });
+ assert.equal(typeof config.viewport.width, 'number');
+ assert.ok(config.viewport.width > 0 && config.viewport.height > 0);
+ const [click] = normalizeActions(
+ [{ type: 'click', start: 0, overlay_row: 2, overlay_col: 3 }],
+ 500,
+ { strict: true, config, aliases: {}, roundPoints: true }
+ );
+ assert.equal(typeof click.x, 'number');
+ assert.equal(typeof click.y, 'number');
+});
+
+test('grid cell targets resolve to a stable point so traces replay identically', () => {
+ const config = buildSessionConfig({ viewport: { width: 1280, height: 720 } });
+ const points = new Set();
+ for (let i = 0; i < 50; i += 1) {
+ const [click] = normalizeActions(
+ [{ type: 'click', start: 0, overlay_row: 6, overlay_col: 7 }],
+ 500,
+ { strict: true, config, aliases: {}, roundPoints: true }
+ );
+ points.add(`${click.x},${click.y}`);
+ }
+ assert.equal(points.size, 1, `expected one deterministic point, got ${[...points].join(' ')}`);
+});
+
+test('playtest scatter is preserved when sample mode is not set', () => {
+ const config = buildSessionConfig({ viewport: { width: 1280, height: 720 } });
+ delete config.markGridSampleMode;
+ const points = new Set();
+ for (let i = 0; i < 80; i += 1) {
+ const [click] = normalizeActions(
+ [{ type: 'click', start: 0, overlay_row: 6, overlay_col: 7 }],
+ 500,
+ { strict: true, config, aliases: {}, roundPoints: true }
+ );
+ points.add(`${click.x},${click.y}`);
+ }
+ assert.ok(points.size > 5, 'default runwave behaviour must remain random');
+});
+
+test('normalizeViewport falls back on invalid input', () => {
+ assert.deepEqual(normalizeViewport({ width: 0, height: -4 }), { width: 1280, height: 720 });
+ assert.deepEqual(normalizeViewport({ width: 800, height: 600 }), { width: 800, height: 600 });
+});
+
+test('resize halves dimensions and clamps scale above one', () => {
+ const png = solidPng(100, 50);
+ const half = resize(png, 0.5);
+ assert.equal(half.width, 50);
+ assert.equal(half.height, 25);
+ assert.equal(clampScale(4), 1);
+ assert.equal(resize(png, 1).width, 100);
+});
+
+test('resize preserves colour when downscaling a solid image', () => {
+ const png = solidPng(40, 40, [200, 100, 50]);
+ const small = resize(png, 0.25);
+ assert.deepEqual([small.data[0], small.data[1], small.data[2]], [200, 100, 50]);
+});
+
+test('crop clamps an out-of-bounds region instead of throwing', () => {
+ const png = solidPng(100, 100);
+ const { region } = crop(png, { x: 90, y: 90, width: 400, height: 400 });
+ assert.deepEqual(region, { x: 90, y: 90, width: 10, height: 10 });
+});
+
+test('compactState keeps the largest canvas as the game area and drops noise', () => {
+ const state = compactState({
+ generic: {
+ title: 'Game',
+ url: 'http://x/',
+ activeElement: { tagName: 'BODY' },
+ webgl: { renderer: 'SwiftShader', vendor: 'Google', supported: true },
+ canvases: [
+ { clientWidth: 10, clientHeight: 10, left: 0, top: 0 },
+ { clientWidth: 640, clientHeight: 360, left: 20, top: 30 },
+ ],
+ },
+ });
+ assert.deepEqual(state.game_area, { x: 20, y: 30, width: 640, height: 360 });
+ assert.equal(state.canvas_count, 2);
+ assert.equal(state.webgl, undefined, 'renderer probe is per-turn noise for a player');
+ assert.equal(state.focus, undefined, 'a BODY focus carries no signal');
+});
+
+test('compactState surfaces custom state and errors from a stateExpression', () => {
+ assert.equal(compactState({ generic: {}, custom: { score: 7 } }).custom.score, 7);
+ assert.match(compactState({ generic: {}, customError: 'boom' }).custom_error, /boom/);
+});
+
+test('act schema accepts every action type the executor implements', () => {
+ const cases = [
+ { type: 'key', start: 0, end: 900, key: 'ArrowRight' },
+ { type: 'click', start: 100, x: 10, y: 20 },
+ { type: 'click', start: 100, overlay_row: 6, overlay_col: 7 },
+ { type: 'multi_click', start: 0, cells: [{ overlay_row: 1, overlay_col: 1 }], count: 5 },
+ { type: 'drag', start: 0, end: 500, from: { x: 1, y: 2 }, to: { x: 3, y: 4 }, mode: 'mouse' },
+ { type: 'cursor_move', start: 0, x: 5, y: 5, steps: 8 },
+ { type: 'view_move', start: 0, end: 400, dx: 120, dy: -20 },
+ ];
+ for (const item of cases) assert.doesNotThrow(() => action.parse(item), `failed: ${item.type}`);
+});
+
+test('act schema rejects unknown action types and negative offsets', () => {
+ assert.throws(() => action.parse({ type: 'scroll', start: 0 }));
+ assert.throws(() => action.parse({ type: 'key', start: -5, key: 'a' }));
+});
diff --git a/package-lock.json b/package-lock.json
index a78e6e4..79d0119 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,17 +9,508 @@
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
+ "@modelcontextprotocol/sdk": "1.30.0",
"playwright": "1.61.1",
- "pngjs": "7.0.0"
+ "pngjs": "7.0.0",
+ "zod": "3.25.76"
},
"bin": {
"runwave": "runwave/cli.js",
- "runwave-controller": "runwave/controller.js"
+ "runwave-controller": "runwave/controller.js",
+ "runwave-mcp": "mcp/bin/runwave-mcp.js"
},
"engines": {
"node": ">=20"
}
},
+ "node_modules/@hono/node-server": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
+ "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
+ "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.6.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz",
+ "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -34,6 +525,343 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
+ "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+ "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.8",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
+ "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
@@ -72,6 +900,344 @@
"engines": {
"node": ">=14.19.0"
}
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
}
}
}
diff --git a/package.json b/package.json
index 1a1ed5e..ec7d693 100644
--- a/package.json
+++ b/package.json
@@ -7,13 +7,14 @@
"main": "runwave/index.js",
"bin": {
"runwave": "runwave/cli.js",
- "runwave-controller": "runwave/controller.js"
+ "runwave-controller": "runwave/controller.js",
+ "runwave-mcp": "mcp/bin/runwave-mcp.js"
},
"scripts": {
"cli": "node runwave/cli.js",
"controller": "node runwave/controller.js",
"smoke": "npm run test:smoke",
- "test": "node --test runwave/protocol/test/*.test.js runwave/agent/test/*.test.js runwave/controller/test/*.test.js stress-test/test/*.test.js runwave/test/*.test.js",
+ "test": "node --test runwave/protocol/test/*.test.js runwave/agent/test/*.test.js runwave/controller/test/*.test.js stress-test/test/*.test.js runwave/test/*.test.js mcp/test/*.test.js",
"test:agent": "node --test runwave/agent/test/*.test.js",
"test:all": "npm test && npm run test:py",
"test:controller": "node --test runwave/controller/test/*.test.js",
@@ -21,6 +22,7 @@
"test:integration": "npm run test:runwave",
"test:runwave": "node --test runwave/test/*.test.js",
"test:stress-test": "node --test stress-test/test/*.test.js",
+ "test:mcp": "node --test mcp/test/*.test.js",
"test:smoke": "node --test runwave/test/smoke.test.js",
"test:py": "PYTHONWARNINGS=error python3 -m unittest discover -s runwavepy/tests -t runwavepy"
},
@@ -33,7 +35,9 @@
},
"license": "UNLICENSED",
"dependencies": {
+ "@modelcontextprotocol/sdk": "1.30.0",
"playwright": "1.61.1",
- "pngjs": "7.0.0"
+ "pngjs": "7.0.0",
+ "zod": "3.25.76"
}
}
diff --git a/runwave/controller/src/action-normalizer.js b/runwave/controller/src/action-normalizer.js
index 388499b..a9548f8 100644
--- a/runwave/controller/src/action-normalizer.js
+++ b/runwave/controller/src/action-normalizer.js
@@ -4,6 +4,7 @@ const {
cellsFromObject,
clickBurstTimes,
gridSafeSampleRatio,
+ gridSampleMode,
markGridFromConfig,
randomPointInCells,
viewportFromConfig,
@@ -167,7 +168,8 @@ function normalizePoint(point, label, options) {
viewport,
grid,
Math.random,
- gridSafeSampleRatio(options.config || {})
+ gridSafeSampleRatio(options.config || {}),
+ gridSampleMode(options.config || {})
);
} catch (error) {
if (options.strict) throw new Error(`${label} ${error.message}`);
diff --git a/runwave/protocol/src/mark-grid.js b/runwave/protocol/src/mark-grid.js
index 57640c4..f657aca 100644
--- a/runwave/protocol/src/mark-grid.js
+++ b/runwave/protocol/src/mark-grid.js
@@ -17,6 +17,12 @@ function viewportFromConfig(config = {}) {
return config.viewport || config.videoSize || null;
}
+// Defaults to the historical scatter so existing playtest behaviour is unchanged.
+function gridSampleMode(config = {}) {
+ const raw = String(config.markGridSampleMode ?? config.gridSampleMode ?? 'random').toLowerCase();
+ return raw === 'center' || raw === 'centre' ? 'center' : 'random';
+}
+
function gridSafeSampleRatio(config = {}) {
const raw = Number(
config.markGridSafeSampleRatio
@@ -91,7 +97,8 @@ function randomPointInCells(
viewport,
grid = DEFAULT_MARK_GRID,
rng = Math.random,
- safeSampleRatio = DEFAULT_GRID_SAFE_SAMPLE_RATIO
+ safeSampleRatio = DEFAULT_GRID_SAFE_SAMPLE_RATIO,
+ sampleMode = 'random'
) {
const normalized = normalizeCellList(cells, grid, 4);
if (!normalized.length) {
@@ -99,6 +106,16 @@ function randomPointInCells(
}
const cell = normalized[Math.floor(rng() * normalized.length)];
const bounds = cellBounds(cell, viewport, grid);
+ // Scattering within a cell varies footage for playtest recordings. An agent
+ // aiming at a specific target instead needs the same cell to mean the same
+ // pixel every time, so a saved action trace replays identically.
+ if (sampleMode === 'center') {
+ return {
+ x: Math.max(0, Math.min(Math.round((bounds.left + bounds.right) / 2), Math.round(Number(viewport.width)) - 1)),
+ y: Math.max(0, Math.min(Math.round((bounds.top + bounds.bottom) / 2), Math.round(Number(viewport.height)) - 1)),
+ cells: normalized,
+ };
+ }
const ratio = Number.isFinite(Number(safeSampleRatio))
&& Number(safeSampleRatio) > 0
&& Number(safeSampleRatio) <= 1
@@ -131,6 +148,7 @@ function clickBurstTimes(at, duration, count = 10, intervalMs = 100) {
module.exports = {
DEFAULT_GRID_SAFE_SAMPLE_RATIO,
DEFAULT_MARK_GRID,
+ gridSampleMode,
gridSafeSampleRatio,
markGridFromConfig,
viewportFromConfig,