Current section
Files
Jump to
Current section
Files
lib/codex/app_server.ex
defmodule Codex.AppServer do
@moduledoc """
App-server transport for stateful, bidirectional communication with Codex.
"""
alias Codex.AppServer.Connection
alias Codex.AppServer.Params
alias Codex.AppServer.Supervisor, as: ConnectionSupervisor
alias Codex.Options
@type connection :: pid()
@type connect_opts :: [
init_timeout_ms: pos_integer(),
client_name: String.t(),
client_title: String.t(),
client_version: String.t()
]
@default_init_timeout_ms 10_000
@spec connect(Options.t(), connect_opts()) :: {:ok, connection()} | {:error, term()}
def connect(%Options{} = codex_opts, opts \\ []) do
init_timeout_ms = Keyword.get(opts, :init_timeout_ms, @default_init_timeout_ms)
child_spec =
{Connection, {codex_opts, opts}}
|> Supervisor.child_spec(restart: :temporary)
case DynamicSupervisor.start_child(ConnectionSupervisor, child_spec) do
{:ok, conn} ->
case Connection.await_ready(conn, init_timeout_ms) do
:ok ->
{:ok, conn}
{:error, _reason} = error ->
_ = DynamicSupervisor.terminate_child(ConnectionSupervisor, conn)
error
end
{:error, _} = error ->
error
end
end
@spec disconnect(connection()) :: :ok
def disconnect(conn) when is_pid(conn) do
_ = DynamicSupervisor.terminate_child(ConnectionSupervisor, conn)
:ok
end
@spec alive?(connection()) :: boolean()
def alive?(conn) when is_pid(conn), do: Process.alive?(conn)
@spec subscribe(connection(), keyword()) :: :ok | {:error, term()}
def subscribe(conn, opts \\ []) when is_pid(conn) do
Connection.subscribe(conn, opts)
end
@spec unsubscribe(connection()) :: :ok
def unsubscribe(conn) when is_pid(conn) do
Connection.unsubscribe(conn)
end
@spec respond(connection(), String.t() | integer(), map()) :: :ok | {:error, term()}
def respond(conn, id, result) when is_pid(conn) and (is_integer(id) or is_binary(id)) do
Connection.respond(conn, id, result)
end
@spec thread_start(connection(), map() | keyword()) :: {:ok, map()} | {:error, term()}
def thread_start(conn, params \\ %{}) when is_pid(conn) do
params = Params.normalize_map(params)
wire_params =
%{}
|> Params.put_optional("model", fetch_any(params, [:model, "model"]))
|> Params.put_optional(
"modelProvider",
fetch_any(params, [:model_provider, "model_provider", :modelProvider, "modelProvider"])
)
|> Params.put_optional(
"cwd",
fetch_any(params, [:cwd, "cwd", :working_directory, "working_directory"])
)
|> Params.put_optional(
"approvalPolicy",
params
|> fetch_any([:approval_policy, "approval_policy"])
|> Params.ask_for_approval()
)
|> Params.put_optional(
"sandbox",
params
|> fetch_any([:sandbox, "sandbox"])
|> Params.sandbox_mode()
)
|> Params.put_optional("config", fetch_any(params, [:config, "config"]))
|> Params.put_optional(
"baseInstructions",
fetch_any(params, [:base_instructions, "base_instructions"])
)
|> Params.put_optional(
"developerInstructions",
fetch_any(params, [:developer_instructions, "developer_instructions"])
)
Connection.request(conn, "thread/start", wire_params, timeout_ms: 30_000)
end
@spec thread_resume(connection(), String.t(), map() | keyword()) ::
{:ok, map()} | {:error, term()}
def thread_resume(conn, thread_id, params \\ %{}) when is_pid(conn) and is_binary(thread_id) do
params = Params.normalize_map(params)
wire_params =
%{"threadId" => thread_id}
|> Params.put_optional("model", fetch_any(params, [:model, "model"]))
|> Params.put_optional(
"modelProvider",
fetch_any(params, [:model_provider, "model_provider", :modelProvider, "modelProvider"])
)
|> Params.put_optional(
"cwd",
fetch_any(params, [:cwd, "cwd", :working_directory, "working_directory"])
)
|> Params.put_optional(
"approvalPolicy",
params
|> fetch_any([:approval_policy, "approval_policy"])
|> Params.ask_for_approval()
)
|> Params.put_optional(
"sandbox",
params
|> fetch_any([:sandbox, "sandbox"])
|> Params.sandbox_mode()
)
|> Params.put_optional("config", fetch_any(params, [:config, "config"]))
|> Params.put_optional(
"baseInstructions",
fetch_any(params, [:base_instructions, "base_instructions"])
)
|> Params.put_optional(
"developerInstructions",
fetch_any(params, [:developer_instructions, "developer_instructions"])
)
Connection.request(conn, "thread/resume", wire_params, timeout_ms: 30_000)
end
@spec thread_list(connection(), keyword()) :: {:ok, map()} | {:error, term()}
def thread_list(conn, opts \\ []) when is_pid(conn) and is_list(opts) do
wire_params =
%{}
|> Params.put_optional("cursor", Keyword.get(opts, :cursor))
|> Params.put_optional("limit", Keyword.get(opts, :limit))
|> Params.put_optional("modelProviders", Keyword.get(opts, :model_providers))
Connection.request(conn, "thread/list", wire_params, timeout_ms: 30_000)
end
@spec thread_archive(connection(), String.t()) :: :ok | {:error, term()}
def thread_archive(conn, thread_id) when is_pid(conn) and is_binary(thread_id) do
case Connection.request(conn, "thread/archive", %{"threadId" => thread_id},
timeout_ms: 30_000
) do
{:ok, _} -> :ok
{:error, _} = error -> error
end
end
@spec thread_compact(connection(), String.t()) :: :ok | {:error, term()}
def thread_compact(conn, thread_id) when is_pid(conn) and is_binary(thread_id) do
case Connection.request(conn, "thread/compact", %{"threadId" => thread_id},
timeout_ms: 30_000
) do
{:ok, _} -> :ok
{:error, _} = error -> error
end
end
@spec turn_start(connection(), String.t(), String.t() | [map()], keyword()) ::
{:ok, map()} | {:error, term()}
def turn_start(conn, thread_id, input, opts \\ [])
when is_pid(conn) and is_binary(thread_id) and is_list(opts) do
wire_params =
%{
"threadId" => thread_id,
"input" => Params.user_input(input)
}
|> Params.put_optional("cwd", Keyword.get(opts, :cwd))
|> Params.put_optional(
"approvalPolicy",
opts
|> Keyword.get(:approval_policy)
|> Params.ask_for_approval()
)
|> Params.put_optional("sandboxPolicy", Keyword.get(opts, :sandbox_policy))
|> Params.put_optional("model", Keyword.get(opts, :model))
|> Params.put_optional("effort", Keyword.get(opts, :effort))
|> Params.put_optional("summary", Keyword.get(opts, :summary))
Connection.request(conn, "turn/start", wire_params, timeout_ms: 300_000)
end
@spec turn_interrupt(connection(), String.t(), String.t()) :: :ok | {:error, term()}
def turn_interrupt(conn, thread_id, turn_id)
when is_pid(conn) and is_binary(thread_id) and is_binary(turn_id) do
params = %{"threadId" => thread_id, "turnId" => turn_id}
case Connection.request(conn, "turn/interrupt", params, timeout_ms: 30_000) do
{:ok, _} -> :ok
{:error, _} = error -> error
end
end
@spec skills_list(connection(), keyword()) :: {:ok, map()} | {:error, term()}
def skills_list(conn, opts \\ []) when is_pid(conn) and is_list(opts) do
cwds = Keyword.get(opts, :cwds, [])
params = %{"cwds" => List.wrap(cwds)}
Connection.request(conn, "skills/list", params, timeout_ms: 30_000)
end
@spec model_list(connection(), keyword()) :: {:ok, map()} | {:error, term()}
def model_list(conn, opts \\ []) when is_pid(conn) and is_list(opts) do
params =
%{}
|> Params.put_optional("cursor", Keyword.get(opts, :cursor))
|> Params.put_optional("limit", Keyword.get(opts, :limit))
Connection.request(conn, "model/list", params, timeout_ms: 30_000)
end
@spec config_read(connection(), keyword()) :: {:ok, map()} | {:error, term()}
def config_read(conn, opts \\ []) when is_pid(conn) and is_list(opts) do
params = %{"includeLayers" => !!Keyword.get(opts, :include_layers, false)}
Connection.request(conn, "config/read", params, timeout_ms: 10_000)
end
@spec config_write(connection(), String.t(), term(), keyword()) ::
{:ok, map()} | {:error, term()}
def config_write(conn, key_path, value, opts \\ [])
when is_pid(conn) and is_binary(key_path) and is_list(opts) do
merge_strategy = Params.merge_strategy(Keyword.get(opts, :merge_strategy, :replace))
params =
%{
"keyPath" => key_path,
"value" => value,
"mergeStrategy" => merge_strategy
}
|> Params.put_optional("filePath", Keyword.get(opts, :file_path))
|> Params.put_optional("expectedVersion", Keyword.get(opts, :expected_version))
Connection.request(conn, "config/value/write", params, timeout_ms: 10_000)
end
@spec config_batch_write(connection(), [map()], keyword()) :: {:ok, map()} | {:error, term()}
def config_batch_write(conn, edits, opts \\ [])
when is_pid(conn) and is_list(edits) and is_list(opts) do
wire_edits =
Enum.map(edits, fn edit ->
edit = Params.normalize_map(edit)
%{
"keyPath" =>
Map.get(edit, :key_path) || Map.get(edit, "key_path") || Map.get(edit, :keyPath) ||
Map.get(edit, "keyPath"),
"value" => Map.get(edit, :value) || Map.get(edit, "value"),
"mergeStrategy" =>
Params.merge_strategy(
Map.get(edit, :merge_strategy) || Map.get(edit, "merge_strategy") || :replace
)
}
end)
params =
%{"edits" => wire_edits}
|> Params.put_optional("filePath", Keyword.get(opts, :file_path))
|> Params.put_optional("expectedVersion", Keyword.get(opts, :expected_version))
Connection.request(conn, "config/batchWrite", params, timeout_ms: 10_000)
end
@spec review_start(connection(), String.t(), term(), keyword()) ::
{:ok, map()} | {:error, term()}
def review_start(conn, thread_id, target, opts \\ [])
when is_pid(conn) and is_binary(thread_id) and is_list(opts) do
params =
%{
"threadId" => thread_id,
"target" => review_target(target)
}
|> Params.put_optional("delivery", normalize_review_delivery(Keyword.get(opts, :delivery)))
Connection.request(conn, "review/start", params, timeout_ms: 300_000)
end
@spec command_exec(connection(), [String.t()], keyword()) :: {:ok, map()} | {:error, term()}
def command_exec(conn, command, opts \\ [])
when is_pid(conn) and is_list(command) and is_list(opts) do
timeout_ms = Keyword.get(opts, :timeout_ms)
params =
%{"command" => command}
|> Params.put_optional("timeoutMs", timeout_ms)
|> Params.put_optional("cwd", Keyword.get(opts, :cwd))
|> Params.put_optional("sandboxPolicy", Keyword.get(opts, :sandbox_policy))
request_timeout_ms =
case timeout_ms do
value when is_integer(value) and value > 0 -> value + 5_000
_ -> 30_000
end
Connection.request(conn, "command/exec", params, timeout_ms: request_timeout_ms)
end
@spec feedback_upload(connection(), keyword()) :: {:ok, map()} | {:error, term()}
def feedback_upload(conn, opts) when is_pid(conn) and is_list(opts) do
params =
%{
"classification" => Keyword.fetch!(opts, :classification),
"includeLogs" => !!Keyword.get(opts, :include_logs, false)
}
|> Params.put_optional("reason", Keyword.get(opts, :reason))
|> Params.put_optional("threadId", Keyword.get(opts, :thread_id))
Connection.request(conn, "feedback/upload", params, timeout_ms: 30_000)
end
defp review_target({:uncommitted_changes}), do: %{"type" => "uncommittedChanges"}
defp review_target({:base_branch, branch}) when is_binary(branch),
do: %{"type" => "baseBranch", "branch" => branch}
defp review_target({:commit, sha, title}) when is_binary(sha) do
%{"type" => "commit", "sha" => sha}
|> Params.put_optional("title", title)
end
defp review_target({:custom, instructions}) when is_binary(instructions) do
%{"type" => "custom", "instructions" => instructions}
end
defp review_target(%{} = target), do: target
defp review_target(other), do: %{"type" => "custom", "instructions" => to_string(other)}
defp normalize_review_delivery(nil), do: nil
defp normalize_review_delivery(:inline), do: "inline"
defp normalize_review_delivery(:detached), do: "detached"
defp normalize_review_delivery("inline"), do: "inline"
defp normalize_review_delivery("detached"), do: "detached"
defp normalize_review_delivery(other) when is_binary(other), do: other
defp normalize_review_delivery(_other), do: nil
defp fetch_any(%{} = map, keys) when is_list(keys) do
Enum.find_value(keys, &Map.get(map, &1))
end
end