Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
412 changes: 412 additions & 0 deletions install.ps1

Large diffs are not rendered by default.

33 changes: 23 additions & 10 deletions lib/ourocode/model/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ defmodule Ourocode.Model.Cli do
end
end

@doc false
@spec runner_command(
String.t(),
[String.t()],
{:unix | :win32, atom()},
(String.t() -> String.t() | nil)
) :: {String.t(), [String.t()]}
def runner_command(path, args, os_type \\ :os.type(), which \\ &System.find_executable/1)

def runner_command(path, args, {:win32, _name}, _which), do: {path, args}

def runner_command(path, args, _os_type, which) when is_function(which, 1) do
{which.("sh") || "/bin/sh", ["-c", ~s(exec "$0" "$@" </dev/null), path | args]}
end

@doc """
Runs the CLI for one prompt, invoking `on_chunk` for each stdout chunk.

Expand All @@ -47,6 +62,7 @@ defmodule Ourocode.Model.Cli do
{:ok, String.t()} | {:error, term()}
def stream(id, prompt, opts, on_chunk) when is_binary(prompt) and is_function(on_chunk, 1) do
which = Keyword.get(opts, :which, &System.find_executable/1)
run = Keyword.get(opts, :run, &run/4)
bin = Map.fetch!(@bins, id)

case which.(bin) do
Expand All @@ -55,23 +71,23 @@ defmodule Ourocode.Model.Cli do

path ->
delay = Keyword.get(opts, :retry_base_delay_ms, @retry_base_delay_ms)
run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1)
run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1, run)
end
end

defp run_with_retry(id, path, args, on_chunk, delay, attempt) do
defp run_with_retry(id, path, args, on_chunk, delay, attempt, run) do
emitted = :counters.new(1, [])

counted_chunk = fn chunk ->
:counters.add(emitted, 1, 1)
on_chunk.(chunk)
end

case run(id, path, args, counted_chunk) do
case run.(id, path, args, counted_chunk) do
{:error, {:exit, _status}} = error ->
if :counters.get(emitted, 1) == 0 and attempt < @max_attempts do
Process.sleep(delay * Integer.pow(2, attempt - 1))
run_with_retry(id, path, args, on_chunk, delay, attempt + 1)
run_with_retry(id, path, args, on_chunk, delay, attempt + 1, run)
else
error
end
Expand All @@ -82,18 +98,15 @@ defmodule Ourocode.Model.Cli do
end

defp run(id, path, args, on_chunk) do
# Spawn through `sh -c 'exec "$0" "$@" </dev/null'` so the CLI's stdin is
# /dev/null, not the BEAM port pipe. Args are passed positionally, so the
# prompt needs no shell escaping.
shell = System.find_executable("sh") || "/bin/sh"
{command, command_args} = runner_command(path, args)

port =
Port.open({:spawn_executable, shell}, [
Port.open({:spawn_executable, command}, [
:binary,
:exit_status,
:hide,
:stderr_to_stdout,
args: ["-c", ~s(exec "$0" "$@" </dev/null), path | args]
args: command_args
])

collect(id, port, [], "", on_chunk)
Expand Down
63 changes: 53 additions & 10 deletions lib/ourocode/runtime/mcp_daemon/process.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,21 @@ defmodule Ourocode.Runtime.McpDaemon.Process do
@spec spawn_plan(String.t(), [String.t()], :inet.port_number(), String.t() | nil) :: map()
def spawn_plan(exe, args, port, shell \\ nil)
when is_binary(exe) and is_list(args) and is_integer(port) do
shell = shell || System.find_executable("sh") || "/bin/sh"
log_path = Path.join(System.tmp_dir!(), "ourocode-mcp-#{port}.log")
{command, command_args} = launch_plan(shell, exe, args, log_path)

%{
shell: shell,
args: ["-c", "exec \"$0\" \"$@\" >\"#{log_path}\" 2>&1", exe] ++ args,
shell: command,
args: command_args,
log_path: log_path
}
end

@spec stop(map() | nil) :: :ok
def stop(%{mode: :spawned} = handle) do
case Map.get(handle, :os_pid) do
pid when is_integer(pid) and pid > 0 ->
System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true)

_none ->
:ok
end
handle
|> Map.get(:os_pid)
|> terminate_os_process()

erl_port = Map.get(handle, :port)
if is_port(erl_port) and Port.info(erl_port) != nil, do: Port.close(erl_port)
Expand All @@ -68,4 +64,51 @@ defmodule Ourocode.Runtime.McpDaemon.Process do
end

def stop(_handle), do: :ok

defp launch_plan(nil, exe, args, log_path) do
if windows?() do
{exe, args}
else
shell = default_shell()
{shell, shell_args(shell, exe, args, log_path)}
end
end

defp launch_plan(shell, exe, args, log_path) do
if windows?() and windows_shell?(shell) do
{exe, args}
else
{shell, shell_args(shell, exe, args, log_path)}
end
end

defp default_shell do
System.find_executable("sh") || "/bin/sh"
end

defp shell_args(_shell, exe, args, log_path) do
["-c", "exec \"$0\" \"$@\" >\"#{log_path}\" 2>&1", exe] ++ args
end

defp terminate_os_process(pid) when is_integer(pid) and pid > 0 do
if windows?() do
command = System.find_executable("taskkill") || "taskkill"
System.cmd(command, ["/PID", Integer.to_string(pid), "/T", "/F"], stderr_to_stdout: true)
else
System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true)
end

:ok
end

defp terminate_os_process(_pid), do: :ok

defp windows_shell?(shell) do
shell
|> Path.basename()
|> String.downcase()
|> then(&(&1 in ["cmd", "cmd.exe"]))
end

defp windows?, do: match?({:win32, _name}, :os.type())
end
146 changes: 136 additions & 10 deletions lib/ourocode/terminal/tty_driver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ defmodule Ourocode.Terminal.TtyDriver do
"""

