marketplace/utility

Remote Control

Connect your programs to Rebind over WebSocket using the documented remote-control server. Works with the client libraries and MCP.

@rebindv0.1.1free

More install options
$rebind install @rebind/remote-control

Gallery

Remote Control preview

Readme

The documented Rebind remote.luau server, packaged for installation. Connect your programs through JSON over WebSocket to send keyboard and mouse input, read supported host state, and subscribe to events. It uses the existing protocol and works with the client libraries and MCP server.

Set up

Requires Rebind Engine 3.5.0 or newer. Rebind Link is optional. Software mode uses host input APIs; hardware mode sends output through Link. Core scripting runs on Windows, macOS, and Linux, but screen, window, clipboard, and other OS integrations differ. Check platform support for the commands you intend to use.

  1. Open the installed package's main.luau in Rebind and select Options.
  2. Set a strong, unique Auth token before starting the script. The default is blank, which leaves remote commands open to any peer that can reach it.
  3. Leave WS port at 19561, or choose an unused port. Start the script from the editor toolbar. Restart it after changing the port.
  4. Connect to ws://127.0.0.1:19561 from this computer, or use the computer's address from another trusted machine. Use your chosen port if you changed it.

The listener binds all network interfaces, not just localhost. There is no localhost-only setting. Restrict access with the host firewall and use a trusted network or encrypted tunnel: the script serves plain WebSocket, not TLS. Stop the script when remote control is not needed. To revoke existing sessions after changing the token, stop and restart the script.

Connect

On a new WebSocket connection, authenticate with the token you configured:

{"t":"auth","id":1,"token":"YOUR_AUTH_TOKEN"}

Then request the available command names or the current time:

{"t":"commands","id":2}
{"t":"system.time","id":3}

These are requests, not recorded responses. The protocol reference owns the full command list and response formats. For the MCP client, set REBIND_URL to your server address and REBIND_TOKEN to the same token.

Optional command gates

The reference server keeps ALLOW_EXEC, ALLOW_FILE, ALLOW_REGISTRY, and ALLOW_LUA_EXEC off by default. Change these constants in your installed main.luau only when your client needs those commands, then restart the script. They are not remotely editable settings. Some MCP tools require ALLOW_LUA_EXEC; see the MCP documentation linked above before enabling it.

The manifest declares net for WebSocket and outbound HTTP commands, and exec to retain the reference server's optional process-execution capability. Declaring exec does not turn on ALLOW_EXEC. These gates do not make an open server read-only: input, clipboard, window, and other control commands remain available after authentication, or without it when the token is blank.

Stop and maintain

Stop from the editor toolbar. Left Ctrl + Left Alt + K stops all scripts and releases held input. Stopping this server also closes its listener and cancels its timers and audio playback.

In this repository, edit packages/lua-sdk/examples/remote.luau and run just sync-remote-luau. The package entry is generated from that source with only the standalone modeline removed; rebind.toml owns package metadata. The parity test prevents an independently maintained protocol fork.

Listing artwork is an original illustration, not a runtime screenshot. Package preparation does not establish every command's behavior on every platform.

Reviews

No reviews yet. Write a review in the app.

Source

main.luau

--[[ overview

  the successor to remote_access.lua. same wire protocol (json over websocket),
  but every sdk namespace is exposed rather than the original six, and the
  dispatch layer is rewritten: auth, danger-gating, and async handling are
  enforced centrally, so each command is a single line of intent.

  protocol: json messages over websocket. every request may carry an `id`; when
  present the server replies with the same id so the client can correlate.

    {"t":"hid.down", "code":"A"}                    one-shot
    {"t":"screen.pixel", "id":7, "x":100, "y":50}   request/response
    {"t":"subscribe", "events":["mouse","input"]}   push stream
    {"t":"commands", "id":1}                         introspect the surface

  backward compatible with remote_access.lua v1.1 clients: every v1 command name
  and response shape is preserved. new namespaces are additive.

  canonical copy: packages/lua-sdk/examples/remote.luau. mirrored to
  packages/client-ts/lua-server/ (so the npm package ships a matching reference
  server) and www/rebind-gg/public/assets/ (served at www.rebind.gg/assets/).
  edit THIS file, then `just sync-remote-luau` to copy it to both. the three
  MUST stay byte-identical (client-ts/tests/remote-luau-sync.test.ts fails on
  drift); bump PROTOCOL_VERSION here on a breaking change.
--]]

--[[ user config

  danger gates stay file constants, not UI fields: they are the dangerous
  switches, so auditing them by reading the script (grep ALLOW_) is the point,
  and nothing over the wire can flip them.

    ALLOW_EXEC      System.Exec / System.ExecDetached / Process.Kill.
    ALLOW_FILE      File.* and Config.ReadTOML/WriteTOML (sandboxed to the
                    script directory, but still disabled by default remotely).
    ALLOW_REGISTRY  Registry writes/deletes (windows). reads stay open.
    ALLOW_LUA_EXEC  lua.exec — arbitrary lua in the sandbox. highest risk.

  the auth token IS a schema field (the port panel), because a secret is easier
  to rotate from the ui than by editing + reinstalling. it is redacted from
  ui.get / ui.get_all so an authenticated client can never read it back, and the
  compare is done over sha256 so timing can't leak its length or prefix.

  SECURITY: Net.WSListen always binds 0.0.0.0 — the server is reachable from the
  lan, not just localhost. there is no script-side way to restrict it. set a
  token whenever the machine is on an untrusted network; "open" is localhost-safe
  only if you trust everything that can route to this port.
--]]
local ALLOW_EXEC = false
local ALLOW_FILE = false
local ALLOW_REGISTRY = false
local ALLOW_LUA_EXEC = false

