@rebind.gg/mcp-server 0.3.1 → 0.4.1

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/README.md CHANGED
@@ -7,7 +7,7 @@ Stdio MCP server that turns [Rebind](https://rebind.gg) into a computer-use tool
7
7
  - [Bun](https://bun.sh) >= 1.1 (the server runs via `bunx`)
8
8
  - The [Rebind app](https://rebind.gg/download) running the free [Remote Control](https://www.rebind.gg/marketplace/script_AAHteeDcYPGOT7GfXZ_OK) package (`rebind install @rebind/remote-control --allow`)
9
9
 
10
- The first time the server sends a command, Remote Control asks on that computer whether to allow **Rebind MCP server**. Allow it once; the name is remembered. Tool calls fail with `awaiting_approval` until you answer. Remote Control accepts connections from its own computer only unless **Local only** is turned off in its Options, which an agent on another computer or inside WSL needs. `run_lua`, `focus_window`, `position_window` and `read_page` also need **Allow custom Luau** turned on there; the server reads that setting at startup.
10
+ The first time the server sends a command, Remote Control asks on that computer whether to allow **Rebind MCP server**. Allow it once; the name is remembered. Tool calls fail with `awaiting_approval` until you answer. Remote Control accepts connections from its own computer only unless **Local only** is turned off in its Options, which an agent on another computer or inside WSL needs. `run_lua`, `focus_window`, `position_window` and `read_page` also need **Allow custom Luau** turned on there; the server reads that setting at startup. The `observe_*` tools need **Allow observation** turned on there, Remote Control 0.5.0 and Rebind 3.15.0.
11
11
 
12
12
  ## Install
13
13
 
@@ -61,6 +61,11 @@ Connects, waits up to a minute for the approval prompt on a first run, takes a c
61
61
  | `meditate` | Research a problem page until a reasoning model has the data to solve it: fetches the URL directly, pulls the script bundles behind a JS shell so its API endpoints are visible, fetches those endpoints and assets, and reads the live page with vision. The browser steps (rendering a JS shell, same-origin fetches through the page, and the vision pass) need a macOS relay; elsewhere it reads pages over HTTP only. Returns a markdown dossier plus JSON `{error, problem, requirements, plan, submittable, form, live_captcha_hint, confidence, sources, assets, pending_fetch, notes, cost_usd}`. `error` is non-null when no problem could be identified or the page could not be read; partial data is still returned. Gated behind `REBIND_ALLOW_DELEGATION=1` (spends OpenRouter credits). See the pipeline below. |
62
62
  | `run_lua` | Run a Luau script on the connected machine and return its `return` value as JSON — the fast path for any deterministic sequence (launch apps, place windows, shell commands, pixel search). |
63
63
  | `lua_docs` | Full Luau SDK type definitions for `run_lua`. |
64
+ | `observe_start` / `observe_stop` | Start and stop a recording of the user: every key and button they press (passwords included), mouse movement, the active window, and screenshots, saved in Rebind's observations folder. The server's instructions tell the agent to ask before starting one. It also stops when the server disconnects. |
65
+ | `observe_mark` | Add a note (`mark`, `success`, `failure`, `ai` or `own`, with a label and JSON data) to the recording the server started. |
66
+ | `observe_list` | Every recording in the observations folder, newest first, including ones made in the app or with `rebind observe`. |
67
+ | `observe_events` | A recording's log lines from `from_seq`, 64 KB at a time unless `max_bytes` says otherwise, and `next`, the seq to read on from. |
68
+ | `observe_frame` | One screenshot from a recording, as a `frame` or `crop` line names it, returned as an image. |
64
69
  | `list_displays` / `list_windows` / `focus_window` / `position_window` / `active_window` | Display and window enumeration, focus, and placement. |
65
70
  | `get_cursor` / `wait` / `calibrate` | Cursor readback, pauses, and the mouse scale probe. |
66
71
  | `delegate_task` / `rebind_versus` | Server-side reasoning helpers with no desktop access: one bounded text task, or parallel critics that attack a plan. Registered only with `REBIND_ALLOW_DELEGATION=1` (spends OpenRouter credits). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebind.gg/mcp-server",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "MCP server exposing Rebind as computer-use tools: screenshot, verified click, type, key, scroll, and window control over the Remote Control protocol.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  "scripts": {
42
42
  "start": "bun run src/index.ts",
43
43
  "selftest": "bun run src/index.ts --selftest",
44
- "test": "bun test src/ tests/",
44
+ "test": "bun test tests/",
45
45
  "typecheck": "tsc --noEmit",
46
46
  "bench": "bun run bench/runner.ts",
47
47
  "bench:selftest": "bun run bench/runner.ts --selftest",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.12.0",
53
- "@rebind.gg/client-ts": "0.3.0",
53
+ "@rebind.gg/client-ts": "0.3.3",
54
54
  "sharp": "^0.34.2",
55
55
  "zod": "^3.25.0"
56
56
  },
@@ -0,0 +1,55 @@
1
+ // The meditate tool's check on model- and page-chosen URLs before they reach
2
+ // relay-side curl (spliced into a shell command inside Lua long-bracket
3
+ // quoting) or the user's browser.
4
+
5
+ import { isIP } from "node:net";
6
+
7
+ /** plain http(s), with nothing that could leave the shell or Lua quoting or
8
+ * that curl reads as a glob ([] and {}); bracketed IPv6 literals stop here */
9
+ const SAFE_URL = /^https?:\/\/[^\s"'`\\\][{}]+$/;
10
+
11
+ // IPv4 ranges that are not public, as [base address, prefix length]
12
+ const RESERVED_V4: [string, number][] = [
13
+ ["0.0.0.0", 8],
14
+ ["10.0.0.0", 8],
15
+ ["100.64.0.0", 10],
16
+ ["127.0.0.0", 8],
17
+ ["169.254.0.0", 16],
18
+ ["172.16.0.0", 12],
19
+ ["192.0.0.0", 24],
20
+ ["192.0.2.0", 24],
21
+ ["192.168.0.0", 16],
22
+ ["198.18.0.0", 15],
23
+ ["198.51.100.0", 24],
24
+ ["203.0.113.0", 24],
25
+ ["224.0.0.0", 3], // multicast and reserved, through 255.255.255.255
26
+ ];
27
+
28
+ const toInt = (ip: string) =>
29
+ ip.split(".").reduce((n, octet) => n * 256 + Number(octet), 0);
30
+ const inRange = (ip: string, [base, bits]: [string, number]) =>
31
+ Math.floor(toInt(ip) / 2 ** (32 - bits)) ===
32
+ Math.floor(toInt(base) / 2 ** (32 - bits));
33
+
34
+ // names that resolve to this machine, the LAN, or a cloud metadata service
35
+ const PRIVATE_NAME = /(^|\.)(localhost|local|internal|home\.arpa)$/;
36
+
37
+ /** Whether a URL may be fetched or opened for the user. Names are checked as
38
+ * written, not resolved, so a public name that points at a private address
39
+ * (127.0.0.1.nip.io, DNS rebinding) still passes, and the browser follows
40
+ * redirects before the landed URL is checked. Relay-side curl runs without
41
+ * -L, so its redirects are not followed. */
42
+ export function isSafeFetchUrl(u: string): boolean {
43
+ if (!SAFE_URL.test(u)) return false;
44
+ let host: string;
45
+ try {
46
+ host = new URL(u).hostname.toLowerCase();
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (host.endsWith(".")) host = host.slice(0, -1);
51
+ // the URL parser has already turned 127.1, 0x7f.0.0.1 and 2130706433
52
+ // into dotted decimal
53
+ if (isIP(host) === 4) return !RESERVED_V4.some((r) => inRange(host, r));
54
+ return host.includes(".") && !PRIVATE_NAME.test(host);
55
+ }
package/src/index.ts CHANGED
@@ -14,9 +14,11 @@ import { RebindRemote, ServerError } from "@rebind.gg/client-ts";
14
14
  import { z } from "zod";
15
15
  import pkg from "../package.json";
16
16
  import { mapToImage } from "./coords.ts";
17
+ import { isSafeFetchUrl } from "./fetch-guard.ts";
17
18
  import { renderScreenshot } from "./image.ts";
18
19
  import { normalizeCombo } from "./keys.ts";
19
20
  import { llmStep, type Msg } from "./llm.ts";
21
+ import { observeRefusal } from "./observe.ts";
20
22
  import {
21
23
  BATCHING_DOCTRINE,
22
24
  DETERMINISTIC_WORK_DOCTRINE,
@@ -24,6 +26,7 @@ import {
24
26
  KEY_DESC,
25
27
  LUA_DOCS_DESC,
26
28
  modifierNote,
29
+ OBSERVE_DOCTRINE,
27
30
  type OS,
28
31
  openAppHow,
29
32
  PROBE_CLIS_CMD,
@@ -114,40 +117,11 @@ const MEDITATE_TOTAL_CHARS = 600_000;
114
117
  const MEDITATE_DL_MAX_BYTES = 26_214_400; // 25 MiB
115
118
  const MEDITATE_DL_TIMEOUT_S = 45;
116
119
 
117
- /** URLs the model may visit/download: plain http(s), no quotes/brackets that
118
- * could escape the shell or Lua long-bracket quoting they are spliced into. */
119
- const SAFE_URL = /^https?:\/\/[^\s"'`\\\][]+$/;
120
-
121
120
  /** Front app is a browser we can drive (Safari included — it fails later at the
122
121
  * javascript: step, but it is still a browser, not a terminal we'd type into). */
123
122
  const BROWSER_RE =
124
123
  /chrome|chromium|brave|firefox|edge|opera|vivaldi|arc|safari/i;
125
124
 
126
- /** SSRF guard: block loopback, RFC1918, link-local (incl. cloud metadata), and
127
- * *.local before a model-chosen URL reaches the real browser or relay curl.
128
- * Hostname-based — does not defeat DNS rebinding, but stops the direct hits. */
129
- function isSafeFetchUrl(u: string): boolean {
130
- if (!SAFE_URL.test(u)) return false;
131
- let host: string;
132
- try {
133
- host = new URL(u).hostname.toLowerCase();
134
- } catch {
135
- return false;
136
- }
137
- if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".local"))
138
- return false;
139
- if (
140
- /^(127\.|10\.|192\.168\.|169\.254\.|::1$|\[?::1\]?$|fe80:|fc|fd)/i.test(
141
- host,
142
- )
143
- )
144
- return false;
145
- const m = /^(\d+)\.(\d+)\./.exec(host);
146
- if (m && Number(m[1]) === 172 && Number(m[2]) >= 16 && Number(m[2]) <= 31)
147
- return false;
148
- return true;
149
- }
150
-
151
125
  /** per-asset inlined-content budget fed to the model (text/csv sources) */
152
126
  const MEDITATE_ASSET_CHARS = 60_000;
153
127
  /** a fetched page shorter than this is a JS shell, not content — go to the browser */
@@ -338,6 +312,7 @@ async function buildInstructions(
338
312
  BATCHING_DOCTRINE,
339
313
  "When you do need to see the screen, prefer screenshot_window over a full-display screenshot — a window shot costs roughly a quarter of the image tokens. Every screenshot stays in your context for the rest of the session, so each one you take keeps costing you on every later turn: verify with values (clipboard, file contents, run_lua reads) where possible.",
340
314
  guiTips(os),
315
+ OBSERVE_DOCTRINE,
341
316
  "run_lua chunks execute once and must end with `return <value>`. File.* paths are relative to the script dir (absolute rejected); System.Exec takes real absolute paths.",
342
317
  `Full run_lua Luau SDK — every namespace and signature (do not guess APIs):\n${sdk}`,
343
318
  ]
@@ -530,6 +505,7 @@ async function main() {
530
505
  // an app finishing launch) and the payload would be typed into it instead.
531
506
  if (!BROWSER_RE.test(await frontApp())) {
532
507
  if (!appName) return null;
508
+ // App.Activate focuses a running browser only; a closed one stays closed
533
509
  await client.luaExec(
534
510
  `App.Activate(${JSON.stringify(appName)}) return true`,
535
511
  );
@@ -649,15 +625,11 @@ async function main() {
649
625
  await sleep(3000);
650
626
  };
651
627
 
652
- // Capture the display under the cursor, render it, and record the image->screen
653
- // mapping on the session so a subsequent verifiedClick(mapImagePoint(...)) lands
654
- // correctly. Returns the webp bytes as a base64 data URL for vision models.
655
- const captureScreen = async (): Promise<{
656
- dataUrl: string;
657
- width: number;
658
- height: number;
659
- }> => {
660
- const cap = await client.screenCapture({});
628
+ // Capture a display (default: the one under the cursor), render it, and record
629
+ // the image->screen mapping on the session so a subsequent
630
+ // verifiedClick(mapImagePoint(...)) lands correctly.
631
+ const captureScreen = async (display?: number) => {
632
+ const cap = await client.screenCapture({ display });
661
633
  const shot = await renderScreenshot({
662
634
  png: Buffer.from(cap.image, "base64"),
663
635
  logicalWidth: cap.display.width,
@@ -675,11 +647,7 @@ async function main() {
675
647
  imageWidth: shot.width,
676
648
  imageHeight: shot.height,
677
649
  };
678
- return {
679
- dataUrl: `data:image/webp;base64,${shot.data.toString("base64")}`,
680
- width: shot.width,
681
- height: shot.height,
682
- };
650
+ return { cap, shot };
683
651
  };
684
652
 
685
653
  // Resolve a window (title substring, or the front window when want=""),
@@ -737,24 +705,7 @@ return { ok = true, proc = proc }
737
705
  },
738
706
  },
739
707
  async ({ display }) => {
740
- const cap = await client.screenCapture({ display });
741
- const shot = await renderScreenshot({
742
- png: Buffer.from(cap.image, "base64"),
743
- logicalWidth: cap.display.width,
744
- logicalHeight: cap.display.height,
745
- cursor: {
746
- x: cap.cursor.x - cap.display.x,
747
- y: cap.cursor.y - cap.display.y,
748
- },
749
- });
750
- session.lastShot = {
751
- displayIndex: cap.display.index,
752
- originX: cap.display.x,
753
- originY: cap.display.y,
754
- scale: shot.scale,
755
- imageWidth: shot.width,
756
- imageHeight: shot.height,
757
- };
708
+ const { cap, shot } = await captureScreen(display);
758
709
  const win = await client.systemWindow().catch(() => null);
759
710
  const lines = [
760
711
  `display ${cap.display.index}: ${cap.display.width}x${cap.display.height} at (${cap.display.x}, ${cap.display.y})${cap.display.primary ? " (primary)" : ""}`,
@@ -1220,6 +1171,191 @@ return { ok = Window.IsActive(h), name = name }
1220
1171
  async () => text(await v1Sdk()),
1221
1172
  );
1222
1173
 
1174
+ // --- observation: rb.observe in Remote Control (observe.* commands) ---
1175
+ // A refusal the user fixes (Allow observation off, an older Remote Control
1176
+ // or Rebind) comes back as a plain error result; anything else throws.
1177
+ const observe = async (
1178
+ command: string,
1179
+ args: Record<string, unknown>,
1180
+ answer: (r: any) => any,
1181
+ ) => {
1182
+ try {
1183
+ return await answer(await client.call(`observe.${command}`, args));
1184
+ } catch (e) {
1185
+ const refusal = observeRefusal(e);
1186
+ if (refusal === null) throw e;
1187
+ return { ...text(refusal), isError: true };
1188
+ }
1189
+ };
1190
+ const json = (v: unknown) => text(JSON.stringify(v, null, 2));
1191
+
1192
+ register(
1193
+ "observe_start",
1194
+ {
1195
+ description:
1196
+ "Start recording the USER: every key and button they press, mouse movement, the active window, and screenshots, saved in Rebind's observations folder. " +
1197
+ "Ask the user first and start only after they agree. One recording runs at a time. It ends after `seconds`, at `stop_key`, on observe_stop, or when this server disconnects. " +
1198
+ "Needs Allow observation on in Remote Control's Options. Returns the recording's status, including its id.",
1199
+ inputSchema: {
1200
+ seconds: z
1201
+ .number()
1202
+ .int()
1203
+ .min(1)
1204
+ .max(14400)
1205
+ .optional()
1206
+ .describe("how long to record (default 120)"),
1207
+ stop_key: z
1208
+ .string()
1209
+ .optional()
1210
+ .describe(
1211
+ 'a keyboard key that ends the recording early, such as "F8"',
1212
+ ),
1213
+ goal: z
1214
+ .string()
1215
+ .max(500)
1216
+ .optional()
1217
+ .describe("what the recording is for, saved with it"),
1218
+ frame_ms: z
1219
+ .number()
1220
+ .int()
1221
+ .min(0)
1222
+ .max(30000)
1223
+ .optional()
1224
+ .describe(
1225
+ "ms between screenshots while nothing happens (default 1000; 0 turns them off)",
1226
+ ),
1227
+ frame_edge: z
1228
+ .number()
1229
+ .int()
1230
+ .min(256)
1231
+ .max(2048)
1232
+ .optional()
1233
+ .describe("long edge of the screenshots in pixels (default 1024)"),
1234
+ },
1235
+ },
1236
+ async (args) => observe("start", args, (r) => json(r.session)),
1237
+ );
1238
+
1239
+ register(
1240
+ "observe_mark",
1241
+ {
1242
+ description:
1243
+ "Add a note to the recording this server started, among the inputs around it: a moment worth finding later, a success, or a failure. Returns whether it was queued (false when no recording of this server's is running).",
1244
+ inputSchema: {
1245
+ kind: z
1246
+ .enum(["mark", "success", "failure", "ai", "own"])
1247
+ .default("mark"),
1248
+ label: z.string().max(200).optional(),
1249
+ data: z.unknown().optional().describe("any JSON value, up to 8 KB"),
1250
+ },
1251
+ },
1252
+ async (args) =>
1253
+ observe("mark", args, (r) =>
1254
+ text(
1255
+ r.queued
1256
+ ? "marked"
1257
+ : "not marked: no recording of this server's is running",
1258
+ ),
1259
+ ),
1260
+ );
1261
+
1262
+ register(
1263
+ "observe_stop",
1264
+ {
1265
+ description:
1266
+ "Stop the recording this server started. Returns its final counts and the reason it ended.",
1267
+ },
1268
+ async () => observe("stop", {}, (r) => json(r.session)),
1269
+ );
1270
+
1271
+ register(
1272
+ "observe_list",
1273
+ {
1274
+ description:
1275
+ "List every recording in Rebind's observations folder, newest first, including ones the user made in the app or with `rebind observe`: id, name, goal, owner, times, and counts.",
1276
+ },
1277
+ async () => observe("list", {}, (r) => json(r.recordings)),
1278
+ );
1279
+
1280
+ register(
1281
+ "observe_events",
1282
+ {
1283
+ description:
1284
+ "Read a recording's log lines (events.jsonl) in seq order: key and button presses with hold times, mouse movement, scrolls, window changes, frame and crop lines naming screenshots, and marks. " +
1285
+ "Returns `lines` and `next`, the seq to pass as from_seq to read on.",
1286
+ inputSchema: {
1287
+ id: z
1288
+ .string()
1289
+ .describe("the recording id, from observe_list or observe_start"),
1290
+ from_seq: z
1291
+ .number()
1292
+ .int()
1293
+ .min(0)
1294
+ .optional()
1295
+ .describe("first seq (default 0)"),
1296
+ to_seq: z.number().int().min(0).optional().describe("last seq"),
1297
+ max_bytes: z
1298
+ .number()
1299
+ .int()
1300
+ .min(1)
1301
+ .max(4194304)
1302
+ .default(65536)
1303
+ .describe(
1304
+ "stop before this many bytes of lines; at least one line is returned",
1305
+ ),
1306
+ },
1307
+ },
1308
+ async ({ id, ...rest }) =>
1309
+ observe("events", { session: id, ...rest }, (r) =>
1310
+ json({ lines: r.lines, next: r.next }),
1311
+ ),
1312
+ );
1313
+
1314
+ register(
1315
+ "observe_frame",
1316
+ {
1317
+ description:
1318
+ "Show one screenshot from a recording, as a frame or crop line names it (frames/NNNNNN.png, frames/NNNNNN.win.png or crops/NNNNNN.png). " +
1319
+ "It shows the screen as it was then: do not click by its coordinates.",
1320
+ inputSchema: {
1321
+ id: z.string().describe("the recording id"),
1322
+ file: z.string().describe("the `file` of a frame or crop line"),
1323
+ max_edge: z
1324
+ .number()
1325
+ .int()
1326
+ .min(1)
1327
+ .default(1280)
1328
+ .describe("scale the image down so its long edge fits"),
1329
+ },
1330
+ },
1331
+ async ({ id, file, max_edge }) =>
1332
+ observe(
1333
+ "frame",
1334
+ { session: id, file, max_edge },
1335
+ async (r: { data: string; width: number; height: number }) => {
1336
+ const shot = await renderScreenshot({
1337
+ png: Buffer.from(r.data, "base64"),
1338
+ logicalWidth: r.width,
1339
+ logicalHeight: r.height,
1340
+ maxEdge: max_edge,
1341
+ });
1342
+ return {
1343
+ content: [
1344
+ {
1345
+ type: "text" as const,
1346
+ text: `${file} of recording ${id}: ${shot.width}x${shot.height}`,
1347
+ },
1348
+ {
1349
+ type: "image" as const,
1350
+ data: shot.data.toString("base64"),
1351
+ mimeType: "image/webp",
1352
+ },
1353
+ ],
1354
+ };
1355
+ },
1356
+ ),
1357
+ );
1358
+
1223
1359
  // Server-side subagents. Gated: a stock install ships without them. They make
1224
1360
  // OpenRouter calls (money) and touch no HID, so they register DIRECTLY on the
1225
1361
  // server — NOT through `register()`, whose serial `chain` would let one
@@ -1446,7 +1582,8 @@ return { ok = Window.IsActive(h), name = name }
1446
1582
  // Fetch a URL's text relay-side. This is the primary way content is
1447
1583
  // gathered: a GET returns exactly what the server has, where driving the
1448
1584
  // browser to the same URL costs HID, focus, and a fragile extraction.
1449
- // Same SSRF guard and size/time limits as the download path.
1585
+ // Same SSRF guard and size/time limits as the download path, and no -L:
1586
+ // a redirect target is a host the guard never checked.
1450
1587
  const fetchText = async (
1451
1588
  u: string,
1452
1589
  maxChars: number,
@@ -1454,7 +1591,7 @@ return { ok = Window.IsActive(h), name = name }
1454
1591
  if (!isSafeFetchUrl(u)) return null;
1455
1592
  try {
1456
1593
  const out = await client.luaExec(
1457
- `local r = System.Exec([[curl -fsSL --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.stdout`,
1594
+ `local r = System.Exec([[curl -fsS -g --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.stdout`,
1458
1595
  );
1459
1596
  const s = String(out ?? "");
1460
1597
  return s.trim() ? s.slice(0, maxChars) : null;
@@ -1567,7 +1704,12 @@ return { ok = Window.IsActive(h), name = name }
1567
1704
  if (haveBrowser)
1568
1705
  await client.luaExec(positionLua("", 0, 0, 1200, 900));
1569
1706
  if (isShell) {
1570
- if (haveBrowser) first = await extractPage(browser, pageUrl);
1707
+ const d = haveBrowser ? await extractPage(browser, pageUrl) : null;
1708
+ if (d && isSafeFetchUrl(d.split("\n")[1] ?? "")) first = d;
1709
+ else if (d)
1710
+ note(
1711
+ "rendered page refused: the browser landed on a host the fetch guard refuses",
1712
+ );
1571
1713
  if (!first) first = raw; // no browser — the shell is all we have
1572
1714
  }
1573
1715
  // PREPEND, never append: the per-page budget keeps only the first
@@ -1649,11 +1791,12 @@ return { ok = Window.IsActive(h), name = name }
1649
1791
  .replace(/[^\w.-]/g, "_")
1650
1792
  .slice(0, 80) || "asset";
1651
1793
  const path = `${dlDir}/${assets.length}-${name}`;
1652
- // isSafeFetchUrl bans quotes/brackets AND private hosts, so this
1653
- // splice is injection-safe; --proto and no -L block redirect-SSRF,
1794
+ // isSafeFetchUrl bans quotes, brackets and braces and private
1795
+ // hosts, and -g turns curl's URL globbing off, so this splice is
1796
+ // injection-safe; --proto and no -L block redirect-SSRF,
1654
1797
  // --max-filesize/--max-time bound a hostile response.
1655
1798
  const exit = await client.luaExec(
1656
- `local r = System.Exec([[curl -fsS --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} -o '${path}' '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.exit`,
1799
+ `local r = System.Exec([[curl -fsS -g --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} -o '${path}' '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.exit`,
1657
1800
  );
1658
1801
  const ok = Number(exit) === 0;
1659
1802
  // inline text/csv content so the model can READ the source, not
@@ -1729,7 +1872,11 @@ return { ok = Window.IsActive(h), name = name }
1729
1872
  if (haveBrowser) {
1730
1873
  await gotoUrl(pageUrl);
1731
1874
  try {
1732
- const shot = await captureScreen();
1875
+ const { shot } = await captureScreen();
1876
+ if (!isSafeFetchUrl(await tabUrl(browser || front)))
1877
+ throw new Error(
1878
+ "the browser landed on a URL the fetch guard refuses",
1879
+ );
1733
1880
  const vstep = await llmStep(
1734
1881
  [
1735
1882
  { role: "system", content: MEDITATE_VISION_SYS },
@@ -1737,7 +1884,12 @@ return { ok = Window.IsActive(h), name = name }
1737
1884
  role: "user",
1738
1885
  content: [
1739
1886
  { type: "text", text: "Read this page." },
1740
- { type: "image_url", image_url: { url: shot.dataUrl } },
1887
+ {
1888
+ type: "image_url",
1889
+ image_url: {
1890
+ url: `data:image/webp;base64,${shot.data.toString("base64")}`,
1891
+ },
1892
+ },
1741
1893
  ],
1742
1894
  },
1743
1895
  ],
@@ -1756,7 +1908,7 @@ return { ok = Window.IsActive(h), name = name }
1756
1908
  }
1757
1909
  } catch (e) {
1758
1910
  note(
1759
- `vision pass skipped (model may lack image input): ${e instanceof Error ? e.message : e}`,
1911
+ `vision pass skipped: ${e instanceof Error ? e.message : e}`,
1760
1912
  );
1761
1913
  }
1762
1914
  }
package/src/observe.ts ADDED
@@ -0,0 +1,19 @@
1
+ // the observe_* tools' plain answers when Remote Control refuses an observe.*
2
+ // command for a reason the user fixes, not the agent.
3
+
4
+ import { ServerError } from "@rebind.gg/client-ts";
5
+
6
+ /** the text to show for a refusal the user must fix, or null for any other error */
7
+ export function observeRefusal(e: unknown): string | null {
8
+ if (!(e instanceof ServerError)) return null;
9
+ switch (e.code) {
10
+ case "disabled":
11
+ return "Allow observation is off in Remote Control's Options on the computer running Rebind. Ask the user to turn it on if they want to be recorded; while it is on, a connected program can record every key they press and their screen.";
12
+ case "unknown_command":
13
+ return "This Remote Control has no observe commands: it is older than 0.5.0. Ask the user to update the Remote Control package.";
14
+ case "unavailable":
15
+ return "This Rebind cannot record observations: it is older than 3.15.0. Ask the user to update Rebind.";
16
+ default:
17
+ return null;
18
+ }
19
+ }
package/src/prompts.ts CHANGED
@@ -73,6 +73,10 @@ export const DETERMINISTIC_WORK_DOCTRINE =
73
73
  export const SAVE_WORK_DOCTRINE =
74
74
  "When a sequence works and the user wants to keep it, save it as a Rebind script so it runs again with no agent and no tokens. On the computer running Rebind: `rebind init <dir>` scaffolds rebind.toml and main.luau; put the sequence in main.luau bound to a key (Bind or OnDown; hooks never fire inside run_lua, they do in an installed script); add `[runtime] permissions = [...]` to rebind.toml listing only what it uses; then run `rebind check <dir>`, `rebind pack <dir>` (writes <name>.rbp in the current directory), `rebind install <name>.rbp --allow` (it prints what the package asks for; add --force to replace an earlier install) and `rebind script start packages/<name>/main.luau`. Tell the user which key runs it, and that it runs until Rebind restarts and can be started again from Rebind's scripts list.";
75
75
 
76
+ /** MCP server only: the observe_* tools record the user, so they ask first. */
77
+ export const OBSERVE_DOCTRINE =
78
+ "observe_start records the user, not you: every key and button they press and release (passwords included, by name), mouse movement, the active window, and screenshots of their screen, saved in Rebind's observations folder until they delete it. Use it to watch the user do a task before you script it. Never start one on your own: ask first, saying what it records, for how long, and the stop key if you set one, and start only after the user agrees. Tell them when recording has started and not to type secrets. When they say they are done, call observe_stop, then read the recording with observe_events (log lines in seq order) and observe_frame (the screenshots its frame and crop lines name). observe_list shows every recording, including ones the user made in the app.";
79
+
76
80
  // --- tool descriptions (shared only where the underlying behavior is identical
77
81
  // across both surfaces: run_lua, lua_docs, and key all hit the same relay) ---
78
82
 
@@ -90,7 +94,7 @@ export const RUN_LUA_DESC =
90
94
  '(Sleep, HID.Press, Dialog file pickers, Screen.Capture, Screen.FindImage, Screen.SearchForColor over 200k pixels) error — wait with System.Exec("sleep 0.5") instead, and take screenshots with the screenshot tool, not from a chunk. There is no Lua stdlib `io`/`os`/`require`. ' +
91
95
  "Use it to launch apps (System.ExecDetached), place windows (Window.Wait / Window.Move — on macOS the window handle is advisory and Move targets the FRONTMOST window, so bring the app forward first), run shell commands " +
92
96
  '(System.Exec(cmd) -> {exit,stdout,stderr}, absolute paths fine; inside a chunk it runs asynchronously on a background thread and does not block, so a slow command is safe), find UI by color in a small region (Screen.SearchForColor({x1,y1,x2,y2}, "rrggbb", tolerance), region under 200k pixels), type, click, read pixels (Screen.GetPixelColor), manage processes. ' +
93
- "File.* (Read/Write/Append/ReadJSON/WriteJSON/Exists/Delete/List/MkDir/RmDir/Copy/Move/GetSize/GetTime/GetScriptDir) is jailed to the script dir: paths RELATIVE, absolute rejected. " +
97
+ "File.* (Read/Write/Append/ReadJSON/WriteJSON/Exists/Delete/List/MkDir/RmDir/Copy/Move/GetSize/GetTime/GetScriptDir/GetDataDir) is jailed to the script dir: paths RELATIVE, absolute rejected. Paths starting \"@data/\" reach the script's data folder (3.13.0), which a chunk does not have. " +
94
98
  'A folder described as on the Desktop or in home is reachable at its BARE name from the jail root (e.g. File.List("myfolder")) — never prefix "Desktop/" or "~". ' +
95
99
  "JSON is JSON.Parse(s) and JSON.Stringify(v) — there is no JSON.Decode/Encode; for JSON files File.ReadJSON/File.WriteJSON is one call. " +
96
100
  "System.Exec commands run in an UNRELATED working directory — a relative shell path does NOT land in the File jail, so do file work with File.* (File.Write always works in the jail), and use absolute paths inside shell commands. " +
package/src/rebind.d.luau CHANGED
@@ -190,10 +190,10 @@ export type InputDevice = {
190
190
  id: string,
191
191
  name: string,
192
192
  nickname: string?,
193
- kind: "keyboard" | "mouse" | "other",
193
+ kind: "keyboard" | "mouse" | "gamepad" | "joystick" | "other",
194
194
  vid: string,
195
195
  pid: string,
196
- source: "vm" | "host",
196
+ source: "vm",
197
197
  exact: boolean,
198
198
  }
199
199
 
@@ -407,6 +407,9 @@ export type SliderOpts = WidgetOpts & {
407
407
  suffix: string?,
408
408
  }
409
409
 
410
+ -- a keybind setting holds a key name, or "<key>@<device id>" once the user
411
+ -- restricts it to the device that pressed it; Bind, Bind.Toggle, Bind.Remap
412
+ -- and rb.bind read the suffix as their device filter. A default is a plain key.
410
413
  export type KeybindOpts = WidgetOpts
411
414
 
412
415
  export type SelectOpts = WidgetOpts
@@ -419,6 +422,12 @@ export type TextOpts = WidgetOpts & {
419
422
 
420
423
  export type ColorOpts = WidgetOpts
421
424
 
425
+ export type ListOpts = WidgetOpts & {
426
+ placeholder: string?,
427
+ maxLength: number?,
428
+ max_length: number?,
429
+ }
430
+
422
431
  export type RecordOpts = {
423
432
  ignore_mouse: boolean?,
424
433
  ignore_keyboard: boolean?,
@@ -432,7 +441,7 @@ export type UiProxy = { [string]: any }
432
441
 
433
442
  -- bind config for table-style binding
434
443
  export type BindConfig = {
435
- device: string?, -- nickname or id: the bind answers only to that device
444
+ device: string?, -- nickname or id: the bind answers only to that device; replaces a pin in the key
436
445
  when: ((device: InputDevice?) -> boolean)?,
437
446
  action: ((device: InputDevice?) -> boolean?)?,
438
447
  release: ((device: InputDevice?) -> ())?,
@@ -445,9 +454,8 @@ export type RebindInfo = {
445
454
  version: string,
446
455
  name: string,
447
456
  runtime_uuid: string,
448
- id: string?,
449
457
  instance: string,
450
- -- host operating system: "windows" | "macos" | "linux"
458
+ -- host operating system: "windows" | "macos"
451
459
  platform: string,
452
460
  }
453
461
 
@@ -486,7 +494,8 @@ export type RbBindOptions = {
486
494
  when: ((device: InputDevice?) -> boolean)?,
487
495
  -- runs when the key is released
488
496
  release: ((device: InputDevice?) -> ())?,
489
- -- nickname or id: the bind answers only to that device
497
+ -- nickname or id: the bind answers only to that device; replaces a pin in
498
+ -- the key ("<key>@<device id>", saved by a keybind setting)
490
499
  device: string?,
491
500
  }
492
501
 
@@ -541,9 +550,298 @@ export type RbPhone = {
541
550
  notify: (text: string, title: string?) -> PhoneResult,
542
551
  }
543
552
 
553
+ -- a colour is "#rrggbb" or "#rrggbbaa"; in widget text it may also be a theme
554
+ -- role: "text", "dim", "faint", "accent", "positive", "warning" or "danger"
555
+ export type OverlaySpan = string | { any }
556
+
557
+ -- a string (a newline starts a line) or one line of spans
558
+ export type OverlayText = string | { OverlaySpan }
559
+
560
+ -- one draw operation in a surface, in logical units from its top-left corner
561
+ export type OverlayOp = {
562
+ kind: "rect" | "text" | "image",
563
+ x: number?,
564
+ y: number?,
565
+ -- rect and image; an image defaults to its size in pixels
566
+ w: number?,
567
+ h: number?,
568
+ fill: string?,
569
+ border: string?,
570
+ border_width: number?,
571
+ radius: number?,
572
+ -- true (50% black), false, or the shadow's opacity from 0 to 1
573
+ shadow: (boolean | number)?,
574
+ -- text: one line, as text plus color and bold, or as spans
575
+ text: string?,
576
+ spans: { OverlaySpan }?,
577
+ color: string?,
578
+ bold: boolean?,
579
+ size: number?,
580
+ -- image (3.14.0): the bytes of a PNG file, such as
581
+ -- rb.file.read_bytes("icon.png")
582
+ png: string?,
583
+ }
584
+
585
+ -- where a surface goes: x and y in rb.screen coordinates, or an anchor
586
+ -- ("top-left", "top", "top-right", "left", "center", "right", "bottom-left",
587
+ -- "bottom", "bottom-right") on a display ("active": the foreground window's
588
+ -- monitor, "primary", "mouse"), moved offset_x and offset_y inward
589
+ export type OverlayPlacement = {
590
+ x: number?,
591
+ y: number?,
592
+ anchor: string?,
593
+ display: ("active" | "primary" | "mouse")?,
594
+ offset_x: number?,
595
+ offset_y: number?,
596
+ }
597
+
598
+ export type OverlaySurfaceOptions = OverlayPlacement & {
599
+ width: number,
600
+ height: number,
601
+ opacity: number?,
602
+ -- true shows the surface in screenshots and recordings (3.14.0)
603
+ capturable: boolean?,
604
+ ops: { OverlayOp }?,
605
+ }
606
+
607
+ export type OverlaySurface = {
608
+ -- merges fields into the surface; x/y and anchor/display replace each other
609
+ set: (self: OverlaySurface, fields: { [string]: any }) -> OverlaySurface,
610
+ draw: (self: OverlaySurface, ops: { OverlayOp }) -> OverlaySurface,
611
+ remove: (self: OverlaySurface) -> (),
612
+ }
613
+
614
+ -- shared by the widgets; theme is a name from rb.overlay.themes or a table
615
+ -- of the same roles
616
+ export type OverlayWidgetOptions = OverlayPlacement & {
617
+ theme: (string | { [string]: any })?,
618
+ size: number?,
619
+ -- true shows the widget in screenshots and recordings (3.14.0)
620
+ capturable: boolean?,
621
+ }
622
+
623
+ export type OverlayToastOptions = OverlayWidgetOptions & {
624
+ title: string?,
625
+ meta: string?,
626
+ -- ms on screen (default 4000)
627
+ duration: number?,
628
+ -- ms to wait for hidden overlays to show before giving up (default 60000)
629
+ expires: number?,
630
+ max_width: number?,
631
+ }
632
+
633
+ export type OverlayToast = {
634
+ close: () -> (),
635
+ }
636
+
637
+ export type OverlayMenuItem = string | {
638
+ label: string,
639
+ hint: string?,
640
+ action: ((index: number, item: any) -> ())?,
641
+ }
642
+
643
+ export type OverlayMenuOptions = OverlayWidgetOptions & {
644
+ title: string?,
645
+ subtitle: string?,
646
+ width: number?,
647
+ keep_open: boolean?,
648
+ on_select: ((index: number, item: any) -> ())?,
649
+ on_close: (() -> ())?,
650
+ }
651
+
652
+ export type OverlayMenu = {
653
+ selected: number,
654
+ select: (index: number) -> (),
655
+ close: () -> (),
656
+ }
657
+
658
+ export type OverlayPromptOptions = OverlayWidgetOptions & {
659
+ title: string?,
660
+ -- the text to start with
661
+ value: string?,
662
+ -- shown faint while the field is empty
663
+ placeholder: string?,
664
+ -- characters, 1 to 256 (default 128)
665
+ max_length: number?,
666
+ width: number?,
667
+ on_submit: ((text: string) -> ())?,
668
+ on_cancel: (() -> ())?,
669
+ on_close: (() -> ())?,
670
+ }
671
+
672
+ export type OverlayPrompt = {
673
+ text: string,
674
+ set: (text: string) -> (),
675
+ close: () -> (),
676
+ }
677
+
678
+ export type OverlayStatus = {
679
+ set: (text: OverlayText) -> OverlayStatus,
680
+ remove: () -> (),
681
+ }
682
+
683
+ export type OverlayProgressOptions = OverlayWidgetOptions & {
684
+ -- logical units (default 320)
685
+ width: number?,
686
+ }
687
+
688
+ export type OverlayProgress = {
689
+ set: (fraction: number?, label: string?) -> OverlayProgress,
690
+ remove: () -> (),
691
+ }
692
+
693
+ export type RbOverlay = {
694
+ -- a surface you draw yourself; at most 16 per script covering at most
695
+ -- 2,000,000 square units together, removed when the script stops
696
+ surface: (options: OverlaySurfaceOptions) -> OverlaySurface,
697
+ -- width and height of one line of text in the bundled monospace face
698
+ measure: (text: string, size: number?, bold: boolean?) -> (number, number),
699
+ -- false only when the OS is known to hide overlays (Windows: an exclusive
700
+ -- full-screen Direct3D game; macOS: the overlays on screen covered for 2
701
+ -- seconds, so with none on screen it is true)
702
+ visible: () -> boolean,
703
+ -- removes every surface of this script
704
+ clear: () -> (),
705
+ -- "rebind", "rebind-light", "catppuccin", "tokyo-night", "contrast"
706
+ themes: { [string]: { [string]: any } },
707
+ -- a timed notification; toasts at one anchor stack, and one asked for while
708
+ -- overlays are hidden waits until they show or it expires. A waiting toast
709
+ -- that then cannot show is dropped with a warning in the script's log.
710
+ toast: (text: OverlayText, options: OverlayToastOptions?) -> OverlayToast,
711
+ -- a keyboard menu: while open it takes Up, Down, Enter, Escape and 1 to 9 and
712
+ -- swallows them. A pick closes it (unless keep_open), then runs the item's
713
+ -- action and on_select. Losing focus or the OS hiding overlays also closes
714
+ -- it; on_close runs for every close except rb.overlay.clear() and stopping.
715
+ -- nil and a reason when overlays are hidden, the script is unfocused, or a
716
+ -- menu or prompt is already open, in this script or another.
717
+ menu: (items: { OverlayMenuItem }, options: OverlayMenuOptions?) -> (OverlayMenu?, string?),
718
+ -- (3.15.0) a one-line text field typed through swallowing binds, so it needs no focus.
719
+ -- Enter closes it and runs on_submit(text); Escape, close(), losing focus or
720
+ -- the OS hiding overlays close it and run on_cancel; on_close then runs for
721
+ -- either. Each key types its US-layout character, Shift picks the shifted
722
+ -- one, Backspace deletes, Ctrl+V (Cmd+V on macOS) pastes. Ctrl, Alt and Win
723
+ -- chords reach the app. Holding a key acts once. It holds the same keyboard
724
+ -- as menu, so it returns the same reasons as menu.
725
+ prompt: (label: string, options: OverlayPromptOptions?) -> (OverlayPrompt?, string?),
726
+ -- a one-line strip that stays until removed
727
+ status: (text: OverlayText, options: OverlayWidgetOptions?) -> OverlayStatus,
728
+ -- a labelled bar, fraction 0 to 1
729
+ progress: (label: string, fraction: number?, options: OverlayProgressOptions?) -> OverlayProgress,
730
+ }
731
+
732
+ -- rb.observe (3.15.0) records what the user does, as `rebind observe` does:
733
+ -- every key and button with its hold time, mouse movement, the active window
734
+ -- and screenshots, in a session folder the script reads back. It needs the
735
+ -- observe permission, which a script holds only when it declares it, with
736
+ -- min_sdk=3.15.0 or later. A script marks and stops only the session it
737
+ -- started, and renames or deletes only recordings a script made under its
738
+ -- name (owner_kind "script"); it reads every recording.
739
+ -- start, stop, list, events, frame, rename and delete wait for an answer, so
740
+ -- call them inside rb.run; mark and status answer at once.
741
+
742
+ export type ObserveStartOptions = {
743
+ -- 1 to 14400 (default 120)
744
+ seconds: number?,
745
+ -- a keyboard key that ends the session early, such as "F8"
746
+ stop_key: string?,
747
+ -- what the recording is for, up to 500 characters
748
+ goal: string?,
749
+ -- ms between idle screenshots, 0 to 30000; 0 turns them off (default 1000)
750
+ frame_ms: number?,
751
+ -- the long edge of screenshots, 256 to 2048 pixels (default 1024)
752
+ frame_edge: number?,
753
+ }
754
+
755
+ export type ObserveStatus = {
756
+ id: string,
757
+ -- the session folder, holding events.jsonl, frames/ and crops/
758
+ dir: string,
759
+ elapsed_ms: number,
760
+ seconds: number,
761
+ events: number,
762
+ frames: number,
763
+ crops: number,
764
+ marks: number,
765
+ drops: number,
766
+ bytes: number,
767
+ -- the script's name, or the app or CLI that started it
768
+ owner: string,
769
+ -- "script" for a session rb.observe started, "client" for the app or CLI
770
+ owner_kind: string,
771
+ goal: string?,
772
+ -- why no screenshots are taken, when they are not
773
+ frames_off: string?,
774
+ -- why it ended, once it has
775
+ reason: string?,
776
+ }
777
+
778
+ export type ObserveSummary = {
779
+ id: string,
780
+ name: string?,
781
+ goal: string?,
782
+ owner: string,
783
+ -- "script" or "client", as on ObserveStatus
784
+ owner_kind: string,
785
+ -- epoch ms
786
+ started_at: number,
787
+ ended_at: number?,
788
+ seconds: number,
789
+ events: number,
790
+ frames: number,
791
+ crops: number,
792
+ marks: number,
793
+ drops: number,
794
+ dropped_marks: number,
795
+ bytes: number,
796
+ frames_off: string?,
797
+ reason: string?,
798
+ -- true while it records
799
+ running: boolean?,
800
+ }
801
+
802
+ export type ObserveEvents = {
803
+ -- events.jsonl lines, each a table with seq, t and kind
804
+ lines: { { [string]: any } },
805
+ -- the seq to ask for next
806
+ next: number,
807
+ }
808
+
809
+ export type ObserveImage = {
810
+ mime: string,
811
+ width: number,
812
+ height: number,
813
+ -- the PNG, base64
814
+ data: string,
815
+ }
816
+
817
+ export type RbObserve = {
818
+ -- starts a session this script owns; one session runs at a time
819
+ start: (options: ObserveStartOptions?) -> ObserveStatus,
820
+ -- ends the session this script started; its final counts and reason
821
+ stop: () -> ObserveStatus,
822
+ -- a note in this script's running session: label up to 200 characters,
823
+ -- data up to 8 KB as JSON and 32 tables deep. false when this script has
824
+ -- no session running or the note could not be queued
825
+ mark: (kind: "mark" | "success" | "failure" | "ai" | "own", label: string?, data: any?) -> boolean,
826
+ -- the running session, whoever started it, or nil
827
+ status: () -> ObserveStatus?,
828
+ -- every recording, newest first
829
+ list: () -> { ObserveSummary },
830
+ -- lines from from_seq through to_seq, stopping before max_bytes of them
831
+ -- (default 262144, at most 4194304) but returning at least one
832
+ events: (id: string, from_seq: number?, to_seq: number?, max_bytes: number?) -> ObserveEvents,
833
+ -- "frames/NNNNNN.png", "frames/NNNNNN.win.png" or "crops/NNNNNN.png",
834
+ -- scaled down to max_edge
835
+ frame: (id: string, file: string, max_edge: number?) -> ObserveImage,
836
+ -- names a finished recording; "" clears the name
837
+ rename: (id: string, name: string) -> ObserveSummary,
838
+ -- removes a finished recording's folder; returns its id
839
+ delete: (id: string) -> string,
840
+ }
841
+
544
842
  -- events for rb.on (Rebind 3.9.0 and later), with the arguments each
545
- -- handler receives. For down, up, move and scroll, a handler that returns
546
- -- false swallows the input.
843
+ -- handler receives. For down, up, scroll and axis, a handler that returns
844
+ -- false swallows the input; for move only with mouse_block=true.
547
845
  export type Event =
548
846
  | "start" -- ()
549
847
  | "stop" -- ()
@@ -557,6 +855,7 @@ export type Event =
557
855
  | "move" -- (dx: number, dy: number, device: InputDevice?) hardware mode only
558
856
  | "scroll" -- (delta: number, device: InputDevice?)
559
857
  | "scroll_h" -- (delta: number)
858
+ | "axis" -- (axis: string, value: number, device: InputDevice?) hardware mode only
560
859
  | "input_down" -- (key: string, device: InputDevice?)
561
860
  | "input_up" -- (key: string, duration_ms: number, device: InputDevice?)
562
861
  | "input_move" -- (dx: number, dy: number, device: InputDevice?)
@@ -577,9 +876,19 @@ export type Event =
577
876
  -- system: PrintScreen, Pause, Menu
578
877
  -- numpad: Kp0-Kp9, KpPlus, KpMinus, KpMultiply, KpDivide, KpEnter, KpDot
579
878
  --
580
- -- HID output only:
879
+ -- delivered as input only in hardware mode:
581
880
  -- function: F13-F24
582
- -- media: MediaNext, MediaPrev, MediaStop, MediaPlay, VolumeUp, VolumeDown, Mute
881
+ -- media: MediaNext, MediaPrev, MediaStop, MediaPlay, VolumeUp, VolumeDown, VolumeMute
882
+ --
883
+ -- game controllers, hardware mode only (named by position, Xbox letters):
884
+ -- buttons: PadA (bottom), PadB (right), PadX (left), PadY (top), PadLB,
885
+ -- PadRB, PadLT, PadRT, PadView, PadMenu, PadHome, PadLS, PadRS,
886
+ -- PadUp, PadDown, PadLeft, PadRight, PadP1-PadP4 (paddles)
887
+ -- other pads: Joy1-Joy32, in the order the controller reports them
888
+ -- axes: LeftX, LeftY, RightX, RightY (-1..1, up and left negative),
889
+ -- LT, RT (0..1); the "axis" event and input.axis use these
890
+ --
891
+ -- HID output and bind chord modifiers only:
583
892
  -- aliases: Numpad0-9, NP0-9, NPAdd, NPSubtract, NPMultiply, NPDivide, NPEnter,
584
893
  -- NPDecimal, Ctrl, Shift, Alt, Win, Meta, Del, PgUp, PgDn, etc.
585
894
 
@@ -652,7 +961,6 @@ declare rb: {
652
961
  -- most), waiting with sleep in rb.run and blocking elsewhere. With ms
653
962
  -- it glides there over ms on an eased path; a duration needs rb.run or
654
963
  -- rb.async.
655
- -- Not supported on Linux (no cursor source).
656
964
  move_to: (x: number, y: number, ms: number?) -> (),
657
965
  scroll: (amount: number) -> (),
658
966
  -- deprecated: the mode is stored but no longer changes move_to
@@ -662,13 +970,22 @@ declare rb: {
662
970
  -- up, press and combo also accept "usage:N" (decimal or 0x hex, 0x04-0xE7).
663
971
  usage: (name: string) -> number?,
664
972
  -- sets the whole joypad state (last write wins across scripts). Needs a
665
- -- Rebind Link whose USB profile has a joypad; raises otherwise.
973
+ -- Rebind Link whose USB profile has a joypad; raises otherwise. down, up,
974
+ -- press and combo also press controller buttons ("PadA").
666
975
  joypad: (state: JoypadState?) -> (),
667
- -- back to neutral; the relay also does this when a script stops or errors
976
+ -- holds one controller axis (LeftX..RightY -1..1, LT/RT 0..1) until set
977
+ -- again or reset; it wins over a captured controller's value
978
+ axis: (name: string, value: number) -> (),
979
+ -- drops what scripts set, keeping what a captured controller holds; the
980
+ -- relay also does this when a script stops or errors
668
981
  joypad_reset: () -> (),
669
982
  },
670
983
  input: {
671
984
  is_down: (code: string) -> boolean,
985
+ -- a controller axis by name, 0 at rest
986
+ axis: (name: string) -> number,
987
+ -- the devices Rebind has identified (3.12.0); empty in software mode
988
+ devices: () -> { InputDevice },
672
989
  duration: (code: string) -> number,
673
990
  keys: () -> { string },
674
991
  mouse_pos: () -> MousePos,
@@ -682,6 +999,7 @@ declare rb: {
682
999
  select: (default: string, choices: { string }, opts: SelectOpts?) -> any,
683
1000
  text: (default: string, opts: TextOpts?) -> any,
684
1001
  color: (default: string, opts: ColorOpts?) -> any,
1002
+ list: (default: { string }, opts: ListOpts?) -> any,
685
1003
  get: (id: string) -> any,
686
1004
  set: (id: string, value: any) -> (),
687
1005
  get_all: () -> { [string]: any },
@@ -770,8 +1088,9 @@ declare rb: {
770
1088
  wait_close: (title: string, timeout: number?) -> boolean,
771
1089
  },
772
1090
  -- application control. macOS matches app display names ignoring case;
773
- -- Windows matches executable names (".exe" optional) and cannot hide;
774
- -- Linux raises on every call.
1091
+ -- Windows matches executable names (".exe" optional) and cannot hide.
1092
+ -- activate focuses a running app only (it needs Accessibility on macOS);
1093
+ -- start one with rb.system.exec_detached (permission: exec).
775
1094
  app: {
776
1095
  front: () -> AppInfo?,
777
1096
  is_front: (name: string) -> boolean,
@@ -791,17 +1110,21 @@ declare rb: {
791
1110
  head: (url: string, options: RbHttpOptions?) -> HttpResponse,
792
1111
  request: (options: HttpRequestOptions) -> HttpResponse,
793
1112
  listen: (port: number, handler: (request: HttpRequest) -> HttpResponseSpec | string) -> ServerHandle,
794
- ws_listen: (port: number, handlers: WsListenHandlers) -> WsServerHandle,
1113
+ -- options.host binds one IP ("127.0.0.1" keeps it on this computer); default all interfaces
1114
+ ws_listen: (port: number, handlers: WsListenHandlers, options: { host: string? }?) -> WsServerHandle,
795
1115
  ws_connect: (url: string, handlers: WsConnectHandlers) -> WsConnectHandle,
796
1116
  -- binds a UDP socket; handler receives each datagram's raw bytes and sender.
797
1117
  udp_listen: (bind_ip: string, port: number, handler: (data: string, ip: string, port: number) -> ()) -> UdpHandle,
798
1118
  -- this computer's IPv4 address on its default route, or nil when there is none.
799
1119
  local_ip: () -> string?,
800
1120
  },
1121
+ -- permission: exec
801
1122
  pipe: {
802
1123
  open: (name: string, options: { size: number? }?) -> PipeHandle,
803
1124
  },
804
- -- paths are relative to the script directory; absolute paths are rejected
1125
+ -- paths are relative to the script directory; absolute paths are rejected.
1126
+ -- "@data/..." paths (3.13.0) reach the script's data folder instead, kept
1127
+ -- outside the scripts tree across updates and deleted with the script.
805
1128
  file: {
806
1129
  read: (path: string) -> string,
807
1130
  read_bytes: (path: string) -> string,
@@ -812,13 +1135,16 @@ declare rb: {
812
1135
  -- nil path lists the script's own directory
813
1136
  list: (path: string?) -> { string },
814
1137
  mkdir: (path: string) -> boolean,
815
- -- recursive; returns true even when the directory did not exist.
1138
+ -- recursive; raises when the directory does not exist or cannot be removed.
816
1139
  rmdir: (path: string) -> boolean,
817
1140
  copy: (src: string, dst: string) -> (),
818
1141
  move: (src: string, dst: string) -> (),
819
1142
  read_json: (path: string) -> any,
820
1143
  write_json: (path: string, value: any) -> (),
821
1144
  script_dir: () -> string,
1145
+ -- absolute path of the script's data folder (3.13.0); raises for a
1146
+ -- script with no file
1147
+ data_dir: () -> string,
822
1148
  size: (path: string) -> number,
823
1149
  time: (path: string) -> number,
824
1150
  is_dir: (path: string) -> boolean,
@@ -879,6 +1205,7 @@ declare rb: {
879
1205
  },
880
1206
  env: {
881
1207
  get: (name: string) -> string?,
1208
+ -- Requires the 'exec' permission.
882
1209
  set: (name: string, value: string) -> (),
883
1210
  home: () -> string?,
884
1211
  config: () -> string?,
@@ -900,6 +1227,10 @@ declare rb: {
900
1227
  -- Rebind Pro; require("@rebind/pro") returns rb
901
1228
  ai: RbAi,
902
1229
  phone: RbPhone,
1230
+ -- drawing on screen over every app, games included (Windows and macOS)
1231
+ overlay: RbOverlay,
1232
+ -- recording input, windows and screenshots (3.15.0); permission: observe
1233
+ observe: RbObserve,
903
1234
  }
904
1235
 
905
1236
  -- v1 names
@@ -965,7 +1296,6 @@ declare HID: {
965
1296
  -- rereads the cursor and corrects until within 2 px (about 150 ms at
966
1297
  -- most), waiting with Sleep in Run and blocking elsewhere. With ms it
967
1298
  -- glides there over ms on an eased path; a duration needs Run or Async.
968
- -- Not supported on Linux (no cursor source).
969
1299
  MoveTo: (x: number, y: number, ms: number?) -> (),
970
1300
  Scroll: (amount: number) -> (),
971
1301
  -- deprecated: the mode is stored but no longer changes MoveTo
@@ -977,8 +1307,13 @@ declare HID: {
977
1307
  -- set the whole joypad state (last write wins across scripts). Needs a
978
1308
  -- Rebind Link whose USB profile has a joypad; raises otherwise. Keyboard
979
1309
  -- and mouse calls raise the same way when the profile leaves them out.
1310
+ -- Down, Up, Press and Combo also press controller buttons ("PadA").
980
1311
  Joypad: (state: JoypadState?) -> (),
981
- -- back to neutral; the relay also does this when a script stops or errors
1312
+ -- hold one controller axis (LeftX..RightY -1..1, LT/RT 0..1) until set
1313
+ -- again or reset; it wins over a captured controller's value
1314
+ Axis: (name: string, value: number) -> (),
1315
+ -- drop what scripts set, keeping what a captured controller holds; the
1316
+ -- relay also does this when a script stops or errors
982
1317
  JoypadReset: () -> (),
983
1318
  }
984
1319
 
@@ -986,6 +1321,10 @@ declare HID: {
986
1321
 
987
1322
  declare Input: {
988
1323
  IsDown: (code: string) -> boolean,
1324
+ -- a controller axis by name, 0 at rest
1325
+ GetAxis: (name: string) -> number,
1326
+ -- the devices Rebind has identified (3.12.0); empty in software mode
1327
+ Devices: () -> { InputDevice },
989
1328
  GetDuration: (code: string) -> number,
990
1329
  GetActiveKeys: () -> { string },
991
1330
  GetMousePos: () -> MousePos,
@@ -1002,6 +1341,7 @@ declare UI: {
1002
1341
  Select: (default: string, choices: { string }, opts: SelectOpts?) -> any,
1003
1342
  Text: (default: string, opts: TextOpts?) -> any,
1004
1343
  Color: (default: string, opts: ColorOpts?) -> any,
1344
+ List: (default: { string }, opts: ListOpts?) -> any,
1005
1345
  Get: (id: string) -> any,
1006
1346
  Set: (id: string, value: any) -> (),
1007
1347
  GetAll: () -> { [string]: any },
@@ -1131,12 +1471,13 @@ declare Window: {
1131
1471
  }
1132
1472
 
1133
1473
  -- app namespace: application-level control (Keyboard Maestro's Application
1134
- -- Control). macOS: names match case-insensitively by app display name;
1135
- -- Front/IsRunning/Activate are permission-free; Hide/Quit/IsHidden drive an
1136
- -- AppleEvent and prompt for macOS Automation permission on first use.
1474
+ -- Control). Activate focuses a running app and never launches one; start an
1475
+ -- app with System.ExecDetached (permission: exec). macOS: names match
1476
+ -- case-insensitively by app display name; Activate needs Accessibility;
1477
+ -- Quit/IsHidden drive an AppleEvent and prompt for macOS Automation
1478
+ -- permission on first use; Quit leaves an app that is not running alone.
1137
1479
  -- Windows: names are executable names (case-insensitive, ".exe" optional);
1138
- -- Activate focuses a running app and does not launch one; Quit closes its
1139
- -- windows; Hide/IsHidden raise. Linux: every call raises.
1480
+ -- Quit closes its windows; Hide/IsHidden raise.
1140
1481
 
1141
1482
  declare App: {
1142
1483
  Front: () -> AppInfo?,
@@ -1159,7 +1500,7 @@ declare Net: {
1159
1500
  Head: (url: string, headers: { [string]: string }?, options: HttpOptions?) -> HttpResponse,
1160
1501
  Request: (options: HttpRequestOptions) -> HttpResponse,
1161
1502
  Listen: (port: number, handler: (request: HttpRequest) -> HttpResponseSpec | string) -> ServerHandle,
1162
- WSListen: (port: number, handlers: WsListenHandlers) -> WsServerHandle,
1503
+ WSListen: (port: number, handlers: WsListenHandlers, options: { host: string? }?) -> WsServerHandle,
1163
1504
  WSConnect: (url: string, handlers: WsConnectHandlers) -> WsConnectHandle,
1164
1505
  -- bind a UDP socket; handler receives each datagram's raw bytes and sender.
1165
1506
  UDPListen: (bindIp: string, port: number, handler: (data: string, ip: string, port: number) -> ()) -> UdpHandle,
@@ -1167,7 +1508,7 @@ declare Net: {
1167
1508
  LocalIP: () -> string?,
1168
1509
  }
1169
1510
 
1170
- -- pipe namespace
1511
+ -- pipe namespace (permission: exec)
1171
1512
 
1172
1513
  declare Pipe: {
1173
1514
  Open: (name: string, options: { size: number? }?) -> PipeHandle,
@@ -1183,16 +1524,20 @@ declare File: {
1183
1524
  Exists: (path: string) -> boolean,
1184
1525
  Delete: (path: string) -> boolean,
1185
1526
  -- nil path lists the script's own directory. Paths are relative to the
1186
- -- script dir (the File.* jail); absolute paths are rejected.
1527
+ -- script dir (the File.* jail); absolute paths are rejected. "@data/..."
1528
+ -- paths (3.13.0) reach the script's data folder instead.
1187
1529
  List: (path: string?) -> { string },
1188
1530
  MkDir: (path: string) -> boolean,
1189
- -- recursive; returns true even when the directory did not exist.
1531
+ -- recursive; raises when the directory does not exist or cannot be removed.
1190
1532
  RmDir: (path: string) -> boolean,
1191
1533
  Copy: (src: string, dst: string) -> (),
1192
1534
  Move: (src: string, dst: string) -> (),
1193
1535
  ReadJSON: (path: string) -> any,
1194
1536
  WriteJSON: (path: string, value: any) -> (),
1195
1537
  GetScriptDir: () -> string,
1538
+ -- absolute path of the script's data folder (3.13.0); raises for a
1539
+ -- script with no file
1540
+ GetDataDir: () -> string,
1196
1541
  GetSize: (path: string) -> number,
1197
1542
  GetTime: (path: string) -> number,
1198
1543
  IsDir: (path: string) -> boolean,
@@ -1279,6 +1624,7 @@ declare Codec: {
1279
1624
 
1280
1625
  declare Env: {
1281
1626
  Get: (name: string) -> string?,
1627
+ -- Requires the 'exec' permission.
1282
1628
  Set: (name: string, value: string) -> (),
1283
1629
  Home: () -> string?,
1284
1630
  Config: () -> string?,
@@ -1313,6 +1659,7 @@ declare Registry: {
1313
1659
  -- declare function OnMove(dx: number, dy: number, device: InputDevice?): boolean? -- return value only used with mouse_block=true (hardware mode only)
1314
1660
  -- declare function OnScroll(delta: number, device: InputDevice?): boolean?
1315
1661
  -- declare function OnScrollH(delta: number): boolean?
1662
+ -- declare function OnAxis(axis: string, value: number, device: InputDevice?): boolean? -- hardware mode only
1316
1663
  -- declare function OnInputDown(key: string, device: InputDevice?): ()
1317
1664
  -- declare function OnInputUp(key: string, durationMs: number, device: InputDevice?): ()
1318
1665
  -- declare function OnInputMove(dx: number, dy: number, device: InputDevice?): ()
package/src/keys.test.ts DELETED
@@ -1,15 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { normalizeCombo } from "./keys";
3
-
4
- test("normalizes every Command spelling models emit", () => {
5
- for (const alias of ["LCmd", "Cmd", "Command", "⌘", "Super", "LWin", "Win"]) {
6
- expect(normalizeCombo(`${alias}+L`)).toBe("LMeta+L");
7
- }
8
- expect(normalizeCombo("RCmd+Tab")).toBe("RMeta+Tab");
9
- });
10
-
11
- test("passes through already-valid combos and bare keys", () => {
12
- expect(normalizeCombo("LCtrl+Shift+P")).toBe("LCtrl+Shift+P");
13
- expect(normalizeCombo("Enter")).toBe("Enter");
14
- expect(normalizeCombo(" LMeta + A ")).toBe("LMeta+A");
15
- });