diff --git a/Cargo.lock b/Cargo.lock
index a27bb55693f2..617eecbb1ba0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1432,9 +1432,9 @@ dependencies = [
[[package]]
name = "clap_complete"
-version = "4.5.62"
+version = "4.5.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "004eef6b14ce34759aa7de4aea3217e368f463f46a3ed3764ca4b5a4404003b4"
+checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1"
dependencies = [
"clap",
]
diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs
index eb788086fe42..0cfe574a2be8 100644
--- a/crates/goose-mcp/src/developer/rmcp_developer.rs
+++ b/crates/goose-mcp/src/developer/rmcp_developer.rs
@@ -968,6 +968,10 @@ impl DeveloperServer {
.and_then(|s| s.to_str())
.unwrap_or("bash");
+ let working_dir = std::env::var("GOOSE_WORKING_DIR")
+ .ok()
+ .map(std::path::PathBuf::from);
+
if let Some(ref env_file) = self.bash_env_file {
if shell_name == "bash" {
shell_config.envs.push((
@@ -977,7 +981,7 @@ impl DeveloperServer {
}
}
- let mut command = configure_shell_command(&shell_config, command);
+ let mut command = configure_shell_command(&shell_config, command, working_dir.as_deref());
if self.extend_path_with_shell {
if let Err(e) = get_shell_path_dirs()
diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs
index 51f91dc4faa5..a05242833d7b 100644
--- a/crates/goose-mcp/src/developer/shell.rs
+++ b/crates/goose-mcp/src/developer/shell.rs
@@ -109,8 +109,14 @@ pub fn normalize_line_endings(text: &str) -> String {
pub fn configure_shell_command(
shell_config: &ShellConfig,
command: &str,
+ working_dir: Option<&std::path::Path>,
) -> tokio::process::Command {
let mut command_builder = tokio::process::Command::new(&shell_config.executable);
+
+ if let Some(dir) = working_dir {
+ command_builder.current_dir(dir);
+ }
+
command_builder
.stdout(Stdio::piped())
.stderr(Stdio::piped())
diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs
index 04debe7dec2d..384fad28e1dc 100644
--- a/crates/goose-server/src/openapi.rs
+++ b/crates/goose-server/src/openapi.rs
@@ -354,6 +354,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::get_pricing,
super::routes::agent::start_agent,
super::routes::agent::resume_agent,
+ super::routes::agent::restart_agent,
+ super::routes::agent::update_working_dir,
super::routes::agent::get_tools,
super::routes::agent::read_resource,
super::routes::agent::call_tool,
@@ -372,6 +374,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::import_session,
super::routes::session::update_session_user_recipe_values,
super::routes::session::edit_message,
+ super::routes::session::get_session_extensions,
super::routes::schedule::create_schedule,
super::routes::schedule::list_schedules,
super::routes::schedule::delete_schedule,
@@ -431,6 +434,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::EditType,
super::routes::session::EditMessageRequest,
super::routes::session::EditMessageResponse,
+ super::routes::session::SessionExtensionsResponse,
Message,
MessageContent,
MessageMetadata,
@@ -529,9 +533,14 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::CallToolResponse,
super::routes::agent::StartAgentRequest,
super::routes::agent::ResumeAgentRequest,
+ super::routes::agent::RestartAgentRequest,
+ super::routes::agent::UpdateWorkingDirRequest,
super::routes::agent::UpdateFromSessionRequest,
super::routes::agent::AddExtensionRequest,
super::routes::agent::RemoveExtensionRequest,
+ super::routes::agent::ResumeAgentResponse,
+ super::routes::agent::RestartAgentResponse,
+ goose::agents::ExtensionLoadResult,
super::routes::setup::SetupResponse,
super::tunnel::TunnelInfo,
super::tunnel::TunnelState,
diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs
index 70b871fa7048..e4c150c3ff3b 100644
--- a/crates/goose-server/src/routes/agent.rs
+++ b/crates/goose-server/src/routes/agent.rs
@@ -10,6 +10,7 @@ use axum::{
routing::{get, post},
Json, Router,
};
+use goose::agents::ExtensionLoadResult;
use goose::config::PermissionManager;
use base64::Engine;
@@ -20,8 +21,9 @@ use goose::prompt_template::render_global_file;
use goose::providers::create;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
+use goose::session::extension_data::ExtensionState;
use goose::session::session_manager::SessionType;
-use goose::session::{Session, SessionManager};
+use goose::session::{EnabledExtensionsState, Session, SessionManager};
use goose::{
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
config::permission::PermissionLevel,
@@ -34,7 +36,7 @@ use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
-use tracing::{error, warn};
+use tracing::error;
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateFromSessionRequest {
@@ -63,6 +65,8 @@ pub struct StartAgentRequest {
recipe_id: Option,
#[serde(default)]
recipe_deeplink: Option,
+ #[serde(default)]
+ extension_overrides: Option>,
}
#[derive(Deserialize, utoipa::ToSchema)]
@@ -70,6 +74,17 @@ pub struct StopAgentRequest {
session_id: String,
}
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct RestartAgentRequest {
+ session_id: String,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct UpdateWorkingDirRequest {
+ session_id: String,
+ working_dir: String,
+}
+
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ResumeAgentRequest {
session_id: String,
@@ -122,6 +137,18 @@ pub struct CallToolResponse {
_meta: Option,
}
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct ResumeAgentResponse {
+ pub session: Session,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub extension_results: Option>,
+}
+
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct RestartAgentResponse {
+ pub extension_results: Vec,
+}
+
#[utoipa::path(
post,
path = "/agent/start",
@@ -133,6 +160,7 @@ pub struct CallToolResponse {
(status = 500, description = "Internal server error", body = ErrorResponse)
)
)]
+#[allow(clippy::too_many_lines)]
async fn start_agent(
State(state): State>,
Json(payload): Json,
@@ -144,6 +172,7 @@ async fn start_agent(
recipe,
recipe_id,
recipe_deeplink,
+ extension_overrides,
} = payload;
let original_recipe = if let Some(deeplink) = recipe_deeplink {
@@ -191,30 +220,83 @@ async fn start_agent(
}
})?;
- if let Some(recipe) = original_recipe {
+ // Initialize session with extensions (either overrides from hub or global defaults)
+ let extensions_to_use =
+ extension_overrides.unwrap_or_else(goose::config::get_enabled_extensions);
+ let mut extension_data = session.extension_data.clone();
+ let extensions_state = EnabledExtensionsState::new(extensions_to_use);
+ if let Err(e) = extensions_state.to_extension_data(&mut extension_data) {
+ tracing::warn!("Failed to initialize session with extensions: {}", e);
+ } else {
SessionManager::update_session(&session.id)
- .recipe(Some(recipe))
+ .extension_data(extension_data.clone())
.apply()
.await
.map_err(|err| {
- error!("Failed to update session with recipe: {}", err);
+ error!("Failed to save initial extension state: {}", err);
ErrorResponse {
- message: format!("Failed to update session with recipe: {}", err),
+ message: format!("Failed to save initial extension state: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
+ }
- session = SessionManager::get_session(&session.id, false)
+ if let Some(recipe) = original_recipe {
+ SessionManager::update_session(&session.id)
+ .recipe(Some(recipe))
+ .apply()
.await
.map_err(|err| {
- error!("Failed to get updated session: {}", err);
+ error!("Failed to update session with recipe: {}", err);
ErrorResponse {
- message: format!("Failed to get updated session: {}", err),
+ message: format!("Failed to update session with recipe: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
}
+ // Refetch session to get all updates
+ session = SessionManager::get_session(&session.id, false)
+ .await
+ .map_err(|err| {
+ error!("Failed to get updated session: {}", err);
+ ErrorResponse {
+ message: format!("Failed to get updated session: {}", err),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ })?;
+
+ // Eagerly start loading extensions in the background
+ let session_for_spawn = session.clone();
+ let state_for_spawn = state.clone();
+ let session_id_for_task = session.id.clone();
+ let task = tokio::spawn(async move {
+ match state_for_spawn
+ .get_agent(session_for_spawn.id.clone())
+ .await
+ {
+ Ok(agent) => {
+ let results = agent.load_extensions_from_session(&session_for_spawn).await;
+ tracing::debug!(
+ "Background extension loading completed for session {}",
+ session_for_spawn.id
+ );
+ results
+ }
+ Err(e) => {
+ tracing::warn!(
+ "Failed to create agent for background extension loading: {}",
+ e
+ );
+ vec![]
+ }
+ }
+ });
+
+ state
+ .set_extension_loading_task(session_id_for_task, task)
+ .await;
+
Ok(Json(session))
}
@@ -223,7 +305,7 @@ async fn start_agent(
path = "/agent/resume",
request_body = ResumeAgentRequest,
responses(
- (status = 200, description = "Agent started successfully", body = Session),
+ (status = 200, description = "Agent started successfully", body = ResumeAgentResponse),
(status = 400, description = "Bad request - invalid working directory"),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 500, description = "Internal server error")
@@ -232,7 +314,7 @@ async fn start_agent(
async fn resume_agent(
State(state): State>,
Json(payload): Json,
-) -> Result, ErrorResponse> {
+) -> Result, ErrorResponse> {
goose::posthog::set_session_context("desktop", true);
let session = SessionManager::get_session(&payload.session_id, true)
@@ -246,7 +328,7 @@ async fn resume_agent(
}
})?;
- if payload.load_model_and_extensions {
+ let extension_results = if payload.load_model_and_extensions {
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await
@@ -255,81 +337,41 @@ async fn resume_agent(
status: code,
})?;
- let config = Config::global();
+ agent
+ .restore_provider_from_session(&session)
+ .await
+ .map_err(|e| ErrorResponse {
+ message: e.to_string(),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ })?;
- let provider_result = async {
- let provider_name = session
- .provider_name
- .clone()
- .or_else(|| config.get_goose_provider().ok())
- .ok_or_else(|| ErrorResponse {
- message: "Could not configure agent: missing provider".into(),
- status: StatusCode::INTERNAL_SERVER_ERROR,
- })?;
-
- let model_config = match session.model_config.clone() {
- Some(saved_config) => saved_config,
- None => {
- let model_name = config.get_goose_model().map_err(|_| ErrorResponse {
- message: "Could not configure agent: missing model".into(),
- status: StatusCode::INTERNAL_SERVER_ERROR,
- })?;
- ModelConfig::new(&model_name).map_err(|e| ErrorResponse {
- message: format!("Could not configure agent: invalid model {}", e),
- status: StatusCode::INTERNAL_SERVER_ERROR,
- })?
- }
+ let extension_results =
+ if let Some(results) = state.take_extension_loading_task(&payload.session_id).await {
+ tracing::debug!(
+ "Using background extension loading results for session {}",
+ payload.session_id
+ );
+ state
+ .remove_extension_loading_task(&payload.session_id)
+ .await;
+ results
+ } else {
+ tracing::debug!(
+ "No background task found, loading extensions for session {}",
+ payload.session_id
+ );
+ agent.load_extensions_from_session(&session).await
};
- let provider =
- create(&provider_name, model_config)
- .await
- .map_err(|e| ErrorResponse {
- message: format!("Could not create provider: {}", e),
- status: StatusCode::INTERNAL_SERVER_ERROR,
- })?;
-
- agent
- .update_provider(provider, &payload.session_id)
- .await
- .map_err(|e| ErrorResponse {
- message: format!("Could not configure agent: {}", e),
- status: StatusCode::INTERNAL_SERVER_ERROR,
- })
- };
-
- let extensions_result = async {
- let enabled_configs = goose::config::get_enabled_extensions();
- let agent_clone = agent.clone();
-
- let extension_futures = enabled_configs
- .into_iter()
- .map(|config| {
- let config_clone = config.clone();
- let agent_ref = agent_clone.clone();
-
- async move {
- if let Err(e) = agent_ref.add_extension(config_clone.clone()).await {
- warn!("Failed to load extension {}: {}", config_clone.name(), e);
- goose::posthog::emit_error(
- "extension_load_failed",
- &format!("{}: {}", config_clone.name(), e),
- );
- }
- Ok::<_, ErrorResponse>(())
- }
- })
- .collect::>();
-
- futures::future::join_all(extension_futures).await;
- Ok::<(), ErrorResponse>(()) // Fixed type annotation
- };
-
- let (provider_result, _) = tokio::join!(provider_result, extensions_result);
- provider_result?;
- }
+ Some(extension_results)
+ } else {
+ None
+ };
- Ok(Json(session))
+ Ok(Json(ResumeAgentResponse {
+ session,
+ extension_results,
+ }))
}
#[utoipa::path(
@@ -519,7 +561,8 @@ async fn agent_add_extension(
Json(request): Json,
) -> Result {
let extension_name = request.config.name();
- let agent = state.get_agent(request.session_id).await?;
+ let agent = state.get_agent(request.session_id.clone()).await?;
+
agent.add_extension(request.config).await.map_err(|e| {
goose::posthog::emit_error(
"extension_add_failed",
@@ -527,6 +570,18 @@ async fn agent_add_extension(
);
ErrorResponse::internal(format!("Failed to add extension: {}", e))
})?;
+
+ // Persist here rather than in add_extension to ensure we only save state
+ // after the extension successfully loads. This prevents failed extensions
+ // from being persisted as enabled in the session.
+ agent
+ .persist_extension_state(&request.session_id)
+ .await
+ .map_err(|e| {
+ error!("Failed to persist extension state: {}", e);
+ ErrorResponse::internal(format!("Failed to persist extension state: {}", e))
+ })?;
+
Ok(StatusCode::OK)
}
@@ -545,8 +600,20 @@ async fn agent_remove_extension(
State(state): State>,
Json(request): Json,
) -> Result {
- let agent = state.get_agent(request.session_id).await?;
+ let agent = state.get_agent(request.session_id.clone()).await?;
agent.remove_extension(&request.name).await?;
+
+ agent
+ .persist_extension_state(&request.session_id)
+ .await
+ .map_err(|e| {
+ error!("Failed to persist extension state: {}", e);
+ ErrorResponse {
+ message: format!("Failed to persist extension state: {}", e),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ })?;
+
Ok(StatusCode::OK)
}
@@ -578,6 +645,159 @@ async fn stop_agent(
Ok(StatusCode::OK)
}
+async fn restart_agent_internal(
+ state: &Arc,
+ session_id: &str,
+ session: &Session,
+) -> Result, ErrorResponse> {
+ // Remove existing agent (ignore error if not found)
+ let _ = state.agent_manager.remove_session(session_id).await;
+
+ let agent = state
+ .get_agent_for_route(session_id.to_string())
+ .await
+ .map_err(|code| ErrorResponse {
+ message: "Failed to create new agent during restart".into(),
+ status: code,
+ })?;
+
+ let provider_future = agent.restore_provider_from_session(session);
+ let extensions_future = agent.load_extensions_from_session(session);
+
+ let (provider_result, extension_results) = tokio::join!(provider_future, extensions_future);
+ provider_result.map_err(|e| ErrorResponse {
+ message: e.to_string(),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ })?;
+
+ let context: HashMap<&str, Value> = HashMap::new();
+ let desktop_prompt =
+ render_global_file("desktop_prompt.md", &context).expect("Prompt should render");
+ let mut update_prompt = desktop_prompt;
+
+ if let Some(ref recipe) = session.recipe {
+ match build_recipe_with_parameter_values(
+ recipe,
+ session.user_recipe_values.clone().unwrap_or_default(),
+ )
+ .await
+ {
+ Ok(Some(recipe)) => {
+ if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await {
+ update_prompt = prompt;
+ }
+ }
+ Ok(None) => {
+ // Recipe has missing parameters - use default prompt
+ }
+ Err(e) => {
+ return Err(ErrorResponse {
+ message: e.to_string(),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ });
+ }
+ }
+ }
+ agent.extend_system_prompt(update_prompt).await;
+
+ Ok(extension_results)
+}
+
+#[utoipa::path(
+ post,
+ path = "/agent/restart",
+ request_body = RestartAgentRequest,
+ responses(
+ (status = 200, description = "Agent restarted successfully", body = RestartAgentResponse),
+ (status = 401, description = "Unauthorized - invalid secret key"),
+ (status = 404, description = "Session not found"),
+ (status = 500, description = "Internal server error")
+ )
+)]
+async fn restart_agent(
+ State(state): State>,
+ Json(payload): Json,
+) -> Result, ErrorResponse> {
+ let session_id = payload.session_id.clone();
+
+ let session = SessionManager::get_session(&session_id, false)
+ .await
+ .map_err(|err| {
+ error!("Failed to get session during restart: {}", err);
+ ErrorResponse {
+ message: format!("Failed to get session: {}", err),
+ status: StatusCode::NOT_FOUND,
+ }
+ })?;
+
+ let extension_results = restart_agent_internal(&state, &session_id, &session).await?;
+
+ Ok(Json(RestartAgentResponse { extension_results }))
+}
+
+#[utoipa::path(
+ post,
+ path = "/agent/update_working_dir",
+ request_body = UpdateWorkingDirRequest,
+ responses(
+ (status = 200, description = "Working directory updated and agent restarted successfully"),
+ (status = 400, description = "Bad request - invalid directory path"),
+ (status = 401, description = "Unauthorized - invalid secret key"),
+ (status = 404, description = "Session not found"),
+ (status = 500, description = "Internal server error")
+ )
+)]
+async fn update_working_dir(
+ State(state): State>,
+ Json(payload): Json,
+) -> Result {
+ let session_id = payload.session_id.clone();
+ let working_dir = payload.working_dir.trim();
+
+ if working_dir.is_empty() {
+ return Err(ErrorResponse {
+ message: "Working directory cannot be empty".into(),
+ status: StatusCode::BAD_REQUEST,
+ });
+ }
+
+ let path = PathBuf::from(working_dir);
+ if !path.exists() || !path.is_dir() {
+ return Err(ErrorResponse {
+ message: "Invalid directory path".into(),
+ status: StatusCode::BAD_REQUEST,
+ });
+ }
+
+ // Update the session's working directory
+ SessionManager::update_session(&session_id)
+ .working_dir(path)
+ .apply()
+ .await
+ .map_err(|e| {
+ error!("Failed to update session working directory: {}", e);
+ ErrorResponse {
+ message: format!("Failed to update working directory: {}", e),
+ status: StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ })?;
+
+ // Get the updated session and restart the agent
+ let session = SessionManager::get_session(&session_id, false)
+ .await
+ .map_err(|err| {
+ error!("Failed to get session after working dir update: {}", err);
+ ErrorResponse {
+ message: format!("Failed to get session: {}", err),
+ status: StatusCode::NOT_FOUND,
+ }
+ })?;
+
+ restart_agent_internal(&state, &session_id, &session).await?;
+
+ Ok(StatusCode::OK)
+}
+
#[utoipa::path(
post,
path = "/agent/read_resource",
@@ -702,6 +922,8 @@ pub fn routes(state: Arc) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
.route("/agent/resume", post(resume_agent))
+ .route("/agent/restart", post(restart_agent))
+ .route("/agent/update_working_dir", post(update_working_dir))
.route("/agent/tools", get(get_tools))
.route("/agent/read_resource", post(read_resource))
.route("/agent/call_tool", post(call_tool))
diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs
index 09dc95b1f3a7..1f3fc922bcaa 100644
--- a/crates/goose-server/src/routes/session.rs
+++ b/crates/goose-server/src/routes/session.rs
@@ -9,9 +9,11 @@ use axum::{
routing::{delete, get, put},
Json, Router,
};
+use goose::agents::ExtensionConfig;
use goose::recipe::Recipe;
+use goose::session::extension_data::ExtensionState;
use goose::session::session_manager::SessionInsights;
-use goose::session::{Session, SessionManager};
+use goose::session::{EnabledExtensionsState, Session, SessionManager};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
@@ -393,6 +395,44 @@ async fn edit_message(
}
}
+#[derive(Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionExtensionsResponse {
+ extensions: Vec,
+}
+
+#[utoipa::path(
+ get,
+ path = "/sessions/{session_id}/extensions",
+ params(
+ ("session_id" = String, Path, description = "Unique identifier for the session")
+ ),
+ responses(
+ (status = 200, description = "Session extensions retrieved successfully", body = SessionExtensionsResponse),
+ (status = 401, description = "Unauthorized - Invalid or missing API key"),
+ (status = 404, description = "Session not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("api_key" = [])
+ ),
+ tag = "Session Management"
+)]
+async fn get_session_extensions(
+ Path(session_id): Path,
+) -> Result, StatusCode> {
+ let session = SessionManager::get_session(&session_id, false)
+ .await
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ // Try to get session-specific extensions, fall back to global config
+ let extensions = EnabledExtensionsState::from_extension_data(&session.extension_data)
+ .map(|state| state.extensions)
+ .unwrap_or_else(goose::config::get_enabled_extensions);
+
+ Ok(Json(SessionExtensionsResponse { extensions }))
+}
+
pub fn routes(state: Arc) -> Router {
Router::new()
.route("/sessions", get(list_sessions))
@@ -407,5 +447,9 @@ pub fn routes(state: Arc) -> Router {
put(update_session_user_recipe_values),
)
.route("/sessions/{session_id}/edit_message", post(edit_message))
+ .route(
+ "/sessions/{session_id}/extensions",
+ get(get_session_extensions),
+ )
.with_state(state)
}
diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs
index 4a9c582e39e2..845eccda8f6e 100644
--- a/crates/goose-server/src/state.rs
+++ b/crates/goose-server/src/state.rs
@@ -6,8 +6,13 @@ use std::path::PathBuf;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use tokio::sync::Mutex;
+use tokio::task::JoinHandle;
use crate::tunnel::TunnelManager;
+use goose::agents::ExtensionLoadResult;
+
+type ExtensionLoadingTasks =
+ Arc>>>>>>>;
#[derive(Clone)]
pub struct AppState {
@@ -17,6 +22,7 @@ pub struct AppState {
/// Tracks sessions that have already emitted recipe telemetry to prevent double counting.
recipe_session_tracker: Arc>>,
pub tunnel_manager: Arc,
+ pub extension_loading_tasks: ExtensionLoadingTasks,
}
impl AppState {
@@ -30,9 +36,47 @@ impl AppState {
session_counter: Arc::new(AtomicUsize::new(0)),
recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())),
tunnel_manager,
+ extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())),
}))
}
+ pub async fn set_extension_loading_task(
+ &self,
+ session_id: String,
+ task: JoinHandle>,
+ ) {
+ let mut tasks = self.extension_loading_tasks.lock().await;
+ tasks.insert(session_id, Arc::new(Mutex::new(Some(task))));
+ }
+
+ pub async fn take_extension_loading_task(
+ &self,
+ session_id: &str,
+ ) -> Option> {
+ let task_holder = {
+ let tasks = self.extension_loading_tasks.lock().await;
+ tasks.get(session_id).cloned()
+ };
+
+ if let Some(holder) = task_holder {
+ let task = holder.lock().await.take();
+ if let Some(handle) = task {
+ match handle.await {
+ Ok(results) => return Some(results),
+ Err(e) => {
+ tracing::warn!("Background extension loading task failed: {}", e);
+ }
+ }
+ }
+ }
+ None
+ }
+
+ pub async fn remove_extension_loading_task(&self, session_id: &str) {
+ let mut tasks = self.extension_loading_tasks.lock().await;
+ tasks.remove(session_id);
+ }
+
pub fn scheduler(&self) -> Arc {
self.agent_manager.scheduler()
}
diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs
index 1fb2165aca50..e5b6cbe6a44a 100644
--- a/crates/goose/src/agents/agent.rs
+++ b/crates/goose/src/agents/agent.rs
@@ -13,7 +13,7 @@ use super::platform_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use crate::action_required_manager::ActionRequiredManager;
use crate::agents::extension::{ExtensionConfig, ExtensionResult, ToolInfo};
-use crate::agents::extension_manager::{get_parameter_names, ExtensionManager};
+use crate::agents::extension_manager::{get_parameter_names, normalize, ExtensionManager};
use crate::agents::extension_manager_extension::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME};
use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME;
@@ -77,6 +77,14 @@ pub struct ToolCategorizeResult {
pub filtered_response: Message,
}
+#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
+pub struct ExtensionLoadResult {
+ pub name: String,
+ pub success: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
/// The main goose Agent
pub struct Agent {
pub(super) provider: SharedProvider,
@@ -565,6 +573,91 @@ impl Agent {
Ok(())
}
+ /// Save current extension state to session by session_id
+ pub async fn persist_extension_state(&self, session_id: &str) -> Result<()> {
+ let extension_configs = self.extension_manager.get_extension_configs().await;
+ let extensions_state = EnabledExtensionsState::new(extension_configs);
+
+ let session = SessionManager::get_session(session_id, false).await?;
+ let mut extension_data = session.extension_data.clone();
+
+ extensions_state
+ .to_extension_data(&mut extension_data)
+ .map_err(|e| anyhow!("Failed to serialize extension state: {}", e))?;
+
+ SessionManager::update_session(session_id)
+ .extension_data(extension_data)
+ .apply()
+ .await?;
+
+ Ok(())
+ }
+
+ /// Load extensions from session into the agent
+ /// Skips extensions that are already loaded
+ pub async fn load_extensions_from_session(
+ self: &Arc,
+ session: &Session,
+ ) -> Vec {
+ let session_extensions =
+ EnabledExtensionsState::from_extension_data(&session.extension_data);
+ let enabled_configs = match session_extensions {
+ Some(state) => state.extensions,
+ None => {
+ tracing::warn!(
+ "No extensions found in session {}. This is unexpected.",
+ session.id
+ );
+ return vec![];
+ }
+ };
+
+ let extension_futures = enabled_configs
+ .into_iter()
+ .map(|config| {
+ let config_clone = config.clone();
+ let agent_ref = self.clone();
+
+ async move {
+ let name = config_clone.name().to_string();
+ let normalized_name = normalize(&name);
+
+ if agent_ref
+ .extension_manager
+ .is_extension_enabled(&normalized_name)
+ .await
+ {
+ tracing::debug!("Extension {} already loaded, skipping", name);
+ return ExtensionLoadResult {
+ name,
+ success: true,
+ error: None,
+ };
+ }
+
+ match agent_ref.add_extension(config_clone).await {
+ Ok(_) => ExtensionLoadResult {
+ name,
+ success: true,
+ error: None,
+ },
+ Err(e) => {
+ let error_msg = e.to_string();
+ warn!("Failed to load extension {}: {}", name, error_msg);
+ ExtensionLoadResult {
+ name,
+ success: false,
+ error: Some(error_msg),
+ }
+ }
+ }
+ }
+ })
+ .collect::>();
+
+ futures::future::join_all(extension_futures).await
+ }
+
pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> {
match &extension {
ExtensionConfig::Frontend {
@@ -936,6 +1029,7 @@ impl Agent {
let conversation_with_moim = super::moim::inject_moim(
conversation.clone(),
&self.extension_manager,
+ &working_dir,
).await;
let mut stream = Self::stream_response_from_provider(
@@ -1321,6 +1415,35 @@ impl Agent {
.context("Failed to persist provider config to session")
}
+ /// Restore the provider from session data or fall back to global config
+ /// This is used when resuming a session to restore the provider state
+ pub async fn restore_provider_from_session(&self, session: &Session) -> Result<()> {
+ let config = Config::global();
+
+ let provider_name = session
+ .provider_name
+ .clone()
+ .or_else(|| config.get_goose_provider().ok())
+ .ok_or_else(|| anyhow!("Could not configure agent: missing provider"))?;
+
+ let model_config = match session.model_config.clone() {
+ Some(saved_config) => saved_config,
+ None => {
+ let model_name = config
+ .get_goose_model()
+ .map_err(|_| anyhow!("Could not configure agent: missing model"))?;
+ crate::model::ModelConfig::new(&model_name)
+ .map_err(|e| anyhow!("Could not configure agent: invalid model {}", e))?
+ }
+ };
+
+ let provider = crate::providers::create(&provider_name, model_config)
+ .await
+ .map_err(|e| anyhow!("Could not create provider: {}", e))?;
+
+ self.update_provider(provider, &session.id).await
+ }
+
/// Override the system prompt with a custom template
pub async fn override_system_prompt(&self, template: String) {
let mut prompt_manager = self.prompt_manager.lock().await;
diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs
index fe2ba045b16b..bc071ecfa3c0 100644
--- a/crates/goose/src/agents/extension_manager.rs
+++ b/crates/goose/src/agents/extension_manager.rs
@@ -133,7 +133,7 @@ impl ResourceItem {
/// Sanitizes a string by replacing invalid characters with underscores.
/// Valid characters match [a-zA-Z0-9_-]
-fn normalize(input: String) -> String {
+pub fn normalize(input: &str) -> String {
let mut result = String::with_capacity(input.len());
for c in input.chars() {
result.push(match c {
@@ -153,7 +153,7 @@ fn generate_extension_name(
let base = server_info
.and_then(|info| {
let name = info.server_info.name.as_str();
- (!name.is_empty()).then(|| normalize(name.to_string()))
+ (!name.is_empty()).then(|| normalize(name))
})
.unwrap_or_else(|| "unnamed".to_string());
@@ -219,6 +219,7 @@ async fn child_process_client(
mut command: Command,
timeout: &Option,
provider: SharedProvider,
+ working_dir: Option<&PathBuf>,
) -> ExtensionResult {
#[cfg(unix)]
command.process_group(0);
@@ -228,6 +229,27 @@ async fn child_process_client(
command.env("PATH", path);
}
+ // Use explicitly passed working_dir, falling back to GOOSE_WORKING_DIR env var
+ let effective_working_dir = working_dir
+ .map(|p| p.to_path_buf())
+ .or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from));
+
+ if let Some(ref dir) = effective_working_dir {
+ if dir.exists() && dir.is_dir() {
+ tracing::info!("Setting MCP process working directory: {:?}", dir);
+ command.current_dir(dir);
+ // Also set GOOSE_WORKING_DIR env var for the child process
+ command.env("GOOSE_WORKING_DIR", dir);
+ } else {
+ tracing::warn!(
+ "Working directory doesn't exist or isn't a directory: {:?}",
+ dir
+ );
+ }
+ } else {
+ tracing::info!("No working directory specified, using default");
+ }
+
let (transport, mut stderr) = TokioChildProcess::builder(command)
.stderr(Stdio::piped())
.spawn()?;
@@ -422,25 +444,6 @@ async fn create_streamable_http_client(
}
}
-async fn create_stdio_client(
- cmd: &str,
- args: &[String],
- all_envs: HashMap,
- timeout: &Option,
- provider: SharedProvider,
-) -> ExtensionResult> {
- extension_malware_check::deny_if_malicious_cmd_args(cmd, args).await?;
-
- let resolved_cmd = resolve_command(cmd);
- let command = Command::new(resolved_cmd).configure(|command| {
- command.args(args).envs(all_envs);
- });
-
- Ok(Box::new(
- child_process_client(command, timeout, provider).await?,
- ))
-}
-
impl ExtensionManager {
pub fn new(provider: SharedProvider) -> Self {
Self {
@@ -466,6 +469,22 @@ impl ExtensionManager {
self.context.lock().await.clone()
}
+ /// Resolve the working directory for an extension.
+ /// Priority: session working_dir > current_dir
+ async fn resolve_working_dir(&self) -> PathBuf {
+ // Try to get working_dir from session via context
+ if let Some(ref session_id) = self.context.lock().await.session_id {
+ if let Ok(session) =
+ crate::session::SessionManager::get_session(session_id, false).await
+ {
+ return session.working_dir;
+ }
+ }
+
+ // Fall back to current_dir
+ std::env::current_dir().unwrap_or_default()
+ }
+
pub async fn supports_resources(&self) -> bool {
self.extensions
.lock()
@@ -476,12 +495,15 @@ impl ExtensionManager {
pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> {
let config_name = config.key().to_string();
- let sanitized_name = normalize(config_name.clone());
+ let sanitized_name = normalize(&config_name);
if self.extensions.lock().await.contains_key(&sanitized_name) {
return Ok(());
}
+ // Resolve working_dir: session > current_dir
+ let effective_working_dir = self.resolve_working_dir().await;
+
let mut temp_dir = None;
let client: Box = match &config {
@@ -519,7 +541,24 @@ impl ExtensionManager {
..
} => {
let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?;
- create_stdio_client(cmd, args, all_envs, timeout, self.provider.clone()).await?
+
+ // Check for malicious packages before launching the process
+ extension_malware_check::deny_if_malicious_cmd_args(cmd, args).await?;
+
+ let cmd = resolve_command(cmd);
+
+ let command = Command::new(cmd).configure(|command| {
+ command.args(args).envs(all_envs);
+ });
+
+ let client = child_process_client(
+ command,
+ timeout,
+ self.provider.clone(),
+ Some(&effective_working_dir),
+ )
+ .await?;
+ Box::new(client)
}
ExtensionConfig::Builtin { name, timeout, .. } => {
let cmd = std::env::current_exe()
@@ -540,10 +579,17 @@ impl ExtensionManager {
let command = Command::new(cmd).configure(|command| {
command.arg("mcp").arg(name);
});
- Box::new(child_process_client(command, timeout, self.provider.clone()).await?)
+ let client = child_process_client(
+ command,
+ timeout,
+ self.provider.clone(),
+ Some(&effective_working_dir),
+ )
+ .await?;
+ Box::new(client)
}
ExtensionConfig::Platform { name, .. } => {
- let normalized_key = normalize(name.clone());
+ let normalized_key = normalize(name);
let def = PLATFORM_EXTENSIONS
.get(normalized_key.as_str())
.ok_or_else(|| {
@@ -572,7 +618,15 @@ impl ExtensionManager {
command.arg("python").arg(file_path.to_str().unwrap());
});
- Box::new(child_process_client(command, timeout, self.provider.clone()).await?)
+ let client = child_process_client(
+ command,
+ timeout,
+ self.provider.clone(),
+ Some(&effective_working_dir),
+ )
+ .await?;
+
+ Box::new(client)
}
ExtensionConfig::Frontend { .. } => {
return Err(ExtensionError::ConfigError(
@@ -630,7 +684,7 @@ impl ExtensionManager {
/// Get aggregated usage statistics
pub async fn remove_extension(&self, name: &str) -> ExtensionResult<()> {
- let sanitized_name = normalize(name.to_string());
+ let sanitized_name = normalize(name);
self.extensions.lock().await.remove(&sanitized_name);
Ok(())
}
@@ -1247,10 +1301,14 @@ impl ExtensionManager {
.map(|ext| ext.get_client())
}
- pub async fn collect_moim(&self) -> Option {
+ pub async fn collect_moim(&self, working_dir: &std::path::Path) -> Option {
// Use minute-level granularity to prevent conversation changes every second
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:00").to_string();
- let mut content = format!("\nIt is currently {}\n", timestamp);
+ let mut content = format!(
+ "\nIt is currently {}\nWorking directory: {}\n",
+ timestamp,
+ working_dir.display()
+ );
let platform_clients: Vec<(String, McpClientBox)> = {
let extensions = self.extensions.lock().await;
@@ -1308,7 +1366,7 @@ mod tests {
client: McpClientBox,
available_tools: Vec,
) {
- let sanitized_name = normalize(name.clone());
+ let sanitized_name = normalize(&name);
let config = ExtensionConfig::Builtin {
name: name.clone(),
display_name: Some(name.clone()),
@@ -1760,8 +1818,9 @@ mod tests {
#[tokio::test]
async fn test_collect_moim_uses_minute_granularity() {
let em = ExtensionManager::new_without_provider();
+ let working_dir = std::path::Path::new("/tmp");
- if let Some(moim) = em.collect_moim().await {
+ if let Some(moim) = em.collect_moim(working_dir).await {
// Timestamp should end with :00 (seconds fixed to 00)
assert!(
moim.contains(":00\n"),
diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs
index 0384990594eb..badece6751ae 100644
--- a/crates/goose/src/agents/mod.rs
+++ b/crates/goose/src/agents/mod.rs
@@ -24,10 +24,10 @@ pub(crate) mod todo_extension;
mod tool_execution;
pub mod types;
-pub use agent::{Agent, AgentEvent};
+pub use agent::{Agent, AgentEvent, ExtensionLoadResult};
pub use execute_commands::COMPACT_TRIGGERS;
pub use extension::ExtensionConfig;
-pub use extension_manager::ExtensionManager;
+pub use extension_manager::{normalize, ExtensionManager};
pub use prompt_manager::PromptManager;
pub use subagent_task_config::TaskConfig;
pub use types::{FrontendTool, RetryConfig, SessionConfig, SuccessCheck};
diff --git a/crates/goose/src/agents/moim.rs b/crates/goose/src/agents/moim.rs
index 97f273d52412..2cef90d5b9a5 100644
--- a/crates/goose/src/agents/moim.rs
+++ b/crates/goose/src/agents/moim.rs
@@ -2,6 +2,7 @@ use crate::agents::extension_manager::ExtensionManager;
use crate::conversation::message::Message;
use crate::conversation::{fix_conversation, Conversation};
use rmcp::model::Role;
+use std::path::Path;
// Test-only utility. Do not use in production code. No `test` directive due to call outside crate.
thread_local! {
@@ -11,12 +12,13 @@ thread_local! {
pub async fn inject_moim(
conversation: Conversation,
extension_manager: &ExtensionManager,
+ working_dir: &Path,
) -> Conversation {
if SKIP.with(|f| f.get()) {
return conversation;
}
- if let Some(moim) = extension_manager.collect_moim().await {
+ if let Some(moim) = extension_manager.collect_moim(working_dir).await {
let mut messages = conversation.messages().clone();
let idx = messages
.iter()
@@ -45,17 +47,19 @@ pub async fn inject_moim(
mod tests {
use super::*;
use rmcp::model::CallToolRequestParam;
+ use std::path::PathBuf;
#[tokio::test]
async fn test_moim_injection_before_assistant() {
let em = ExtensionManager::new_without_provider();
+ let working_dir = PathBuf::from("/test/dir");
let conv = Conversation::new_unvalidated(vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi"),
Message::user().with_text("Bye"),
]);
- let result = inject_moim(conv, &em).await;
+ let result = inject_moim(conv, &em, &working_dir).await;
let msgs = result.messages();
assert_eq!(msgs.len(), 3);
@@ -70,14 +74,16 @@ mod tests {
.join("");
assert!(merged_content.contains("Hello"));
assert!(merged_content.contains(""));
+ assert!(merged_content.contains("Working directory: /test/dir"));
}
#[tokio::test]
async fn test_moim_injection_no_assistant() {
let em = ExtensionManager::new_without_provider();
+ let working_dir = PathBuf::from("/test/dir");
let conv = Conversation::new_unvalidated(vec![Message::user().with_text("Hello")]);
- let result = inject_moim(conv, &em).await;
+ let result = inject_moim(conv, &em, &working_dir).await;
assert_eq!(result.messages().len(), 1);
@@ -89,11 +95,13 @@ mod tests {
.join("");
assert!(merged_content.contains("Hello"));
assert!(merged_content.contains(""));
+ assert!(merged_content.contains("Working directory: /test/dir"));
}
#[tokio::test]
async fn test_moim_with_tool_calls() {
let em = ExtensionManager::new_without_provider();
+ let working_dir = PathBuf::from("/test/dir");
let conv = Conversation::new_unvalidated(vec![
Message::user().with_text("Search for something"),
@@ -135,7 +143,7 @@ mod tests {
),
]);
- let result = inject_moim(conv, &em).await;
+ let result = inject_moim(conv, &em, &working_dir).await;
let msgs = result.messages();
assert_eq!(msgs.len(), 6);
diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json
index d539388d16ed..f7091b80cc63 100644
--- a/ui/desktop/openapi.json
+++ b/ui/desktop/openapi.json
@@ -209,6 +209,45 @@
}
}
},
+ "/agent/restart": {
+ "post": {
+ "tags": [
+ "super::routes::agent"
+ ],
+ "operationId": "restart_agent",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RestartAgentRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Agent restarted successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RestartAgentResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - invalid secret key"
+ },
+ "404": {
+ "description": "Session not found"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ }
+ }
+ },
"/agent/resume": {
"post": {
"tags": [
@@ -231,7 +270,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/Session"
+ "$ref": "#/components/schemas/ResumeAgentResponse"
}
}
}
@@ -418,6 +457,41 @@
}
}
},
+ "/agent/update_working_dir": {
+ "post": {
+ "tags": [
+ "super::routes::agent"
+ ],
+ "operationId": "update_working_dir",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateWorkingDirRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Working directory updated and agent restarted successfully"
+ },
+ "400": {
+ "description": "Bad request - invalid directory path"
+ },
+ "401": {
+ "description": "Unauthorized - invalid secret key"
+ },
+ "404": {
+ "description": "Session not found"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ }
+ }
+ },
"/config": {
"get": {
"tags": [
@@ -2272,6 +2346,51 @@
]
}
},
+ "/sessions/{session_id}/extensions": {
+ "get": {
+ "tags": [
+ "Session Management"
+ ],
+ "operationId": "get_session_extensions",
+ "parameters": [
+ {
+ "name": "session_id",
+ "in": "path",
+ "description": "Unique identifier for the session",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Session extensions retrieved successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SessionExtensionsResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - Invalid or missing API key"
+ },
+ "404": {
+ "description": "Session not found"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "api_key": []
+ }
+ ]
+ }
+ },
"/sessions/{session_id}/name": {
"put": {
"tags": [
@@ -3535,6 +3654,25 @@
}
]
},
+ "ExtensionLoadResult": {
+ "type": "object",
+ "required": [
+ "name",
+ "success"
+ ],
+ "properties": {
+ "error": {
+ "type": "string",
+ "nullable": true
+ },
+ "name": {
+ "type": "string"
+ },
+ "success": {
+ "type": "boolean"
+ }
+ }
+ },
"ExtensionQuery": {
"type": "object",
"required": [
@@ -4920,6 +5058,31 @@
}
}
},
+ "RestartAgentRequest": {
+ "type": "object",
+ "required": [
+ "session_id"
+ ],
+ "properties": {
+ "session_id": {
+ "type": "string"
+ }
+ }
+ },
+ "RestartAgentResponse": {
+ "type": "object",
+ "required": [
+ "extension_results"
+ ],
+ "properties": {
+ "extension_results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionLoadResult"
+ }
+ }
+ }
+ },
"ResumeAgentRequest": {
"type": "object",
"required": [
@@ -4935,6 +5098,24 @@
}
}
},
+ "ResumeAgentResponse": {
+ "type": "object",
+ "required": [
+ "session"
+ ],
+ "properties": {
+ "extension_results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionLoadResult"
+ },
+ "nullable": true
+ },
+ "session": {
+ "$ref": "#/components/schemas/Session"
+ }
+ }
+ },
"RetryConfig": {
"type": "object",
"description": "Configuration for retry logic in recipe execution",
@@ -5275,6 +5456,20 @@
}
}
},
+ "SessionExtensionsResponse": {
+ "type": "object",
+ "required": [
+ "extensions"
+ ],
+ "properties": {
+ "extensions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionConfig"
+ }
+ }
+ }
+ },
"SessionInsights": {
"type": "object",
"required": [
@@ -5431,6 +5626,13 @@
"working_dir"
],
"properties": {
+ "extension_overrides": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ExtensionConfig"
+ },
+ "nullable": true
+ },
"recipe": {
"allOf": [
{
@@ -5974,6 +6176,21 @@
}
}
},
+ "UpdateWorkingDirRequest": {
+ "type": "object",
+ "required": [
+ "session_id",
+ "working_dir"
+ ],
+ "properties": {
+ "session_id": {
+ "type": "string"
+ },
+ "working_dir": {
+ "type": "string"
+ }
+ }
+ },
"UpsertConfigQuery": {
"type": "object",
"required": [
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index 927960cdc1ef..5c1a575decbe 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -42,6 +42,7 @@ import { View, ViewOptions } from './utils/navigationUtils';
import { useNavigation } from './hooks/useNavigation';
import { errorMessage } from './utils/conversionUtils';
+import { getInitialWorkingDir } from './utils/workingDir';
import { usePageViewTracking } from './hooks/useAnalytics';
import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics';
@@ -53,82 +54,74 @@ function PageViewTracker() {
// Route Components
const HubRouteWrapper = () => {
const setView = useNavigation();
-
return ;
};
const PairRouteWrapper = ({
chat,
setChat,
- activeSessionId,
- setActiveSessionId,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
- activeSessionId: string | null;
- setActiveSessionId: (id: string | null) => void;
}) => {
+ const { extensionsList } = useConfig();
const location = useLocation();
- const routeState =
- (location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
- const [searchParams, setSearchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const routeState = (location.state as PairRouteState) || {};
+ const [searchParams] = useSearchParams();
+ const [isCreatingSession, setIsCreatingSession] = useState(false);
- // Capture initialMessage in local state to survive route state being cleared by setSearchParams
+ // Capture initialMessage in local state to survive route state being cleared
const [capturedInitialMessage, setCapturedInitialMessage] = useState(
undefined
);
- const [lastSessionId, setLastSessionId] = useState(undefined);
- const [isCreatingSession, setIsCreatingSession] = useState(false);
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const recipeId = searchParams.get('recipeId') ?? undefined;
const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined;
- // Determine which session ID to use:
- // 1. From route state (when navigating from Hub with a new session)
- // 2. From URL params (when resuming a session or after refresh)
- // 3. From active session state (when navigating back from other routes)
- // 4. From the existing chat state
- const sessionId =
- routeState.resumeSessionId || resumeSessionId || activeSessionId || chat.sessionId;
+ // Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink)
+ const sessionIdFromState = routeState.resumeSessionId;
+ const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined;
// Use route state if available, otherwise use captured state
const initialMessage = routeState.initialMessage || capturedInitialMessage;
+ // Capture initialMessage when it comes from route state
useEffect(() => {
+ console.log(
+ '[PairRouteWrapper] capture effect:',
+ JSON.stringify({
+ routeStateInitialMessage: routeState.initialMessage,
+ })
+ );
if (routeState.initialMessage) {
setCapturedInitialMessage(routeState.initialMessage);
}
}, [routeState.initialMessage]);
+ // Create session if we have an initialMessage, recipeId, or recipeDeeplink but no sessionId
useEffect(() => {
- // Create a new session if we have an initialMessage, recipeId, or recipeDeeplink from config but no sessionId
if (
(initialMessage || recipeId || recipeDeeplinkFromConfig) &&
!sessionId &&
!isCreatingSession
) {
- console.log(
- '[PairRouteWrapper] Creating new session for initialMessage, recipeId, or recipeDeeplink from config'
- );
setIsCreatingSession(true);
(async () => {
try {
- const newSession = await createSession({
+ const newSession = await createSession(getInitialWorkingDir(), {
recipeId,
recipeDeeplink: recipeDeeplinkFromConfig,
+ allExtensions: extensionsList,
});
-
- setSearchParams((prev) => {
- prev.set('resumeSessionId', newSession.id);
- // Remove recipeId from URL after session is created
- prev.delete('recipeId');
- return prev;
+ navigate(`/pair?resumeSessionId=${newSession.id}`, {
+ replace: true,
+ state: { resumeSessionId: newSession.id, initialMessage },
});
- setActiveSessionId(newSession.id);
} catch (error) {
- console.error('[PairRouteWrapper] Failed to create session:', error);
+ console.error('Failed to create session:', error);
trackErrorWithContext(error, {
component: 'PairRouteWrapper',
action: 'create_session',
@@ -145,39 +138,38 @@ const PairRouteWrapper = ({
recipeDeeplinkFromConfig,
sessionId,
isCreatingSession,
- setSearchParams,
- setActiveSessionId,
+ extensionsList,
+ navigate,
]);
- // Clear captured initialMessage when sessionId actually changes to a different session
- useEffect(() => {
- if (sessionId !== lastSessionId) {
- setLastSessionId(sessionId);
- if (!routeState.initialMessage) {
- setCapturedInitialMessage(undefined);
- }
- }
- }, [sessionId, lastSessionId, routeState.initialMessage]);
-
- // Update URL with session ID when on /pair route (for refresh support)
+ // Sync URL with session ID for refresh support (only if not already in URL)
useEffect(() => {
if (sessionId && sessionId !== resumeSessionId) {
- setSearchParams((prev) => {
- prev.set('resumeSessionId', sessionId);
- return prev;
+ navigate(`/pair?resumeSessionId=${sessionId}`, {
+ replace: true,
+ state: { resumeSessionId: sessionIdFromState, initialMessage },
});
}
- }, [sessionId, resumeSessionId, setSearchParams]);
+ }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]);
- // Update active session state when session ID changes
+ // Clear captured initialMessage when session changes (to prevent re-sending on navigation)
useEffect(() => {
- if (sessionId && sessionId !== activeSessionId) {
- setActiveSessionId(sessionId);
+ if (sessionId && capturedInitialMessage && sessionIdFromState) {
+ const timer = setTimeout(() => {
+ setCapturedInitialMessage(undefined);
+ }, 100);
+ return () => clearTimeout(timer);
}
- }, [sessionId, activeSessionId, setActiveSessionId]);
+ return undefined;
+ }, [sessionId, capturedInitialMessage, sessionIdFromState]);
return (
-
+
);
};
@@ -377,9 +369,6 @@ export function AppInner() {
recipe: null,
});
- // Store the active session ID for navigation persistence
- const [activeSessionId, setActiveSessionId] = useState(null);
-
const { addExtension } = useConfig();
useEffect(() => {
@@ -436,9 +425,7 @@ export function AppInner() {
if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') {
event.preventDefault();
try {
- const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR');
- console.log(`Creating new chat window with working dir: ${workingDir}`);
- window.electron.createChatWindow(undefined, workingDir as string);
+ window.electron.createChatWindow(undefined, getInitialWorkingDir());
} catch (error) {
console.error('Error creating new window:', error);
}
@@ -541,11 +528,21 @@ export function AppInner() {
// Handle initial message from launcher
useEffect(() => {
- const handleSetInitialMessage = (_event: IpcRendererEvent, ...args: unknown[]) => {
+ const handleSetInitialMessage = async (_event: IpcRendererEvent, ...args: unknown[]) => {
const initialMessage = args[0] as string;
if (initialMessage) {
console.log('Received initial message from launcher:', initialMessage);
- navigate('/pair', { state: { initialMessage } });
+ try {
+ const session = await createSession(getInitialWorkingDir(), {});
+ navigate('/pair', {
+ state: {
+ initialMessage,
+ resumeSessionId: session.id,
+ },
+ });
+ } catch (error) {
+ console.error('Failed to create session for launcher message:', error);
+ }
}
};
window.electron.on('set-initial-message', handleSetInitialMessage);
@@ -597,17 +594,7 @@ export function AppInner() {
}
>
} />
-
- }
- />
+ } />
} />
= Options2 & {
/**
@@ -63,6 +63,15 @@ export const agentRemoveExtension = (optio
}
});
+export const restartAgent = (options: Options) => (options.client ?? client).post({
+ url: '/agent/restart',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
export const resumeAgent = (options: Options) => (options.client ?? client).post({
url: '/agent/resume',
...options,
@@ -101,6 +110,15 @@ export const updateAgentProvider = (option
}
});
+export const updateWorkingDir = (options: Options) => (options.client ?? client).post({
+ url: '/agent/update_working_dir',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
export const readAllConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config', ...options });
export const backupConfig = (options?: Options) => (options?.client ?? client).post({ url: '/config/backup', ...options });
@@ -395,6 +413,8 @@ export const editMessage = (options: Optio
export const exportSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/export', ...options });
+export const getSessionExtensions = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/extensions', ...options });
+
export const updateSessionName = (options: Options) => (options.client ?? client).put({
url: '/sessions/{session_id}/name',
...options,
diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts
index 4c8ff01c4362..e6f6d6745585 100644
--- a/ui/desktop/src/api/types.gen.ts
+++ b/ui/desktop/src/api/types.gen.ts
@@ -335,6 +335,12 @@ export type ExtensionEntry = ExtensionConfig & {
enabled: boolean;
};
+export type ExtensionLoadResult = {
+ error?: string | null;
+ name: string;
+ success: boolean;
+};
+
export type ExtensionQuery = {
config: ExtensionConfig;
enabled: boolean;
@@ -776,11 +782,24 @@ export type Response = {
json_schema?: unknown;
};
+export type RestartAgentRequest = {
+ session_id: string;
+};
+
+export type RestartAgentResponse = {
+ extension_results: Array;
+};
+
export type ResumeAgentRequest = {
load_model_and_extensions: boolean;
session_id: string;
};
+export type ResumeAgentResponse = {
+ extension_results?: Array | null;
+ session: Session;
+};
+
/**
* Configuration for retry logic in recipe execution
*/
@@ -887,6 +906,10 @@ export type SessionDisplayInfo = {
workingDir: string;
};
+export type SessionExtensionsResponse = {
+ extensions: Array;
+};
+
export type SessionInsights = {
totalSessions: number;
totalTokens: number;
@@ -937,6 +960,7 @@ export type SlashCommandsResponse = {
};
export type StartAgentRequest = {
+ extension_overrides?: Array | null;
recipe?: Recipe | null;
recipe_deeplink?: string | null;
recipe_id?: string | null;
@@ -1144,6 +1168,11 @@ export type UpdateSessionUserRecipeValuesResponse = {
recipe: Recipe;
};
+export type UpdateWorkingDirRequest = {
+ session_id: string;
+ working_dir: string;
+};
+
export type UpsertConfigQuery = {
is_secret: boolean;
key: string;
@@ -1311,6 +1340,37 @@ export type AgentRemoveExtensionResponses = {
export type AgentRemoveExtensionResponse = AgentRemoveExtensionResponses[keyof AgentRemoveExtensionResponses];
+export type RestartAgentData = {
+ body: RestartAgentRequest;
+ path?: never;
+ query?: never;
+ url: '/agent/restart';
+};
+
+export type RestartAgentErrors = {
+ /**
+ * Unauthorized - invalid secret key
+ */
+ 401: unknown;
+ /**
+ * Session not found
+ */
+ 404: unknown;
+ /**
+ * Internal server error
+ */
+ 500: unknown;
+};
+
+export type RestartAgentResponses = {
+ /**
+ * Agent restarted successfully
+ */
+ 200: RestartAgentResponse;
+};
+
+export type RestartAgentResponse2 = RestartAgentResponses[keyof RestartAgentResponses];
+
export type ResumeAgentData = {
body: ResumeAgentRequest;
path?: never;
@@ -1337,10 +1397,10 @@ export type ResumeAgentResponses = {
/**
* Agent started successfully
*/
- 200: Session;
+ 200: ResumeAgentResponse;
};
-export type ResumeAgentResponse = ResumeAgentResponses[keyof ResumeAgentResponses];
+export type ResumeAgentResponse2 = ResumeAgentResponses[keyof ResumeAgentResponses];
export type StartAgentData = {
body: StartAgentRequest;
@@ -1473,6 +1533,39 @@ export type UpdateAgentProviderResponses = {
200: unknown;
};
+export type UpdateWorkingDirData = {
+ body: UpdateWorkingDirRequest;
+ path?: never;
+ query?: never;
+ url: '/agent/update_working_dir';
+};
+
+export type UpdateWorkingDirErrors = {
+ /**
+ * Bad request - invalid directory path
+ */
+ 400: unknown;
+ /**
+ * Unauthorized - invalid secret key
+ */
+ 401: unknown;
+ /**
+ * Session not found
+ */
+ 404: unknown;
+ /**
+ * Internal server error
+ */
+ 500: unknown;
+};
+
+export type UpdateWorkingDirResponses = {
+ /**
+ * Working directory updated and agent restarted successfully
+ */
+ 200: unknown;
+};
+
export type ReadAllConfigData = {
body?: never;
path?: never;
@@ -2916,6 +3009,42 @@ export type ExportSessionResponses = {
export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses];
+export type GetSessionExtensionsData = {
+ body?: never;
+ path: {
+ /**
+ * Unique identifier for the session
+ */
+ session_id: string;
+ };
+ query?: never;
+ url: '/sessions/{session_id}/extensions';
+};
+
+export type GetSessionExtensionsErrors = {
+ /**
+ * Unauthorized - Invalid or missing API key
+ */
+ 401: unknown;
+ /**
+ * Session not found
+ */
+ 404: unknown;
+ /**
+ * Internal server error
+ */
+ 500: unknown;
+};
+
+export type GetSessionExtensionsResponses = {
+ /**
+ * Session extensions retrieved successfully
+ */
+ 200: SessionExtensionsResponse;
+};
+
+export type GetSessionExtensionsResponse = GetSessionExtensionsResponses[keyof GetSessionExtensionsResponses];
+
export type UpdateSessionNameData = {
body: UpdateSessionNameRequest;
path: {
diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx
index 7fd02ba19870..67e38830f729 100644
--- a/ui/desktop/src/components/BaseChat.tsx
+++ b/ui/desktop/src/components/BaseChat.tsx
@@ -36,6 +36,9 @@ import { substituteParameters } from '../utils/providerUtils';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { toastSuccess } from '../toasts';
import { Recipe } from '../recipe';
+import { createSession } from '../sessions';
+import { getInitialWorkingDir } from '../utils/workingDir';
+import { useConfig } from './ConfigContext';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
@@ -66,11 +69,13 @@ function BaseChatContent({
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const scrollRef = useRef(null);
+ const { extensionsList } = useConfig();
const disableAnimation = location.state?.disableAnimation || false;
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState();
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
+ const [isCreatingSession, setIsCreatingSession] = useState(false);
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
@@ -95,6 +100,7 @@ function BaseChatContent({
session,
messages,
chatState,
+ setChatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
@@ -131,20 +137,40 @@ function BaseChatContent({
const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true';
if (initialMessage) {
- // Submit the initial message (e.g., from fork)
hasAutoSubmittedRef.current = true;
handleSubmit(initialMessage);
+ // Clear initialMessage from navigation state to prevent re-sending on refresh
+ navigate(location.pathname + location.search, {
+ replace: true,
+ state: { ...location.state, initialMessage: undefined },
+ });
} else if (shouldStartAgent) {
- // Trigger agent to continue with existing conversation
hasAutoSubmittedRef.current = true;
handleSubmit('');
}
- }, [session, initialMessage, searchParams, handleSubmit]);
+ }, [session, initialMessage, searchParams, handleSubmit, navigate, location]);
- const handleFormSubmit = (e: React.FormEvent) => {
+ const handleFormSubmit = async (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const textValue = customEvent.detail?.value || '';
+ // If no session exists, create one and navigate with the initial message
+ if (!session && !sessionId && textValue.trim() && !isCreatingSession) {
+ setIsCreatingSession(true);
+ try {
+ const newSession = await createSession(getInitialWorkingDir(), {
+ allExtensions: extensionsList,
+ });
+ navigate(`/pair?resumeSessionId=${newSession.id}`, {
+ replace: true,
+ state: { resumeSessionId: newSession.id, initialMessage: textValue },
+ });
+ } catch {
+ setIsCreatingSession(false);
+ }
+ return;
+ }
+
if (recipe && textValue.trim()) {
setHasStartedUsingRecipe(true);
}
@@ -284,8 +310,7 @@ function BaseChatContent({
: recipe.prompt;
}
- const initialPrompt =
- (initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt;
+ const initialPrompt = recipePrompt;
if (sessionLoadError) {
return (
@@ -402,6 +427,7 @@ function BaseChatContent({
sessionId={sessionId}
handleSubmit={handleFormSubmit}
chatState={chatState}
+ setChatState={setChatState}
onStop={stopStreaming}
commandHistory={commandHistory}
initialValue={initialPrompt}
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx
index 4c6c48e8ef9e..ec3e63d7f0b9 100644
--- a/ui/desktop/src/components/ChatInput.tsx
+++ b/ui/desktop/src/components/ChatInput.tsx
@@ -27,9 +27,10 @@ import { Recipe } from '../recipe';
import MessageQueue from './MessageQueue';
import { detectInterruption } from '../utils/interruptionDetector';
import { DiagnosticsModal } from './ui/DownloadDiagnostics';
-import { Message } from '../api';
+import { getSession, Message } from '../api';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import CreateEditRecipeModal from './recipes/CreateEditRecipeModal';
+import { getInitialWorkingDir } from '../utils/workingDir';
import {
trackFileAttached,
trackVoiceDictation,
@@ -73,6 +74,7 @@ interface ChatInputProps {
sessionId: string | null;
handleSubmit: (e: React.FormEvent) => void;
chatState: ChatState;
+ setChatState?: (state: ChatState) => void;
onStop?: () => void;
commandHistory?: string[];
initialValue?: string;
@@ -97,12 +99,14 @@ interface ChatInputProps {
initialPrompt?: string;
toolCount: number;
append?: (message: Message) => void;
+ onWorkingDirChange?: (newDir: string) => void;
}
export default function ChatInput({
sessionId,
handleSubmit,
chatState = ChatState.Idle,
+ setChatState,
onStop,
commandHistory = [],
initialValue = '',
@@ -121,6 +125,7 @@ export default function ChatInput({
initialPrompt,
toolCount,
append: _append,
+ onWorkingDirChange,
}: ChatInputProps) {
const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
@@ -149,6 +154,26 @@ export default function ChatInput({
const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false);
const [showEditRecipeModal, setShowEditRecipeModal] = useState(false);
const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
+ const [sessionWorkingDir, setSessionWorkingDir] = useState(null);
+
+ useEffect(() => {
+ if (!sessionId) {
+ return;
+ }
+
+ const fetchSessionWorkingDir = async () => {
+ try {
+ const response = await getSession({ path: { session_id: sessionId } });
+ if (response.data?.working_dir) {
+ setSessionWorkingDir(response.data.working_dir);
+ }
+ } catch (error) {
+ console.error('[ChatInput] Failed to fetch session working dir:', error);
+ }
+ };
+
+ fetchSessionWorkingDir();
+ }, [sessionId]);
// Save queue state (paused/interrupted) to storage
useEffect(() => {
@@ -1108,7 +1133,8 @@ export default function ChatInput({
isAnyImageLoading ||
isAnyDroppedFileLoading ||
isRecording ||
- isTranscribing;
+ isTranscribing ||
+ chatState === ChatState.RestartingAgent;
// Queue management functions - no storage persistence, only in-memory
const handleRemoveQueuedMessage = (messageId: string) => {
@@ -1359,7 +1385,9 @@ export default function ChatInput({
? 'Recording...'
: isTranscribing
? 'Transcribing...'
- : 'Send'}
+ : chatState === ChatState.RestartingAgent
+ ? 'Restarting session...'
+ : 'Send'}
@@ -1499,8 +1527,19 @@ export default function ChatInput({
{/* Secondary actions and controls row below input */}
- {/* Directory path */}
-
+
{
+ setSessionWorkingDir(newDir);
+ if (onWorkingDirChange) {
+ onWorkingDirChange(newDir);
+ }
+ }}
+ onRestartStart={() => setChatState?.(ChatState.RestartingAgent)}
+ onRestartEnd={() => setChatState?.(ChatState.Idle)}
+ />
@@ -1544,12 +1583,8 @@ export default function ChatInput({
- {sessionId && process.env.ALPHA && (
- <>
-
-
- >
- )}
+
+
{sessionId && messages.length > 0 && (
<>
@@ -1619,6 +1654,7 @@ export default function ChatInput({
onSelectedIndexChange={(index) =>
setMentionPopover((prev) => ({ ...prev, selectedIndex: index }))
}
+ workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
/>
{sessionId && showCreateRecipeModal && (
diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx
index e99bfb0584d9..b91b21adf015 100644
--- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx
+++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx
@@ -1,6 +1,6 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useRef } from 'react';
import { FileText, Clock, Home, Puzzle, History } from 'lucide-react';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
import {
SidebarContent,
SidebarFooter,
@@ -96,7 +96,16 @@ const menuItems: NavigationEntry[] = [
const AppSidebar: React.FC = ({ currentPath }) => {
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const chatContext = useChatContext();
+ const lastSessionIdRef = useRef(null);
+ const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null;
+
+ useEffect(() => {
+ if (currentSessionId) {
+ lastSessionIdRef.current = currentSessionId;
+ }
+ }, [currentSessionId]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -130,6 +139,17 @@ const AppSidebar: React.FC = ({ currentPath }) => {
return currentPath === path;
};
+ const handleNavigation = (path: string) => {
+ // For /pair, preserve the current session if one exists
+ // Priority: current URL param > last known session > context
+ const sessionId = currentSessionId || lastSessionIdRef.current || chatContext?.chat?.sessionId;
+ if (path === '/pair' && sessionId && sessionId.length > 0) {
+ navigate(`/pair?resumeSessionId=${sessionId}`);
+ } else {
+ navigate(path);
+ }
+ };
+
const renderMenuItem = (entry: NavigationEntry, index: number) => {
if (entry.type === 'separator') {
return ;
@@ -144,7 +164,7 @@ const AppSidebar: React.FC = ({ currentPath }) => {
navigate(entry.path)}
+ onClick={() => handleNavigation(entry.path)}
isActive={isActivePath(entry.path)}
tooltip={entry.tooltip}
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
index 3eee2d6a9dcf..47ca1f89d48b 100644
--- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
+++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
@@ -5,6 +5,8 @@ import { Button } from './ui/button';
import { startNewSession } from '../sessions';
import { useNavigation } from '../hooks/useNavigation';
import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils';
+import { getInitialWorkingDir } from '../utils/workingDir';
+import { formatExtensionName } from './settings/extensions/subcomponents/ExtensionList';
export interface ExtensionLoadingStatus {
name: string;
@@ -91,46 +93,53 @@ export function GroupedExtensionLoadingToast({
- {extensions.map((ext) => (
-
-
- {getStatusIcon(ext.status)}
-
{ext.name}
-
- {ext.status === 'error' && ext.error && (
-
-
- {formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
-
- {ext.recoverHints && setView ? (
-
{
- e.stopPropagation();
- startNewSession(ext.recoverHints, setView);
- }}
- className="self-start"
- >
- Ask goose
-
- ) : (
-
{
- e.stopPropagation();
- navigator.clipboard.writeText(ext.error!);
- setCopiedExtension(ext.name);
- setTimeout(() => setCopiedExtension(null), 2000);
- }}
- className="self-start"
- >
- {copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
-
- )}
+ {extensions.map((ext) => {
+ const friendlyName = formatExtensionName(ext.name);
+
+ return (
+
+
+ {getStatusIcon(ext.status)}
+
{friendlyName}
- )}
-
- ))}
+ {ext.status === 'error' && ext.error && (
+
+
+ {formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
+
+
+ {ext.recoverHints && setView && (
+ {
+ e.stopPropagation();
+ startNewSession(
+ getInitialWorkingDir(),
+ ext.recoverHints,
+ setView
+ );
+ }}
+ >
+ Ask goose
+
+ )}
+ {
+ e.stopPropagation();
+ navigator.clipboard.writeText(ext.error!);
+ setCopiedExtension(ext.name);
+ setTimeout(() => setCopiedExtension(null), 2000);
+ }}
+ >
+ {copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
+
+
+
+ )}
+
+ );
+ })}
diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx
index cb792e6b962c..f4528769aed5 100644
--- a/ui/desktop/src/components/Hub.tsx
+++ b/ui/desktop/src/components/Hub.tsx
@@ -7,45 +7,81 @@
* Key Responsibilities:
* - Displays SessionInsights to show session statistics and recent chats
* - Provides a ChatInput for users to start new conversations
- * - Navigates to Pair with the submitted message to start a new conversation
- * - Ensures each submission from Hub always starts a fresh conversation
+ * - Creates a new session and navigates to Pair with the session ID
+ * - Shows loading state while session is being created
*
* Navigation Flow:
- * Hub (input submission) → Pair (new conversation with the submitted message)
+ * Hub (input submission) → Create Session → Pair (with session ID and initial message)
*/
+import { useState } from 'react';
import { SessionInsights } from './sessions/SessionsInsights';
import ChatInput from './ChatInput';
import { ChatState } from '../types/chatState';
import 'react-toastify/dist/ReactToastify.css';
import { View, ViewOptions } from '../utils/navigationUtils';
-import { startNewSession } from '../sessions';
+import { useConfig } from './ConfigContext';
+import {
+ getExtensionConfigsWithOverrides,
+ clearExtensionOverrides,
+} from '../store/extensionOverrides';
+import { getInitialWorkingDir } from '../utils/workingDir';
+import { createSession } from '../sessions';
+import LoadingGoose from './LoadingGoose';
export default function Hub({
setView,
}: {
setView: (view: View, viewOptions?: ViewOptions) => void;
}) {
+ const { extensionsList } = useConfig();
+ const [workingDir, setWorkingDir] = useState(getInitialWorkingDir());
+ const [isCreatingSession, setIsCreatingSession] = useState(false);
+
const handleSubmit = async (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
- if (combinedTextFromInput.trim()) {
- await startNewSession(combinedTextFromInput, setView);
+ if (combinedTextFromInput.trim() && !isCreatingSession) {
+ const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList);
+ clearExtensionOverrides();
+ setIsCreatingSession(true);
+
+ try {
+ const session = await createSession(workingDir, {
+ extensionConfigs,
+ allExtensions: extensionConfigs.length > 0 ? undefined : extensionsList,
+ });
+
+ setView('pair', {
+ disableAnimation: true,
+ resumeSessionId: session.id,
+ initialMessage: combinedTextFromInput,
+ });
+ } catch (error) {
+ console.error('Failed to create session:', error);
+ setIsCreatingSession(false);
+ }
+
e.preventDefault();
}
};
return (
-
+
+ {isCreatingSession && (
+
+
+
+ )}
{}}
initialValue=""
setView={setView}
@@ -58,6 +94,7 @@ export default function Hub({
disableAnimation={false}
sessionCosts={undefined}
toolCount={0}
+ onWorkingDirChange={setWorkingDir}
/>
);
diff --git a/ui/desktop/src/components/LauncherView.tsx b/ui/desktop/src/components/LauncherView.tsx
index 60b0ed3f7ebd..c601b560e189 100644
--- a/ui/desktop/src/components/LauncherView.tsx
+++ b/ui/desktop/src/components/LauncherView.tsx
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react';
+import { getInitialWorkingDir } from '../utils/workingDir';
export default function LauncherView() {
const [query, setQuery] = useState('');
@@ -7,11 +8,8 @@ export default function LauncherView() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
- // Create a new chat window with the query
- const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR') as string;
- window.electron.createChatWindow(query, workingDir);
+ window.electron.createChatWindow(query, getInitialWorkingDir());
setQuery('');
- // Don't manually close - the blur handler will close the launcher when the new window takes focus
}
};
diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx
index 26029c48f63e..d801fa86ce6a 100644
--- a/ui/desktop/src/components/Layout/AppLayout.tsx
+++ b/ui/desktop/src/components/Layout/AppLayout.tsx
@@ -5,6 +5,7 @@ import { View, ViewOptions } from '../../utils/navigationUtils';
import { AppWindowMac, AppWindow } from 'lucide-react';
import { Button } from '../ui/button';
import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
+import { getInitialWorkingDir } from '../../utils/workingDir';
const AppLayoutContent: React.FC = () => {
const navigate = useNavigate();
@@ -66,10 +67,7 @@ const AppLayoutContent: React.FC = () => {
};
const handleNewWindow = () => {
- window.electron.createChatWindow(
- undefined,
- window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined
- );
+ window.electron.createChatWindow(undefined, getInitialWorkingDir());
};
return (
diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx
index 56cddb27aa61..c4bbb1c929f0 100644
--- a/ui/desktop/src/components/LoadingGoose.tsx
+++ b/ui/desktop/src/components/LoadingGoose.tsx
@@ -15,6 +15,7 @@ const STATE_MESSAGES: Record
= {
[ChatState.WaitingForUserInput]: 'goose is waiting…',
[ChatState.Compacting]: 'goose is compacting the conversation...',
[ChatState.Idle]: 'goose is working on it…',
+ [ChatState.RestartingAgent]: 'restarting session...',
};
const STATE_ICONS: Record = {
@@ -26,6 +27,7 @@ const STATE_ICONS: Record = {
),
[ChatState.Compacting]: ,
[ChatState.Idle]: ,
+ [ChatState.RestartingAgent]: ,
};
const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => {
diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx
index 0220ab617dd8..a115ccfbb3e8 100644
--- a/ui/desktop/src/components/MentionPopover.tsx
+++ b/ui/desktop/src/components/MentionPopover.tsx
@@ -9,6 +9,7 @@ import {
} from 'react';
import { ItemIcon } from './ItemIcon';
import { CommandType, getSlashCommands } from '../api';
+import { getInitialWorkingDir } from '../utils/workingDir';
type DisplayItemType = CommandType | 'Directory' | 'File';
@@ -41,6 +42,7 @@ interface MentionPopoverProps {
isSlashCommand: boolean;
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
+ workingDir?: string;
}
// Enhanced fuzzy matching algorithm
@@ -121,6 +123,7 @@ const MentionPopover = forwardRef<
isSlashCommand,
selectedIndex,
onSelectedIndexChange,
+ workingDir,
},
ref
) => {
@@ -128,8 +131,7 @@ const MentionPopover = forwardRef<
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef(null);
const listRef = useRef(null);
-
- const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
+ const currentWorkingDir = workingDir ?? getInitialWorkingDir();
const scanDirectoryFromRoot = useCallback(
async (dirPath: string, relativePath = '', depth = 0): Promise => {
diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx
index fec16b6d1deb..414835bb8e5a 100644
--- a/ui/desktop/src/components/ParameterInputModal.tsx
+++ b/ui/desktop/src/components/ParameterInputModal.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Parameter } from '../recipe';
import { Button } from './ui/button';
+import { getInitialWorkingDir } from '../utils/workingDir';
interface ParameterInputModalProps {
parameters: Parameter[];
@@ -72,16 +73,12 @@ const ParameterInputModal: React.FC = ({
const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => {
if (option === 'new-chat') {
- // Create a new chat window without recipe config
try {
- const workingDir = window.appConfig.get('GOOSE_WORKING_DIR');
- console.log(`Creating new chat window without recipe, working dir: ${workingDir}`);
- window.electron.createChatWindow(undefined, workingDir as string);
- // Close the current window after creating the new one
+ const workingDir = getInitialWorkingDir();
+ window.electron.createChatWindow(undefined, workingDir);
window.electron.hideWindow();
} catch (error) {
console.error('Error creating new window:', error);
- // Fallback: just close the modal
onClose();
}
} else {
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
index 05b0b11b782f..4cb334d3bdb7 100644
--- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
+++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx
@@ -1,25 +1,119 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Puzzle } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { Input } from '../ui/input';
import { Switch } from '../ui/switch';
import { FixedExtensionEntry, useConfig } from '../ConfigContext';
-import { toggleExtension } from '../settings/extensions/extension-manager';
import { toastService } from '../../toasts';
-import { getFriendlyTitle } from '../settings/extensions/subcomponents/ExtensionList';
+import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
+import { ExtensionConfig, getSessionExtensions } from '../../api';
+import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api';
+import {
+ setExtensionOverride,
+ getExtensionOverride,
+ getExtensionOverrides,
+} from '../../store/extensionOverrides';
interface BottomMenuExtensionSelectionProps {
- sessionId: string;
+ sessionId: string | null;
}
export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
- const { extensionsList, addExtension } = useConfig();
+ const [sessionExtensions, setSessionExtensions] = useState([]);
+ const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0);
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const [pendingSort, setPendingSort] = useState(false);
+ const [togglingExtension, setTogglingExtension] = useState(null);
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
+ const sortTimeoutRef = useRef | null>(null);
+ const { extensionsList: allExtensions } = useConfig();
+ const isHubView = !sessionId;
+
+ useEffect(() => {
+ const handleSessionLoaded = () => {
+ setTimeout(() => {
+ setRefreshTrigger((prev) => prev + 1);
+ }, 500);
+ };
+
+ window.addEventListener('session-created', handleSessionLoaded);
+ window.addEventListener('message-stream-finished', handleSessionLoaded);
+
+ return () => {
+ window.removeEventListener('session-created', handleSessionLoaded);
+ window.removeEventListener('message-stream-finished', handleSessionLoaded);
+ };
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (sortTimeoutRef.current) {
+ clearTimeout(sortTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ // Fetch session-specific extensions or use global defaults
+ useEffect(() => {
+ const fetchExtensions = async () => {
+ if (!sessionId) {
+ return;
+ }
+
+ try {
+ const response = await getSessionExtensions({
+ path: { session_id: sessionId },
+ });
+
+ if (response.data?.extensions) {
+ setSessionExtensions(response.data.extensions);
+ }
+ } catch (error) {
+ console.error('Failed to fetch session extensions:', error);
+ }
+ };
+
+ fetchExtensions();
+ }, [sessionId, isOpen, refreshTrigger]);
const handleToggle = useCallback(
async (extensionConfig: FixedExtensionEntry) => {
+ if (togglingExtension === extensionConfig.name) {
+ return;
+ }
+
+ setIsTransitioning(true);
+ setTogglingExtension(extensionConfig.name);
+
+ if (isHubView) {
+ const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled;
+ setExtensionOverride(extensionConfig.name, !currentState);
+ setPendingSort(true);
+
+ if (sortTimeoutRef.current) {
+ clearTimeout(sortTimeoutRef.current);
+ }
+
+ // Delay the re-sort to allow animation
+ sortTimeoutRef.current = setTimeout(() => {
+ setHubUpdateTrigger((prev) => prev + 1);
+ setPendingSort(false);
+ setIsTransitioning(false);
+ setTogglingExtension(null);
+ }, 800);
+
+ toastService.success({
+ title: 'Extension Updated',
+ msg: `${formatExtensionName(extensionConfig.name)} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`,
+ });
+ return;
+ }
+
if (!sessionId) {
+ setIsTransitioning(false);
+ setTogglingExtension(null);
toastService.error({
title: 'Extension Toggle Error',
msg: 'No active session found. Please start a chat session first.',
@@ -29,26 +123,65 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}
try {
- const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
-
- await toggleExtension({
- toggle: toggleDirection,
- extensionConfig: extensionConfig,
- addToConfig: addExtension,
- toastOptions: { silent: false },
- sessionId: sessionId,
- });
- } catch (error) {
- toastService.error({
- title: 'Extension Error',
- msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`,
- traceback: error instanceof Error ? error.message : String(error),
- });
+ if (extensionConfig.enabled) {
+ await removeFromAgent(extensionConfig.name, sessionId, true);
+ } else {
+ await addToAgent(extensionConfig, sessionId, true);
+ }
+
+ setPendingSort(true);
+
+ if (sortTimeoutRef.current) {
+ clearTimeout(sortTimeoutRef.current);
+ }
+
+ sortTimeoutRef.current = setTimeout(async () => {
+ const response = await getSessionExtensions({
+ path: { session_id: sessionId },
+ });
+
+ if (response.data?.extensions) {
+ setSessionExtensions(response.data.extensions);
+ }
+ setPendingSort(false);
+ setIsTransitioning(false);
+ setTogglingExtension(null);
+ }, 800);
+ } catch {
+ setIsTransitioning(false);
+ setPendingSort(false);
+ setTogglingExtension(null);
}
},
- [sessionId, addExtension]
+ [sessionId, isHubView, togglingExtension]
);
+ // Merge all available extensions with session-specific or hub override state
+ const extensionsList = useMemo(() => {
+ const hubOverrides = getExtensionOverrides();
+
+ if (isHubView) {
+ return allExtensions.map(
+ (ext) =>
+ ({
+ ...ext,
+ enabled: hubOverrides.has(ext.name) ? hubOverrides.get(ext.name)! : ext.enabled,
+ }) as FixedExtensionEntry
+ );
+ }
+
+ const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name));
+
+ return allExtensions.map(
+ (ext) =>
+ ({
+ ...ext,
+ enabled: sessionExtensionNames.has(ext.name),
+ }) as FixedExtensionEntry
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]);
+
const filteredExtensions = useMemo(() => {
return extensionsList.filter((ext) => {
const query = searchQuery.toLowerCase();
@@ -60,24 +193,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
}, [extensionsList, searchQuery]);
const sortedExtensions = useMemo(() => {
- const getTypePriority = (type: string): number => {
- const priorities: Record = {
- builtin: 0,
- platform: 1,
- frontend: 2,
- };
- return priorities[type] ?? Number.MAX_SAFE_INTEGER;
- };
-
return [...filteredExtensions].sort((a, b) => {
- // First sort by priority type
- const typeDiff = getTypePriority(a.type) - getTypePriority(b.type);
- if (typeDiff !== 0) return typeDiff;
-
- // Then sort by enabled status (enabled first)
+ // Primary sort: enabled first
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
- // Finally sort alphabetically
+ // Secondary sort: alphabetically by name
return a.name.localeCompare(b.name);
});
}, [filteredExtensions]);
@@ -92,7 +212,13 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
- setSearchQuery(''); // Reset search when closing
+ setSearchQuery('');
+ if (sortTimeoutRef.current) {
+ clearTimeout(sortTimeoutRef.current);
+ }
+ setIsTransitioning(false);
+ setPendingSort(false);
+ setTogglingExtension(null);
}
}}
>
@@ -105,7 +231,14 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
{activeCount}
-
+ {
+ e.preventDefault();
+ }}
+ >
-
+
{sortedExtensions.length === 0 ? (
{searchQuery ? 'no extensions found' : 'no extensions available'}
) : (
- sortedExtensions.map((ext) => (
-
handleToggle(ext)}
- title={ext.description || ext.name}
- >
-
{getFriendlyTitle(ext)}
-
e.stopPropagation()}>
-
handleToggle(ext)}
- variant="mono"
- />
+ sortedExtensions.map((ext) => {
+ const isToggling = togglingExtension === ext.name;
+ return (
+ !isToggling && handleToggle(ext)}
+ title={ext.description || ext.name}
+ >
+
+ {formatExtensionName(ext.name)}
+
+
e.stopPropagation()}>
+ handleToggle(ext)}
+ variant="mono"
+ disabled={isToggling}
+ />
+
-
- ))
+ );
+ })
)}
diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
index 8aa80e25aa4d..26c8dc399eae 100644
--- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
+++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
@@ -1,23 +1,65 @@
import React, { useState } from 'react';
import { FolderDot } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
+import { updateWorkingDir } from '../../api';
+import { toast } from 'react-toastify';
interface DirSwitcherProps {
- className?: string;
+ className: string;
+ sessionId: string | undefined;
+ workingDir: string;
+ onWorkingDirChange?: (newDir: string) => void;
+ onRestartStart?: () => void;
+ onRestartEnd?: () => void;
}
-export const DirSwitcher: React.FC
= ({ className = '' }) => {
+export const DirSwitcher: React.FC = ({
+ className,
+ sessionId,
+ workingDir,
+ onWorkingDirChange,
+ onRestartStart,
+ onRestartEnd,
+}) => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false);
const handleDirectoryChange = async () => {
if (isDirectoryChooserOpen) return;
setIsDirectoryChooserOpen(true);
+
+ let result;
try {
- await window.electron.directoryChooser(true);
+ result = await window.electron.directoryChooser();
} finally {
setIsDirectoryChooserOpen(false);
}
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return;
+ }
+
+ const newDir = result.filePaths[0];
+
+ window.electron.addRecentDir(newDir);
+
+ if (sessionId) {
+ onWorkingDirChange?.(newDir);
+ onRestartStart?.();
+
+ try {
+ await updateWorkingDir({
+ body: { session_id: sessionId, working_dir: newDir },
+ });
+ } catch (error) {
+ console.error('[DirSwitcher] Failed to update working directory:', error);
+ toast.error('Failed to update working directory');
+ } finally {
+ onRestartEnd?.();
+ }
+ } else {
+ onWorkingDirChange?.(newDir);
+ }
};
const handleDirectoryClick = async (event: React.MouseEvent) => {
@@ -31,7 +73,6 @@ export const DirSwitcher: React.FC = ({ className = '' }) => {
if (isCmdOrCtrlClick) {
event.preventDefault();
event.stopPropagation();
- const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
await window.electron.openDirectoryInExplorer(workingDir);
} else {
await handleDirectoryChange();
@@ -53,14 +94,10 @@ export const DirSwitcher: React.FC = ({ className = '' }) => {
disabled={isDirectoryChooserOpen}
>
-
- {String(window.appConfig.get('GOOSE_WORKING_DIR'))}
-
+ {workingDir}
-
- {window.appConfig.get('GOOSE_WORKING_DIR') as string}
-
+ {workingDir}
);
diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx
index 405744f5389e..061330a29ee0 100644
--- a/ui/desktop/src/components/extensions/ExtensionsView.tsx
+++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx
@@ -1,5 +1,4 @@
import { View, ViewOptions } from '../../utils/navigationUtils';
-import { useChatContext } from '../../contexts/ChatContext';
import ExtensionsSection from '../settings/extensions/ExtensionsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
@@ -14,7 +13,7 @@ import {
ExtensionFormData,
createExtensionConfig,
} from '../settings/extensions/utils';
-import { activateExtension } from '../settings/extensions';
+import { activateExtensionDefault } from '../settings/extensions';
import { useConfig } from '../ConfigContext';
import { SearchView } from '../conversation/SearchView';
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
@@ -35,8 +34,6 @@ export default function ExtensionsView({
const [refreshKey, setRefreshKey] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const { addExtension } = useConfig();
- const chatContext = useChatContext();
- const sessionId = chatContext?.chat.sessionId;
// Only trigger refresh when deep link config changes AND we don't need to show env vars
useEffect(() => {
@@ -80,7 +77,10 @@ export default function ExtensionsView({
const extensionConfig = createExtensionConfig(formData);
try {
- await activateExtension(extensionConfig, addExtension, sessionId);
+ await activateExtensionDefault({
+ addToConfig: addExtension,
+ extensionConfig: extensionConfig,
+ });
// Trigger a refresh of the extensions list
setRefreshKey((prevKey) => prevKey + 1);
} catch (error) {
@@ -100,11 +100,15 @@ export default function ExtensionsView({
Extensions
-
+
These extensions use the Model Context Protocol (MCP). They can expand Goose's
capabilities using three main components: Prompts, Resources, and Tools.{' '}
{getSearchShortcutText()} to search.
+
+ Extensions enabled here are used as the default for new chats. You can also toggle
+ active extensions during chat.
+
{/* Action Buttons */}
@@ -134,7 +138,6 @@ export default function ExtensionsView({
setSearchTerm(term)} placeholder="Search extensions...">
formatExtensionName(ext.name));
+ } catch {
+ return [];
+ }
+}
+
interface EditSessionModalProps {
session: Session | null;
isOpen: boolean;
@@ -49,7 +67,6 @@ const EditSessionModal = React.memo(
if (session && isOpen) {
setDescription(session.name);
} else if (!isOpen) {
- // Reset state when modal closes
setDescription('');
setIsUpdating(false);
}
@@ -72,8 +89,6 @@ const EditSessionModal = React.memo(
throwOnError: true,
});
await onSave(session.id, trimmedDescription);
-
- // Close modal, then show success toast on a timeout to let the UI update complete.
onClose();
setTimeout(() => {
toast.success('Session description updated successfully');
@@ -548,6 +563,12 @@ const SessionListView: React.FC = React.memo(
[onOpenInNewWindow, session]
);
+ // Get extension names for this session
+ const extensionNames = useMemo(
+ () => getSessionExtensionNames(session.extension_data),
+ [session.extension_data]
+ );
+
return (
= React.memo(
{(session.total_tokens || 0).toLocaleString()}
)}
+ {extensionNames.length > 0 && (
+
+
+
+ e.stopPropagation()}>
+
+
{extensionNames.length}
+
+
+
+
+
Extensions:
+
+ {extensionNames.map((name) => (
+ {name}
+ ))}
+
+
+
+
+
+ )}
diff --git a/ui/desktop/src/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx
index 15c2ad53578a..b9ec0e2cf7dc 100644
--- a/ui/desktop/src/components/sessions/SessionsInsights.tsx
+++ b/ui/desktop/src/components/sessions/SessionsInsights.tsx
@@ -78,7 +78,6 @@ export function SessionInsights() {
loadInsights();
loadRecentSessions();
- // Cleanup timeout on unmount
return () => {
if (loadingTimeout) {
window.clearTimeout(loadingTimeout);
diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx
index c694980e863e..b68aea9e6cad 100644
--- a/ui/desktop/src/components/settings/app/UpdateSection.tsx
+++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx
@@ -125,7 +125,6 @@ export default function UpdateSection() {
}
});
- // Cleanup timeout on unmount
return () => {
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx
index 11d2aa6e2162..ef3b577ce9a3 100644
--- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx
+++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Button } from '../../ui/button';
-import { Plus, AlertTriangle } from 'lucide-react';
+import { Plus } from 'lucide-react';
import { GPSIcon } from '../../ui/icons';
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
import ExtensionList from './subcomponents/ExtensionList';
@@ -12,11 +12,10 @@ import {
getDefaultFormData,
} from './utils';
-import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index';
-import { ExtensionConfig } from '../../../api';
+import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
+import { ExtensionConfig } from '../../../api/types.gen';
interface ExtensionSectionProps {
- sessionId?: string;
deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean;
hideButtons?: boolean;
@@ -28,7 +27,6 @@ interface ExtensionSectionProps {
}
export default function ExtensionsSection({
- sessionId,
deepLinkConfig,
showEnvVars,
hideButtons,
@@ -38,8 +36,7 @@ export default function ExtensionsSection({
onModalClose,
searchTerm = '',
}: ExtensionSectionProps) {
- const { getExtensions, addExtension, removeExtension, extensionsList, extensionWarnings } =
- useConfig();
+ const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig();
const [selectedExtension, setSelectedExtension] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@@ -49,25 +46,12 @@ export default function ExtensionsSection({
const [showEnvVarsStateVar, setShowEnvVarsStateVar] = useState(
showEnvVars
);
- const [pendingActivationExtensions, setPendingActivationExtensions] = useState>(
- new Set()
- );
- // Update deep link state when props change
useEffect(() => {
setDeepLinkConfigStateVar(deepLinkConfig);
setShowEnvVarsStateVar(showEnvVars);
-
- if (deepLinkConfig && !showEnvVars) {
- setPendingActivationExtensions((prev) => {
- const updated = new Set(prev);
- updated.add(deepLinkConfig.name);
- return updated;
- });
- }
}, [deepLinkConfig, showEnvVars]);
- // Process extensions from context - this automatically updates when extensionsList changes
const extensions = useMemo(() => {
if (extensionsList.length === 0) return [];
@@ -103,21 +87,12 @@ export default function ExtensionsSection({
return true;
}
- // If extension is enabled, we are trying to toggle if off, otherwise on
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
- await toggleExtension({
+ await toggleExtensionDefault({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
- toastOptions: { silent: false },
- sessionId,
- });
-
- setPendingActivationExtensions((prev) => {
- const updated = new Set(prev);
- updated.delete(extensionConfig.name);
- return updated;
});
await fetchExtensions();
@@ -135,22 +110,12 @@ export default function ExtensionsSection({
const extensionConfig = createExtensionConfig(formData);
try {
- await activateExtension(extensionConfig, addExtension, sessionId);
- setPendingActivationExtensions((prev) => {
- const updated = new Set(prev);
- updated.delete(extensionConfig.name);
- return updated;
+ await activateExtensionDefault({
+ addToConfig: addExtension,
+ extensionConfig: extensionConfig,
});
} catch (error) {
- console.error('Failed to activate extension:', error);
- // If activation fails, mark as pending if it's enabled in config
- if (formData.enabled) {
- setPendingActivationExtensions((prev) => {
- const updated = new Set(prev);
- updated.add(extensionConfig.name);
- return updated;
- });
- }
+ console.error('Failed to add extension:', error);
} finally {
await fetchExtensions();
if (onModalClose) {
@@ -174,42 +139,28 @@ export default function ExtensionsSection({
const originalName = selectedExtension.name;
try {
- await updateExtension({
- enabled: formData.enabled,
- extensionConfig: extensionConfig,
- addToConfig: addExtension,
- removeFromConfig: removeExtension,
- originalName: originalName,
- sessionId: sessionId,
- });
+ if (originalName !== extensionConfig.name) {
+ await removeExtension(originalName);
+ }
+ await addExtension(extensionConfig.name, extensionConfig, formData.enabled);
} catch (error) {
console.error('Failed to update extension:', error);
- // We don't reopen the modal on failure
} finally {
- // Refresh the extensions list regardless of success or failure
await fetchExtensions();
}
};
const handleDeleteExtension = async (name: string) => {
- // Capture the selected extension before closing the modal
- const extensionToDelete = selectedExtension;
-
- // Close the modal immediately
handleModalClose();
try {
await deleteExtension({
name,
removeFromConfig: removeExtension,
- sessionId,
- extensionConfig: extensionToDelete ?? undefined,
});
} catch (error) {
console.error('Failed to delete extension:', error);
- // We don't reopen the modal on failure
} finally {
- // Refresh the extensions list regardless of success or failure
await fetchExtensions();
}
};
@@ -231,29 +182,12 @@ export default function ExtensionsSection({
return (
- {/* Unsupported extension warnings */}
- {extensionWarnings.length > 0 && (
-
-
-
-
- {extensionWarnings.map((warning, index) => (
-
0 ? 'mt-1' : ''}>
- {warning}
-
- ))}
-
-
-
- )}
-
{!hideButtons && (
diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts
deleted file mode 100644
index 0151ebca3658..000000000000
--- a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { addToAgentOnStartup, updateExtension, toggleExtension } from './extension-manager';
-import * as agentApi from './agent-api';
-import * as toasts from '../../../toasts';
-
-// Mock dependencies
-vi.mock('./agent-api');
-vi.mock('../../../toasts');
-
-const mockAddToAgent = vi.mocked(agentApi.addToAgent);
-const mockRemoveFromAgent = vi.mocked(agentApi.removeFromAgent);
-const mockSanitizeName = vi.mocked(agentApi.sanitizeName);
-const mockToastService = vi.mocked(toasts.toastService);
-
-describe('Extension Manager', () => {
- const mockAddToConfig = vi.fn();
- const mockRemoveFromConfig = vi.fn();
-
- const mockExtensionConfig = {
- type: 'stdio' as const,
- name: 'test-extension',
- description: 'test-extension',
- cmd: 'python',
- args: ['script.py'],
- timeout: 300,
- };
-
- beforeEach(() => {
- vi.clearAllMocks();
- mockSanitizeName.mockImplementation((name: string) => name.toLowerCase());
- mockAddToConfig.mockResolvedValue(undefined);
- mockRemoveFromConfig.mockResolvedValue(undefined);
- });
-
- describe('addToAgentOnStartup', () => {
- it('should successfully add extension on startup', async () => {
- mockAddToAgent.mockResolvedValue(undefined);
-
- await addToAgentOnStartup({
- sessionId: 'test-session',
- extensionConfig: mockExtensionConfig,
- });
-
- expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
- });
-
- it('should successfully add extension on startup with custom toast options', async () => {
- mockAddToAgent.mockResolvedValue(undefined);
-
- await addToAgentOnStartup({
- sessionId: 'test-session',
- extensionConfig: mockExtensionConfig,
- });
-
- expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
- });
-
- it('should retry on 428 errors', async () => {
- const error428 = new Error('428 Precondition Required');
- mockAddToAgent
- .mockRejectedValueOnce(error428)
- .mockRejectedValueOnce(error428)
- .mockResolvedValue(undefined);
-
- await addToAgentOnStartup({
- sessionId: 'test-session',
- extensionConfig: mockExtensionConfig,
- });
-
- expect(mockAddToAgent).toHaveBeenCalledTimes(3);
- });
-
- it('should throw error after max retries', async () => {
- const error428 = new Error('428 Precondition Required');
- mockAddToAgent.mockRejectedValue(error428);
-
- await expect(
- addToAgentOnStartup({
- sessionId: 'test-session',
- extensionConfig: mockExtensionConfig,
- })
- ).rejects.toThrow('428 Precondition Required');
-
- expect(mockAddToAgent).toHaveBeenCalledTimes(4); // Initial + 3 retries
- });
- });
-
- describe('updateExtension', () => {
- it('should update extension without name change', async () => {
- mockAddToAgent.mockResolvedValue(undefined);
- mockAddToConfig.mockResolvedValue(undefined);
- mockToastService.success = vi.fn();
-
- await updateExtension({
- enabled: true,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- removeFromConfig: mockRemoveFromConfig,
- extensionConfig: mockExtensionConfig,
- originalName: 'test-extension',
- });
-
- expect(mockAddToConfig).toHaveBeenCalledWith(
- 'test-extension',
- { ...mockExtensionConfig, name: 'test-extension' },
- true
- );
- expect(mockToastService.success).toHaveBeenCalledWith({
- title: 'Update extension',
- msg: 'Successfully updated test-extension extension',
- });
- });
-
- it('should handle name change by removing old and adding new', async () => {
- mockAddToAgent.mockResolvedValue(undefined);
- mockRemoveFromAgent.mockResolvedValue(undefined);
- mockRemoveFromConfig.mockResolvedValue(undefined);
- mockAddToConfig.mockResolvedValue(undefined);
- mockToastService.success = vi.fn();
-
- await updateExtension({
- enabled: true,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- removeFromConfig: mockRemoveFromConfig,
- extensionConfig: { ...mockExtensionConfig, name: 'new-extension' },
- originalName: 'old-extension',
- });
-
- expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension');
- expect(mockAddToAgent).toHaveBeenCalledWith(
- { ...mockExtensionConfig, name: 'new-extension' },
- 'test-session',
- false
- );
- expect(mockAddToConfig).toHaveBeenCalledWith(
- 'new-extension',
- { ...mockExtensionConfig, name: 'new-extension' },
- true
- );
- });
-
- it('should update disabled extension without calling agent', async () => {
- mockAddToConfig.mockResolvedValue(undefined);
- mockToastService.success = vi.fn();
-
- await updateExtension({
- enabled: false,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- removeFromConfig: mockRemoveFromConfig,
- extensionConfig: mockExtensionConfig,
- originalName: 'test-extension',
- });
-
- expect(mockAddToAgent).not.toHaveBeenCalled();
- expect(mockAddToConfig).toHaveBeenCalledWith(
- 'test-extension',
- { ...mockExtensionConfig, name: 'test-extension' },
- false
- );
- expect(mockToastService.success).toHaveBeenCalledWith({
- title: 'Update extension',
- msg: 'Successfully updated test-extension extension',
- });
- });
- });
-
- describe('toggleExtension', () => {
- it('should toggle extension on successfully', async () => {
- mockAddToAgent.mockResolvedValue(undefined);
- mockAddToConfig.mockResolvedValue(undefined);
-
- await toggleExtension({
- toggle: 'toggleOn',
- extensionConfig: mockExtensionConfig,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- });
-
- expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
- expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
- });
-
- it('should toggle extension off successfully', async () => {
- mockRemoveFromAgent.mockResolvedValue(undefined);
- mockAddToConfig.mockResolvedValue(undefined);
-
- await toggleExtension({
- toggle: 'toggleOff',
- extensionConfig: mockExtensionConfig,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- });
-
- expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true);
- expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
- });
-
- it('should rollback on agent failure when toggling on', async () => {
- const agentError = new Error('Agent failed');
- mockAddToAgent.mockRejectedValue(agentError);
- mockAddToConfig.mockResolvedValue(undefined);
-
- await expect(
- toggleExtension({
- toggle: 'toggleOn',
- extensionConfig: mockExtensionConfig,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- })
- ).rejects.toThrow('Agent failed');
-
- expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
- // addToConfig is called during the rollback (toggleOff)
- expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
- });
-
- it('should remove from agent if config update fails when toggling on', async () => {
- const configError = new Error('Config failed');
- mockAddToAgent.mockResolvedValue(undefined);
- mockAddToConfig.mockRejectedValue(configError);
-
- await expect(
- toggleExtension({
- toggle: 'toggleOn',
- extensionConfig: mockExtensionConfig,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- })
- ).rejects.toThrow('Config failed');
-
- expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
- expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
- expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true);
- });
-
- it('should update config even if agent removal fails when toggling off', async () => {
- const agentError = new Error('Agent removal failed');
- mockRemoveFromAgent.mockRejectedValue(agentError);
- mockAddToConfig.mockResolvedValue(undefined);
-
- await expect(
- toggleExtension({
- toggle: 'toggleOff',
- extensionConfig: mockExtensionConfig,
- addToConfig: mockAddToConfig,
- sessionId: 'test-session',
- })
- ).rejects.toThrow('Agent removal failed');
-
- expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
- });
- });
-});
diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts
index 867e0d6c0057..bf5c501eb4c2 100644
--- a/ui/desktop/src/components/settings/extensions/extension-manager.ts
+++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts
@@ -1,6 +1,5 @@
import type { ExtensionConfig } from '../../../api/types.gen';
-import { toastService, ToastServiceOptions } from '../../../toasts';
-import { addToAgent, removeFromAgent, sanitizeName } from './agent-api';
+import { toastService } from '../../../toasts';
import {
trackExtensionAdded,
trackExtensionEnabled,
@@ -13,385 +12,97 @@ function isBuiltinExtension(config: ExtensionConfig): boolean {
return config.type === 'builtin';
}
-type AddExtension = (name: string, config: ExtensionConfig, enabled: boolean) => Promise
;
-
-type ExtensionError = {
- message?: string;
- code?: number;
- name?: string;
- stack?: string;
-};
-
-type RetryOptions = {
- retries?: number;
- delayMs?: number;
- shouldRetry?: (error: ExtensionError, attempt: number) => boolean;
- backoffFactor?: number; // multiplier for exponential backoff
-};
-
-async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise {
- const { retries = 3, delayMs = 1000, backoffFactor = 1.5, shouldRetry = () => true } = options;
-
- let attempt = 0;
- let lastError: ExtensionError = new Error('Unknown error');
-
- while (attempt <= retries) {
- try {
- return await fn();
- } catch (err) {
- lastError = err as ExtensionError;
- attempt++;
-
- if (attempt > retries || !shouldRetry(lastError, attempt)) {
- break;
- }
-
- const waitTime = delayMs * Math.pow(backoffFactor, attempt - 1);
- console.warn(`Retry attempt ${attempt} failed. Retrying in ${waitTime}ms...`, err);
- await new Promise((res) => setTimeout(res, waitTime));
- }
- }
-
- throw lastError;
-}
-
-/**
- * Activates an extension by adding it config and if a session is set, to the agent
- */
-export async function activateExtension(
- extensionConfig: ExtensionConfig,
- addExtension: AddExtension,
- sessionId?: string
-) {
- const isBuiltin = isBuiltinExtension(extensionConfig);
-
- if (sessionId) {
- try {
- await addToAgent(extensionConfig, sessionId, true);
- } catch (error) {
- console.error('Failed to add extension to agent:', error);
- await addExtension(extensionConfig.name, extensionConfig, false);
- trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
- throw error;
- }
- }
-
- try {
- await addExtension(extensionConfig.name, extensionConfig, true);
- trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
- } catch (error) {
- console.error('Failed to add extension to config:', error);
- if (sessionId) {
- try {
- await removeFromAgent(extensionConfig.name, sessionId, true);
- } catch (removeError) {
- console.error('Failed to remove extension from agent after config failure:', removeError);
- }
- }
- trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
- throw error;
- }
-}
-
-interface AddToAgentOnStartupProps {
- extensionConfig: ExtensionConfig;
- toastOptions?: ToastServiceOptions;
- sessionId: string;
-}
-
-/**
- * Adds an extension to the agent during application startup with retry logic
- *
- * TODO(Douwe): Delete this after basecamp lands
- */
-export async function addToAgentOnStartup({
- extensionConfig,
- sessionId,
- toastOptions,
-}: AddToAgentOnStartupProps): Promise {
- const showToast = !toastOptions?.silent;
-
- // Errors are caught by the grouped notification in providerUtils.ts
- // Individual error toasts are suppressed during startup (showToast=false)
- await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, showToast), {
- retries: 3,
- delayMs: 1000,
- shouldRetry: (error: ExtensionError) =>
- !!error.message &&
- (error.message.includes('428') ||
- error.message.includes('Precondition Required') ||
- error.message.includes('Agent is not initialized')),
- });
-}
-
-interface UpdateExtensionProps {
- enabled: boolean;
- addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise;
+interface DeleteExtensionProps {
+ name: string;
removeFromConfig: (name: string) => Promise;
- extensionConfig: ExtensionConfig;
- originalName?: string;
- sessionId?: string;
+ extensionConfig?: ExtensionConfig;
}
/**
- * Updates an extension configuration, handling name changes
+ * Deletes an extension from config (will no longer be loaded in new sessions)
*/
-export async function updateExtension({
- enabled,
- addToConfig,
+export async function deleteExtension({
+ name,
removeFromConfig,
extensionConfig,
- originalName,
- sessionId,
-}: UpdateExtensionProps) {
- // Sanitize the new name to match the behavior when adding extensions
- const sanitizedNewName = sanitizeName(extensionConfig.name);
- const sanitizedOriginalName = originalName ? sanitizeName(originalName) : undefined;
-
- // Check if the sanitized name has changed
- const nameChanged = sanitizedOriginalName && sanitizedOriginalName !== sanitizedNewName;
-
- if (nameChanged) {
- // Handle name change: remove old extension and add new one
-
- // First remove the old extension from agent (using original name)
- try {
- if (sessionId) {
- await removeFromAgent(originalName!, sessionId, false);
- }
- } catch (error) {
- console.error('Failed to remove old extension from agent during rename:', error);
- // Continue with the process even if agent removal fails
- }
-
- // Remove old extension from config (using original name)
- try {
- await removeFromConfig(originalName!); // We know originalName is not undefined here because nameChanged is true
- } catch (error) {
- console.error('Failed to remove old extension from config during rename:', error);
- throw error; // This is more critical, so we throw
- }
-
- // Create a copy of the extension config with the sanitized name
- const sanitizedExtensionConfig = {
- ...extensionConfig,
- name: sanitizedNewName,
- };
-
- // Add new extension with sanitized name
- if (enabled && sessionId) {
- try {
- await addToAgent(sanitizedExtensionConfig, sessionId, false);
- } catch (error) {
- console.error('[updateExtension]: Failed to add renamed extension to agent:', error);
- throw error;
- }
- }
-
- // Add to config with sanitized name
- try {
- await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
- } catch (error) {
- console.error('[updateExtension]: Failed to add renamed extension to config:', error);
- throw error;
- }
-
- toastService.configure({ silent: false });
- toastService.success({
- title: `Update extension`,
- msg: `Successfully updated ${sanitizedNewName} extension`,
- });
- } else {
- // Create a copy of the extension config with the sanitized name
- const sanitizedExtensionConfig = {
- ...extensionConfig,
- name: sanitizedNewName,
- };
-
- if (enabled && sessionId) {
- try {
- await addToAgent(sanitizedExtensionConfig, sessionId, false);
- } catch (error) {
- console.error('[updateExtension]: Failed to add extension to agent during update:', error);
- // Failed to add to agent -- show that error to user and do not update the config file
- throw error;
- }
-
- // Then add to config
- try {
- await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
- } catch (error) {
- console.error('[updateExtension]: Failed to update extension in config:', error);
- throw error;
- }
-
- // show a toast that it was successfully updated
- toastService.success({
- title: `Update extension`,
- msg: `Successfully updated ${sanitizedNewName} extension`,
- });
- } else {
- try {
- await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
- } catch (error) {
- console.error('[updateExtension]: Failed to update disabled extension in config:', error);
- throw error;
- }
+}: DeleteExtensionProps) {
+ const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false;
- // show a toast that it was successfully updated
- toastService.success({
- title: `Update extension`,
- msg: `Successfully updated ${sanitizedNewName} extension`,
- });
- }
+ try {
+ await removeFromConfig(name);
+ trackExtensionDeleted(name, true, undefined, isBuiltin);
+ } catch (error) {
+ console.error('Failed to remove extension from config:', error);
+ trackExtensionDeleted(name, false, getErrorType(error), isBuiltin);
+ throw error;
}
}
-interface ToggleExtensionProps {
+interface ToggleExtensionDefaultProps {
toggle: 'toggleOn' | 'toggleOff';
extensionConfig: ExtensionConfig;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise;
- toastOptions?: ToastServiceOptions;
- sessionId?: string;
}
-/**
- * Toggles an extension between enabled and disabled states
- */
-export async function toggleExtension({
+export async function toggleExtensionDefault({
toggle,
extensionConfig,
addToConfig,
- toastOptions = {},
- sessionId,
-}: ToggleExtensionProps) {
+}: ToggleExtensionDefaultProps) {
const isBuiltin = isBuiltinExtension(extensionConfig);
+ const enabled = toggle === 'toggleOn';
- // disabled to enabled
- if (toggle == 'toggleOn') {
- try {
- // add to agent with toast options
- if (sessionId) {
- await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
- }
- } catch (error) {
- console.error('Error adding extension to agent. Attempting to toggle back off.');
- trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
- try {
- await toggleExtension({
- toggle: 'toggleOff',
- extensionConfig,
- addToConfig,
- toastOptions: { silent: true }, // otherwise we will see a toast for removing something that was never added
- sessionId,
- });
- } catch (toggleError) {
- console.error('Failed to toggle extension off after agent error:', toggleError);
- }
- throw error;
- }
-
- // update the config
- try {
- await addToConfig(extensionConfig.name, extensionConfig, true);
+ try {
+ await addToConfig(extensionConfig.name, extensionConfig, enabled);
+ if (enabled) {
trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin);
- } catch (error) {
- console.error('Failed to update config after enabling extension:', error);
- trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
- // remove from agent
- try {
- if (sessionId) {
- await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
- }
- } catch (removeError) {
- console.error('Failed to remove extension from agent after config failure:', removeError);
- }
- throw error;
- }
- } else if (toggle == 'toggleOff') {
- // enabled to disabled
- let agentRemoveError = null;
- try {
- if (sessionId) {
- await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
- }
- } catch (error) {
- // note there was an error, but attempt to remove from config anyway
- console.error('Error removing extension from agent', extensionConfig.name, error);
- agentRemoveError = error;
+ } else {
+ trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin);
}
-
- // update the config
- try {
- await addToConfig(extensionConfig.name, extensionConfig, false);
- if (agentRemoveError) {
- trackExtensionDisabled(
- extensionConfig.name,
- false,
- getErrorType(agentRemoveError),
- isBuiltin
- );
- } else {
- trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin);
- }
- } catch (error) {
- console.error('Error removing extension from config', extensionConfig.name, 'Error:', error);
+ toastService.success({
+ title: extensionConfig.name,
+ msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults',
+ });
+ } catch (error) {
+ console.error('Failed to update extension default in config:', error);
+ if (enabled) {
+ trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
+ } else {
trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
- throw error;
- }
-
- // If we had an error removing from agent but succeeded updating config, still throw the original error
- if (agentRemoveError) {
- throw agentRemoveError;
}
+ toastService.error({
+ title: extensionConfig.name,
+ msg: 'Failed to update extension default',
+ });
+ throw error;
}
}
-interface DeleteExtensionProps {
- name: string;
- removeFromConfig: (name: string) => Promise;
- sessionId?: string;
- extensionConfig?: ExtensionConfig;
+interface ActivateExtensionDefaultProps {
+ addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise;
+ extensionConfig: ExtensionConfig;
}
-/**
- * Deletes an extension completely from both agent and config
- */
-export async function deleteExtension({
- name,
- removeFromConfig,
- sessionId,
+export async function activateExtensionDefault({
+ addToConfig,
extensionConfig,
-}: DeleteExtensionProps) {
- const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false;
-
- let agentRemoveError = null;
- try {
- if (sessionId) {
- await removeFromAgent(name, sessionId, true);
- }
- } catch (error) {
- console.error('Failed to remove extension from agent during deletion:', error);
- agentRemoveError = error;
- }
+}: ActivateExtensionDefaultProps): Promise {
+ const isBuiltin = isBuiltinExtension(extensionConfig);
try {
- await removeFromConfig(name);
- if (agentRemoveError) {
- trackExtensionDeleted(name, false, getErrorType(agentRemoveError), isBuiltin);
- } else {
- trackExtensionDeleted(name, true, undefined, isBuiltin);
- }
+ await addToConfig(extensionConfig.name, extensionConfig, true);
+ trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
+ toastService.success({
+ title: extensionConfig.name,
+ msg: 'Extension added as default',
+ });
} catch (error) {
- console.error(
- 'Failed to remove extension from config after removing from agent. Error:',
- error
- );
- trackExtensionDeleted(name, false, getErrorType(error), isBuiltin);
+ console.error('Failed to add extension to config:', error);
+ trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
+ toastService.error({
+ title: extensionConfig.name,
+ msg: 'Failed to add extension',
+ });
throw error;
}
-
- if (agentRemoveError) {
- throw agentRemoveError;
- }
}
diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts
index 5469fc52ad69..67d0dc161df0 100644
--- a/ui/desktop/src/components/settings/extensions/index.ts
+++ b/ui/desktop/src/components/settings/extensions/index.ts
@@ -1,20 +1,13 @@
-// Export public API
export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
-// Export extension management functions
export {
- activateExtension,
- addToAgentOnStartup,
- updateExtension,
- toggleExtension,
+ activateExtensionDefault,
+ toggleExtensionDefault,
deleteExtension,
} from './extension-manager';
-// Export built-in extension functions
export { syncBundledExtensions, initializeBundledExtensions } from './bundled-extensions';
-// Export deeplink handling
export { addExtensionFromDeepLink } from './deeplink';
-// Export agent API functions
-export { addToAgent as AddToAgent, removeFromAgent as RemoveFromAgent } from './agent-api';
+export { addToAgent, removeFromAgent } from './agent-api';
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx
index f677260332eb..92663222a58a 100644
--- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx
+++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx
@@ -11,7 +11,6 @@ interface ExtensionItemProps {
onToggle: (extension: FixedExtensionEntry) => Promise | void;
onConfigure?: (extension: FixedExtensionEntry) => void;
isStatic?: boolean; // to not allow users to edit configuration
- isPendingActivation?: boolean;
}
export default function ExtensionItem({
@@ -19,7 +18,6 @@ export default function ExtensionItem({
onToggle,
onConfigure,
isStatic,
- isPendingActivation = false,
}: ExtensionItemProps) {
// Add local state to track the visual toggle state
const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled);
@@ -81,17 +79,7 @@ export default function ExtensionItem({
onClick={() => handleToggle(extension)}
>
-
- {getFriendlyTitle(extension)}
- {isPendingActivation && (
-
- Pending
-
- )}
-
+ {getFriendlyTitle(extension)}
e.stopPropagation()}>
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
index a2ab0b121fbf..378bc5c7fda6 100644
--- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
+++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx
@@ -11,7 +11,6 @@ interface ExtensionListProps {
isStatic?: boolean;
disableConfiguration?: boolean;
searchTerm?: string;
- pendingActivationExtensions?: Set
;
}
export default function ExtensionList({
@@ -21,7 +20,6 @@ export default function ExtensionList({
isStatic,
disableConfiguration: _disableConfiguration,
searchTerm = '',
- pendingActivationExtensions = new Set(),
}: ExtensionListProps) {
const matchesSearch = (extension: FixedExtensionEntry): boolean => {
if (!searchTerm) return true;
@@ -55,7 +53,7 @@ export default function ExtensionList({
- Enabled Extensions ({sortedEnabledExtensions.length})
+ Default Extensions ({sortedEnabledExtensions.length})
{sortedEnabledExtensions.map((extension) => (
@@ -65,7 +63,6 @@ export default function ExtensionList({
onToggle={onToggle}
onConfigure={onConfigure}
isStatic={isStatic}
- isPendingActivation={pendingActivationExtensions.has(extension.name)}
/>
))}
@@ -100,14 +97,18 @@ export default function ExtensionList({
}
// Helper functions
-export function getFriendlyTitle(extension: FixedExtensionEntry): string {
- const name = (extension.type === 'builtin' && extension.display_name) || extension.name;
+export function formatExtensionName(name: string): string {
return name
.split(/[-_]/) // Split on hyphens and underscores
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+export function getFriendlyTitle(extension: FixedExtensionEntry): string {
+ const name = (extension.type === 'builtin' && extension.display_name) || extension.name;
+ return formatExtensionName(name);
+}
+
function normalizeExtensionName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '');
}
diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts
index 41580f7609ae..70e9fcbf125e 100644
--- a/ui/desktop/src/hooks/useChatStream.ts
+++ b/ui/desktop/src/hooks/useChatStream.ts
@@ -20,6 +20,7 @@ import {
NotificationEvent,
} from '../types/message';
import { errorMessage } from '../utils/conversionUtils';
+import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
const resultsCache = new Map
();
@@ -33,6 +34,7 @@ interface UseChatStreamReturn {
session?: Session;
messages: Message[];
chatState: ChatState;
+ setChatState: (state: ChatState) => void;
handleSubmit: (userMessage: string) => Promise;
submitElicitationResponse: (
elicitationId: string,
@@ -221,6 +223,7 @@ export function useChatStream({
accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0,
});
setChatState(ChatState.Idle);
+ onSessionLoaded?.();
return;
}
@@ -246,16 +249,20 @@ export function useChatStream({
return;
}
- const session = response.data;
- setSession(session);
- updateMessages(session?.conversation || []);
+ const resumeData = response.data;
+ const loadedSession = resumeData?.session;
+ const extensionResults = resumeData?.extension_results;
+
+ showExtensionLoadResults(extensionResults);
+ setSession(loadedSession);
+ updateMessages(loadedSession?.conversation || []);
setTokenState({
- inputTokens: session?.input_tokens ?? 0,
- outputTokens: session?.output_tokens ?? 0,
- totalTokens: session?.total_tokens ?? 0,
- accumulatedInputTokens: session?.accumulated_input_tokens ?? 0,
- accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0,
- accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0,
+ inputTokens: loadedSession?.input_tokens ?? 0,
+ outputTokens: loadedSession?.output_tokens ?? 0,
+ totalTokens: loadedSession?.total_tokens ?? 0,
+ accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0,
+ accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0,
+ accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0,
});
setChatState(ChatState.Idle);
onSessionLoaded?.();
@@ -507,6 +514,7 @@ export function useChatStream({
messages: maybe_cached_messages,
session: maybe_cached_session,
chatState,
+ setChatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 85bcb31813a8..b10b937ebf3b 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -1189,9 +1189,17 @@ ipcMain.handle('open-external', async (_event, url: string) => {
}
});
-// Handle directory chooser
-ipcMain.handle('directory-chooser', (_event) => {
- return openDirectoryDialog();
+ipcMain.handle('directory-chooser', async () => {
+ return dialog.showOpenDialog({
+ properties: ['openDirectory', 'createDirectory'],
+ defaultPath: os.homedir(),
+ });
+});
+
+ipcMain.handle('add-recent-dir', (_event, dir: string) => {
+ if (dir) {
+ addRecentDir(dir);
+ }
});
// Handle scheduling engine settings
diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts
index b012691da077..043508a293be 100644
--- a/ui/desktop/src/preload.ts
+++ b/ui/desktop/src/preload.ts
@@ -61,7 +61,7 @@ type ElectronAPI = {
reactReady: () => void;
getConfig: () => Record;
hideWindow: () => void;
- directoryChooser: (replace?: boolean) => Promise;
+ directoryChooser: () => Promise;
createChatWindow: (
query?: string,
dir?: string,
@@ -134,6 +134,7 @@ type ElectronAPI = {
hasAcceptedRecipeBefore: (recipe: Recipe) => Promise;
recordRecipeHash: (recipe: Recipe) => Promise;
openDirectoryInExplorer: (directoryPath: string) => Promise;
+ addRecentDir: (dir: string) => Promise;
};
type AppConfigAPI = {
@@ -270,6 +271,7 @@ const electronAPI: ElectronAPI = {
recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe),
openDirectoryInExplorer: (directoryPath: string) =>
ipcRenderer.invoke('open-directory-in-explorer', directoryPath),
+ addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir),
};
const appConfigAPI: AppConfigAPI = {
diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts
index 5217dd0f42b0..1efec4d44c0d 100644
--- a/ui/desktop/src/sessions.ts
+++ b/ui/desktop/src/sessions.ts
@@ -1,5 +1,11 @@
-import { Session, startAgent } from './api';
+import { Session, startAgent, ExtensionConfig } from './api';
import type { setViewType } from './hooks/useNavigation';
+import {
+ getExtensionConfigsWithOverrides,
+ clearExtensionOverrides,
+ hasExtensionOverrides,
+} from './store/extensionOverrides';
+import type { FixedExtensionEntry } from './components/ConfigContext';
export function resumeSession(session: Session, setView: setViewType) {
setView('pair', {
@@ -8,16 +14,22 @@ export function resumeSession(session: Session, setView: setViewType) {
});
}
-export async function createSession(options?: {
- recipeId?: string;
- recipeDeeplink?: string;
-}): Promise {
+export async function createSession(
+ workingDir: string,
+ options?: {
+ recipeId?: string;
+ recipeDeeplink?: string;
+ extensionConfigs?: ExtensionConfig[];
+ allExtensions?: FixedExtensionEntry[];
+ }
+): Promise {
const body: {
working_dir: string;
recipe_id?: string;
recipe_deeplink?: string;
+ extension_overrides?: ExtensionConfig[];
} = {
- working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
+ working_dir: workingDir,
};
if (options?.recipeId) {
@@ -26,22 +38,37 @@ export async function createSession(options?: {
body.recipe_deeplink = options.recipeDeeplink;
}
+ if (options?.extensionConfigs && options.extensionConfigs.length > 0) {
+ body.extension_overrides = options.extensionConfigs;
+ } else if (options?.allExtensions) {
+ const extensionConfigs = getExtensionConfigsWithOverrides(options.allExtensions);
+ if (extensionConfigs.length > 0) {
+ body.extension_overrides = extensionConfigs;
+ }
+ if (hasExtensionOverrides()) {
+ clearExtensionOverrides();
+ }
+ }
+
const newAgent = await startAgent({
body,
throwOnError: true,
});
+
return newAgent.data;
}
export async function startNewSession(
+ workingDir: string,
initialText: string | undefined,
setView: setViewType,
options?: {
recipeId?: string;
recipeDeeplink?: string;
+ allExtensions?: FixedExtensionEntry[];
}
): Promise {
- const session = await createSession(options);
+ const session = await createSession(workingDir, options);
setView('pair', {
disableAnimation: true,
diff --git a/ui/desktop/src/store/extensionOverrides.ts b/ui/desktop/src/store/extensionOverrides.ts
new file mode 100644
index 000000000000..5755126961a0
--- /dev/null
+++ b/ui/desktop/src/store/extensionOverrides.ts
@@ -0,0 +1,59 @@
+// Store for extension overrides when starting a new session from the hub
+// These overrides allow temporarily enabling/disabling extensions before creating a session
+// Resets after session creation
+
+import type { ExtensionConfig } from '../api';
+
+// Map of extension name -> enabled state (overrides from hub view)
+type ExtensionOverrides = Map;
+
+const state: {
+ extensionOverrides: ExtensionOverrides;
+} = {
+ extensionOverrides: new Map(),
+};
+
+export function setExtensionOverride(name: string, enabled: boolean): void {
+ state.extensionOverrides.set(name, enabled);
+}
+
+export function getExtensionOverride(name: string): boolean | undefined {
+ return state.extensionOverrides.get(name);
+}
+
+export function hasExtensionOverrides(): boolean {
+ return state.extensionOverrides.size > 0;
+}
+
+export function getExtensionOverrides(): ExtensionOverrides {
+ return state.extensionOverrides;
+}
+
+export function clearExtensionOverrides(): void {
+ state.extensionOverrides.clear();
+}
+
+export function getExtensionConfigsWithOverrides(
+ allExtensions: Array<{ name: string; enabled: boolean } & Omit>
+): ExtensionConfig[] {
+ if (state.extensionOverrides.size === 0) {
+ return allExtensions
+ .filter((ext) => ext.enabled)
+ .map((ext) => {
+ const { enabled: _enabled, ...config } = ext;
+ return config as ExtensionConfig;
+ });
+ }
+
+ return allExtensions
+ .filter((ext) => {
+ if (state.extensionOverrides.has(ext.name)) {
+ return state.extensionOverrides.get(ext.name);
+ }
+ return ext.enabled;
+ })
+ .map((ext) => {
+ const { enabled: _enabled, ...config } = ext;
+ return config as ExtensionConfig;
+ });
+}
diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx
index 6e3754e26579..c6d19cd1efbf 100644
--- a/ui/desktop/src/toasts.tsx
+++ b/ui/desktop/src/toasts.tsx
@@ -8,6 +8,7 @@ import {
GroupedExtensionLoadingToast,
ExtensionLoadingStatus,
} from './components/GroupedExtensionLoadingToast';
+import { getInitialWorkingDir } from './utils/workingDir';
export interface ToastServiceOptions {
silent?: boolean;
@@ -109,7 +110,7 @@ class ToastService {
{
...commonToastOptions,
toastId,
- autoClose: false,
+ autoClose: isComplete ? 5000 : false,
closeButton: true,
closeOnClick: false, // Prevent closing when clicking to expand/collapse
}
@@ -195,7 +196,9 @@ function ToastErrorContent({
{showRecovery && (
- startNewSession(recoverHints, setView)}>Ask goose
+ startNewSession(getInitialWorkingDir(), recoverHints, setView)}>
+ Ask goose
+
)}
{hasBoth && (
diff --git a/ui/desktop/src/types/chatState.ts b/ui/desktop/src/types/chatState.ts
index 067aee4f7b0b..46ec6c36853a 100644
--- a/ui/desktop/src/types/chatState.ts
+++ b/ui/desktop/src/types/chatState.ts
@@ -5,4 +5,5 @@ export enum ChatState {
WaitingForUserInput = 'waitingForUserInput',
Compacting = 'compacting',
LoadingConversation = 'loadingConversation',
+ RestartingAgent = 'restartingAgent',
}
diff --git a/ui/desktop/src/utils/extensionErrorUtils.ts b/ui/desktop/src/utils/extensionErrorUtils.ts
index e707c6c0e757..369a64c82cec 100644
--- a/ui/desktop/src/utils/extensionErrorUtils.ts
+++ b/ui/desktop/src/utils/extensionErrorUtils.ts
@@ -2,6 +2,9 @@
* Shared constants and utilities for extension error handling
*/
+import { ExtensionLoadResult } from '../api/types.gen';
+import { toastService, ExtensionLoadingStatus } from '../toasts';
+
export const MAX_ERROR_MESSAGE_LENGTH = 70;
/**
@@ -28,3 +31,43 @@ export function formatExtensionErrorMessage(
): string {
return errorMsg.length < MAX_ERROR_MESSAGE_LENGTH ? errorMsg : fallback;
}
+
+/**
+ * Shows toast notifications for extension load results.
+ * Uses grouped toast for multiple extensions, individual error toast for single failed extension.
+ * @param results - Array of extension load results from the backend
+ */
+export function showExtensionLoadResults(results: ExtensionLoadResult[] | null | undefined): void {
+ if (!results || results.length === 0) {
+ return;
+ }
+
+ const failedExtensions = results.filter((r) => !r.success);
+
+ if (results.length === 1 && failedExtensions.length === 1) {
+ const failed = failedExtensions[0];
+ const errorMsg = failed.error || 'Unknown error';
+ const recoverHints = createExtensionRecoverHints(errorMsg);
+ const displayMsg = formatExtensionErrorMessage(errorMsg, 'Failed to load extension');
+
+ toastService.error({
+ title: failed.name,
+ msg: displayMsg,
+ traceback: errorMsg,
+ recoverHints,
+ });
+ return;
+ }
+
+ const extensionStatuses: ExtensionLoadingStatus[] = results.map((r) => {
+ const errorMsg = r.error || 'Unknown error';
+ return {
+ name: r.name,
+ status: r.success ? 'success' : 'error',
+ error: r.success ? undefined : errorMsg,
+ recoverHints: r.success ? undefined : createExtensionRecoverHints(errorMsg),
+ };
+ });
+
+ toastService.extensionLoading(extensionStatuses, results.length, true);
+}
diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts
index d9bbe36e7263..aaa6ba67b1c6 100644
--- a/ui/desktop/src/utils/navigationUtils.ts
+++ b/ui/desktop/src/utils/navigationUtils.ts
@@ -19,9 +19,7 @@ export type View =
| 'recipes'
| 'permission';
-// TODO(Douwe): check these for usage, especially key: string for resetChat
export type ViewOptions = {
- extensionId?: string;
showEnvVars?: boolean;
deepLinkConfig?: unknown;
sessionDetails?: unknown;
@@ -32,7 +30,6 @@ export type ViewOptions = {
parentViewOptions?: ViewOptions;
disableAnimation?: boolean;
initialMessage?: string;
- resetChat?: boolean;
shareToken?: string;
resumeSessionId?: string;
pendingScheduleDeepLink?: string;
diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts
index 5206339df9aa..40627920e3c7 100644
--- a/ui/desktop/src/utils/providerUtils.ts
+++ b/ui/desktop/src/utils/providerUtils.ts
@@ -1,13 +1,9 @@
import {
initializeBundledExtensions,
syncBundledExtensions,
- addToAgentOnStartup,
} from '../components/settings/extensions';
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
import { Recipe, updateAgentProvider, updateFromSession } from '../api';
-import { toastService, ExtensionLoadingStatus } from '../toasts';
-import { errorMessage } from './conversionUtils';
-import { createExtensionRecoverHints } from './extensionErrorUtils';
// Helper function to substitute parameters in text
export const substituteParameters = (text: string, params: Record): string => {
@@ -29,7 +25,6 @@ export const initializeSystem = async (
options?: {
getExtensions?: (b: boolean) => Promise;
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise;
- setIsExtensionsLoading?: (loading: boolean) => void;
recipeParameters?: Record | null;
recipe?: Recipe;
}
@@ -72,95 +67,11 @@ export const initializeSystem = async (
if (refreshedExtensions.length === 0) {
await initializeBundledExtensions(options.addExtension);
- refreshedExtensions = await options.getExtensions(false);
} else {
await syncBundledExtensions(refreshedExtensions, options.addExtension);
}
-
- // Add enabled extensions to agent in parallel
- const enabledExtensions = refreshedExtensions.filter((ext) => ext.enabled);
-
- if (enabledExtensions.length === 0) {
- return;
- }
-
- options?.setIsExtensionsLoading?.(true);
-
- // Initialize extension status tracking
- const extensionStatuses: Map = new Map(
- enabledExtensions.map((ext) => [ext.name, { name: ext.name, status: 'loading' as const }])
- );
-
- // Show initial loading toast
- const updateToast = (isComplete: boolean = false) => {
- toastService.extensionLoading(
- Array.from(extensionStatuses.values()),
- enabledExtensions.length,
- isComplete
- );
- };
-
- updateToast();
-
- // Load extensions in parallel and update status
- const extensionLoadingPromises = enabledExtensions.map(async (extensionConfig) => {
- const extensionName = extensionConfig.name;
-
- // SSE is unsupported - fail immediately without calling the backend
- if (extensionConfig.type === 'sse') {
- const errMsg = 'SSE is unsupported, migrate to streamable_http';
- extensionStatuses.set(extensionName, {
- name: extensionName,
- status: 'error',
- error: errMsg,
- recoverHints: createExtensionRecoverHints(errMsg),
- });
- updateToast();
- return;
- }
-
- try {
- await addToAgentOnStartup({
- extensionConfig,
- toastOptions: { silent: true }, // Silent since we're using grouped notification
- sessionId,
- });
-
- // Update status to success
- extensionStatuses.set(extensionName, {
- name: extensionName,
- status: 'success',
- });
- updateToast();
- } catch (error) {
- console.error(`Failed to load extension ${extensionName}:`, error);
-
- // Extract error message using shared utility
- const errMsg = errorMessage(error);
-
- // Create recovery hints for "Ask goose" button
- const recoverHints = createExtensionRecoverHints(errMsg);
-
- // Update status to error
- extensionStatuses.set(extensionName, {
- name: extensionName,
- status: 'error',
- error: errMsg,
- recoverHints,
- });
- updateToast();
- }
- });
-
- await Promise.allSettled(extensionLoadingPromises);
-
- // Show final completion toast
- updateToast(true);
-
- options?.setIsExtensionsLoading?.(false);
} catch (error) {
console.error('Failed to initialize agent:', error);
- options?.setIsExtensionsLoading?.(false);
throw error;
}
};
diff --git a/ui/desktop/src/utils/workingDir.ts b/ui/desktop/src/utils/workingDir.ts
new file mode 100644
index 000000000000..413e38f6cc87
--- /dev/null
+++ b/ui/desktop/src/utils/workingDir.ts
@@ -0,0 +1,3 @@
+export const getInitialWorkingDir = (): string => {
+ return (window.appConfig?.get('GOOSE_WORKING_DIR') as string) || '';
+};