Current section
Files
Jump to
Current section
Files
README.md
# pi_bridge
BEAM runtime bridge for [pi](https://github.com/earendil-works/pi-coding-agent) and the [`pi-elixir`](https://github.com/elixir-vibe/pi-elixir) package. It provides the Elixir-side `Pi.*` modules used for Livebook-style stateful eval, ExAST-backed structural tools, stdio transport, executable Elixir skills, LLM calls through pi's active model, OTP-backed logical agents, and bidirectional plugin UI events.
`pi_bridge` is inspired by [Vibe](https://github.com/elixir-vibe/vibe): keep the model-facing surface small, but let trusted Elixir code operate from inside the running BEAM.
## Installation and runtime boundary
Users install the npm `pi-elixir` package; it bundles this Mix project and runs it as an isolated control VM. Target projects do **not** add `:pi_bridge` to `mix.exs`. The control bridge identifies the target with `PI_ELIXIR_PROJECT_CWD`, negotiates a build/protocol/capability handshake with the extension, and starts dependencyless project/application workers as needed.
The Hex package remains published for bridge development and protocol consumers, but installing it into each target project is no longer the pi-elixir runtime path.
## Public API ergonomics
The public API intentionally separates single-call and orchestration shapes:
- `Pi.LLM.complete/2` and `Pi.LLM.stream/2` are low-level model calls over the active pi session.
- `Pi.Session.start/1` creates a server-owned BEAM session process for OTP-backed agent/subagent work.
- `Pi.Agent.run/2` returns a single `%Pi.Agent.Result{}` and is backed by `Pi.Session` workers.
- `Pi.Agent.chain/2`, `Pi.Agent.parallel/2`, and `Pi.Agent.fanout/2` return `%Pi.Agent.Run{}` so partial results, kind, status, and errors are explicit.
- `Pi.Plugin` modules expose optional `init/1`, `handle_event/2`, `commands/0`, `handle_command/3`, `tool_call/3`, `tool_result/3`, `apis/0`, and `shutdown/1`; plugin process lifecycle is handled by `Pi.Plugin.Manager` and `Pi.Plugin.Supervisor`.
- `Pi.Plugin.api/1` registers API metadata at compile time and fills a default alias from the module name.
- `Pi.Plugin.command/1` registers BEAM plugin commands that the pi extension exposes as `/elixir:<name>` slash commands.
- `Pi.Plugin.Manager.load/2` and `unload/1` support dynamic plugin lifecycle changes.
- `Pi.Plugin.Waiters` provides an ETS-backed waiter registry for interactive plugins.
- `Pi.Plugin.Event.emit/2` publishes BEAM events onto pi's TypeScript extension event bus.
- `Pi.Host.info/1`, `active_tools/1`, `append_entry/3`, and `send_message/3` expose small host-session APIs back to BEAM code. `Pi.Session` remains the BEAM-owned runtime session API.
Boundary JSON examples are documented in [`docs/protocol.md`](docs/protocol.md).
## Eval
`elixir_eval` has four explicit trusted targets:
- `project` (default): persistent dependencyless target VM; project modules/deps/config are available, but the application is not started;
- `application`: managed target VM with the target application intentionally started;
- `runtime`: attached distributed node configured by `PI_ELIXIR_NODE`, for observing existing PIDs/ETS/application state;
- `bridge`: isolated control VM for `Pi.*`, `AST`, `CodeMap`, `Self`, `Q`, and `Docs` helpers.
Structured eval is stateful per pi execution path: bindings and `Macro.Env` are persisted as sidecar snapshots next to the pi session. Failed eval or compilation preserves the last good state/code. This gives IEx/Livebook-like continuity across calls and resume/branch navigation without inlining large state into JSONL transcripts.
Useful eval helpers:
```elixir
Pi.Eval.bindings()
Pi.Eval.forget(:large_result)
Pi.Eval.reset()
```
QuackDB mirror analytics are available through token-efficient aliases in eval:
```elixir
# preloaded: import Ecto.Query; use QuackDB.Ecto
# preloaded: alias Pi.Self, as: Self
# preloaded: alias Pi.CodeMap, as: CodeMap
# preloaded: alias Pi.Quack, as: Q; require Q
# preloaded: alias Pi.Quack.Event, as: E; alias Pi.Quack.SessionFile, as: SF
Self.status()
Self.context("why did sync crash?", limit: 5)
# Reach-backed semantic reflection after edits.
CodeMap.reflect(changed: true)
CodeMap.hotspots(path: "lib/my_app/module.ex")
CodeMap.context("MyApp.Module.fun/2")
from(e in E,
group_by: e.tool_name,
order_by: [desc: count(e.id)],
select: %{tool: e.tool_name, n: count(e.id)}
)
|> Q.table()
```
Use `Q.score/2`, `Q.matches/2`, `Q.json/2`, and `Q.json_text/2` inside normal QuackDB/Ecto queries for FTS and payload analysis.
For untrusted snippets, use the Dune-backed sandbox:
```elixir
{:ok, %{inspected: "42"}} = Pi.Eval.sandbox("40 + 2")
# Negative example: restricted system access is blocked.
{:error, message} = Pi.Eval.sandbox(~s(System.cmd("ls", [])))
```
The sandbox applies timeout, reduction, heap, and allowlist limits. It returns `{:error, :unavailable}` if the optional `:dune` dependency is not present.
## LLM
pi owns provider/model selection, credentials, streaming, cancellation, usage, and transcript UI. The BEAM side sends structured completion/stream requests over the active bridge; it does not create a separate provider stack.
```elixir
{:ok, text} = Pi.LLM.complete("Explain this module")
stream = Pi.LLM.stream("Draft a migration plan")
Enum.each(stream.stream, &IO.write/1)
```
ReqLLM can route through the active pi session as an adapter on top of that pi-owned model path:
```elixir
Pi.ReqLLM.install()
ReqLLM.generate_text(Pi.ReqLLM.current_model(), "Summarize the current project")
```
`Pi.ReqLLM.current_model/0` returns ReqLLM's inline model struct for the active pi session. Use it instead of the string `"pi:current"` so ReqLLM does not try to verify the dynamic local route against its public model catalog.
> **Feature flag:** `PI_ELIXIR_LLM=0` disables BEAM-initiated LLM requests.
## Sessions and agents
The bridge keeps one pi Node.js/TUI process and one embedded BEAM process. Subagents are not extra pi processes; they are lightweight OTP session workers supervised inside BEAM:
```text
pi Node.js/TUI
└─ embedded BEAM
├─ Pi.LLM.Broker
└─ Pi.Session.Supervisor
├─ Pi.Session.Worker
└─ Pi.Session.Worker
```
Use `Pi.Session` when you need attachable, subscribable session state:
```elixir
{:ok, root} = Pi.Session.start(name: :root)
{:ok, reviewer} = Pi.Session.child(root, name: :reviewer)
{:ok, "done"} = Pi.Session.run(reviewer, "Review this change")
{:ok, state} = Pi.Session.subscribe(reviewer)
```
Session snapshots are emitted as `pi_session` events. The extension renders active/running work as a compact live widget, then emits completed root session trees once as inline transcript entries (`elixir-sessions`). Active BEAM snapshots are reloaded directly from the bridge on session start. Private slash commands control active sessions without adding model-facing tools. The TUI accepts either `id=session_123` or the raw `session_123` as the command argument:
```text
/elixir:sessions.cancel id=session_123
/elixir:sessions.rerun id=session_123
```
Snapshots carry structured fields such as prompt/response previews, current activity, recent streaming output, `run_count`, `completed_at`, and timing. Streaming session runs can emit `:delta` events before the final assistant message:
```elixir
{:ok, text} = Pi.Session.run(session, "Draft notes", stream: true)
```
> **Feature flag:** `PI_ELIXIR_SESSIONS=0` disables session snapshot/control affordances.
Use `Pi.Agent` for convenience orchestration over those sessions. Agent helpers use canonical `%Pi.Session.State{}` values and runtime `Pi.Session` workers; there is no separate agent session registry:
```elixir
{:ok, result} = Pi.Agent.run("Review this change", name: :reviewer)
{:ok, run} =
Pi.Agent.chain([
"Draft an implementation plan",
"Review the plan for risks"
])
{:ok, fanout} = Pi.Agent.fanout(["Review tests", "Review API", "Review docs"])
```
For supervised delegation, start jobs. A job owns lifecycle; its child `Pi.Session` owns the transcript:
```elixir
{:ok, job} = Pi.Agent.start("Review this module", role: :reviewer)
job.status
#=> :running
{:ok, done} = Pi.Agent.await(job, 60_000)
done.status
#=> :done
{:ok, text} = Pi.Agent.result(done)
Pi.Session.state(done.child_session_id)
```
Run multiple jobs when the tasks are independent:
```elixir
{:ok, jobs} =
Pi.Agent.run_many([
%{task: "Review tests", role: :reviewer},
%{task: "Review API", role: :reviewer},
"Review docs"
])
Enum.map(jobs, &Pi.Agent.await(&1, 60_000))
```
Attach jobs to a parent session when you want parent-visible lifecycle events in the session widget:
```elixir
{:ok, parent} = Pi.Session.start(name: :review)
parent_id = Pi.Session.state(parent).id
{:ok, job} = Pi.Agent.start("Review tests", role: :reviewer, parent_session_id: parent_id)
{:ok, done} = Pi.Agent.await(job, 60_000)
Pi.Session.state(parent).events
# includes :agent_job_started and :agent_job_finished
```
Cancel long-running work through the job lifecycle handle:
```elixir
{:ok, job} = Pi.Agent.start("Explore a risky option", role: :researcher)
:ok = Pi.Agent.cancel(job)
{:error, cancelled} = Pi.Agent.await(job, 100)
cancelled.status
#=> :cancelled
```
`Pi.Agent.run/2` keeps the single-run shape `{:ok, %Pi.Agent.Result{}} | {:error, %Pi.Agent.Result{}}`. `chain/2`, `parallel/2`, and `fanout/2` return `{:ok, %Pi.Agent.Run{}} | {:error, %Pi.Agent.Run{}}` so orchestration metadata and partial results are explicit. Job APIs return `%Pi.Agent.Job{}` lifecycle handles with `status`, `result`, `error`, `parent_session_id`, and `child_session_id`.
## Plugin command/event/hook lifecycle
1. On stdio startup, BEAM sends `ready` with plugin command inventory.
2. The TypeScript extension registers each plugin command as `/elixir:<name>`.
3. Running the slash command sends `pi_plugin_command` to BEAM and dispatches `handle_command/3`.
4. `Pi.Plugin.Event.emit/2` sends `{type: "event"}` back to pi and is published on `pi.events`.
5. Before a pi tool executes, the extension calls `pi_plugin_tool_call`; plugin `tool_call/3` may block or return an input-only patch.
6. After a pi tool result, the extension calls `pi_plugin_tool_result`; plugin `tool_result/3` may patch result `content` or `isError`.
7. Malformed hook payloads are rejected before plugin callbacks run.
## Session bridge APIs
BEAM code can ask the pi extension for small session-state snapshots, persist branch-aware custom entries, or emit a visible custom transcript message:
```elixir
{:ok, info} = Pi.Host.info()
{:ok, %{tools: tools}} = Pi.Host.active_tools()
{:ok, "ok"} = Pi.Host.append_entry("demo-state", count: 1)
{:ok, "ok"} = Pi.Host.send_message("demo-message", count: 1)
```
## Plugins
> **Feature flags:** `PI_ELIXIR_PLUGINS=0` disables built-in/project-local plugins, hooks, UI events, and plugin commands. `PI_ELIXIR_SKILLS=0` disables executable skill discovery.
Built-in optional plugins are loaded before project-local plugins. The built-in DuckDB event mirror (`Pi.Mirror.QuackDB`) is enabled by default; set `PI_ELIXIR_MIRROR=0` to disable it. By default it writes `~/.pi/elixir/session-mirror.duckdb`; override with `PI_ELIXIR_MIRROR_DB`, or point at an existing Quack server with `PI_ELIXIR_MIRROR_QUACKDB_URI` and `PI_ELIXIR_MIRROR_QUACKDB_TOKEN`.
Project-local plugins live in `priv/pi_plugins`, `.pi/plugins`, or `pi_plugins`. Each plugin is isolated behind a `Pi.Plugin.Worker` process.
```elixir
defmodule DemoPiPlugin do
use Pi.Plugin
def init(_opts), do: {:ok, %{events: 0}}
def handle_event(_event, state), do: {:noreply, Map.update(state, :events, 1, &(&1 + 1))}
command name: :demo, description: "Run the demo plugin command"
def handle_command(:demo, args, state), do: {{:ok, "demo #{args}"}, state}
# Negative example: block a tool call.
# Return {:block, reason} to prevent a tool call, or {:ok, patch} to merge into the tool input only.
def tool_call(%{"toolName" => "bash"}, _context, state), do: {{:block, "bash blocked"}, state}
def tool_call(_call, _context, state), do: {:ok, state}
# Return {:ok, patch} to patch a tool result. Supported TypeScript-side patches include
# string `content` and boolean `isError`.
def tool_result(%{"toolName" => "demo"}, _context, state) do
{{:ok, %{"content" => "patched by plugin"}}, state}
end
def tool_result(_result, _context, state), do: {:ok, state}
def apis do
[name: :demo_plugin, module: __MODULE__, alias: :DemoPlugin]
end
end
```
## Examples
See `examples/vibe_workflow.exs` and `examples/demo_plugin.exs`.