@weavetab/plugin-realtime 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/reflex.js ADDED
@@ -0,0 +1,171 @@
1
+ import { run, arm } from "./page.js";
2
+ import { canvasTool } from "./canvas.js";
3
+ // one rAF loop per page, only alive while a rule is active.
4
+ // it grabs the un-spied rAF so reflex ticks never show up as canvas frames.
5
+ const RUNTIME = `(() => {
6
+ if (window.__wt_rf) return 1;
7
+ const nativeRaf = (window.__wt_cv && window.__wt_cv.raf) || window.requestAnimationFrame;
8
+ const rf = window.__wt_rf = { rules: {}, on: false };
9
+
10
+ const CODES = { ' ': ['Space', 32], Enter: ['Enter', 13], Escape: ['Escape', 27], Tab: ['Tab', 9],
11
+ ArrowLeft: ['ArrowLeft', 37], ArrowUp: ['ArrowUp', 38], ArrowRight: ['ArrowRight', 39], ArrowDown: ['ArrowDown', 40] };
12
+ const press = (k, hold) => {
13
+ if (k === 'Space') k = ' ';
14
+ const c = CODES[k] || (/^[a-z]$/i.test(k) ? ['Key' + k.toUpperCase(), k.toUpperCase().charCodeAt(0)]
15
+ : /^[0-9]$/.test(k) ? ['Digit' + k, k.charCodeAt(0)] : [k, 0]);
16
+ const o = { key: k, code: c[0], keyCode: c[1], which: c[1], bubbles: true, cancelable: true };
17
+ const tgt = document.activeElement || document.body;
18
+ tgt.dispatchEvent(new KeyboardEvent('keydown', o));
19
+ if (tgt !== document) document.dispatchEvent(new KeyboardEvent('keydown', o));
20
+ setTimeout(() => {
21
+ tgt.dispatchEvent(new KeyboardEvent('keyup', o));
22
+ if (tgt !== document) document.dispatchEvent(new KeyboardEvent('keyup', o));
23
+ }, hold);
24
+ };
25
+ const click = (el, x, y) => {
26
+ if (x == null) { const b = el.getBoundingClientRect(); x = b.left + b.width / 2; y = b.top + b.height / 2; }
27
+ const o = { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, view: window, button: 0 };
28
+ for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'])
29
+ el.dispatchEvent(t.startsWith('pointer') ? new PointerEvent(t, o) : new MouseEvent(t, o));
30
+ };
31
+ const shown = (el) => {
32
+ const r = el.getBoundingClientRect();
33
+ if (!r.width || !r.height) return false;
34
+ const s = getComputedStyle(el);
35
+ return s.visibility !== 'hidden' && s.display !== 'none';
36
+ };
37
+ const isNew = (el, since) => {
38
+ const rt = window.__wt_rt;
39
+ if (!rt) return false;
40
+ for (let n = el; n; n = n.parentElement) { const b = rt.born.get(n); if (b !== undefined) return b >= since; }
41
+ return false;
42
+ };
43
+ const find = (r) => {
44
+ for (const el of document.querySelectorAll(r.sel)) {
45
+ if (r.enabled && (el.disabled || el.getAttribute('aria-disabled') === 'true')) continue;
46
+ if (r.text && !((el.getAttribute('aria-label') || '') + ' ' + (el.value || '') + ' ' + el.textContent).toLowerCase().includes(r.text)) continue;
47
+ if (r.onlyNew && !isNew(el, r.t0)) continue;
48
+ if (shown(el)) return el;
49
+ }
50
+ return null;
51
+ };
52
+ const dist = (a, b) => (a && b) ? Math.hypot(a.x + a.w / 2 - b.x - b.w / 2, a.y + a.h / 2 - b.y - b.h / 2) : Infinity;
53
+ const gap = (a, b) => (a && b) ? b.x - (a.x + a.w) : Infinity;
54
+ const E = new Proxy({}, { get: (_, k) => {
55
+ const cv = window.__wt_cv;
56
+ if (!cv) return [];
57
+ if (k === 'all') return cv.track('*', 2);
58
+ if (k === 'moving') return cv.track('*', 2).filter((e) => Math.abs(e.vx) > 0.1 || Math.abs(e.vy) > 0.1);
59
+ const sig = rf.names && rf.names[k];
60
+ return sig ? cv.track(sig, 2) : [];
61
+ } });
62
+
63
+ const tick = () => {
64
+ const now = performance.now();
65
+ let live = 0;
66
+ for (const id in rf.rules) {
67
+ const r = rf.rules[id];
68
+ if (r.done) continue;
69
+ try {
70
+ if (now - r.t0 > r.ttl) { r.done = 'ttl'; continue; }
71
+ if (r.stop && document.querySelector(r.stop)) { r.done = 'stop_selector'; continue; }
72
+ live++;
73
+ if (now - r.last < r.cooldown) continue;
74
+ rf.names = r.names;
75
+ let el = null;
76
+ if (r.sel) { el = find(r); if (!el) continue; }
77
+ else if (!r.whenFn(E, dist, gap)) continue;
78
+ const a = r.act;
79
+ if (a.type === 'click') click(el);
80
+ else if (a.type === 'click_xy') click(document.elementFromPoint(a.x, a.y) || document.body, a.x, a.y);
81
+ else if (a.type === 'key') press(a.key, a.hold);
82
+ else r.jsFn();
83
+ r.hits++; r.last = now;
84
+ if (r.hits >= r.max) { r.done = 'max_hits'; live--; }
85
+ } catch (e) { r.done = 'error'; r.err = String((e && e.message) || e); }
86
+ }
87
+ if (live) nativeRaf.call(window, tick); else rf.on = false;
88
+ };
89
+ rf.add = (rule, whenFn, jsFn) => {
90
+ rf.rules[rule.id] = Object.assign({}, rule, { t0: performance.now(), last: -1e9, hits: 0, done: null, err: null, whenFn, jsFn });
91
+ if (!rf.on) { rf.on = true; nativeRaf.call(window, tick); }
92
+ };
93
+ rf.snap = () => {
94
+ const now = performance.now();
95
+ return Object.keys(rf.rules).map((id) => {
96
+ const r = rf.rules[id];
97
+ return { id, kind: r.sel ? 'dom' : 'canvas', hits: r.hits, done: r.done, error: r.err,
98
+ last_hit_ms_ago: r.hits ? Math.round(now - r.last) : null, age_ms: Math.round(now - r.t0) };
99
+ });
100
+ };
101
+ return 0;
102
+ })()`;
103
+ let seq = 0;
104
+ export async function reflexTool(sess, a) {
105
+ await arm(sess);
106
+ await run(sess, RUNTIME);
107
+ if (a.action === "list")
108
+ return run(sess, "__wt_rf.snap()");
109
+ if (a.action === "clear") {
110
+ await run(sess, "(() => { for (const k in __wt_rf.rules) delete __wt_rf.rules[k]; })()");
111
+ return { cleared: true };
112
+ }
113
+ if (a.action === "remove") {
114
+ if (!a.id)
115
+ throw new Error("id required");
116
+ const had = await run(sess, `(() => { const had = ${JSON.stringify(a.id)} in __wt_rf.rules; delete __wt_rf.rules[${JSON.stringify(a.id)}]; return had; })()`);
117
+ return { removed: had };
118
+ }
119
+ if (!a.when || !a.do)
120
+ throw new Error("add needs both when and do");
121
+ const w = a.when, d = a.do;
122
+ const cv = w.canvas;
123
+ if (!!w.selector === !!cv)
124
+ throw new Error("when needs exactly one of selector or canvas");
125
+ if (d.type === "click" && !w.selector)
126
+ throw new Error("do.click only works with a selector trigger, use click_xy");
127
+ if (d.type === "click_xy" && (d.x == null || d.y == null))
128
+ throw new Error("click_xy needs x and y");
129
+ if (d.type === "key" && !d.key)
130
+ throw new Error("key needs a key");
131
+ if (d.type === "js" && !d.code)
132
+ throw new Error("js needs code");
133
+ const id = a.id || `r${++seq}`;
134
+ const rule = {
135
+ id,
136
+ sel: w.selector || null,
137
+ text: w.text ? w.text.toLowerCase() : null,
138
+ onlyNew: Boolean(w.only_new),
139
+ enabled: w.enabled !== false,
140
+ names: cv?.names ?? null,
141
+ act: { type: d.type, x: d.x, y: d.y, key: d.key, hold: d.hold_ms ?? 40 },
142
+ cooldown: a.cooldown_ms ?? 100,
143
+ max: a.max_hits ?? 50,
144
+ ttl: Math.min(a.ttl_ms ?? 30000, 600000),
145
+ stop: a.stop_selector || null,
146
+ };
147
+ if (cv)
148
+ await canvasTool(sess, { action: "attach" });
149
+ // fns go in as source so page CSP (no unsafe-eval) can't block them
150
+ const whenFn = cv ? `(E, dist, gap) => (\n${cv.js}\n)` : "null";
151
+ const jsFn = d.type === "js" ? `() => {\n${d.code}\n}` : "null";
152
+ await run(sess, `__wt_rf.add(${JSON.stringify(rule)}, ${whenFn}, ${jsFn})`);
153
+ return { id, armed: true };
154
+ }
155
+ export function statusTool(sess, waitMs = 0) {
156
+ const ms = Math.min(Math.max(waitMs, 0), 30000);
157
+ return run(sess, `new Promise((res) => {
158
+ const t0 = performance.now();
159
+ const snap = () => ({
160
+ probe: Boolean(window.__wt_rt),
161
+ canvas: window.__wt_cv ? { attached: true, frames_seen: window.__wt_cv.n } : { attached: false },
162
+ reflexes: window.__wt_rf ? window.__wt_rf.snap() : []
163
+ });
164
+ const tick = () => {
165
+ const s = snap();
166
+ if (!${ms} || !s.reflexes.some((r) => !r.done) || performance.now() - t0 > ${ms}) res(s);
167
+ else setTimeout(tick, 50);
168
+ };
169
+ tick();
170
+ })`, true);
171
+ }
@@ -0,0 +1,19 @@
1
+ export declare function readFeed(sess: any, winMs: number): Promise<{
2
+ active_tab: boolean;
3
+ url?: undefined;
4
+ probe?: undefined;
5
+ last_click?: undefined;
6
+ last_mut_ms_ago?: undefined;
7
+ recent_elements?: undefined;
8
+ reflexes?: undefined;
9
+ canvas?: undefined;
10
+ } | {
11
+ url: any;
12
+ probe: any;
13
+ last_click: any;
14
+ last_mut_ms_ago: any;
15
+ recent_elements: any;
16
+ reflexes: any;
17
+ canvas: any;
18
+ active_tab?: undefined;
19
+ }>;
@@ -0,0 +1,59 @@
1
+ import { run, arm } from "./page.js";
2
+ import { canvasSnap } from "./canvas.js";
3
+ import { statusTool } from "./reflex.js";
4
+ const RECENT = `((win) => {
5
+ const rt = window.__wt_rt;
6
+ if (!rt) return { click: null, last_mut_ms_ago: null, fresh: [] };
7
+ const now = performance.now();
8
+ const clk = rt.click && now - rt.click.t < 15000
9
+ ? { x: Math.round(rt.click.x), y: Math.round(rt.click.y), age_ms: Math.round(now - rt.click.t) }
10
+ : null;
11
+ const sel = 'button, a[href], input, select, textarea, summary, dialog[open], [role=button], [role=link], [role=menuitem], [role=tab], [role=dialog], [role=alertdialog], [onclick]';
12
+ const fresh = [];
13
+ for (const el of document.querySelectorAll(sel)) {
14
+ let b;
15
+ for (let n = el; n && b === undefined; n = n.parentElement) b = rt.born.get(n);
16
+ if (b === undefined || now - b > win) continue;
17
+ const r = el.getBoundingClientRect();
18
+ if (r.width < 2 || r.height < 2) continue;
19
+ const s = getComputedStyle(el);
20
+ if (s.visibility === 'hidden' || s.display === 'none') continue;
21
+ const label = (el.getAttribute('aria-label') || el.innerText || el.value || el.title || '').trim().replace(/\\s+/g, ' ').slice(0, 60);
22
+ const dist = clk ? Math.round(Math.hypot(r.left + r.width / 2 - clk.x, r.top + r.height / 2 - clk.y)) : null;
23
+ fresh.push({
24
+ tag: el.getAttribute('role') || el.tagName.toLowerCase(),
25
+ id: el.id || null,
26
+ wt_id: el.dataset && el.dataset.wtId || null,
27
+ label,
28
+ age_ms: Math.round(now - b),
29
+ dist_from_click_px: dist
30
+ });
31
+ if (fresh.length >= 25) break;
32
+ }
33
+ fresh.sort((a, b) => a.age_ms - b.age_ms);
34
+ return {
35
+ url: location.href,
36
+ click: clk,
37
+ last_mut_ms_ago: rt.lastMut ? Math.round(now - rt.lastMut) : null,
38
+ fresh
39
+ };
40
+ })`;
41
+ export async function readFeed(sess, winMs) {
42
+ if (!sess?.Runtime)
43
+ return { active_tab: false };
44
+ await arm(sess);
45
+ const [st, dom, cv] = await Promise.all([
46
+ statusTool(sess, 0),
47
+ run(sess, `${RECENT}(${winMs})`),
48
+ canvasSnap(sess),
49
+ ]);
50
+ return {
51
+ url: dom.url,
52
+ probe: st.probe,
53
+ last_click: dom.click,
54
+ last_mut_ms_ago: dom.last_mut_ms_ago,
55
+ recent_elements: dom.fresh,
56
+ reflexes: st.reflexes,
57
+ canvas: cv || st.canvas,
58
+ };
59
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@weavetab/plugin-realtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Causal age/proximity tags for browser_map, 60Hz in-page reflex rules, canvas entity sampling and a bounded click loop for Weavetab.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "weavetab.json",
17
+ "README.md",
18
+ "AGENTS.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "typecheck": "tsc --noEmit",
24
+ "preflight": "weavetab preflight",
25
+ "test": "node --test test/*.test.mjs",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "dependencies": {
29
+ "@weavetab/sdk": "^2.6.2"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.0",
33
+ "chrome-remote-interface": "^0.33.2",
34
+ "typescript": "^5.5.0"
35
+ },
36
+ "keywords": [
37
+ "weavetab",
38
+ "weavetab-plugin",
39
+ "cdp",
40
+ "canvas",
41
+ "browser-automation"
42
+ ],
43
+ "author": {
44
+ "name": "fy2ne",
45
+ "url": "https://fy2ne.me"
46
+ },
47
+ "license": "Apache-2.0",
48
+ "repository": { "type": "git", "url": "git+https://github.com/Weavetab/Realtime.git" },
49
+ "bugs": { "url": "https://github.com/Weavetab/Realtime/issues" },
50
+ "homepage": "https://github.com/Weavetab/Realtime#readme",
51
+ "engines": {
52
+ "node": ">=18.0.0"
53
+ }
54
+ }
package/weavetab.json ADDED
@@ -0,0 +1,270 @@
1
+ {
2
+ "$schema": "https://weavetab.dev/schema/plugin.json",
3
+ "name": "@weavetab/plugin-realtime",
4
+ "displayName": "Realtime",
5
+ "version": "0.3.0",
6
+ "description": "Causal age/proximity tags for browser_map, 60Hz in-page reflex rules, canvas entity sampling and a bounded click loop.",
7
+ "entry": "dist/index.js",
8
+ "agentsMd": "AGENTS.md",
9
+ "author": {
10
+ "name": "fy2ne",
11
+ "url": "https://fy2ne.me"
12
+ },
13
+ "license": "Apache-2.0",
14
+ "homepage": "https://github.com/Weavetab/Realtime#readme",
15
+ "repository": "https://github.com/Weavetab/Realtime",
16
+ "engines": {
17
+ "weavetab": "^2.6.2",
18
+ "node": ">=18.0.0"
19
+ },
20
+ "environments": [
21
+ "mcp",
22
+ "cli",
23
+ "browser"
24
+ ],
25
+ "keywords": [
26
+ "weavetab",
27
+ "weavetab-plugin",
28
+ "cdp",
29
+ "canvas",
30
+ "browser-automation"
31
+ ],
32
+ "defaultConfig": {
33
+ "causalWindowMs": 4000
34
+ },
35
+ "permissions": {
36
+ "cdp": {
37
+ "evaluate": true,
38
+ "input": true,
39
+ "reason": "Installs the in-page MutationObserver, hooks Canvas2D/rAF, and dispatches click/key events."
40
+ },
41
+ "tools": {
42
+ "add": true,
43
+ "reason": "Registers realtime_canvas, realtime_reflex, realtime_autopilot, and realtime_status."
44
+ },
45
+ "commands": {
46
+ "add": true,
47
+ "reason": "Registers status and reflex-clear CLI commands."
48
+ },
49
+ "prompts": {
50
+ "add": true,
51
+ "reason": "Registers the /realtime_watch prompt utility."
52
+ },
53
+ "resources": {
54
+ "add": true,
55
+ "reason": "Registers the @realtime_feed resource utility."
56
+ },
57
+ "hooks": {
58
+ "onBeforeAction": true,
59
+ "onAfterAction": true,
60
+ "reason": "Arms the in-page mutation probe before actions and tags newly added nodes in browser_map output."
61
+ },
62
+ "network": {
63
+ "domains": [
64
+ "127.0.0.1",
65
+ "localhost"
66
+ ],
67
+ "reason": "Allows realtime_autopilot to query an optional local OpenAI-compatible model endpoint."
68
+ }
69
+ },
70
+ "commands": [
71
+ {
72
+ "name": "status",
73
+ "description": "Read the in-page realtime probe state, canvas frame counter, and active 60Hz reflex rules.",
74
+ "usage": "weavetab plugin use realtime status [--wait_ms <ms>]",
75
+ "requiresSession": true,
76
+ "options": [
77
+ {
78
+ "name": "wait_ms",
79
+ "type": "number",
80
+ "description": "Block up to wait_ms until active reflexes finish",
81
+ "required": false
82
+ }
83
+ ]
84
+ },
85
+ {
86
+ "name": "reflex-clear",
87
+ "description": "Clear all active 60Hz reflex rules on the active browser tab via CLI.",
88
+ "usage": "weavetab plugin use realtime reflex-clear",
89
+ "requiresSession": true,
90
+ "options": []
91
+ },
92
+ {
93
+ "name": "autopilot",
94
+ "description": "Run bounded in-page autopilot toward a goal directly via CLI.",
95
+ "usage": "weavetab plugin use realtime autopilot --goal <text> [--max_steps <n>]",
96
+ "requiresSession": true,
97
+ "options": [
98
+ {
99
+ "name": "goal",
100
+ "type": "string",
101
+ "description": "Natural language click goal",
102
+ "required": true
103
+ },
104
+ {
105
+ "name": "max_steps",
106
+ "type": "number",
107
+ "description": "Maximum click steps (default 5)",
108
+ "required": false
109
+ },
110
+ {
111
+ "name": "stop_when_text",
112
+ "type": "string",
113
+ "description": "Stop when this text appears",
114
+ "required": false
115
+ }
116
+ ]
117
+ }
118
+ ],
119
+ "prompts": [
120
+ {
121
+ "name": "realtime_watch",
122
+ "description": "Snapshot live DOM mutations, active reflexes, and Canvas2D entities, then guide the agent to wire a 60Hz reflex rule.",
123
+ "arguments": [
124
+ {
125
+ "name": "goal",
126
+ "description": "What the reflex should watch for and do",
127
+ "required": true
128
+ },
129
+ {
130
+ "name": "mode",
131
+ "description": "Trigger mode (auto, dom, canvas)",
132
+ "required": false
133
+ }
134
+ ]
135
+ }
136
+ ],
137
+ "resources": [
138
+ {
139
+ "name": "realtime_canvas_feed",
140
+ "uri": "realtime://canvas",
141
+ "description": "Live snapshot of Canvas2D draw counts, rAF frame rate, and active moving entity bounding boxes.",
142
+ "mimeType": "application/json"
143
+ },
144
+ {
145
+ "name": "realtime_feed",
146
+ "uri": "realtime://feed",
147
+ "description": "Live snapshot of newly mutated DOM elements (with click distance), active 60Hz reflex rules, and tracked Canvas2D entities.",
148
+ "mimeType": "application/json"
149
+ }
150
+ ],
151
+ "tools": [
152
+ {
153
+ "name": "realtime_canvas",
154
+ "description": "Records Canvas2D draw calls across requestAnimationFrame ticks and returns moving/static entities with bounding boxes and px/frame velocity.",
155
+ "inputSchema": {
156
+ "type": "object",
157
+ "properties": {
158
+ "action": {
159
+ "type": "string",
160
+ "enum": [
161
+ "sample",
162
+ "attach",
163
+ "detach"
164
+ ],
165
+ "description": "sample (default), attach, or detach"
166
+ },
167
+ "frames": {
168
+ "type": "number",
169
+ "description": "Frames to sample (2-15, default 6)"
170
+ }
171
+ }
172
+ }
173
+ },
174
+ {
175
+ "name": "realtime_reflex",
176
+ "description": "Registers, removes, lists, or clears in-page 60Hz requestAnimationFrame rules that click or press keys on DOM or Canvas2D triggers without an LLM round-trip.",
177
+ "inputSchema": {
178
+ "type": "object",
179
+ "properties": {
180
+ "action": {
181
+ "type": "string",
182
+ "enum": [
183
+ "add",
184
+ "remove",
185
+ "list",
186
+ "clear"
187
+ ],
188
+ "description": "Operation to perform"
189
+ },
190
+ "id": {
191
+ "type": "string",
192
+ "description": "Rule identifier"
193
+ },
194
+ "when": {
195
+ "type": "object",
196
+ "description": "DOM selector or Canvas2D expression trigger"
197
+ },
198
+ "do": {
199
+ "type": "object",
200
+ "description": "Response action (click, click_xy, key, js)"
201
+ },
202
+ "cooldown_ms": {
203
+ "type": "number",
204
+ "description": "Minimum ms between firings (default 100)"
205
+ },
206
+ "max_hits": {
207
+ "type": "number",
208
+ "description": "Auto-stop after N hits (default 50)"
209
+ },
210
+ "ttl_ms": {
211
+ "type": "number",
212
+ "description": "Rule lifetime in ms (default 30000)"
213
+ },
214
+ "stop_selector": {
215
+ "type": "string",
216
+ "description": "Selector that halts the rule when present"
217
+ }
218
+ },
219
+ "required": [
220
+ "action"
221
+ ]
222
+ }
223
+ },
224
+ {
225
+ "name": "realtime_autopilot",
226
+ "description": "Runs a bounded in-page click loop toward a short goal ('accept cookies', 'continue') with causal age/dialog scoring and optional local model pick.",
227
+ "inputSchema": {
228
+ "type": "object",
229
+ "properties": {
230
+ "goal": {
231
+ "type": "string",
232
+ "description": "Short natural-language click goal"
233
+ },
234
+ "max_steps": {
235
+ "type": "number",
236
+ "description": "Maximum clicks (default 8, max 25)"
237
+ },
238
+ "stop_when_selector": {
239
+ "type": "string",
240
+ "description": "Stop when selector becomes visible"
241
+ },
242
+ "stop_when_text": {
243
+ "type": "string",
244
+ "description": "Stop when body text contains string"
245
+ },
246
+ "stop_when_url": {
247
+ "type": "string",
248
+ "description": "Stop when URL matches regex"
249
+ }
250
+ },
251
+ "required": [
252
+ "goal"
253
+ ]
254
+ }
255
+ },
256
+ {
257
+ "name": "realtime_status",
258
+ "description": "Returns the live state of the in-page probe, Canvas2D spy, and active 60Hz reflex rules. Optionally waits until reflexes complete.",
259
+ "inputSchema": {
260
+ "type": "object",
261
+ "properties": {
262
+ "wait_ms": {
263
+ "type": "number",
264
+ "description": "Wait up to wait_ms (max 30000) for active reflexes to finish"
265
+ }
266
+ }
267
+ }
268
+ }
269
+ ]
270
+ }