Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs,heex}"]
]
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
/deps/
/doc/
/cover/
/assets/node_modules/
/priv/static/assets/app.js
/priv/static/assets/app.js.map
/mix.lock
100 changes: 93 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,93 @@ agents that speak the Agent Client Protocol (ACP). It builds on
- a long-lived session process that survives UI disconnects;
- prompt, cancellation, permission, event, and diagnostic state management;
- an `ExMCP.Transport` implementation backed by a pluggable process runner;
- interactive-shell startup isolation for remote execution environments; and
- optional notification and persistence callbacks.
- interactive-shell startup isolation for remote execution environments;
- optional notification and persistence callbacks;
- a reusable Phoenix LiveView conversation component; and
- a loopback-only web playground for launching local ACP agent commands.

The host application remains responsible for authorization, agent records,
credentials, persistence schemas, and presentation.
Host applications remain responsible for authorization, agent records,
credentials, and persistence schemas. They can provide an action adapter to
the shared conversation component while retaining their own authorization
boundary.

## Local web playground

The repository includes a runnable Phoenix application that launches an ACP
agent as a local stdio process and renders the same conversation component that
can be embedded in another Phoenix application.

```sh
mix setup
mix phx.server
```

Open <http://127.0.0.1:4000>, then enter:

- the ACP agent executable;
- one command argument per line;
- the repository working directory; and
- any additional `NAME=value` environment entries.

The launch form can be prefilled from application configuration instead of
typing these values on every launch:

```elixir
config :acp_runtime,
agent_process: [
executable: "opencode",
args: ["acp"],
working_directory: "/absolute/path/to/project",
environment: %{}
]
```

Values set in the form override the configured defaults, and unconfigured
fields fall back to their previous behavior (blank fields, server working
directory).

For the locally installed OpenCode ACP server, use `opencode` as the executable
and `acp` as its single argument.

The spawned process inherits the environment of the Phoenix server. Commands
are represented as an executable plus an argument list and are not evaluated
as a shell command string.

The development endpoint binds to `127.0.0.1` and the playground route uses
only the Phoenix browser pipeline. It has no authentication and should not be
exposed on a public interface without adding an authentication boundary.

## Shared LiveView component

`ACPRuntimeWeb.ConversationComponent` owns the prompt form, streamed events,
permission controls, session status presentation, and safe Markdown rendering
for agent messages. A parent LiveView passes the initial state and forwards
`{:acp_status, state}` and
`{:acp_event, event}` messages with `Phoenix.LiveView.send_update/3`.

```heex
<.live_component
module={ACPRuntimeWeb.ConversationComponent}
id="acp-conversation"
session_id={@session_id}
session_state={@session_state}
agent_name={@agent_name}
action_adapter={ACPRuntimeWeb.SessionActions}
action_context={@session_pid}
/>
```

Custom adapters implement `ACPRuntimeWeb.ConversationActions`. This lets a
host authorize prompt, cancellation, permission, and termination operations
before delegating to the runtime.

The component's stylesheet is shipped at
`priv/static/assets/acp_runtime.css`. A Tailwind host can include it in its own
application bundle:

```css
@import "../../deps/acp_runtime/priv/static/assets/acp_runtime.css";
```

## Supervision

Expand All @@ -35,8 +117,12 @@ agent. See `ACPRuntime.ProcessRunner` for the required contract. This permits
the same runtime to operate against local ports, containers, VMs, or remote
execution services without depending on their SDKs.

`ACPRuntime.LocalProcessRunner` is the built-in implementation for the local
web playground. It uses an Erlang port and leaves agent stderr attached to the
Phoenix server's stderr so it cannot corrupt the ACP messages on stdout.

## Publishing status

This project is currently consumed as a sibling path-dependency prototype. Public Hex publishing
requires a repository URL, an explicit license decision, package metadata, and
independent CI before the version is changed from `0.1.0-dev`.
This project is currently consumed as a sibling path-dependency prototype.
Public Hex publishing requires an explicit license decision, package metadata,
and independent CI before the version is changed from `0.1.0-dev`.
10 changes: 10 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import "phoenix_html"

import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"

const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})

liveSocket.connect()
window.liveSocket = liveSocket
34 changes: 34 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Config

config :acp_runtime,
web_enabled: true,
agent_process: [
executable: "opencode",
args: ["acp"],
working_directory: nil,
environment: %{}
]

config :acp_runtime, ACPRuntimeWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: ACPRuntimeWeb.ErrorHTML], layout: false],
pubsub_server: ACPRuntime.PubSub,
live_view: [signing_salt: "acpRuntimeLiveView"]

config :esbuild,
version: "0.25.4",
acp_runtime: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]

config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]

config :phoenix, :json_library, Jason

import_config "#{config_env()}.exs"
14 changes: 14 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Config

config :acp_runtime, ACPRuntimeWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "f7NzKjVVWQMVz8GmEsPBymEGFAPjHhxnmtvu7jseuPkxFNfMnLZSnzrXPWNCng5E",
watchers: [
esbuild: {Esbuild, :install_and_run, [:acp_runtime, ~w(--sourcemap=inline --watch)]}
]

config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view, debug_heex_annotations: true, enable_expensive_runtime_checks: true
6 changes: 6 additions & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Config

