use super::client::{AskSink, StatusSink, YepConnection};
use super::package::ExtensionManifest;
use super::protocol::{InitializeParams, InitializeResult, PROTOCOL_VERSION};
use anyhow::{Context, Result, anyhow};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::{Arc, Weak};
use std::time::Duration;
use tokio::process::Command;
use tokio::sync::Mutex;
pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 60_000;
pub struct ExtensionProcessSpec {
pub manifest: ExtensionManifest,
pub package_dir: PathBuf,
pub workspace_root: PathBuf,
pub config: Value,
pub request_timeout: Duration,
pub status_sink: Option<StatusSink>,
pub ask_sink: Option<AskSink>,
}
pub struct ExtensionProcess {
spec: ExtensionProcessSpec,
state: Mutex<Option<Live>>,
}
struct Live {
connection: Arc<YepConnection>,
served_tools: HashSet<String>,
prompt: Option<String>,
_child: tokio::process::Child,
}
impl ExtensionProcess {
pub fn new(spec: ExtensionProcessSpec) -> Self {
Self {
spec,
state: Mutex::new(None),
}
}
pub fn name(&self) -> &str {
&self.spec.manifest.name
}
pub async fn call_tool(&self, tool_call_id: &str, name: &str, args: Value) -> Result<Value> {
let (connection, served) = {
let mut state = self.state.lock().await;
self.ensure_live(&mut state).await?;
let live = state.as_ref().expect("ensured live");
(live.connection.clone(), live.served_tools.contains(name))
};
if !served {
return Err(anyhow!(
"tool `{name}` is declared by extension `{}` but not served by its running \
server (feature-gated build?)",
self.name()
));
}
let params = serde_json::to_value(super::protocol::ToolCallParams {
tool_call_id: tool_call_id.to_string(),
name: name.to_string(),
args,
})?;
let result = connection.request("tool/call", params).await;
if connection.is_closed() {
*self.state.lock().await = None;
}
result
}
pub async fn execute_command(
&self,
name: &str,
arguments: &str,
) -> Result<super::protocol::CommandExecuteResult> {
let connection = {
let mut state = self.state.lock().await;
self.ensure_live(&mut state).await?;
state.as_ref().expect("ensured live").connection.clone()
};
let params = serde_json::to_value(super::protocol::CommandExecuteParams {
name: name.to_string(),
arguments: arguments.to_string(),
})?;
let result = connection.request("command/execute", params).await;
if connection.is_closed() {
*self.state.lock().await = None;
}
Ok(serde_json::from_value(result?).unwrap_or_default())
}
pub async fn prompt_contribution(&self) -> Option<String> {
let mut state = self.state.lock().await;
if let Err(err) = self.ensure_live(&mut state).await {
tracing::warn!(
target: "yolop::ext", ext = %self.name(),
"extension server unavailable for prompt contribution: {err}"
);
return None;
}
state.as_ref().and_then(|live| live.prompt.clone())
}
pub async fn dynamic_prompt(&self) -> Option<String> {
let connection = {
let mut state = self.state.lock().await;
self.ensure_live(&mut state).await.ok()?;
state.as_ref().expect("ensured live").connection.clone()
};
match connection
.request("prompt/contribution", serde_json::Value::Null)
.await
{
Ok(value) => {
let contribution: super::protocol::PromptContribution =
serde_json::from_value(value).unwrap_or_default();
let text = contribution.text.trim();
(!text.is_empty()).then(|| text.to_string())
}
Err(err) => {
tracing::warn!(
target: "yolop::ext", ext = %self.name(),
"dynamic prompt/contribution failed: {err}"
);
None
}
}
}
pub async fn fire_hook(
&self,
event: &str,
tool_name: &str,
args: &serde_json::Value,
) -> Result<super::protocol::HookDecision> {
let connection = {
let mut state = self.state.lock().await;
self.ensure_live(&mut state).await?;
state.as_ref().expect("ensured live").connection.clone()
};
let params = serde_json::to_value(super::protocol::HookFireParams {
event: event.to_string(),
tool_name: tool_name.to_string(),
args: args.clone(),
})?;
let value = connection.request("hook/fire", params).await?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
pub async fn reload(&self) -> bool {
self.state.lock().await.take().is_some()
}
async fn ensure_live(&self, state: &mut Option<Live>) -> Result<()> {
if let Some(live) = state.as_ref()
&& !live.connection.is_closed()
{
return Ok(());
}
*state = Some(self.spawn().await?);
Ok(())
}
async fn spawn(&self) -> Result<Live> {
let server = &self.spec.manifest.capability_server;
let mut command = Command::new(&server.command);
command
.args(&server.args)
.current_dir(&self.spec.workspace_root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
let bin_dir = self.spec.package_dir.join("bin");
if bin_dir.is_dir() {
let mut paths: Vec<PathBuf> = vec![bin_dir];
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
if let Ok(joined) = std::env::join_paths(paths) {
command.env("PATH", joined);
}
}
let mut child = command.spawn().with_context(|| {
format!(
"extension `{}`: cannot spawn `{}` (install it or fix `capabilityServer.command`)",
self.name(),
server.command
)
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("extension server has no stdout"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("extension server has no stdin"))?;
let init = InitializeParams {
protocol_version: PROTOCOL_VERSION.to_string(),
session_id: String::new(),
workspace_root: self.spec.workspace_root.display().to_string(),
config: self.spec.config.clone(),
capabilities: vec!["cancel".into(), "streaming".into()],
};
let (connection, handshake) = YepConnection::connect(
stdout,
stdin,
self.name(),
init,
self.spec.request_timeout,
self.spec.status_sink.clone(),
self.spec.ask_sink.clone(),
)
.await?;
if !handshake.name.is_empty() && handshake.name != self.spec.manifest.name {
tracing::warn!(
target: "yolop::ext", ext = %self.name(),
"server introduced itself as `{}`; the installed manifest name wins",
handshake.name
);
}
tracing::debug!(
target: "yolop::ext", ext = %self.name(),
capabilities = ?handshake.capabilities, "extension server connected"
);
let (served_tools, prompt) = self.clamp(&handshake);
Ok(Live {
connection,
served_tools,
prompt,
_child: child,
})
}
fn clamp(&self, handshake: &InitializeResult) -> (HashSet<String>, Option<String>) {
let manifest = &self.spec.manifest;
let approved: HashSet<&str> = manifest.tools.iter().map(|t| t.name.as_str()).collect();
let mut served = HashSet::new();
for tool in &handshake.capability_params.tools {
if approved.contains(tool.name.as_str()) {
served.insert(tool.name.clone());
} else {
tracing::warn!(
target: "yolop::ext", ext = %self.name(),
"refusing tool `{}` not in the approved manifest", tool.name
);
}
}
let prompt = match &handshake.capability_params.prompt {
Some(prompt) if manifest.prompt => {
let text = prompt.static_text.trim();
(!text.is_empty()).then(|| text.to_string())
}
Some(_) => {
tracing::warn!(
target: "yolop::ext", ext = %self.name(),
"refusing prompt contribution: manifest does not declare `prompt = true`"
);
None
}
None => None,
};
(served, prompt)
}
}
#[derive(Clone, Default)]
pub struct LiveProcessRegistry {
processes: Arc<std::sync::Mutex<HashMap<String, Weak<ExtensionProcess>>>>,
}
impl LiveProcessRegistry {
pub fn register(&self, name: &str, process: &Arc<ExtensionProcess>) {
self.processes
.lock()
.expect("live-process registry lock")
.insert(name.to_string(), Arc::downgrade(process));
}
pub async fn reload(&self, name: &str) -> Option<bool> {
let process = self
.processes
.lock()
.expect("live-process registry lock")
.get(name)
.and_then(Weak::upgrade)?;
Some(process.reload().await)
}
}