@poll_ms 500
@helper_osc_prefix "\e]777;ourocode-"
@helper_osc_resize_prefix "\e]777;ourocode-resize="
@helper_osc_redraw "\e]777;ourocode-control=redraw\a"
@bracketed_paste_start "\e[200~"
@bracketed_paste_end "\e[201~"

@doc "Absolute path of the built tty helper, or nil if it is not present."
@spec helper_path() :: String.t() | nil
def helper_path do
cwd = File.cwd!()

[
System.get_env("OUROCODE_TTY"),
Path.join(File.cwd!(), "rust/ourocode_ipc/target/release/ourocode_tty"),
Path.join(File.cwd!(), "bin/ourocode_tty")
Path.join(cwd, "bin/ourocode_tty.exe"),
Path.join(cwd, "bin/ourocode_tty"),
Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty.exe"),
Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty")
]
|> helper_path()
end
Expand All @@ -30,12 +39,10 @@ defmodule Ourocode.Terminal.TtyDriver do

path ->
port =
Port.open({:spawn_executable, String.to_charlist(path)}, [
:binary,
:exit_status,
:nouse_stdio,
:hide
])
Port.open(
{:spawn_executable, String.to_charlist(path)},
port_options(:os.type())
)

case read_header(port, "") do
{:ok, cols, rows, rest} ->
Expand Down Expand Up @@ -68,11 +75,22 @@ defmodule Ourocode.Terminal.TtyDriver do
end

@spec next_chunk(port() | nil, non_neg_integer()) ::
{:ok, binary()} | {:file_cache_ready, [String.t()]} | :tick | :eof
{:ok, binary()}
| {:ok, binary(), binary()}
| {:resize, {pos_integer(), pos_integer()}}
| {:resize, {pos_integer(), pos_integer()}, binary()}
| {:control, :redraw}
| {:control, :redraw, binary()}
| {:ignore, binary()}
| {:file_cache_ready, [String.t()]}
| :tick
| :eof
def next_chunk(port, poll_ms \\ @poll_ms) do
receive do
{^port, {:data, data}} when is_binary(data) ->
{:ok, data}
data
|> decode_chunk()
|> next_chunk_reply()

{^port, {:exit_status, _status}} ->
:eof
Expand All @@ -99,6 +117,11 @@ defmodule Ourocode.Terminal.TtyDriver do
System.get_env("OUROCODE_FORCE_TTY") == "1" or match?({:ok, _}, :io.columns())
end

@doc false
@spec port_options(:os.type()) :: [:binary | :exit_status | :nouse_stdio | :hide]
def port_options({:win32, _}), do: [:binary, :exit_status]
def port_options(_os_type), do: [:binary, :exit_status, :nouse_stdio, :hide]

