diff --git a/src-tauri/src/acp/opencode_plugins.rs b/src-tauri/src/acp/opencode_plugins.rs index 7341735..c8bb7d5 100644 --- a/src-tauri/src/acp/opencode_plugins.rs +++ b/src-tauri/src/acp/opencode_plugins.rs @@ -310,6 +310,76 @@ pub(crate) fn atomic_rewrite_opencode_json( Ok(()) } +/// Check whether a plugin spec uses a floating version tag like `@latest`. +pub fn spec_has_floating_version(spec: &str) -> bool { + if let Some((_, full)) = parse_plugin_spec(spec) { + full.ends_with("@latest") + } else { + false + } +} + +/// After a successful install, replace `@latest` specs in opencode.json with +/// the actual installed version read from node_modules. This prevents +/// opencode from hitting the npm registry on every startup. +fn pin_latest_specs( + config_path: &Path, + cache_dir: &Path, + specs: &[(String, String)], // (name, original_declared_spec) +) -> Result { + let mut pinned = 0; + + // Collect name → installed_version for specs that have @latest + let mut pin_map: Vec<(String, String)> = Vec::new(); + for (name, declared) in specs { + if !declared.ends_with("@latest") { + continue; + } + let pkg_json = cache_dir + .join("node_modules") + .join(name) + .join("package.json"); + if let Ok(content) = fs::read_to_string(&pkg_json) { + if let Some(version) = serde_json::from_str::(&content) + .ok() + .and_then(|v| v.get("version")?.as_str().map(|s| s.to_string())) + { + pin_map.push((name.clone(), version)); + } + } + } + + if pin_map.is_empty() { + return Ok(0); + } + + atomic_rewrite_opencode_json(config_path, |doc| { + if let Some(arr) = doc + .as_object_mut() + .and_then(|obj| obj.get_mut("plugin")) + .and_then(|v| v.as_array_mut()) + { + for item in arr.iter_mut() { + if let Some(spec_str) = item.as_str() { + if let Some((parsed_name, _)) = parse_plugin_spec(spec_str) { + if let Some((_, version)) = + pin_map.iter().find(|(n, _)| *n == parsed_name) + { + *item = serde_json::Value::String(format!( + "{parsed_name}@{version}" + )); + pinned += 1; + } + } + } + } + } + Ok(()) + })?; + + Ok(pinned) +} + static PLUGIN_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); const PLUGIN_INSTALL_EVENT: &str = "app://opencode-plugin-install"; @@ -446,6 +516,32 @@ pub async fn install_missing_plugins( })?; if exit_status.success() { + // Pin @latest specs to actual installed versions to avoid + // opencode hitting the npm registry on every startup. + let spec_pairs: Vec<(String, String)> = missing + .iter() + .map(|p| (p.name.clone(), p.declared_spec.clone())) + .collect(); + match pin_latest_specs(&summary.config_path, &summary.cache_dir, &spec_pairs) { + Ok(n) if n > 0 => { + emit_plugin_event( + emitter, + &task_id, + PluginInstallEventKind::Log, + format!("Pinned {n} @latest plugin(s) to installed versions in opencode.json"), + ); + } + Err(e) => { + emit_plugin_event( + emitter, + &task_id, + PluginInstallEventKind::Log, + format!("Warning: could not pin @latest versions: {e}"), + ); + } + _ => {} + } + emit_plugin_event( emitter, &task_id, diff --git a/src-tauri/src/acp/preflight.rs b/src-tauri/src/acp/preflight.rs index 288c16b..9ad7591 100644 --- a/src-tauri/src/acp/preflight.rs +++ b/src-tauri/src/acp/preflight.rs @@ -350,7 +350,7 @@ async fn check_binary_environment( // OpenCode plugin checks if agent_type == AgentType::OpenCode { - use crate::acp::opencode_plugins::{self, PluginStatus}; + use crate::acp::opencode_plugins::{self, PluginStatus, spec_has_floating_version}; match opencode_plugins::check_opencode_plugins(None) { Ok(summary) => { let missing: Vec<_> = summary @@ -395,6 +395,32 @@ async fn check_binary_environment( }); } + // Warn about @latest specs that cause slow startup + let floating: Vec<&str> = summary + .plugins + .iter() + .filter(|p| spec_has_floating_version(&p.declared_spec)) + .map(|p| p.name.as_str()) + .collect(); + if !floating.is_empty() { + checks.push(CheckItem { + check_id: "opencode_plugins_floating".into(), + label: "Plugin versions".into(), + status: CheckStatus::Warn, + message: format!( + "{} plugin(s) use @latest which forces a network check on every startup: {}. \ + Install via the plugin manager to auto-pin versions.", + floating.len(), + floating.join(", ") + ), + fixes: vec![FixAction { + label: "Install Plugins".into(), + kind: FixActionKind::InstallOpencodePlugins, + payload: String::new(), + }], + }); + } + // Project-level config hint if summary.has_project_config_hint { checks.push(CheckItem {