local PROTOCOL_VERSION = "1.2.0"

local cfg = UI.Schema({
  port = UI.Slider(19561, { min = 1024, max = 65535, label = "WS port" }),
  auth_token = UI.Text("", {
    label = "Auth token",
    placeholder = "blank = open (lan-exposed!)",
    tooltip = 'when set, clients must send { t="auth", token="..." } first. redacted from ui.get.',
  }),
})

local GATES = {
  exec = ALLOW_EXEC,
  file = ALLOW_FILE,
  registry = ALLOW_REGISTRY,
  lua = ALLOW_LUA_EXEC,
}

-- keys never echoed back over the wire (secrets living in the ui value store).
local REDACTED = { auth_token = true }

-- read the token live so panel edits take effect without a reload.
local function auth_token()
  return cfg.auth_token or ""
end

-- compare via fixed-width sha256 hex so the check time can't leak the token's
-- length or a matching prefix (a plain == short-circuits on first mismatch).
local function token_ok(given)
  local expected = auth_token()
  if expected == "" then
    return true
  end
  return Hash.SHA256(given or "") == Hash.SHA256(expected)
end

-- common banner: what the server is and what it currently allows.
local function server_banner()
  return {
    protocol = PROTOCOL_VERSION,
    auth_required = auth_token() ~= "",
    gates = {
      exec = ALLOW_EXEC,
      file = ALLOW_FILE,
      registry = ALLOW_REGISTRY,
      lua = ALLOW_LUA_EXEC,
    },
  }
end

--[[ server state

  clients      client_id -> live client handle, for server-initiated pushes.
  authed       client_id -> true once verified (or when the token is empty).
  subscribers  client_id -> { client, mouse, window, input } stream flags.
  timers       client_id -> { [handle_id] = timer_handle }; cancelled on close.
  sounds       sound_id  -> SoundHandle, for control-by-id.
  next_id      shared counter backing server-side timer/sound handle ids.
--]]
local server = nil
local clients = {}
local authed = {}
local subscribers = {}
local timers = {}
local sounds = {}
local next_id = 0

local last_mouse_x, last_mouse_y, last_window_title = nil, nil, nil

--[[ json encode

  JSON.Stringify encodes an empty lua table as {} not [], which breaks clients
  expecting an array (empty key list, no windows, no matches). this encoder
  treats any table whose keys are exactly 1..#t as an array — including the
  empty table, which in this protocol is always an empty list. objects (string
  keys) always carry at least one field, so they never collide with that rule.
--]]

local ESCAPES = {
  ['"'] = '\\"',
  ["\\"] = "\\\\",
  ["\n"] = "\\n",
  ["\r"] = "\\r",
  ["\t"] = "\\t",
  ["\b"] = "\\b",
  ["\f"] = "\\f",
}

local function encode_str(s)
  return '"'
    .. s:gsub('[%z\1-\31\\"]', function(c)
      return ESCAPES[c] or string.format("\\u%04x", c:byte())
    end)
    .. '"'
end

