defmodule AgentSea.Surf.Sidecar do
@moduledoc """
Drives a Node browser-automation subprocess over a `Port`.
A `GenServer` owns the subprocess and speaks newline-delimited JSON: each
request `{"id":N,"command":...,"args":{...}}` gets a response
`{"id":N,"ok":true|false,"result"|"error":...}`. Browser/computer-use lives in
the Node side (Playwright); only the I/O is bridged — the same "bridge, don't
reimplement" pattern as the MCP stdio transport.
"""
use GenServer
@timeout 30_000
# --- Client API ---
@doc "Start a sidecar. Options: `:command` (`[executable | args]`), `:name`."
def start_link(opts) do
{gen_opts, init_opts} = Keyword.split(opts, [:name])
GenServer.start_link(__MODULE__, init_opts, gen_opts)
end
@doc "Send a command to the Node side; returns `{:ok, result}` or `{:error, reason}`."
@spec call(GenServer.server(), String.t(), map()) :: {:ok, term()} | {:error, term()}
def call(server, command, args \\ %{}) do
GenServer.call(server, {:call, command, args}, @timeout)
end
# --- Server ---
@impl true
def init(opts) do
[executable | args] = Keyword.fetch!(opts, :command)
path =
System.find_executable(executable) ||
raise ArgumentError, "executable not found on PATH: #{executable}"
port =
Port.open(
{:spawn_executable, path},
[:binary, :exit_status, :use_stdio, :hide, args: args]
)
{:ok, %{port: port, buffer: "", next_id: 1, pending: %{}}}
end
@impl true
def handle_call({:call, command, args}, from, state) do
id = state.next_id
payload = Jason.encode!(%{"id" => id, "command" => command, "args" => args})
Port.command(state.port, payload <> "\n")
{:noreply, %{state | next_id: id + 1, pending: Map.put(state.pending, id, from)}}
end
@impl true
def handle_info({port, {:data, data}}, %{port: port} = state) do
{lines, buffer} = take_lines(state.buffer <> data)
state = Enum.reduce(lines, %{state | buffer: buffer}, &handle_line/2)
{:noreply, state}
end
def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
for {_id, from} <- state.pending,
do: GenServer.reply(from, {:error, {:sidecar_exited, status}})
{:stop, :normal, %{state | pending: %{}}}
end
def handle_info(_message, state), do: {:noreply, state}
# --- Helpers ---
defp handle_line(line, state) do
case Jason.decode(line) do
{:ok, %{"id" => id} = message} ->
case Map.pop(state.pending, id) do
{nil, _pending} -> state
{from, pending} -> reply(state, from, message, pending)
end
_ ->
state
end
end
defp reply(state, from, message, pending) do
GenServer.reply(from, response(message))
%{state | pending: pending}
end
defp response(%{"ok" => true, "result" => result}), do: {:ok, result}
defp response(%{"ok" => false, "error" => error}), do: {:error, error}
defp response(_message), do: {:error, :invalid_response}
defp take_lines(buffer) do
parts = String.split(buffer, "\n")
{lines, [rest]} = Enum.split(parts, length(parts) - 1)
{Enum.reject(lines, &(&1 == "")), rest}
end
end