config :acp_runtime, ACPRuntimeWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"

config :logger, level: :info
9 changes: 9 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Config

if config_env() == :prod do
config :acp_runtime, ACPRuntimeWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base:
System.get_env("SECRET_KEY_BASE") ||
raise("SECRET_KEY_BASE must be set when running the ACP Runtime web UI in production")
end
10 changes: 10 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Config

config :acp_runtime, ACPRuntimeWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "f7NzKjVVWQMVz8GmEsPBymEGFAPjHhxnmtvu7jseuPkxFNfMnLZSnzrXPWNCng5E",
server: false

config :logger, level: :warning
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view, enable_expensive_runtime_checks: true
35 changes: 35 additions & 0 deletions lib/acp_runtime/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule ACPRuntime.Application do
@moduledoc false

use Application

@impl true
def start(_type, _args) do
children =
if Application.get_env(:acp_runtime, :web_enabled, false) do
[
ACPRuntime.Supervisor,
{Registry, keys: :unique, name: ACPRuntime.LocalProcessRegistry},
{DynamicSupervisor, name: ACPRuntime.LocalProcessSupervisor, strategy: :one_for_one},
{Phoenix.PubSub, name: ACPRuntime.PubSub},
ACPRuntimeWeb.Endpoint
]
else
[]
end

Supervisor.start_link(children,
strategy: :one_for_one,
name: ACPRuntime.ApplicationSupervisor
)
end

@impl true
def config_change(changed, _new, removed) do
if Application.get_env(:acp_runtime, :web_enabled, false) do
ACPRuntimeWeb.Endpoint.config_change(changed, removed)
end

:ok
end
end
102 changes: 102 additions & 0 deletions lib/acp_runtime/local.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
defmodule ACPRuntime.Local do
@moduledoc "Starts ACP sessions backed by local operating-system processes."

alias ACPRuntime.{LocalCredentialProvider, LocalProcessRunner, MemoryPersistence, Session}

def start_session(opts) do
with {:ok, executable} <- required_binary(opts, :executable),
{:ok, args} <- string_list(opts, :args, []),
{:ok, working_directory} <- working_directory(opts),
{:ok, environment} <- string_map(opts, :environment, %{}) do
id = session_id()
agent_name = Keyword.get(opts, :agent_name, Path.basename(executable))

agent = %{id: "agent-#{id}", name: agent_name, executable: executable, args: args}
target = %{id: "local", status: "ready"}

session = %{
id: id,
status: "connecting",
started_at: DateTime.utc_now(:second),
remote_session_id: nil,
acp_session_id: nil,
last_error: nil,
agent: agent,
environment: target
}

context = %{environment: environment}

runtime_opts = [
registry: ACPRuntime.Registry,
session_supervisor: ACPRuntime.SessionSupervisor,
task_supervisor: ACPRuntime.TaskSupervisor,
process_runner: LocalProcessRunner,
credential_provider: LocalCredentialProvider,
persistence: MemoryPersistence,
notifier: {ACPRuntime.PubSubNotifier, pubsub: ACPRuntime.PubSub},
working_directory: working_directory,
client_options: [
supervisor: ACPRuntime.SessionSupervisor,
process_runner: LocalProcessRunner,
client_info: %{
"name" => "acp_runtime_web",
"title" => "ACP Runtime Web",
"version" => Application.spec(:acp_runtime, :vsn) |> to_string()
}
]
]

with {:ok, pid} <- Session.ensure_started(context, session, runtime_opts) do
{:ok, %{session: session, pid: pid, session_state: Session.state(pid)}}
end
end
end

def subscribe(session_id) when is_binary(session_id) do
Phoenix.PubSub.subscribe(ACPRuntime.PubSub, ACPRuntime.PubSubNotifier.topic(session_id))
end

defp required_binary(opts, key) do
case Keyword.get(opts, key) do
value when is_binary(value) and value != "" -> {:ok, value}
_other -> {:error, {:invalid_option, key}}
end
end

defp string_list(opts, key, default) do
case Keyword.get(opts, key, default) do
values when is_list(values) ->
if Enum.all?(values, &is_binary/1),
do: {:ok, values},
else: {:error, {:invalid_option, key}}

_other ->
{:error, {:invalid_option, key}}
end
end

defp string_map(opts, key, default) do
case Keyword.get(opts, key, default) do
values when is_map(values) ->
if Enum.all?(values, fn {name, value} -> is_binary(name) and is_binary(value) end),
do: {:ok, values},
else: {:error, {:invalid_option, key}}

_other ->
{:error, {:invalid_option, key}}
end
end

defp working_directory(opts) do
directory = opts |> Keyword.get(:working_directory, File.cwd!()) |> Path.expand()

if File.dir?(directory),
do: {:ok, directory},
else: {:error, {:invalid_option, :working_directory}}
end

defp session_id do
"session-" <> (:crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false))
end
end
7 changes: 7 additions & 0 deletions lib/acp_runtime/local_credential_provider.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule ACPRuntime.LocalCredentialProvider do
@moduledoc false

def credential_env(%{environment: environment}, _agent) when is_map(environment) do
{:ok, environment}
end
end
Loading
Loading