local function encode(v)
  local t = type(v)
  if v == nil then
    return "null"
  elseif t == "boolean" then
    return v and "true" or "false"
  elseif t == "number" then
    if v ~= v or v == math.huge or v == -math.huge then
      return "null"
    end
    if v == math.floor(v) and math.abs(v) < 1e15 then
      return string.format("%.0f", v)
    end
    return string.format("%.14g", v)
  elseif t == "string" then
    return encode_str(v)
  elseif t == "table" then
    local n = #v
    local count = 0
    for _ in pairs(v) do
      count += 1
    end
    if count == n then
      local parts = {}
      for i = 1, n do
        parts[i] = encode(v[i])
      end
      return "[" .. table.concat(parts, ",") .. "]"
    end
    local parts = {}
    for k, val in pairs(v) do
      parts[#parts + 1] = encode_str(tostring(k)) .. ":" .. encode(val)
    end
    return "{" .. table.concat(parts, ",") .. "}"
  end
  return "null" -- function / thread / userdata
end

--[[ wire helpers ]]

local function send(client, obj)
  client:Send(encode(obj))
end

local function reply(client, req, payload)
  if req.id == nil then
    return
  end
  payload.id = req.id
  send(client, payload)
end

local function err(client, req, code, message)
  if req.id == nil then
    Log.Warn(`remote: client {client.id} error ({code}): {message}`)
    return
  end
  send(client, { id = req.id, error = { code = code, message = message } })
end

local function hex_rgb(hex)
  return tonumber(hex:sub(1, 2), 16) or 0,
    tonumber(hex:sub(3, 4), 16) or 0,
    tonumber(hex:sub(5, 6), 16) or 0
end

local function alloc_id()
  next_id += 1
  return next_id
end

--[[ command registry

  a command is `def(name, fn, opts)` where opts = { open?, async?, gate? }:

    fn(client, req)  returns a payload table on success, or (nil, code, msg) for
                     a typed error. a nil return is a void "ok".
    open   command is reachable before auth (handshake / ping only).
    async  fn yields (Net.*, Dialog.*, Window.Wait*, coroutine HID) — run in Run().
    gate   name of a GATES flag that must be true, else "disabled".

  auth, gating, error shaping, and replying are all handled by the dispatcher,
  so each fn below is pure intent.
--]]

local api = {}

local function def(name, fn, opts)
  opts = opts or {}
  opts.fn = fn
  api[name] = opts
end

--[[ handshake & meta ]]

def("hello", function()
  return server_banner()
end, { open = true })

def("ping", function()
  return { pong = true, time_ms = System.Time() }
end, { open = true })

def("auth", function(client, req)
  if auth_token() == "" then
    authed[client.id] = true
    return { ok = true, note = "no token required" }
  end
  if token_ok(req.token) then
    authed[client.id] = true
    return { ok = true }
  end
  return nil, "bad_token", "token does not match"
end, { open = true })

def("commands", function()
  local names = {}
  for name in pairs(api) do
    names[#names + 1] = name
  end
  table.sort(names)
  return { commands = names, protocol = PROTOCOL_VERSION }
end)

--[[ hid — output

  down/up/combo/type/move/scroll are one-shot and fire directly. press and
  typewriter sleep between key events, so they must run inside a coroutine and
  are registered async (see the dispatcher's Run() path).

  mouse movement: the sdk has no built-in smooth / glide / tween. HID.Move(dx, dy)
  is relative and single-shot; HID.MoveTo(x, y) is absolute but teleports (reads
  the cursor, fires the delta in one move, with up to 3 corrections only in
  "absolute" mouse mode). Neither eases nor emits intermediate steps.
  Math.Spline / Math.Interpolate / Math.Resample smooth a macro action list, not
  the live cursor; they never call the transport.

  smooth motion is something you compose, not a function you call: step along an
  eased path with HID.Move, sleeping between steps, which requires a coroutine.
  the idiom is exposed below as the async `hid.move_smooth` command taking
  { x, y, duration_ms?, steps? }.

  this output path works in both hardware (teensy) and software (SendInput /
  CGEvent) mode. what is hardware-only is suppressing or transforming the user's
  *incoming* movement (mouse_block / OnMove). in software mode a failed injection
  (uac secure desktop / uipi) latches gp_transport::injection_blocked, so a glide
  can silently no-op.
--]]

def("hid.down", function(_, req)
  HID.Down(req.code)
end)
def("hid.up", function(_, req)
  HID.Up(req.code)
end)
def("hid.combo", function(_, req)
  HID.Combo(req.code)
end)
def("hid.type", function(_, req)
  HID.Type(req.text or "")
end)
def("hid.move", function(_, req)
  HID.Move(req.dx or 0, req.dy or 0)
end)
def("hid.move_to", function(_, req)
  HID.MoveTo(req.x or 0, req.y or 0)
end)
def("hid.scroll", function(_, req)
  HID.Scroll(req.delta or 0)
end)
def("hid.set_mouse_mode", function(_, req)
  HID.SetMouseMode(req.mode or "relative")
  return { mode = HID.GetMouseMode() }
end)
def("hid.get_mouse_mode", function()
  return { mode = HID.GetMouseMode() }
end)
def("hid.press", function(_, req)
  HID.Press(req.code, req.hold_ms or 20)
end, { async = true })
def("hid.typewriter", function(_, req)
  HID.Typewriter(req.text or "", req.delay_ms)
end, { async = true })
-- Glide to (x, y) over duration_ms across `steps` eased relative moves.
-- Async because Sleep needs a coroutine; replies with the final position.
def("hid.move_smooth", function(_, req)
  local tx, ty = req.x or 0, req.y or 0
  local steps = math.max(1, req.steps or 60)
  local duration_ms = req.duration_ms or 250
  local sx, sy = System.Mouse()
  local ax, ay = 0, 0 -- carry sub-pixel remainder so slow moves don't stall
  for i = 1, steps do
    local t = i / steps
    t = t < 0.5 and 2 * t * t or 1 - (-2 * t + 2) ^ 2 / 2 -- easeInOutQuad
    local wx, wy = sx + (tx - sx) * t, sy + (ty - sy) * t
    local cx, cy = System.Mouse() -- re-read: self-corrects vs ballistics/clamp
    local dx, dy = wx - cx + ax, wy - cy + ay
    local mdx, mdy = math.floor(dx), math.floor(dy)
    ax, ay = dx - mdx, dy - mdy
    if mdx ~= 0 or mdy ~= 0 then
      HID.Move(mdx, mdy)
    end
    Sleep(duration_ms / steps)
  end
  HID.MoveTo(tx, ty) -- snap out residual drift
  local fx, fy = System.Mouse()
  return { x = fx, y = fy }
end, { async = true })

--[[ system ]]

def("system.mouse", function()
  local x, y = System.Mouse()
  return { x = x, y = y }
end)
def("system.window", function()
  return { window = System.Window() }
end)
def("system.time", function()
  return { time_ms = System.Time() }
end)
def("system.screen", function()
  local w, h = System.Screen()
  return { width = w, height = h }
end)
def("system.exec", function(_, req)
  return System.Exec(req.cmd or "", { timeout = req.timeout, cwd = req.cwd })
end, { gate = "exec" })
def("system.exec_detached", function(_, req)
  return {
    pid = System.ExecDetached(req.cmd or "", req.args, { cwd = req.cwd }),
  }
end, { gate = "exec" })

--[[ screen ]]

def("screen.pixel", function(_, req)
  local r, g, b = hex_rgb(Screen.GetPixelColor(req.x, req.y))
  return { r = r, g = g, b = b }
end)
def("screen.pixel_hex", function(_, req)
  return { hex = Screen.GetPixelColor(req.x, req.y) }
end)
def("screen.resolution", function()
  local w, h = System.Screen()
  return { width = w, height = h }
end)
def("screen.displays", function()
  return { displays = Screen.List() }
end)
def("screen.list", function()
  return { displays = Screen.List() }
end)
-- capture just part of the desktop instead of a whole display: pass
-- region = { x, y, w, h } (display-local logical coords) and optionally
-- display (1-based index from screen.displays). omit both for the full display.
-- frames downscale to max_edge (default 1280) so a 4K capture stays well under
-- the script memory limit; pass max_edge = 0 for native resolution.
-- async: capture + encode run on an SDK worker thread and the handler yields
-- until the frame is ready, so captures never stall the relay tick thread.
def("screen.capture", function(_, req)
  return Screen.Capture({
    display = req.display,
    region = req.region,
    max_edge = req.max_edge or 1280,
  })
end, { async = true })
-- convenience: capture exactly one window by handle or title. resolves the
-- window rect (screen coords), finds the display it sits on, and converts to a
-- display-local region clamped to that display. returns the same shape as
-- screen.capture ({ image, width, height, display, cursor }).
def("screen.capture_window", function(_, req)
  local handle = req.handle or Window.Find(req.title or "")
  if not handle then
    return nil, "not_found", "no window matches"
  end
  local pos = Window.GetPos(handle) -- { x, y, width, height } in screen coords
  local target = nil
  for _, d in ipairs(Screen.List()) do
    if
      pos.x >= d.x
      and pos.x < d.x + d.width
      and pos.y >= d.y
      and pos.y < d.y + d.height
    then
      target = d
      break
    end
    if d.primary then
      target = target or d
    end
  end
  if not target then
    return nil, "no_display", "no displays available"
  end
  local rx = math.max(0, pos.x - target.x)
  local ry = math.max(0, pos.y - target.y)
  local rw = math.min(pos.width, target.width - rx)
  local rh = math.min(pos.height, target.height - ry)
  return Screen.Capture({
    display = target.index,
    region = { x = rx, y = ry, w = rw, h = rh },
    max_edge = req.max_edge or 1280,
  })
end, { async = true })
def("screen.search_color", function(_, req)
  local hit = Screen.SearchForColor(req.region, req.color, req.tolerance)
  return { match = hit }
end, { async = true })

--[[ input — state ]]

def("input.keys", function()
  return { keys = Input.GetActiveKeys() }
end)
def("input.is_down", function(_, req)
  return { down = Input.IsDown(req.code) }
end)
def("input.duration", function(_, req)
  return { ms = Input.GetDuration(req.code) }
end)
def("input.modifiers", function()
  return { modifiers = Input.GetModifiers() }
end)
def("input.mouse_pos", function()
  return { pos = Input.GetMousePos() }
end)

--[[ clipboard ]]

def("clipboard.get", function()
  return { text = Clipboard.Get() or "" }
end)
def("clipboard.set", function(_, req)
  Clipboard.Set(req.text or "")
end)

--[[ window ]]

def("window.list", function(_, req)
  return { windows = Window.List(req.filter) }
end)
def("window.find", function(_, req)
  return { handle = Window.Find(req.title or "") }
end)
def("window.get_title", function(_, req)
  return { title = Window.GetTitle(req.handle) }
end)
def("window.get_class", function(_, req)
  return { class = Window.GetClass(req.handle) }
end)
def("window.get_pos", function(_, req)
  return { pos = Window.GetPos(req.handle) }
end)
def("window.get_pid", function(_, req)
  return { pid = Window.GetPID(req.handle) }
end)
def("window.is_visible", function(_, req)
  return { visible = Window.IsVisible(req.handle) }
end)
def("window.is_active", function(_, req)
  return { active = Window.IsActive(req.handle) }
end)
def("window.activate", function(_, req)
  Window.Activate(req.handle)
end)
def("window.move", function(_, req)
  Window.Move(req.handle, req.x, req.y, req.width, req.height)
end)
def("window.minimize", function(_, req)
  Window.Minimize(req.handle)
end)
def("window.maximize", function(_, req)
  Window.Maximize(req.handle)
end)
def("window.restore", function(_, req)
  Window.Restore(req.handle)
end)
def("window.hide", function(_, req)
  Window.Hide(req.handle)
end)
def("window.show", function(_, req)
  Window.Show(req.handle)
end)
def("window.set_title", function(_, req)
  Window.SetTitle(req.handle, req.title or "")
end)
def("window.set_always_on_top", function(_, req)
  Window.SetAlwaysOnTop(req.handle, req.enabled and true or false)
end)
def("window.set_transparency", function(_, req)
  Window.SetTransparency(req.handle, req.alpha or 255)
end)
def("window.close", function(_, req)
  Window.Close(req.handle)
end)
def("window.kill", function(_, req)
  Window.Kill(req.handle)
end)
-- wait/wait_active/wait_close block until the condition (or timeout), so async.
def("window.wait", function(_, req)
  return { handle = Window.Wait(req.title or "", req.timeout) }
end, { async = true })
def("window.wait_active", function(_, req)
  return { activated = Window.WaitActive(req.title or "", req.timeout) }
end, { async = true })
def("window.wait_close", function(_, req)
  return { closed = Window.WaitClose(req.title or "", req.timeout) }
end, { async = true })

--[[ app ]]

def("app.front", function()
  return { app = App.Front() }
end)
def("app.is_front", function(_, req)
  return { result = App.IsFront(req.name or "") }
end)
def("app.is_running", function(_, req)
  return { result = App.IsRunning(req.name or "") }
end)
def("app.is_hidden", function(_, req)
  return { result = App.IsHidden(req.name or "") }
end)
def("app.activate", function(_, req)
  App.Activate(req.name or "")
end)
def("app.hide", function(_, req)
  App.Hide(req.name or "")
end)
def("app.quit", function(_, req)
  App.Quit(req.name or "")
end)

--[[ process ]]

def("process.exists", function(_, req)
  return { pid = Process.Exists(req.name or "") }
end)
def("process.list", function(_, req)
  return { processes = Process.List(req.name) }
end)
def("process.kill", function(_, req)
  return { killed = Process.Kill(req.pid) }
end, { gate = "exec" })

--[[ env ]]

def("env.get", function(_, req)
  return { value = Env.Get(req.name or "") }
end)
def("env.set", function(_, req)
  Env.Set(req.name or "", req.value or "")
end)
-- known-folder getters: home/config/data/desktop/documents/downloads/appdata/temp.
for _, dir in ipairs({
  "Home",
  "Config",
  "Data",
  "Desktop",
  "Documents",
  "Downloads",
  "AppData",
  "Temp",
}) do
  def("env." .. dir:lower(), function()
    return { path = Env[dir]() }
  end)
end

--[[ hash & codec ]]

for _, algo in ipairs({ "MD5", "SHA1", "SHA256", "SHA512", "CRC32" }) do
  def("hash." .. algo:lower(), function(_, req)
    return { digest = Hash[algo](req.data or "") }
  end)
end
def("hash.hmac", function(_, req)
  return {
    digest = Hash.HMAC(req.algo or "sha256", req.key or "", req.data or ""),
  }
end)
def("codec.base64", function(_, req)
  return { result = Codec.Base64(req.data or "") }
end)
def("codec.base64_decode", function(_, req)
  return { result = Codec.Base64Decode(req.data or "") }
end)
def("codec.hex", function(_, req)
  return { result = Codec.Hex(req.data or "") }
end)
def("codec.hex_decode", function(_, req)
  return { result = Codec.HexDecode(req.data or "") }
end)

--[[ regex ]]

def("regex.is_match", function(_, req)
  return { result = Regex.IsMatch(req.text or "", req.pattern or "") }
end)
def("regex.find", function(_, req)
  return { match = Regex.Find(req.text or "", req.pattern or "") }
end)
def("regex.find_all", function(_, req)
  return { matches = Regex.FindAll(req.text or "", req.pattern or "") }
end)
def("regex.replace", function(_, req)
  return {
    result = Regex.Replace(req.text or "", req.pattern or "", req.rep or ""),
  }
end)
def("regex.replace_all", function(_, req)
  return {
    result = Regex.ReplaceAll(req.text or "", req.pattern or "", req.rep or ""),
  }
end)
def("regex.split", function(_, req)
  return { parts = Regex.Split(req.text or "", req.pattern or "") }
end)

--[[ config — toml ]]

def("config.parse_toml", function(_, req)
  return { table = Config.ParseTOML(req.text or "") }
end)
def("config.to_toml", function(_, req)
  return { text = Config.ToTOML(req.table or {}) }
end)
def("config.read_toml", function(_, req)
  return { table = Config.ReadTOML(req.path or "") }
end, { gate = "file" })
def("config.write_toml", function(_, req)
  Config.WriteTOML(req.path or "", req.table or {})
end, { gate = "file" })

--[[ file — sandboxed to the script directory, gated behind ALLOW_FILE ]]

def("file.read", function(_, req)
  return { content = File.Read(req.path or "") }
end, { gate = "file" })
def("file.read_bytes", function(_, req)
  return { bytes = File.ReadBytes(req.path or "") }
end, { gate = "file" })
def("file.write", function(_, req)
  File.Write(req.path or "", req.content or "")
end, { gate = "file" })
def("file.append", function(_, req)
  File.Append(req.path or "", req.content or "")
end, { gate = "file" })
def("file.exists", function(_, req)
  return { exists = File.Exists(req.path or "") }
end, { gate = "file" })
def("file.delete", function(_, req)
  return { deleted = File.Delete(req.path or "") }
end, { gate = "file" })
def("file.list", function(_, req)
  return { entries = File.List(req.path) }
end, { gate = "file" })
def("file.mkdir", function(_, req)
  return { created = File.MkDir(req.path or "") }
end, { gate = "file" })
def("file.rmdir", function(_, req)
  File.RmDir(req.path or "")
end, { gate = "file" })
def("file.is_dir", function(_, req)
  return { result = File.IsDir(req.path or "") }
end, { gate = "file" })
def("file.is_file", function(_, req)
  return { result = File.IsFile(req.path or "") }
end, { gate = "file" })
def("file.size", function(_, req)
  return { bytes = File.GetSize(req.path or "") }
end, { gate = "file" })
def("file.time", function(_, req)
  return { mtime = File.GetTime(req.path or "") }
end, { gate = "file" })
def("file.copy", function(_, req)
  File.Copy(req.src or "", req.dst or "")
end, { gate = "file" })
def("file.move", function(_, req)
  File.Move(req.src or "", req.dst or "")
end, { gate = "file" })
def("file.read_json", function(_, req)
  return { value = File.ReadJSON(req.path or "") }
end, { gate = "file" })
def("file.write_json", function(_, req)
  File.WriteJSON(req.path or "", req.value or {})
end, { gate = "file" })
def("file.script_dir", function()
  return { path = File.GetScriptDir() }
end, { gate = "file" })

--[[ net — outbound http; yields, so async ]]

def("net.get", function(_, req)
  return {
    response = Net.Get(req.url or "", req.headers, { timeout = req.timeout }),
  }
end, { async = true })
def("net.post", function(_, req)
  return {
    response = Net.Post(
      req.url or "",
      req.body or "",
      req.headers,
      { timeout = req.timeout }
    ),
  }
end, { async = true })
def("net.put", function(_, req)
  return {
    response = Net.Put(
      req.url or "",
      req.body or "",
      req.headers,
      { timeout = req.timeout }
    ),
  }
end, { async = true })
def("net.patch", function(_, req)
  return {
    response = Net.Patch(
      req.url or "",
      req.body or "",
      req.headers,
      { timeout = req.timeout }
    ),
  }
end, { async = true })
def("net.delete", function(_, req)
  return {
    response = Net.Delete(
      req.url or "",
      req.headers,
      { timeout = req.timeout }
    ),
  }
end, { async = true })
def("net.head", function(_, req)
  return {
    response = Net.Head(req.url or "", req.headers, { timeout = req.timeout }),
  }
end, { async = true })
def("net.request", function(_, req)
  return { response = Net.Request(req.options or {}) }
end, { async = true })

--[[ dialog — native os dialogs; yield until dismissed ]]

def("dialog.message", function(_, req)
  Dialog.Message(req.text or "", req.options)
end, { async = true })
def("dialog.confirm", function(_, req)
  return { confirmed = Dialog.Confirm(req.text or "", req.options) }
end, { async = true })
def("dialog.open_file", function(_, req)
  return { path = Dialog.OpenFile(req.options) }
end, { async = true })
def("dialog.save_file", function(_, req)
  return { path = Dialog.SaveFile(req.options) }
end, { async = true })
def("dialog.open_dir", function(_, req)
  return { path = Dialog.OpenDir(req.options) }
end, { async = true })

--[[ math — rng + macro transforms ]]

def("math.random", function(_, req)
  return { value = Math.Random(req.min or 0, req.max or 1) }
end)
def("math.gaussian", function(_, req)
  return { value = Math.Gaussian(req.mean or 0, req.std_dev or 1) }
end)
def("math.scale", function(_, req)
  return {
    macro = Math.Scale(req.macro or {}, req.x_factor or 1, req.y_factor or 1),
  }
end)
def("math.spline", function(_, req)
  return { macro = Math.Spline(req.macro or {}, req.tension or 0.5) }
end)
def("math.resample", function(_, req)
  return { macro = Math.Resample(req.macro or {}, req.interval_ms or 10) }
end)
def("math.interpolate", function(_, req)
  return {
    macro = Math.Interpolate(req.macro or {}, req.interval_ms or 10, req.mode),
  }
end)
def("math.time_comp", function(_, req)
  return { macro = Math.TimeComp(req.macro or {}, req.target_ms or 1000) }
end)

--[[ macro ]]

def("macro.record", function(_, req)
  Macro.Record(req.options)
end)
def("macro.finish", function()
  return { macro = Macro.Finish() }
end)
def("macro.play", function(_, req)
  Macro.Play(req.macro or {}, req.speed, req.mode)
end)
def("macro.stream", function(_, req)
  Macro.Stream(req.macro or {})
end)
def("macro.abort", function()
  Macro.Abort()
end)
def("macro.stop_all", function()
  Macro.StopAll()
end)

--[[ audio

  Audio.Play returns a sound handle. we keep it in a server-side registry and
  hand the client an integer id, so stop/pause/resume/volume can address a
  specific sound by id over the wire.
--]]

def("audio.beep", function()
  Audio.Beep()
end)
def("audio.play", function(_, req)
  local handle = Audio.Play(req.path or "", req.options)
  local id = alloc_id()
  sounds[id] = handle
  return { sound = id }
end)
def("audio.stop", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:Stop()
    sounds[req.sound] = nil
  end
end)
def("audio.pause", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:Pause()
  end
end)
def("audio.resume", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:Resume()
  end
end)
def("audio.is_playing", function(_, req)
  local h = sounds[req.sound]
  return { playing = h ~= nil and h:IsPlaying() or false }
end)
def("audio.set_volume", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:SetVolume(req.volume or 1.0)
  end
end)
def("audio.get_volume", function(_, req)
  local h = sounds[req.sound]
  return { volume = h and h:GetVolume() or 0 }
end)
def("audio.stop_all", function()
  Audio.StopAll()
  sounds = {}
end)
def("audio.set_master_volume", function(_, req)
  Audio.SetMasterVolume(req.volume or 1.0)
end)
def("audio.get_master_volume", function()
  return { volume = Audio.GetMasterVolume() }
end)

--[[ timer

  server-side timers whose callback fires a push { t="timer", handle } to the
  creating client. handles are integer ids in a per-client registry, cancelled
  automatically when the client disconnects.
--]]

def("timer.after", function(client, req)
  local id = alloc_id()
  local cid = client.id
  local handle = Timer.After(req.ms or 1000, function()
    local c = clients[cid]
    if c then
      c:Send(encode({ t = "timer", handle = id, kind = "after" }))
    end
    if timers[cid] then
      timers[cid][id] = nil
    end
  end)
  timers[cid] = timers[cid] or {}
  timers[cid][id] = handle
  return { handle = id }
end)
def("timer.every", function(client, req)
  local id = alloc_id()
  local cid = client.id
  local handle = Timer.Every(req.ms or 1000, function()
    local c = clients[cid]
    if c then
      c:Send(encode({ t = "timer", handle = id, kind = "every" }))
    end
  end)
  timers[cid] = timers[cid] or {}
  timers[cid][id] = handle
  return { handle = id }
end)
def("timer.cancel", function(client, req)
  local set = timers[client.id]
  local handle = set and set[req.handle]
  if handle then
    handle:Cancel()
    set[req.handle] = nil
  end
end)
def("timer.cancel_all", function(client)
  local set = timers[client.id]
  if set then
    for _, handle in pairs(set) do
      handle:Cancel()
    end
    timers[client.id] = {}
  end
end)

--[[ log ]]

for _, level in ipairs({ "Info", "Warn", "Error", "Debug" }) do
  def("log." .. level:lower(), function(_, req)
    Log[level](req.message or "")
  end)
end

--[[ ui — this script's own config panel; read + notify only ]]

def("ui.notify", function(_, req)
  UI.Notify(req.message or "", req.variant or "info")
end)
def("ui.get", function(_, req)
  if REDACTED[req.id] then
    return nil, "redacted", "that value is not readable over the wire"
  end
  return { value = UI.Get(req.id or "") }
end)
def("ui.get_all", function()
  local all = UI.GetAll()
  for k in pairs(REDACTED) do
    all[k] = nil
  end
  return { values = all }
end)
def("ui.schema", function()
  return { schema = UI.GetSchema() }
end)

--[[ registry — windows only; reads open, writes gated ]]

if Registry then
  def("registry.read", function(_, req)
    return { value = Registry.Read(req.key or "", req.name or "") }
  end)
  def("registry.write", function(_, req)
    Registry.Write(
      req.key or "",
      req.type or "string",
      req.name or "",
      req.value
    )
  end, { gate = "registry" })
  def("registry.delete_value", function(_, req)
    Registry.DeleteValue(req.key or "", req.name or "")
  end, { gate = "registry" })
  def("registry.delete_key", function(_, req)
    Registry.DeleteKey(req.key or "")
  end, { gate = "registry" })
  def("registry.create_key", function(_, req)
    Registry.CreateKey(req.key or "")
  end, { gate = "registry" })
end

--[[ script self-control ]]

def("script.reload", function()
  Script.Reload()
end)
def("script.exit", function(_, req)
  Script.Exit(req.reason)
end)

--[[ subscriptions ]]

def("subscribe", function(client, req)
  local events = req.events or {}
  -- stash the live client handle so OnTick / timer callbacks can :Send() to it
  -- without a round-trip. dead clients are cleaned up in OnClose.
  subscribers[client.id] = subscribers[client.id] or { client = client }
  subscribers[client.id].client = client
  for _, name in ipairs(events) do
    subscribers[client.id][name] = true
  end
  return { ok = true, subscribed = events }
end)
def("unsubscribe", function(client, req)
  local subs = subscribers[client.id]
  if subs then
    for _, name in ipairs(req.events or {}) do
      subs[name] = nil
    end
  end
  return { ok = true }
end)

--[[ escape hatch — arbitrary lua in the sandbox; gated ]]

def("lua.exec", function(_, req)
  if type(loadstring) ~= "function" then
    return nil, "unavailable", "loadstring is not available in this sandbox"
  end
  local source = req.source or ""
  local fn, parse_err = loadstring(source)
  if not fn then
    return nil, "parse_error", tostring(parse_err)
  end
  local ok, result = pcall(fn)
  if ok then
    return { result = result }
  end
  -- echo the failing line so a remote caller (human or agent) can see WHAT
  -- failed, not just why: pcall errors carry "[string ...]:<line>:" — quote
  -- that source line back. Without it, agents blindly resend the same script.
  local msg = tostring(result)
  local line = msg:match(":(%d+):")
  if line then
    local n, excerpt = tonumber(line), nil
    local i = 0
    for l in (source .. "\n"):gmatch("(.-)\n") do
      i = i + 1
      if i == n then
        excerpt = l
        break
      end
    end
    if excerpt then
      msg = msg .. " | line " .. line .. ": " .. excerpt:sub(1, 160)
    end
  end
  return nil, "runtime_error", msg
end, { gate = "lua", async = true })

--[[ dispatch

  one place enforces auth, gating, async wrapping, error shaping, and replying.
  async handlers run in a coroutine (Run()) so yielding sdk calls can complete
  before replying; everything else runs inline under a pcall.
--]]

local function invoke(client, req, spec)
  local res, code, msg = spec.fn(client, req)
  if code then
    err(client, req, code, msg or code)
  else
    reply(client, req, res or { ok = true })
  end
end

local function dispatch(client, req)
  local spec = api[req.t or ""]
  if not spec then
    err(client, req, "unknown_command", `unknown command '{tostring(req.t)}'`)
    return
  end

  if auth_token() ~= "" and not spec.open and not authed[client.id] then
    err(
      client,
      req,
      "unauthenticated",
      'send { t = "auth", token = "..." } first'
    )
    return
  end

  if spec.gate and not GATES[spec.gate] then
    err(
      client,
      req,
      "disabled",
      `'{req.t}' is disabled in config (ALLOW_{spec.gate:upper()})`
    )
    return
  end

  if spec.async then
    Run(function()
      local ok, res, code, msg = pcall(spec.fn, client, req)
      if not ok then
        err(client, req, "handler_error", tostring(res))
      elseif code then
        err(client, req, code, msg or code)
      else
        reply(client, req, res or { ok = true })
      end
    end)
    return
  end

  local ok, e = pcall(invoke, client, req, spec)
  if not ok then
    err(client, req, "handler_error", tostring(e))
  end
end

--[[ websocket server lifecycle ]]

function OnStart()
  server = Net.WSListen(cfg.port, {
    OnConnect = function(client)
      Log.Info(`Remote: client {client.id} connected`)
      clients[client.id] = client
      local banner = server_banner()
      banner.t = "hello"
      send(client, banner)
    end,

    OnMessage = function(client, payload, is_binary)
      if is_binary then
        err(
          client,
          { id = nil },
          "no_binary",
          "binary frames are not supported"
        )
        return
      end
      local ok, req = pcall(JSON.Parse, payload)
      if not ok or type(req) ~= "table" then
        err(client, { id = nil }, "bad_json", "could not parse JSON object")
        return
      end
      dispatch(client, req)
    end,

    OnClose = function(client)
      Log.Info(`Remote: client {client.id} disconnected`)
      local set = timers[client.id]
      if set then
        for _, handle in pairs(set) do
          handle:Cancel()
        end
      end
      timers[client.id] = nil
      subscribers[client.id] = nil
      authed[client.id] = nil
      clients[client.id] = nil
    end,
  })

  Log.Info(
    `Remote server listening on ws://0.0.0.0:{cfg.port} (auth={auth_token() ~= "" and "required" or "open"})`
  )
  UI.Notify(`Remote: ws://0.0.0.0:{cfg.port}`, "success")
end

function OnStop()
  if server then
    server:Stop()
    server = nil
  end
  for _, set in pairs(timers) do
    for _, handle in pairs(set) do
      handle:Cancel()
    end
  end
  Audio.StopAll()
  clients = {}
  subscribers = {}
  authed = {}
  timers = {}
  sounds = {}
end

--[[ subscription streams

  push events from OnTick. each stream is sent only to clients subscribed to it,
  and mouse/window only when the underlying state has changed.
--]]

function OnTick()
  if not server or next(subscribers) == nil then
    return
  end

  local mouse_msg, window_msg, input_msg = nil, nil, nil

  local x, y = System.Mouse()
  if x ~= last_mouse_x or y ~= last_mouse_y then
    last_mouse_x, last_mouse_y = x, y
    mouse_msg = encode({ t = "mouse", x = x, y = y })
  end

  local win = System.Window()
  if win.title ~= last_window_title then
    last_window_title = win.title
    window_msg = encode({ t = "window", window = win })
  end

  local any_input = false
  for _, subs in pairs(subscribers) do
    if subs.input then
      any_input = true
      break
    end
  end
  if any_input then
    input_msg = encode({
      t = "input",
      keys = Input.GetActiveKeys(),
      modifiers = Input.GetModifiers(),
    })
  end

  -- fan out: a closed client's :Send() is a silent no-op, so no cleanup here;
  -- OnClose removes the table entry.
  for _, subs in pairs(subscribers) do
    if mouse_msg and subs.mouse then
      subs.client:Send(mouse_msg)
    end
    if window_msg and subs.window then
      subs.client:Send(window_msg)
    end
    if input_msg and subs.input then
      subs.client:Send(input_msg)
    end
  end
end

1 file without text previews