@doc false
def enter_sequence, do: "\e[?1049h\e[?1006h\e[?1003h\e[?25l\e[2J\e[H"

Expand Down Expand Up @@ -136,6 +159,109 @@ defmodule Ourocode.Terminal.TtyDriver do
end
end

@doc false
@spec decode_chunk(binary()) ::
{:ok, binary(), binary()}
| {:resize, {pos_integer(), pos_integer()}, binary()}
| {:control, :redraw, binary()}
| {:ignore, binary()}
def decode_chunk(data) when is_binary(data) do
case helper_frame_bounds(data) do
nil ->
{:ok, data, ""}

{0, frame_size} ->
frame = binary_part(data, 0, frame_size)
rest = binary_part(data, frame_size, byte_size(data) - frame_size)
decode_helper_frame(frame, rest)

{start, _frame_size} ->
raw = binary_part(data, 0, start)
rest = binary_part(data, start, byte_size(data) - start)
{:ok, raw, rest}
end
end

defp next_chunk_reply({:ok, data, ""}), do: {:ok, data}
defp next_chunk_reply({:resize, size, ""}), do: {:resize, size}
defp next_chunk_reply({:control, :redraw, ""}), do: {:control, :redraw}
defp next_chunk_reply({:ignore, ""}), do: :tick
defp next_chunk_reply(other), do: other

defp decode_helper_frame(@helper_osc_redraw, rest), do: {:control, :redraw, rest}

defp decode_helper_frame(@helper_osc_resize_prefix <> rest = frame, remaining) do
with true <- String.ends_with?(rest, "\a"),
value <- binary_part(rest, 0, byte_size(rest) - 1),
[cols_text, rows_text] <- String.split(value, "x", parts: 2),
{cols, ""} when cols > 0 <- Integer.parse(cols_text),
{rows, ""} when rows > 0 <- Integer.parse(rows_text) do
{:resize, {cols, rows}, remaining}
else
_invalid ->
if helper_control_frame?(frame), do: {:ignore, remaining}, else: {:ok, frame, remaining}
end
end

defp decode_helper_frame(frame, rest) do
if helper_control_frame?(frame), do: {:ignore, rest}, else: {:ok, frame, rest}
end

defp helper_control_frame?(data) do
String.starts_with?(data, @helper_osc_prefix) and String.ends_with?(data, "\a")
end

defp helper_frame_bounds(data), do: helper_frame_bounds(data, 0)

defp helper_frame_bounds(data, offset) when offset >= byte_size(data), do: nil

defp helper_frame_bounds(data, offset) do
case next_helper_or_paste(data, offset) do
nil ->
nil

{:helper, index} ->
case match_from(data, "\a", index) do
{bel_index, 1} -> {index, bel_index - index + 1}
:nomatch -> nil
end

{:paste, index} ->
paste_content_index = index + byte_size(@bracketed_paste_start)

case match_from(data, @bracketed_paste_end, paste_content_index) do
{paste_end_index, paste_end_size} ->
helper_frame_bounds(data, paste_end_index + paste_end_size)

:nomatch ->
nil
end
end
end

defp next_helper_or_paste(data, offset) do
match =
[
helper: match_from(data, @helper_osc_prefix, offset),
paste: match_from(data, @bracketed_paste_start, offset)
]
|> Enum.reject(fn {_kind, match} -> match == :nomatch end)
|> Enum.min_by(fn {_kind, {index, _size}} -> index end, fn -> nil end)

case match do
nil -> nil
{kind, {index, _size}} -> {kind, index}
end
end

defp match_from(data, pattern, offset) do
if offset >= byte_size(data) do
:nomatch
else
:binary.match(data, pattern, scope: {offset, byte_size(data) - offset})
end
end

defp safe_close(port) do
if is_port(port) and Port.info(port) != nil, do: Port.close(port)
:ok
Expand Down
2 changes: 1 addition & 1 deletion lib/ourocode/terminal/tui_answer_submission.ex
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,5 @@ defmodule Ourocode.Terminal.TuiAnswerSubmission do
|> Kernel.in(["cancel", "decline", "/cancel"])
end

defp log(output, text), do: IO.puts(output, text)
defp log(output, text), do: IO.puts(output, String.replace_invalid(text, ""))
end
Loading