Skip to main content

lib/agent_sea/surf/sidecar.ex

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