···185185 return Ok(String::new());
186186 }
187187188188- const COMPACT_PROMPT: &str =
189189- "Please provide a detailed but concise summary of our conversation so far. \
188188+ const COMPACT_PROMPT: &str = "Please provide a detailed but concise summary of our conversation so far. \
190189 Include: goals discussed, files viewed or modified, code written or changed, \
191190 decisions made, and the current state of any ongoing tasks. \
192191 This summary will replace the full conversation history as context for \
+27
crates/ein-proto/proto/ein.proto
···1212 rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);
1313 // Permanently deletes a session and its message history.
1414 rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse);
1515+ // Returns the list of plugin sources with their installation status.
1616+ rpc CheckPlugins(CheckPluginsRequest) returns (CheckPluginsResponse);
1717+ // Downloads and installs plugins for the given source.
1818+ rpc InstallPlugins(InstallPluginsRequest) returns (InstallPluginsResponse);
1519}
16201721// A single message sent by the client during a session.
···160164}
161165162166message DeleteSessionResponse {}
167167+168168+message CheckPluginsRequest {}
169169+170170+// The installation status of a single plugin source.
171171+message PluginSourceStatus {
172172+ string id = 1;
173173+ string display_name = 2;
174174+ bool installed = 3;
175175+}
176176+177177+message CheckPluginsResponse {
178178+ repeated PluginSourceStatus sources = 1;
179179+}
180180+181181+message InstallPluginsRequest {
182182+ // Identifier for the plugin source to install (e.g. "default").
183183+ string source_id = 1;
184184+}
185185+186186+message InstallPluginsResponse {
187187+ bool success = 1;
188188+ string message = 2;
189189+}
···99mod grpc;
1010mod model_client;
1111mod persistence;
1212+mod plugins;
1213mod tools;
1414+1515+pub use plugins::install_plugins;
13161417use ein_proto::ein::agent_server::AgentServer as AgentServiceServer;
1518use grpc::AgentServer;
···55585659/// Start the Ein gRPC server and block until it exits.
5760pub async fn run(port: u16) -> anyhow::Result<()> {
6161+ // In release builds, auto-install plugins if none are present.
6262+ if !cfg!(debug_assertions) {
6363+ let config = EinConfig::default();
6464+6565+ if plugins::plugins_missing(&config.plugin_dir).await {
6666+ println!("No plugins found, downloading from GitHub release...");
6767+ plugins::install_plugins(None).await?;
6868+ }
6969+ }
7070+5871 let addr = format!("0.0.0.0:{port}").parse()?;
59726073 let server = AgentServer::new().await?;
+90
crates/ein-server/src/plugins.rs
···11+// SPDX-License-Identifier: Apache-2.0
22+// Copyright 2026 Mason Stallmo
33+44+use anyhow::{Context, Result};
55+use flate2::read::GzDecoder;
66+use std::{io, path::Path};
77+use tar::Archive;
88+use tokio::{fs, task};
99+1010+const DEFAULT_TOOL_PLUGINS: &[&str] = &["ein_bash", "ein_read", "ein_write", "ein_edit"];
1111+const DEFAULT_MODEL_CLIENT_PLUGINS: &[&str] = &[
1212+ "ein_openrouter",
1313+ "ein_anthropic",
1414+ "ein_openai",
1515+ "ein_ollama",
1616+];
1717+1818+pub async fn install_plugins(version: Option<String>) -> Result<()> {
1919+ let ver = version.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
2020+ let ver = ver.trim_start_matches('v');
2121+ let tag = format!("v{ver}");
2222+ let url =
2323+ format!("https://github.com/mstallmo/ein/releases/download/{tag}/ein-plugins-{tag}.tar.gz");
2424+2525+ let plugins_dir = dirs::home_dir()
2626+ .context("Failed to find home directory")?
2727+ .join(".ein")
2828+ .join("plugins");
2929+3030+ fs::create_dir_all(plugins_dir.join("tools")).await?;
3131+ fs::create_dir_all(plugins_dir.join("model_clients")).await?;
3232+3333+ println!("Downloading plugins from {url}...");
3434+3535+ let response = reqwest::get(&url)
3636+ .await
3737+ .with_context(|| format!("Failed to download {url}"))?;
3838+3939+ if !response.status().is_success() {
4040+ anyhow::bail!("Download failed: HTTP {}", response.status());
4141+ }
4242+4343+ let bytes = response
4444+ .bytes()
4545+ .await
4646+ .context("Failed to read response body")?;
4747+4848+ task::spawn_blocking(move || {
4949+ let gz = GzDecoder::new(io::Cursor::new(bytes));
5050+ Archive::new(gz)
5151+ .unpack(&plugins_dir)
5252+ .context("Failed to extract plugin archive")
5353+ })
5454+ .await??;
5555+5656+ println!("Plugins installed successfully");
5757+ Ok(())
5858+}
5959+6060+/// Returns `true` if all default tool and model-client plugins are present.
6161+pub async fn check_default_plugins(plugin_dir: &Path, model_client_dir: &Path) -> bool {
6262+ for name in DEFAULT_TOOL_PLUGINS {
6363+ if !plugin_dir.join(format!("{name}.wasm")).exists() {
6464+ return false;
6565+ }
6666+ }
6767+6868+ for name in DEFAULT_MODEL_CLIENT_PLUGINS {
6969+ if !model_client_dir.join(format!("{name}.wasm")).exists() {
7070+ return false;
7171+ }
7272+ }
7373+7474+ true
7575+}
7676+7777+/// Returns true if the tools plugin directory has no `.wasm` files.
7878+pub async fn plugins_missing(plugin_dir: &Path) -> bool {
7979+ let Ok(mut entries) = fs::read_dir(plugin_dir).await else {
8080+ return true;
8181+ };
8282+8383+ while let Ok(Some(entry)) = entries.next_entry().await {
8484+ if entry.path().extension().is_some_and(|e| e == "wasm") {
8585+ return false;
8686+ }
8787+ }
8888+8989+ true
9090+}
+22-1
crates/ein-tui/src/app.rs
···11// SPDX-License-Identifier: Apache-2.0
22// Copyright 2026 Mason Stallmo
3344-use ein_proto::ein::{AgentEvent, SessionConfig, SessionSummary, UserInput};
44+use ein_proto::ein::{AgentEvent, PluginSourceStatus, SessionConfig, SessionSummary, UserInput};
55use tokio::sync::{mpsc, oneshot};
6677use crate::config::ClientConfig;
···2626 SessionsLoaded(Vec<SessionSummary>, oneshot::Sender<SessionConfig>),
2727 /// A session was successfully deleted; remove it from the session picker.
2828 SessionDeleted(String),
2929+ /// The server returned plugin source statuses for the plugin modal.
3030+ PluginStatusLoaded(Vec<PluginSourceStatus>),
3131+ /// A plugin install RPC completed; carries success flag and a status message.
3232+ PluginInstallResult { success: bool, message: String },
2933}
30343135/// Whether the TUI currently has a live server connection.
···7175// Session picker / CWD prompt state
7276// ---------------------------------------------------------------------------
73777878+/// State for the plugin manager modal, opened via `/plugins`.
7979+pub(crate) struct PluginModalState {
8080+ /// Plugin sources and their install status, fetched from the server.
8181+ pub(crate) sources: Vec<PluginSourceStatus>,
8282+ /// Currently highlighted row index.
8383+ pub(crate) selected: usize,
8484+ /// True while an install RPC is in flight.
8585+ pub(crate) installing: bool,
8686+ /// True while the initial status check RPC is in flight.
8787+ pub(crate) loading: bool,
8888+ /// Last install result message, shown beneath the source list.
8989+ pub(crate) status_message: Option<String>,
9090+}
9191+7492/// State for the session picker modal shown on first connection.
7593pub(crate) struct SessionPickerState {
7694 /// Existing sessions from the server (newest-first). Index 0 in the UI is
···161179 pub(crate) current_cfg: ClientConfig,
162180 /// Session UUID assigned by the server, shown in the status bar.
163181 pub(crate) session_id: Option<String>,
182182+ /// When `Some`, the plugin manager modal is visible.
183183+ pub(crate) pending_plugin_modal: Option<PluginModalState>,
164184}
165185166186impl App {
···190210 cwd,
191211 current_cfg,
192212 session_id: None,
213213+ pending_plugin_modal: None,
193214 }
194215 }
195216}
+33-3
crates/ein-tui/src/connection.rs
···22// Copyright 2026 Mason Stallmo
3344use ein_proto::ein::{
55- DeleteSessionRequest, ListSessionsRequest, SessionConfig, UserInput,
66- agent_client::AgentClient, user_input,
55+ CheckPluginsRequest, DeleteSessionRequest, InstallPluginsRequest, InstallPluginsResponse,
66+ ListSessionsRequest, PluginSourceStatus, SessionConfig, UserInput, agent_client::AgentClient,
77+ user_input,
78};
89use tokio::sync::{mpsc, oneshot};
910use tokio_stream::wrappers::ReceiverStream;
···206207 }
207208}
208209210210+/// Opens a short-lived connection and fetches plugin source statuses.
211211+pub(crate) async fn check_plugins(server_addr: &str) -> anyhow::Result<Vec<PluginSourceStatus>> {
212212+ let channel = Channel::from_shared(server_addr.to_string())?
213213+ .connect()
214214+ .await?;
215215+ let mut client = AgentClient::new(channel);
216216+ let resp = client
217217+ .check_plugins(tonic::Request::new(CheckPluginsRequest {}))
218218+ .await?;
219219+ Ok(resp.into_inner().sources)
220220+}
221221+222222+/// Opens a short-lived connection and requests plugin installation for `source_id`.
223223+pub(crate) async fn install_plugins(
224224+ server_addr: &str,
225225+ source_id: String,
226226+) -> anyhow::Result<InstallPluginsResponse> {
227227+ let channel = Channel::from_shared(server_addr.to_string())?
228228+ .connect()
229229+ .await?;
230230+ let mut client = AgentClient::new(channel);
231231+ let resp = client
232232+ .install_plugins(tonic::Request::new(InstallPluginsRequest { source_id }))
233233+ .await?;
234234+ Ok(resp.into_inner())
235235+}
236236+209237/// Opens a short-lived connection and deletes a session by ID.
210238///
211239/// Returns `Ok(())` on success; errors are logged by the caller.
212240pub(crate) async fn delete_session(server_addr: &str, session_id: String) -> anyhow::Result<()> {
213213- let channel = Channel::from_shared(server_addr.to_string())?.connect().await?;
241241+ let channel = Channel::from_shared(server_addr.to_string())?
242242+ .connect()
243243+ .await?;
214244 let mut client = AgentClient::new(channel);
215245 client
216246 .delete_session(tonic::Request::new(DeleteSessionRequest { session_id }))
+63
crates/ein-tui/src/input.rs
···4646 name: "/compact",
4747 description: "Summarize and compact conversation history",
4848 },
4949+ CommandDef {
5050+ name: "/plugins",
5151+ description: "Manage installed plugins",
5252+ },
4953];
50545155/// Recomputes `autocomplete_matches` and `autocomplete_active` based on the
···8488 OpenSessionPicker,
8589 /// The user pressed Shift+D on an existing session in the picker; delete it.
8690 DeleteSession(String),
9191+ /// Open the plugin manager modal and fetch status from the server.
9292+ OpenPluginModal,
9393+ /// User selected a plugin source to install/update; `source_id` identifies it.
9494+ InstallPlugin { source_id: String },
8795}
88968997// ---------------------------------------------------------------------------
···101109 return KeyAction::Quit;
102110 }
103111112112+ // While the plugin modal is visible, route all key events to it.
113113+ if app.pending_plugin_modal.is_some() {
114114+ return handle_plugin_modal_key(app, key);
115115+ }
116116+104117 // While the session picker is visible, route all key events to it.
105118 if app.pending_session_picker.is_some() {
106119 return handle_session_picker_key(app, key).await;
···112125 }
113126114127 handle_normal_key(app, key).await
128128+}
129129+130130+fn handle_plugin_modal_key(app: &mut App, key: KeyEvent) -> KeyAction {
131131+ // While an async operation is in flight, only Esc is allowed.
132132+ let busy = app
133133+ .pending_plugin_modal
134134+ .as_ref()
135135+ .is_some_and(|m| m.loading || m.installing);
136136+ if busy {
137137+ if key.code == KeyCode::Esc {
138138+ app.pending_plugin_modal = None;
139139+ }
140140+ return KeyAction::Continue;
141141+ }
142142+143143+ match key.code {
144144+ KeyCode::Esc => {
145145+ app.pending_plugin_modal = None;
146146+ }
147147+ KeyCode::Up => {
148148+ if let Some(modal) = app.pending_plugin_modal.as_mut() {
149149+ if modal.selected > 0 {
150150+ modal.selected -= 1;
151151+ }
152152+ }
153153+ }
154154+ KeyCode::Down => {
155155+ if let Some(modal) = app.pending_plugin_modal.as_mut() {
156156+ if modal.selected + 1 < modal.sources.len() {
157157+ modal.selected += 1;
158158+ }
159159+ }
160160+ }
161161+ KeyCode::Enter => {
162162+ let source_id = app
163163+ .pending_plugin_modal
164164+ .as_ref()
165165+ .and_then(|m| m.sources.get(m.selected))
166166+ .map(|s| s.id.clone())
167167+ .unwrap_or_else(|| "default".to_string());
168168+ if let Some(modal) = app.pending_plugin_modal.as_mut() {
169169+ modal.installing = true;
170170+ modal.status_message = None;
171171+ }
172172+ return KeyAction::InstallPlugin { source_id };
173173+ }
174174+ _ => {}
175175+ }
176176+ KeyAction::Continue
115177}
116178117179async fn handle_session_picker_key(app: &mut App, key: KeyEvent) -> KeyAction {
···270332 }
271333 "/exit" => return KeyAction::Quit,
272334 "/new" => return KeyAction::NewSession,
335335+ "/plugins" => return KeyAction::OpenPluginModal,
273336 "/sessions" => return KeyAction::OpenSessionPicker,
274337 _ => {
275338 // Reject unrecognized slash commands — display a local error, do not send to server.