From c345c826663b409f336b328c262ece38f2e27bc5 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 21 Nov 2025 17:47:58 -0800 Subject: [PATCH 01/21] [feat] add term command Term command is a new way to use goose without the builtin REPL - instead the session is tied right into your terminal --- crates/goose-cli/src/cli.rs | 93 ++++++ crates/goose-cli/src/commands/mod.rs | 1 + crates/goose-cli/src/commands/term.rs | 192 +++++++++++++ crates/goose/src/session/session_manager.rs | 269 +++++++++++++++++- .../docs/guides/terminal-integration.md | 75 +++++ 5 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 crates/goose-cli/src/commands/term.rs create mode 100644 documentation/docs/guides/terminal-integration.md diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e0083d774200..5ffafb7bbf74 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -13,6 +13,7 @@ use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; +use crate::commands::term::{handle_term_init, handle_term_log, handle_term_run}; use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -34,6 +35,14 @@ use std::io::Read; use std::path::PathBuf; use tracing::warn; +fn non_empty_string(s: &str) -> Result { + if s.trim().is_empty() { + Err("Prompt cannot be empty".to_string()) + } else { + Ok(s.to_string()) + } +} + #[derive(Parser)] #[command(author, version, display_name = "", about, long_about = None)] struct Cli { @@ -829,6 +838,61 @@ enum Command { #[arg(long, help = "Authentication token to secure the web interface")] auth_token: Option, }, + + /// Terminal-integrated session (one session per terminal) + #[command( + about = "Terminal-integrated goose session", + long_about = "Runs a goose session tied to your terminal window.\n\ + Each terminal maintains its own persistent session that resumes automatically.\n\n\ + Setup:\n \ + eval \"$(goose term init zsh)\" # Add to ~/.zshrc\n\n\ + Usage:\n \ + goose term run \"list files in this directory\"\n \ + gt \"create a python script\" # using alias" + )] + Term { + #[command(subcommand)] + command: TermCommand, + }, +} + +#[derive(Subcommand)] +enum TermCommand { + /// Print shell initialization script + #[command( + about = "Print shell initialization script", + long_about = "Prints shell configuration to set up terminal-integrated sessions.\n\ + Each terminal gets a persistent goose session that automatically resumes.\n\n\ + Setup:\n \ + echo 'eval \"$(goose term init zsh)\"' >> ~/.zshrc\n \ + source ~/.zshrc" + )] + Init { + /// Shell type (bash, zsh, fish, powershell) + #[arg(value_enum)] + shell: Shell, + }, + + /// Log a shell command (called by shell hook) + #[command(about = "Log a shell command to the session", hide = true)] + Log { + /// The command that was executed + command: String, + }, + + /// Run a prompt in the terminal session + #[command( + about = "Run a prompt in the terminal session", + long_about = "Run a prompt in the terminal-integrated session.\n\n\ + Examples:\n \ + goose term run \"list files in this directory\"\n \ + goose term run \"create a python script that prints hello world\"" + )] + Run { + /// The prompt to send to goose + #[arg(value_parser = non_empty_string)] + prompt: String, + }, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -838,6 +902,14 @@ enum CliProviderVariant { Ollama, } +#[derive(clap::ValueEnum, Clone, Debug)] +enum Shell { + Bash, + Zsh, + Fish, + Powershell, +} + #[derive(Debug)] pub struct InputConfig { pub contents: Option, @@ -874,6 +946,7 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Bench { .. }) => "bench", Some(Command::Recipe { .. }) => "recipe", Some(Command::Web { .. }) => "web", + Some(Command::Term { .. }) => "term", None => "default_session", }; @@ -1387,6 +1460,26 @@ pub async fn cli() -> anyhow::Result<()> { crate::commands::web::handle_web(port, host, open, auth_token).await?; return Ok(()); } + Some(Command::Term { command }) => { + match command { + TermCommand::Init { shell } => { + let shell_str = match shell { + Shell::Bash => "bash", + Shell::Zsh => "zsh", + Shell::Fish => "fish", + Shell::Powershell => "powershell", + }; + handle_term_init(shell_str)?; + } + TermCommand::Log { command } => { + handle_term_log(command).await?; + } + TermCommand::Run { prompt } => { + handle_term_run(prompt).await?; + } + } + return Ok(()); + } None => { return if !Config::global().exists() { handle_configure().await?; diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 698fd02fdfe4..e0d54e96780b 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -6,5 +6,6 @@ pub mod project; pub mod recipe; pub mod schedule; pub mod session; +pub mod term; pub mod update; pub mod web; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs new file mode 100644 index 000000000000..685447a842ff --- /dev/null +++ b/crates/goose-cli/src/commands/term.rs @@ -0,0 +1,192 @@ +use anyhow::{anyhow, Result}; +use goose::session::session_manager::SessionType; +use goose::session::SessionManager; +use uuid::Uuid; + +use crate::session::{build_session, SessionBuilderConfig}; + +const TERMINAL_SESSION_PREFIX: &str = "term:"; + +/// Handle `goose term init ` - print shell initialization script +pub fn handle_term_init(shell: &str) -> Result<()> { + let terminal_id = Uuid::new_v4().to_string(); + + // Get the path to the current goose binary + let goose_bin = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "goose".to_string()); + + let script = match shell.to_lowercase().as_str() { + "bash" => { + format!( + r#"export GOOSE_TERMINAL_ID="{terminal_id}" +alias gt='{goose_bin} term run' + +# Log commands to goose (runs silently in background) +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^gt\ ]] && return + ("{goose_bin}" term log "$1" &) 2>/dev/null +}} + +# Install preexec hook for bash +if [[ -z "$goose_preexec_installed" ]]; then + goose_preexec_installed=1 + trap 'goose_preexec "$BASH_COMMAND"' DEBUG +fi"# + ) + } + "zsh" => { + format!( + r#"export GOOSE_TERMINAL_ID="{terminal_id}" +alias gt='{goose_bin} term run' + +# Log commands to goose (runs silently in background) +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^gt\ ]] && return + ("{goose_bin}" term log "$1" &) 2>/dev/null +}} + +# Install preexec hook for zsh +autoload -Uz add-zsh-hook +add-zsh-hook preexec goose_preexec"# + ) + } + "fish" => { + format!( + r#"set -gx GOOSE_TERMINAL_ID "{terminal_id}" +alias gt='{goose_bin} term run' + +# Log commands to goose +function goose_preexec --on-event fish_preexec + string match -q -r '^goose term' -- $argv[1]; and return + string match -q -r '^gt ' -- $argv[1]; and return + {goose_bin} term log $argv[1] 2>/dev/null & +end"# + ) + } + "powershell" | "pwsh" => { + format!( + r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}" +Set-Alias -Name gt -Value {{ {goose_bin} term run $args }} + +# Log commands to goose +Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ + $line = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) + if ($line -notmatch '^goose term' -and $line -notmatch '^gt ') {{ + Start-Job -ScriptBlock {{ {goose_bin} term log $using:line }} | Out-Null + }} + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() +}}"# + ) + } + _ => { + return Err(anyhow!( + "Unsupported shell: {}. Supported shells: bash, zsh, fish, powershell", + shell + )); + } + }; + + println!("{}", script); + Ok(()) +} + +/// Handle `goose term log ` - log a shell command to the database +pub async fn handle_term_log(command: String) -> Result<()> { + let terminal_id = std::env::var("GOOSE_TERMINAL_ID") + .map_err(|_| anyhow!("GOOSE_TERMINAL_ID not set. Run 'goose term init ' first."))?; + + let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); + let working_dir = std::env::current_dir()?; + + // Create session if it doesn't exist (so we can log commands before first run) + if SessionManager::get_session(&session_name, false) + .await + .is_err() + { + let session = SessionManager::create_session_with_id( + session_name.clone(), + working_dir.clone(), + session_name.clone(), + SessionType::User, + ) + .await?; + + SessionManager::update_session(&session.id) + .user_provided_name(session_name.clone()) + .apply() + .await?; + } + + SessionManager::add_shell_command(&session_name, &command, &working_dir).await?; + + Ok(()) +} + +/// Handle `goose term run ` - run a prompt in the terminal session +pub async fn handle_term_run(prompt: String) -> Result<()> { + let terminal_id = std::env::var("GOOSE_TERMINAL_ID").map_err(|_| { + anyhow!( + "GOOSE_TERMINAL_ID not set.\n\n\ + Add to your shell config (~/.zshrc or ~/.bashrc):\n \ + eval \"$(goose term init zsh)\"\n\n\ + Then restart your terminal or run: source ~/.zshrc" + ) + })?; + + let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); + + let session_id = match SessionManager::get_session(&session_name, false).await { + Ok(_) => { + SessionManager::update_session(&session_name) + .working_dir(std::env::current_dir()?) + .apply() + .await?; + session_name.clone() + } + Err(_) => { + let session = SessionManager::create_session_with_id( + session_name.clone(), + std::env::current_dir()?, + session_name.clone(), + SessionType::User, + ) + .await?; + + // Mark with user-provided name so session persists across restarts + SessionManager::update_session(&session.id) + .user_provided_name(session_name) + .apply() + .await?; + + session.id + } + }; + + let commands = SessionManager::get_shell_commands_since_last_message(&session_id).await?; + let prompt_with_context = if commands.is_empty() { + prompt + } else { + format!( + "\n{}\n\n\n{}", + commands.join("\n"), + prompt + ) + }; + + let config = SessionBuilderConfig { + session_id: Some(session_id), + resume: true, + interactive: false, + quiet: true, + ..Default::default() + }; + + let mut session = build_session(config).await; + session.headless(prompt_with_context).await?; + + Ok(()) +} diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7d63e665b45c..f3fc3b621bfa 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -19,7 +19,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -const CURRENT_SCHEMA_VERSION: i32 = 6; +const CURRENT_SCHEMA_VERSION: i32 = 7; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; @@ -261,6 +261,18 @@ impl SessionManager { .await } + pub async fn create_session_with_id( + id: String, + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { + Self::instance() + .await? + .create_session_with_id(id, working_dir, name, session_type) + .await + } + pub async fn get_session(id: &str, include_messages: bool) -> Result { Self::instance() .await? @@ -361,6 +373,24 @@ impl SessionManager { .search_chat_history(query, limit, after_date, before_date, exclude_session_id) .await } + + pub async fn add_shell_command( + session_id: &str, + command: &str, + working_dir: &Path, + ) -> Result<()> { + Self::instance() + .await? + .add_shell_command(session_id, command, working_dir) + .await + } + + pub async fn get_shell_commands_since_last_message(session_id: &str) -> Result> { + Self::instance() + .await? + .get_shell_commands_since_last_message(session_id) + .await + } } pub struct SessionStorage { @@ -598,6 +628,29 @@ impl SessionStorage { .execute(&pool) .await?; + sqlx::query( + r#" + CREATE TABLE shell_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + command TEXT NOT NULL, + working_dir TEXT NOT NULL, + created_timestamp INTEGER NOT NULL + ) + "#, + ) + .execute(&pool) + .await?; + + sqlx::query("CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)") + .execute(&pool) + .await?; + sqlx::query( + "CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)", + ) + .execute(&pool) + .await?; + Ok(Self { pool }) } @@ -836,6 +889,31 @@ impl SessionStorage { .execute(&self.pool) .await?; } + 7 => { + sqlx::query( + r#" + CREATE TABLE shell_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + command TEXT NOT NULL, + working_dir TEXT NOT NULL, + created_timestamp INTEGER NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)", + ) + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)") + .execute(&self.pool) + .await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -883,6 +961,48 @@ impl SessionStorage { Ok(session) } + async fn create_session_with_id( + &self, + id: String, + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { + let mut tx = self.pool.begin().await?; + + // Use INSERT OR IGNORE to handle race conditions where multiple processes + // might try to create the same session simultaneously + sqlx::query( + r#" + INSERT OR IGNORE INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data) + VALUES (?, ?, FALSE, ?, ?, '{}') + "#, + ) + .bind(&id) + .bind(&name) + .bind(session_type.to_string()) + .bind(working_dir.to_string_lossy().as_ref()) + .execute(&mut *tx) + .await?; + + let session = sqlx::query_as::<_, Session>( + r#" + SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, + total_tokens, input_tokens, output_tokens, + accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, + schedule_id, recipe_json, user_recipe_values_json, + provider_name, model_config_json + FROM sessions WHERE id = ? + "#, + ) + .bind(&id) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok(session) + } + async fn get_session(&self, id: &str, include_messages: bool) -> Result { let mut session = sqlx::query_as::<_, Session>( r#" @@ -1286,6 +1406,55 @@ impl SessionStorage { .execute() .await } + + async fn add_shell_command( + &self, + session_id: &str, + command: &str, + working_dir: &Path, + ) -> Result<()> { + // Use seconds to match messages table timestamp format + let timestamp = chrono::Utc::now().timestamp(); + + sqlx::query( + r#" + INSERT INTO shell_commands (session_id, command, working_dir, created_timestamp) + VALUES (?, ?, ?, ?) + "#, + ) + .bind(session_id) + .bind(command) + .bind(working_dir.to_string_lossy().as_ref()) + .bind(timestamp) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn get_shell_commands_since_last_message(&self, session_id: &str) -> Result> { + let last_message_timestamp = sqlx::query_scalar::<_, Option>( + "SELECT MAX(created_timestamp) FROM messages WHERE session_id = ?", + ) + .bind(session_id) + .fetch_one(&self.pool) + .await? + .unwrap_or(0); + + let commands = sqlx::query_scalar::<_, String>( + r#" + SELECT command FROM shell_commands + WHERE session_id = ? AND created_timestamp > ? + ORDER BY created_timestamp ASC + "#, + ) + .bind(session_id) + .bind(last_message_timestamp) + .fetch_all(&self.pool) + .await?; + + Ok(commands) + } } #[cfg(test)] @@ -1492,4 +1661,102 @@ mod tests { assert!(imported.user_set_name); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); } + + #[tokio::test] + async fn test_create_session_with_id_race_condition() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_race.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let session_id = "test-race-session"; + let mut handles = vec![]; + + // Spawn multiple tasks trying to create the same session simultaneously + for _ in 0..10 { + let storage = Arc::clone(&storage); + let id = session_id.to_string(); + handles.push(tokio::spawn(async move { + storage + .create_session_with_id( + id.clone(), + PathBuf::from("/tmp/test"), + id, + SessionType::User, + ) + .await + })); + } + + // All should succeed without UNIQUE constraint errors + for handle in handles { + let result = handle.await.unwrap(); + assert!( + result.is_ok(), + "create_session_with_id failed: {:?}", + result + ); + } + + // Should only have one session with this ID + let session = storage.get_session(session_id, false).await.unwrap(); + assert_eq!(session.id, session_id); + } + + #[tokio::test] + async fn test_shell_commands_since_last_message() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_shell.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let session = storage + .create_session( + PathBuf::from("/tmp/test"), + "test".to_string(), + SessionType::User, + ) + .await + .unwrap(); + + // Add some shell commands + storage + .add_shell_command(&session.id, "ls -la", &PathBuf::from("/tmp")) + .await + .unwrap(); + storage + .add_shell_command(&session.id, "cd foo", &PathBuf::from("/tmp")) + .await + .unwrap(); + + // Should get both commands (no messages yet) + let commands = storage + .get_shell_commands_since_last_message(&session.id) + .await + .unwrap(); + assert_eq!(commands.len(), 2); + assert_eq!(commands[0], "ls -la"); + assert_eq!(commands[1], "cd foo"); + + // Add a message with timestamp in the future to ensure it's after shell commands + let future_timestamp = chrono::Utc::now().timestamp() + 100; + storage + .add_message( + &session.id, + &Message { + id: None, + role: Role::User, + created: future_timestamp, + content: vec![MessageContent::text("test")], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + // Commands before the message should not be returned + let commands = storage + .get_shell_commands_since_last_message(&session.id) + .await + .unwrap(); + assert_eq!(commands.len(), 0); + } } diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md new file mode 100644 index 000000000000..c9b809d2302f --- /dev/null +++ b/documentation/docs/guides/terminal-integration.md @@ -0,0 +1,75 @@ +# Terminal Integration + +The `goose term` commands let you talk to goose directly from your shell prompt. Instead of switching to a separate REPL session, you stay in your terminal and call goose when you need it. + +```bash +gt "what does this error mean?" +``` + +Goose responds, you read the answer, and you're back at your prompt. The conversation lives alongside your work, not in a separate window you have to manage. + +## Command History Awareness + +The real power comes from shell integration. Once set up, goose tracks the commands you run, so when you ask a question, it already knows what you've been doing. + +No more copy-pasting error messages or explaining "I ran these commands...". Just work normally, then ask goose for help. + +## Setup + +Add one line to your shell config: + +**zsh** (`~/.zshrc`) +```bash +eval "$(goose term init zsh)" +``` + +**bash** (`~/.bashrc`) +```bash +eval "$(goose term init bash)" +``` + +**fish** (`~/.config/fish/config.fish`) +```fish +goose term init fish | source +``` + +**PowerShell** (`$PROFILE`) +```powershell +Invoke-Expression (goose term init powershell) +``` + +Then restart your terminal or source the config. + +## Usage + +Once set up, your terminal gets a session ID. All commands you run are logged to that session. + +To talk to goose about what you've been doing: + +```bash +gt "why did that fail?" +``` + +`gt` is just an alias for `goose term run`. It opens goose with your command history already loaded. + +## What Gets Logged + +Every command you type gets stored with its timestamp and working directory. Goose sees commands you ran since your last message to it. + +Commands starting with `goose term` or `gt` are not logged (to avoid noise). + +## Performance + +- **Shell startup**: adds ~10ms +- **Per command**: ~10ms, runs in background (non-blocking) + +You won't notice any delay. The logging happens asynchronously after your command starts executing. + +## How It Works + +`goose term init` outputs shell code that: +1. Sets a `GOOSE_TERMINAL_ID` environment variable +2. Creates the `gt` alias +3. Installs a preexec hook that calls `goose term log` for each command + +The hook runs `goose term log &` in the background, which writes to a local SQLite database. When you run `gt`, goose queries that database for commands since your last message. From 6cb829f25ddc07b8cda8635e9213c0645854252a Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 22 Nov 2025 15:48:53 -0800 Subject: [PATCH 02/21] add info command for prompt integration --- crates/goose-cli/src/cli.rs | 13 ++++++- crates/goose-cli/src/commands/term.rs | 56 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5ffafb7bbf74..bb9a0c12df53 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -13,7 +13,7 @@ use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; -use crate::commands::term::{handle_term_init, handle_term_log, handle_term_run}; +use crate::commands::term::{handle_term_info, handle_term_init, handle_term_log, handle_term_run}; use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -893,6 +893,14 @@ enum TermCommand { #[arg(value_parser = non_empty_string)] prompt: String, }, + + /// Print session info for prompt integration + #[command( + about = "Print session info for prompt integration", + long_about = "Prints compact session info (token usage, model) for shell prompt integration.\n\ + Example output: ●○○○○ sonnet" + )] + Info, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -1477,6 +1485,9 @@ pub async fn cli() -> anyhow::Result<()> { TermCommand::Run { prompt } => { handle_term_run(prompt).await?; } + TermCommand::Info => { + handle_term_info().await?; + } } return Ok(()); } diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 685447a842ff..d5d88099b195 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -190,3 +190,59 @@ pub async fn handle_term_run(prompt: String) -> Result<()> { Ok(()) } + +/// Handle `goose term info` - print compact session info for prompt integration +pub async fn handle_term_info() -> Result<()> { + use goose::config::Config; + + let terminal_id = match std::env::var("GOOSE_TERMINAL_ID") { + Ok(id) => id, + Err(_) => return Ok(()), // Silent exit if no terminal ID + }; + + let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); + + // Get tokens from session or 0 if none started yet in this terminal + let session = SessionManager::get_session(&session_name, false).await.ok(); + let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize; + + let model_name = Config::global() + .get_goose_model() + .ok() + .or_else(|| { + session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.model_name.clone())) + }) + .map(|name| { + // Extract short name: after last / or after last - if it starts with "goose-" + let short = name.rsplit('/').next().unwrap_or(&name); + if let Some(stripped) = short.strip_prefix("goose-") { + stripped.to_string() + } else { + short.to_string() + } + }) + .unwrap_or_else(|| "?".to_string()); + + // Get context limit for the model + let context_limit = session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.context_limit())) + .unwrap_or(128_000); + + // Calculate percentage and create dot visualization + let percentage = if context_limit > 0 { + ((total_tokens as f64 / context_limit as f64) * 100.0).round() as usize + } else { + 0 + }; + + let filled = (percentage / 20).min(5); + let empty = 5 - filled; + let dots = format!("{}{}", "●".repeat(filled), "○".repeat(empty)); + + println!("{} {}", dots, model_name); + + Ok(()) +} From f36f6783476bb47489ffa08ea24fedfe3eec6bd6 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 22 Nov 2025 15:59:52 -0800 Subject: [PATCH 03/21] break up migration function for clippy --- crates/goose/src/session/session_manager.rs | 185 +++++++++----------- 1 file changed, 84 insertions(+), 101 deletions(-) diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index f3fc3b621bfa..129324ad8e03 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -812,113 +812,96 @@ impl SessionStorage { async fn apply_migration(&self, version: i32) -> Result<()> { match version { - 1 => { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - "#, - ) - .execute(&self.pool) - .await?; - } - 2 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN user_recipe_values_json TEXT - "#, - ) - .execute(&self.pool) - .await?; - } - 3 => { - sqlx::query( - r#" - ALTER TABLE messages ADD COLUMN metadata_json TEXT - "#, - ) - .execute(&self.pool) - .await?; - } - 4 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN name TEXT DEFAULT '' - "#, - ) - .execute(&self.pool) - .await?; + 1 => self.migrate_v1_schema_version().await?, + 2 => self.migrate_v2_recipe_values().await?, + 3 => self.migrate_v3_message_metadata().await?, + 4 => self.migrate_v4_session_name().await?, + 5 => self.migrate_v5_session_type().await?, + 6 => self.migrate_v6_provider_config().await?, + 7 => self.migrate_v7_shell_commands().await?, + _ => anyhow::bail!("Unknown migration version: {}", version), + } + Ok(()) + } - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN user_set_name BOOLEAN DEFAULT FALSE - "#, - ) - .execute(&self.pool) - .await?; - } - 5 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user' - "#, - ) - .execute(&self.pool) - .await?; + async fn migrate_v1_schema_version(&self) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&self.pool) + .await?; + Ok(()) + } - sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") - .execute(&self.pool) - .await?; - } - 6 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN provider_name TEXT - "#, - ) - .execute(&self.pool) - .await?; + async fn migrate_v2_recipe_values(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN user_recipe_values_json TEXT") + .execute(&self.pool) + .await?; + Ok(()) + } - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN model_config_json TEXT - "#, - ) - .execute(&self.pool) - .await?; - } - 7 => { - sqlx::query( - r#" - CREATE TABLE shell_commands ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL REFERENCES sessions(id), - command TEXT NOT NULL, - working_dir TEXT NOT NULL, - created_timestamp INTEGER NOT NULL - ) - "#, - ) - .execute(&self.pool) - .await?; + async fn migrate_v3_message_metadata(&self) -> Result<()> { + sqlx::query("ALTER TABLE messages ADD COLUMN metadata_json TEXT") + .execute(&self.pool) + .await?; + Ok(()) + } - sqlx::query( - "CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)", - ) - .execute(&self.pool) - .await?; + async fn migrate_v4_session_name(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN name TEXT DEFAULT ''") + .execute(&self.pool) + .await?; + sqlx::query("ALTER TABLE sessions ADD COLUMN user_set_name BOOLEAN DEFAULT FALSE") + .execute(&self.pool) + .await?; + Ok(()) + } - sqlx::query("CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)") - .execute(&self.pool) - .await?; - } - _ => { - anyhow::bail!("Unknown migration version: {}", version); - } - } + async fn migrate_v5_session_type(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user'") + .execute(&self.pool) + .await?; + sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") + .execute(&self.pool) + .await?; + Ok(()) + } + async fn migrate_v6_provider_config(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN provider_name TEXT") + .execute(&self.pool) + .await?; + sqlx::query("ALTER TABLE sessions ADD COLUMN model_config_json TEXT") + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn migrate_v7_shell_commands(&self) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE shell_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + command TEXT NOT NULL, + working_dir TEXT NOT NULL, + created_timestamp INTEGER NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await?; + sqlx::query("CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)") + .execute(&self.pool) + .await?; + sqlx::query("CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)") + .execute(&self.pool) + .await?; Ok(()) } From 3b9d53b2e95ffb68e5667f72b056b52d7ccc5c96 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 22 Nov 2025 16:13:40 -0800 Subject: [PATCH 04/21] comments --- crates/goose-cli/src/commands/term.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index d5d88099b195..07119d83305c 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -25,7 +25,7 @@ alias gt='{goose_bin} term run' # Log commands to goose (runs silently in background) goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return - [[ "$1" =~ ^gt\ ]] && return + [[ "$1" =~ ^gt($|[[:space:]]) ]] && return ("{goose_bin}" term log "$1" &) 2>/dev/null }} @@ -44,7 +44,7 @@ alias gt='{goose_bin} term run' # Log commands to goose (runs silently in background) goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return - [[ "$1" =~ ^gt\ ]] && return + [[ "$1" =~ ^gt($|[[:space:]]) ]] && return ("{goose_bin}" term log "$1" &) 2>/dev/null }} @@ -56,27 +56,27 @@ add-zsh-hook preexec goose_preexec"# "fish" => { format!( r#"set -gx GOOSE_TERMINAL_ID "{terminal_id}" -alias gt='{goose_bin} term run' +function gt; {goose_bin} term run $argv; end # Log commands to goose function goose_preexec --on-event fish_preexec string match -q -r '^goose term' -- $argv[1]; and return - string match -q -r '^gt ' -- $argv[1]; and return - {goose_bin} term log $argv[1] 2>/dev/null & + string match -q -r '^gt($|\s)' -- $argv[1]; and return + {goose_bin} term log "$argv[1]" 2>/dev/null & end"# ) } "powershell" | "pwsh" => { format!( r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}" -Set-Alias -Name gt -Value {{ {goose_bin} term run $args }} +function gt {{ & '{goose_bin}' term run @args }} # Log commands to goose Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ $line = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) - if ($line -notmatch '^goose term' -and $line -notmatch '^gt ') {{ - Start-Job -ScriptBlock {{ {goose_bin} term log $using:line }} | Out-Null + if ($line -notmatch '^goose term' -and $line -notmatch '^gt($|\s)') {{ + Start-Job -ScriptBlock {{ & '{goose_bin}' term log $using:line }} | Out-Null }} [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() }}"# From 5b768e5ef995e078b703ea7247c15f1beaf9fe71 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 22 Nov 2025 16:17:47 -0800 Subject: [PATCH 05/21] Address remaining PR comments - Fix bash/zsh: properly quote goose binary path to handle spaces - Extract session creation logic to ensure_terminal_session helper - Reduces code duplication between handle_term_log and handle_term_run --- crates/goose-cli/src/commands/term.rs | 69 +++++++++++++-------------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 07119d83305c..8352be449037 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -7,6 +7,31 @@ use crate::session::{build_session, SessionBuilderConfig}; const TERMINAL_SESSION_PREFIX: &str = "term:"; +/// Ensure a terminal session exists, creating it if necessary +async fn ensure_terminal_session( + session_name: String, + working_dir: std::path::PathBuf, +) -> Result<()> { + if SessionManager::get_session(&session_name, false) + .await + .is_err() + { + let session = SessionManager::create_session_with_id( + session_name.clone(), + working_dir, + session_name.clone(), + SessionType::User, + ) + .await?; + + SessionManager::update_session(&session.id) + .user_provided_name(session_name) + .apply() + .await?; + } + Ok(()) +} + /// Handle `goose term init ` - print shell initialization script pub fn handle_term_init(shell: &str) -> Result<()> { let terminal_id = Uuid::new_v4().to_string(); @@ -26,7 +51,7 @@ alias gt='{goose_bin} term run' goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return [[ "$1" =~ ^gt($|[[:space:]]) ]] && return - ("{goose_bin}" term log "$1" &) 2>/dev/null + ('{goose_bin}' term log "$1" &) 2>/dev/null }} # Install preexec hook for bash @@ -45,7 +70,7 @@ alias gt='{goose_bin} term run' goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return [[ "$1" =~ ^gt($|[[:space:]]) ]] && return - ("{goose_bin}" term log "$1" &) 2>/dev/null + ('{goose_bin}' term log "$1" &) 2>/dev/null }} # Install preexec hook for zsh @@ -102,25 +127,7 @@ pub async fn handle_term_log(command: String) -> Result<()> { let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); let working_dir = std::env::current_dir()?; - // Create session if it doesn't exist (so we can log commands before first run) - if SessionManager::get_session(&session_name, false) - .await - .is_err() - { - let session = SessionManager::create_session_with_id( - session_name.clone(), - working_dir.clone(), - session_name.clone(), - SessionType::User, - ) - .await?; - - SessionManager::update_session(&session.id) - .user_provided_name(session_name.clone()) - .apply() - .await?; - } - + ensure_terminal_session(session_name.clone(), working_dir.clone()).await?; SessionManager::add_shell_command(&session_name, &command, &working_dir).await?; Ok(()) @@ -138,31 +145,19 @@ pub async fn handle_term_run(prompt: String) -> Result<()> { })?; let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); + let working_dir = std::env::current_dir()?; let session_id = match SessionManager::get_session(&session_name, false).await { Ok(_) => { SessionManager::update_session(&session_name) - .working_dir(std::env::current_dir()?) + .working_dir(working_dir) .apply() .await?; session_name.clone() } Err(_) => { - let session = SessionManager::create_session_with_id( - session_name.clone(), - std::env::current_dir()?, - session_name.clone(), - SessionType::User, - ) - .await?; - - // Mark with user-provided name so session persists across restarts - SessionManager::update_session(&session.id) - .user_provided_name(session_name) - .apply() - .await?; - - session.id + ensure_terminal_session(session_name.clone(), working_dir).await?; + session_name.clone() } }; From 155e08645b55517158d64aeaef1467b2fd8efa23 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 22 Nov 2025 16:23:36 -0800 Subject: [PATCH 06/21] unlist --- documentation/docs/guides/terminal-integration.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md index c9b809d2302f..d41b8c6c3d2e 100644 --- a/documentation/docs/guides/terminal-integration.md +++ b/documentation/docs/guides/terminal-integration.md @@ -1,3 +1,6 @@ +--- +unlisted: true +--- # Terminal Integration The `goose term` commands let you talk to goose directly from your shell prompt. Instead of switching to a separate REPL session, you stay in your terminal and call goose when you need it. From e1be322f467724d5a798e36812e4d99c46244896 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 24 Nov 2025 09:59:22 +1100 Subject: [PATCH 07/21] tweaks --- crates/goose-cli/src/commands/term.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 8352be449037..6f6e628ac908 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -20,7 +20,7 @@ async fn ensure_terminal_session( session_name.clone(), working_dir, session_name.clone(), - SessionType::User, + SessionType::Hidden, ) .await?; @@ -75,7 +75,13 @@ goose_preexec() {{ # Install preexec hook for zsh autoload -Uz add-zsh-hook -add-zsh-hook preexec goose_preexec"# +add-zsh-hook preexec goose_preexec + +# Add goose indicator to prompt +if [[ -z "$GOOSE_PROMPT_INSTALLED" ]]; then + export GOOSE_PROMPT_INSTALLED=1 + PROMPT='%F{{cyan}}🪿%f '$PROMPT +fi"# ) } "fish" => { From 67a607437fb10249b869455248fdd718e8f7bb39 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 24 Nov 2025 10:45:05 +1100 Subject: [PATCH 08/21] allow gt to run without quotes or without even using gt prefix --- crates/goose-cli/src/cli.rs | 34 +++++++++++++----------- crates/goose-cli/src/commands/term.rs | 37 ++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index bb9a0c12df53..e7f0ef8584f3 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -35,14 +35,6 @@ use std::io::Read; use std::path::PathBuf; use tracing::warn; -fn non_empty_string(s: &str) -> Result { - if s.trim().is_empty() { - Err("Prompt cannot be empty".to_string()) - } else { - Ok(s.to_string()) - } -} - #[derive(Parser)] #[command(author, version, display_name = "", about, long_about = None)] struct Cli { @@ -871,6 +863,14 @@ enum TermCommand { /// Shell type (bash, zsh, fish, powershell) #[arg(value_enum)] shell: Shell, + + /// Enable command_not_found_handler (sends unknown commands to goose) + #[arg( + long = "with-command-not-found", + help = "Enable command_not_found_handler for automatic goose invocation", + long_help = "When enabled, any command not found will be automatically sent to goose for interpretation. Only supported for zsh and bash." + )] + with_command_not_found: bool, }, /// Log a shell command (called by shell hook) @@ -885,13 +885,14 @@ enum TermCommand { about = "Run a prompt in the terminal session", long_about = "Run a prompt in the terminal-integrated session.\n\n\ Examples:\n \ - goose term run \"list files in this directory\"\n \ - goose term run \"create a python script that prints hello world\"" + goose term run list files in this directory\n \ + goose term run create a python script that prints hello world\n \ + gt list files # using alias" )] Run { - /// The prompt to send to goose - #[arg(value_parser = non_empty_string)] - prompt: String, + /// The prompt to send to goose (multiple words allowed without quotes) + #[arg(required = true, num_args = 1..)] + prompt: Vec, }, /// Print session info for prompt integration @@ -1470,14 +1471,17 @@ pub async fn cli() -> anyhow::Result<()> { } Some(Command::Term { command }) => { match command { - TermCommand::Init { shell } => { + TermCommand::Init { + shell, + with_command_not_found, + } => { let shell_str = match shell { Shell::Bash => "bash", Shell::Zsh => "zsh", Shell::Fish => "fish", Shell::Powershell => "powershell", }; - handle_term_init(shell_str)?; + handle_term_init(shell_str, with_command_not_found)?; } TermCommand::Log { command } => { handle_term_log(command).await?; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 6f6e628ac908..ea3a13fbeae3 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -33,7 +33,7 @@ async fn ensure_terminal_session( } /// Handle `goose term init ` - print shell initialization script -pub fn handle_term_init(shell: &str) -> Result<()> { +pub fn handle_term_init(shell: &str, with_command_not_found: bool) -> Result<()> { let terminal_id = Uuid::new_v4().to_string(); // Get the path to the current goose binary @@ -41,6 +41,34 @@ pub fn handle_term_init(shell: &str) -> Result<()> { .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| "goose".to_string()); + let command_not_found_handler = if with_command_not_found { + match shell.to_lowercase().as_str() { + "bash" => format!( + r#" + +# Command not found handler - sends unknown commands to goose +command_not_found_handle() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"# + ), + "zsh" => format!( + r#" + +# Command not found handler - sends unknown commands to goose +command_not_found_handler() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"# + ), + _ => String::new(), + } + } else { + String::new() + }; + let script = match shell.to_lowercase().as_str() { "bash" => { format!( @@ -58,7 +86,7 @@ goose_preexec() {{ if [[ -z "$goose_preexec_installed" ]]; then goose_preexec_installed=1 trap 'goose_preexec "$BASH_COMMAND"' DEBUG -fi"# +fi{command_not_found_handler}"# ) } "zsh" => { @@ -81,7 +109,7 @@ add-zsh-hook preexec goose_preexec if [[ -z "$GOOSE_PROMPT_INSTALLED" ]]; then export GOOSE_PROMPT_INSTALLED=1 PROMPT='%F{{cyan}}🪿%f '$PROMPT -fi"# +fi{command_not_found_handler}"# ) } "fish" => { @@ -140,7 +168,8 @@ pub async fn handle_term_log(command: String) -> Result<()> { } /// Handle `goose term run ` - run a prompt in the terminal session -pub async fn handle_term_run(prompt: String) -> Result<()> { +pub async fn handle_term_run(prompt: Vec) -> Result<()> { + let prompt = prompt.join(" "); let terminal_id = std::env::var("GOOSE_TERMINAL_ID").map_err(|_| { anyhow!( "GOOSE_TERMINAL_ID not set.\n\n\ From fe90b7419c7964c23909216d47b9d098b62d344a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 24 Nov 2025 11:54:29 +1100 Subject: [PATCH 09/21] simplifying out of session manager --- Cargo.lock | 41 +- crates/goose-cli/Cargo.toml | 1 + crates/goose-cli/src/cli.rs | 2 +- crates/goose-cli/src/commands/term.rs | 203 +++++---- crates/goose/src/session/session_manager.rs | 408 ++++-------------- .../docs/guides/terminal-integration.md | 38 +- 6 files changed, 280 insertions(+), 413 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db81d97590ef..f9e40f5d89e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1992,7 +1992,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -2003,10 +2012,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2633,7 +2654,7 @@ dependencies = [ "criterion", "ctor", "dashmap", - "dirs", + "dirs 5.0.1", "dotenvy", "etcetera", "fs2", @@ -2735,6 +2756,7 @@ dependencies = [ "clap", "cliclack", "console", + "dirs 6.0.0", "dotenvy", "etcetera", "futures", @@ -5382,6 +5404,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "ref-cast" version = "1.0.24" @@ -6201,7 +6234,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs", + "dirs 5.0.1", ] [[package]] diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 7404a2f84acc..9dd403b1df71 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -59,6 +59,7 @@ is-terminal = "0.4.16" anstream = "0.6.18" url = "2.5.7" open = "5.3.2" +dirs = "6.0.0" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e7f0ef8584f3..240cc0cbf5b4 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1481,7 +1481,7 @@ pub async fn cli() -> anyhow::Result<()> { Shell::Fish => "fish", Shell::Powershell => "powershell", }; - handle_term_init(shell_str, with_command_not_found)?; + handle_term_init(shell_str, with_command_not_found).await?; } TermCommand::Log { command } => { handle_term_log(command).await?; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index ea3a13fbeae3..6d03cfa14bf3 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -1,42 +1,89 @@ use anyhow::{anyhow, Result}; use goose::session::session_manager::SessionType; use goose::session::SessionManager; -use uuid::Uuid; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::PathBuf; use crate::session::{build_session, SessionBuilderConfig}; const TERMINAL_SESSION_PREFIX: &str = "term:"; -/// Ensure a terminal session exists, creating it if necessary -async fn ensure_terminal_session( - session_name: String, - working_dir: std::path::PathBuf, -) -> Result<()> { - if SessionManager::get_session(&session_name, false) - .await - .is_err() - { - let session = SessionManager::create_session_with_id( - session_name.clone(), - working_dir, - session_name.clone(), - SessionType::Hidden, - ) - .await?; +#[derive(Debug, Serialize, Deserialize, Default)] +struct TerminalConfig { + /// Map from working directory path to session ID + terminal_sessions: HashMap, +} - SessionManager::update_session(&session.id) - .user_provided_name(session_name) - .apply() - .await?; +impl TerminalConfig { + fn config_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; + let config_dir = home.join(".config").join("goose"); + fs::create_dir_all(&config_dir)?; + Ok(config_dir.join("term-sessions.json")) + } + + fn load() -> Result { + let path = Self::config_path()?; + if !path.exists() { + return Ok(Self::default()); + } + let content = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) + } + + fn save(&self) -> Result<()> { + let path = Self::config_path()?; + let content = serde_json::to_string_pretty(self)?; + fs::write(&path, content)?; + Ok(()) + } + + fn get_session_id(&self, working_dir: &str) -> Option<&str> { + self.terminal_sessions.get(working_dir).map(|s| s.as_str()) + } + + fn set_session_id(&mut self, working_dir: String, session_id: String) { + self.terminal_sessions.insert(working_dir, session_id); } - Ok(()) +} + +async fn get_or_create_terminal_session(working_dir: PathBuf) -> Result { + let working_dir_str = working_dir.to_string_lossy().to_string(); + let mut config = TerminalConfig::load()?; + + if let Some(session_id) = config.get_session_id(&working_dir_str) { + if SessionManager::get_session(session_id, false).await.is_ok() { + return Ok(session_id.to_string()); + } + } + + let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, working_dir_str); + let session = SessionManager::create_session( + working_dir.clone(), + session_name.clone(), + SessionType::Hidden, + ) + .await?; + + SessionManager::update_session(&session.id) + .user_provided_name(session_name) + .apply() + .await?; + + config.set_session_id(working_dir_str, session.id.clone()); + config.save()?; + + Ok(session.id) } /// Handle `goose term init ` - print shell initialization script -pub fn handle_term_init(shell: &str, with_command_not_found: bool) -> Result<()> { - let terminal_id = Uuid::new_v4().to_string(); +pub async fn handle_term_init(shell: &str, with_command_not_found: bool) -> Result<()> { + let working_dir = std::env::current_dir()?; + let session_id = get_or_create_terminal_session(working_dir).await?; - // Get the path to the current goose binary let goose_bin = std::env::current_exe() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| "goose".to_string()); @@ -72,7 +119,7 @@ command_not_found_handler() {{ let script = match shell.to_lowercase().as_str() { "bash" => { format!( - r#"export GOOSE_TERMINAL_ID="{terminal_id}" + r#"export GOOSE_SESSION_ID="{session_id}" alias gt='{goose_bin} term run' # Log commands to goose (runs silently in background) @@ -91,7 +138,7 @@ fi{command_not_found_handler}"# } "zsh" => { format!( - r#"export GOOSE_TERMINAL_ID="{terminal_id}" + r#"export GOOSE_SESSION_ID="{session_id}" alias gt='{goose_bin} term run' # Log commands to goose (runs silently in background) @@ -114,7 +161,7 @@ fi{command_not_found_handler}"# } "fish" => { format!( - r#"set -gx GOOSE_TERMINAL_ID "{terminal_id}" + r#"set -gx GOOSE_SESSION_ID "{session_id}" function gt; {goose_bin} term run $argv; end # Log commands to goose @@ -127,7 +174,7 @@ end"# } "powershell" | "pwsh" => { format!( - r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}" + r#"$env:GOOSE_SESSION_ID = "{session_id}" function gt {{ & '{goose_bin}' term run @args }} # Log commands to goose @@ -153,50 +200,71 @@ Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ Ok(()) } -/// Handle `goose term log ` - log a shell command to the database -pub async fn handle_term_log(command: String) -> Result<()> { - let terminal_id = std::env::var("GOOSE_TERMINAL_ID") - .map_err(|_| anyhow!("GOOSE_TERMINAL_ID not set. Run 'goose term init ' first."))?; +fn shell_history_path(session_id: &str) -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; + let history_dir = home.join(".config").join("goose").join("shell-history"); + fs::create_dir_all(&history_dir)?; + Ok(history_dir.join(format!("{}.txt", session_id))) +} - let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); - let working_dir = std::env::current_dir()?; +fn append_shell_command(session_id: &str, command: &str) -> Result<()> { + let path = shell_history_path(session_id)?; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!(file, "{}", command)?; + Ok(()) +} + +fn read_and_clear_shell_history(session_id: &str) -> Result> { + let path = shell_history_path(session_id)?; - ensure_terminal_session(session_name.clone(), working_dir.clone()).await?; - SessionManager::add_shell_command(&session_name, &command, &working_dir).await?; + if !path.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(&path)?; + let commands: Vec = content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|s| s.to_string()) + .collect(); + + fs::write(&path, "")?; + + Ok(commands) +} + +pub async fn handle_term_log(command: String) -> Result<()> { + let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { + anyhow!("GOOSE_SESSION_ID not set. Run 'eval \"$(goose term init )\"' first.") + })?; + + append_shell_command(&session_id, &command)?; Ok(()) } -/// Handle `goose term run ` - run a prompt in the terminal session pub async fn handle_term_run(prompt: Vec) -> Result<()> { let prompt = prompt.join(" "); - let terminal_id = std::env::var("GOOSE_TERMINAL_ID").map_err(|_| { + let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { anyhow!( - "GOOSE_TERMINAL_ID not set.\n\n\ + "GOOSE_SESSION_ID not set.\n\n\ Add to your shell config (~/.zshrc or ~/.bashrc):\n \ eval \"$(goose term init zsh)\"\n\n\ Then restart your terminal or run: source ~/.zshrc" ) })?; - let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); let working_dir = std::env::current_dir()?; - let session_id = match SessionManager::get_session(&session_name, false).await { - Ok(_) => { - SessionManager::update_session(&session_name) - .working_dir(working_dir) - .apply() - .await?; - session_name.clone() - } - Err(_) => { - ensure_terminal_session(session_name.clone(), working_dir).await?; - session_name.clone() - } - }; + SessionManager::update_session(&session_id) + .working_dir(working_dir) + .apply() + .await?; - let commands = SessionManager::get_shell_commands_since_last_message(&session_id).await?; + let commands = read_and_clear_shell_history(&session_id)?; let prompt_with_context = if commands.is_empty() { prompt } else { @@ -223,29 +291,18 @@ pub async fn handle_term_run(prompt: Vec) -> Result<()> { /// Handle `goose term info` - print compact session info for prompt integration pub async fn handle_term_info() -> Result<()> { - use goose::config::Config; - - let terminal_id = match std::env::var("GOOSE_TERMINAL_ID") { + let session_id = match std::env::var("GOOSE_SESSION_ID") { Ok(id) => id, - Err(_) => return Ok(()), // Silent exit if no terminal ID + Err(_) => return Ok(()), }; - let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); - - // Get tokens from session or 0 if none started yet in this terminal - let session = SessionManager::get_session(&session_name, false).await.ok(); + let session = SessionManager::get_session(&session_id, false).await.ok(); let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize; - let model_name = Config::global() - .get_goose_model() - .ok() - .or_else(|| { - session - .as_ref() - .and_then(|s| s.model_config.as_ref().map(|mc| mc.model_name.clone())) - }) + let model_name = session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.model_name.clone())) .map(|name| { - // Extract short name: after last / or after last - if it starts with "goose-" let short = name.rsplit('/').next().unwrap_or(&name); if let Some(stripped) = short.strip_prefix("goose-") { stripped.to_string() @@ -255,13 +312,11 @@ pub async fn handle_term_info() -> Result<()> { }) .unwrap_or_else(|| "?".to_string()); - // Get context limit for the model let context_limit = session .as_ref() .and_then(|s| s.model_config.as_ref().map(|mc| mc.context_limit())) .unwrap_or(128_000); - // Calculate percentage and create dot visualization let percentage = if context_limit > 0 { ((total_tokens as f64 / context_limit as f64) * 100.0).round() as usize } else { diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 129324ad8e03..7d63e665b45c 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -19,7 +19,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -const CURRENT_SCHEMA_VERSION: i32 = 7; +const CURRENT_SCHEMA_VERSION: i32 = 6; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; @@ -261,18 +261,6 @@ impl SessionManager { .await } - pub async fn create_session_with_id( - id: String, - working_dir: PathBuf, - name: String, - session_type: SessionType, - ) -> Result { - Self::instance() - .await? - .create_session_with_id(id, working_dir, name, session_type) - .await - } - pub async fn get_session(id: &str, include_messages: bool) -> Result { Self::instance() .await? @@ -373,24 +361,6 @@ impl SessionManager { .search_chat_history(query, limit, after_date, before_date, exclude_session_id) .await } - - pub async fn add_shell_command( - session_id: &str, - command: &str, - working_dir: &Path, - ) -> Result<()> { - Self::instance() - .await? - .add_shell_command(session_id, command, working_dir) - .await - } - - pub async fn get_shell_commands_since_last_message(session_id: &str) -> Result> { - Self::instance() - .await? - .get_shell_commands_since_last_message(session_id) - .await - } } pub struct SessionStorage { @@ -628,29 +598,6 @@ impl SessionStorage { .execute(&pool) .await?; - sqlx::query( - r#" - CREATE TABLE shell_commands ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL REFERENCES sessions(id), - command TEXT NOT NULL, - working_dir TEXT NOT NULL, - created_timestamp INTEGER NOT NULL - ) - "#, - ) - .execute(&pool) - .await?; - - sqlx::query("CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)") - .execute(&pool) - .await?; - sqlx::query( - "CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)", - ) - .execute(&pool) - .await?; - Ok(Self { pool }) } @@ -812,96 +759,88 @@ impl SessionStorage { async fn apply_migration(&self, version: i32) -> Result<()> { match version { - 1 => self.migrate_v1_schema_version().await?, - 2 => self.migrate_v2_recipe_values().await?, - 3 => self.migrate_v3_message_metadata().await?, - 4 => self.migrate_v4_session_name().await?, - 5 => self.migrate_v5_session_type().await?, - 6 => self.migrate_v6_provider_config().await?, - 7 => self.migrate_v7_shell_commands().await?, - _ => anyhow::bail!("Unknown migration version: {}", version), - } - Ok(()) - } - - async fn migrate_v1_schema_version(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - "#, - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn migrate_v2_recipe_values(&self) -> Result<()> { - sqlx::query("ALTER TABLE sessions ADD COLUMN user_recipe_values_json TEXT") - .execute(&self.pool) - .await?; - Ok(()) - } + 1 => { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&self.pool) + .await?; + } + 2 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN user_recipe_values_json TEXT + "#, + ) + .execute(&self.pool) + .await?; + } + 3 => { + sqlx::query( + r#" + ALTER TABLE messages ADD COLUMN metadata_json TEXT + "#, + ) + .execute(&self.pool) + .await?; + } + 4 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN name TEXT DEFAULT '' + "#, + ) + .execute(&self.pool) + .await?; - async fn migrate_v3_message_metadata(&self) -> Result<()> { - sqlx::query("ALTER TABLE messages ADD COLUMN metadata_json TEXT") - .execute(&self.pool) - .await?; - Ok(()) - } + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN user_set_name BOOLEAN DEFAULT FALSE + "#, + ) + .execute(&self.pool) + .await?; + } + 5 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user' + "#, + ) + .execute(&self.pool) + .await?; - async fn migrate_v4_session_name(&self) -> Result<()> { - sqlx::query("ALTER TABLE sessions ADD COLUMN name TEXT DEFAULT ''") - .execute(&self.pool) - .await?; - sqlx::query("ALTER TABLE sessions ADD COLUMN user_set_name BOOLEAN DEFAULT FALSE") - .execute(&self.pool) - .await?; - Ok(()) - } + sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") + .execute(&self.pool) + .await?; + } + 6 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN provider_name TEXT + "#, + ) + .execute(&self.pool) + .await?; - async fn migrate_v5_session_type(&self) -> Result<()> { - sqlx::query("ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user'") - .execute(&self.pool) - .await?; - sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") - .execute(&self.pool) - .await?; - Ok(()) - } + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN model_config_json TEXT + "#, + ) + .execute(&self.pool) + .await?; + } + _ => { + anyhow::bail!("Unknown migration version: {}", version); + } + } - async fn migrate_v6_provider_config(&self) -> Result<()> { - sqlx::query("ALTER TABLE sessions ADD COLUMN provider_name TEXT") - .execute(&self.pool) - .await?; - sqlx::query("ALTER TABLE sessions ADD COLUMN model_config_json TEXT") - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn migrate_v7_shell_commands(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE shell_commands ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL REFERENCES sessions(id), - command TEXT NOT NULL, - working_dir TEXT NOT NULL, - created_timestamp INTEGER NOT NULL - ) - "#, - ) - .execute(&self.pool) - .await?; - sqlx::query("CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)") - .execute(&self.pool) - .await?; - sqlx::query("CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)") - .execute(&self.pool) - .await?; Ok(()) } @@ -944,48 +883,6 @@ impl SessionStorage { Ok(session) } - async fn create_session_with_id( - &self, - id: String, - working_dir: PathBuf, - name: String, - session_type: SessionType, - ) -> Result { - let mut tx = self.pool.begin().await?; - - // Use INSERT OR IGNORE to handle race conditions where multiple processes - // might try to create the same session simultaneously - sqlx::query( - r#" - INSERT OR IGNORE INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data) - VALUES (?, ?, FALSE, ?, ?, '{}') - "#, - ) - .bind(&id) - .bind(&name) - .bind(session_type.to_string()) - .bind(working_dir.to_string_lossy().as_ref()) - .execute(&mut *tx) - .await?; - - let session = sqlx::query_as::<_, Session>( - r#" - SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, - total_tokens, input_tokens, output_tokens, - accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, - schedule_id, recipe_json, user_recipe_values_json, - provider_name, model_config_json - FROM sessions WHERE id = ? - "#, - ) - .bind(&id) - .fetch_one(&mut *tx) - .await?; - - tx.commit().await?; - Ok(session) - } - async fn get_session(&self, id: &str, include_messages: bool) -> Result { let mut session = sqlx::query_as::<_, Session>( r#" @@ -1389,55 +1286,6 @@ impl SessionStorage { .execute() .await } - - async fn add_shell_command( - &self, - session_id: &str, - command: &str, - working_dir: &Path, - ) -> Result<()> { - // Use seconds to match messages table timestamp format - let timestamp = chrono::Utc::now().timestamp(); - - sqlx::query( - r#" - INSERT INTO shell_commands (session_id, command, working_dir, created_timestamp) - VALUES (?, ?, ?, ?) - "#, - ) - .bind(session_id) - .bind(command) - .bind(working_dir.to_string_lossy().as_ref()) - .bind(timestamp) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_shell_commands_since_last_message(&self, session_id: &str) -> Result> { - let last_message_timestamp = sqlx::query_scalar::<_, Option>( - "SELECT MAX(created_timestamp) FROM messages WHERE session_id = ?", - ) - .bind(session_id) - .fetch_one(&self.pool) - .await? - .unwrap_or(0); - - let commands = sqlx::query_scalar::<_, String>( - r#" - SELECT command FROM shell_commands - WHERE session_id = ? AND created_timestamp > ? - ORDER BY created_timestamp ASC - "#, - ) - .bind(session_id) - .bind(last_message_timestamp) - .fetch_all(&self.pool) - .await?; - - Ok(commands) - } } #[cfg(test)] @@ -1644,102 +1492,4 @@ mod tests { assert!(imported.user_set_name); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); } - - #[tokio::test] - async fn test_create_session_with_id_race_condition() { - let temp_dir = TempDir::new().unwrap(); - let db_path = temp_dir.path().join("test_race.db"); - let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); - - let session_id = "test-race-session"; - let mut handles = vec![]; - - // Spawn multiple tasks trying to create the same session simultaneously - for _ in 0..10 { - let storage = Arc::clone(&storage); - let id = session_id.to_string(); - handles.push(tokio::spawn(async move { - storage - .create_session_with_id( - id.clone(), - PathBuf::from("/tmp/test"), - id, - SessionType::User, - ) - .await - })); - } - - // All should succeed without UNIQUE constraint errors - for handle in handles { - let result = handle.await.unwrap(); - assert!( - result.is_ok(), - "create_session_with_id failed: {:?}", - result - ); - } - - // Should only have one session with this ID - let session = storage.get_session(session_id, false).await.unwrap(); - assert_eq!(session.id, session_id); - } - - #[tokio::test] - async fn test_shell_commands_since_last_message() { - let temp_dir = TempDir::new().unwrap(); - let db_path = temp_dir.path().join("test_shell.db"); - let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); - - let session = storage - .create_session( - PathBuf::from("/tmp/test"), - "test".to_string(), - SessionType::User, - ) - .await - .unwrap(); - - // Add some shell commands - storage - .add_shell_command(&session.id, "ls -la", &PathBuf::from("/tmp")) - .await - .unwrap(); - storage - .add_shell_command(&session.id, "cd foo", &PathBuf::from("/tmp")) - .await - .unwrap(); - - // Should get both commands (no messages yet) - let commands = storage - .get_shell_commands_since_last_message(&session.id) - .await - .unwrap(); - assert_eq!(commands.len(), 2); - assert_eq!(commands[0], "ls -la"); - assert_eq!(commands[1], "cd foo"); - - // Add a message with timestamp in the future to ensure it's after shell commands - let future_timestamp = chrono::Utc::now().timestamp() + 100; - storage - .add_message( - &session.id, - &Message { - id: None, - role: Role::User, - created: future_timestamp, - content: vec![MessageContent::text("test")], - metadata: Default::default(), - }, - ) - .await - .unwrap(); - - // Commands before the message should not be returned - let commands = storage - .get_shell_commands_since_last_message(&session.id) - .await - .unwrap(); - assert_eq!(commands.len(), 0); - } } diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md index d41b8c6c3d2e..1e6bb4a409fc 100644 --- a/documentation/docs/guides/terminal-integration.md +++ b/documentation/docs/guides/terminal-integration.md @@ -43,9 +43,30 @@ Invoke-Expression (goose term init powershell) Then restart your terminal or source the config. +### Advanced: Command Not Found Handler + +For **bash** and **zsh**, you can enable automatic goose invocation for unknown commands: + +```bash +# zsh +eval "$(goose term init zsh --with-command-not-found)" + +# bash +eval "$(goose term init bash --with-command-not-found)" +``` + +With this enabled, any command that doesn't exist will automatically be sent to goose: + +```bash +$ analyze_logs +🪿 Command 'analyze_logs' not found. Asking goose... +``` + +Goose will interpret what you meant and either suggest the correct command or help you accomplish the task. + ## Usage -Once set up, your terminal gets a session ID. All commands you run are logged to that session. +Once set up, your terminal session is linked to a goose session. All commands you run are logged to that session. To talk to goose about what you've been doing: @@ -57,7 +78,7 @@ gt "why did that fail?" ## What Gets Logged -Every command you type gets stored with its timestamp and working directory. Goose sees commands you ran since your last message to it. +Every command you type gets stored with its timestamp. Goose sees commands you ran since your last message to it. Commands starting with `goose term` or `gt` are not logged (to avoid noise). @@ -71,8 +92,15 @@ You won't notice any delay. The logging happens asynchronously after your comman ## How It Works `goose term init` outputs shell code that: -1. Sets a `GOOSE_TERMINAL_ID` environment variable -2. Creates the `gt` alias +1. Sets a `GOOSE_SESSION_ID` environment variable linking your terminal to a goose session +2. Creates the `gt` alias for quick access 3. Installs a preexec hook that calls `goose term log` for each command +4. Optionally installs a command-not-found handler (with `--with-command-not-found`) + +The hook runs `goose term log &` in the background, which appends to a local history file in `~/.config/goose/shell-history/`. When you run `gt`, goose reads commands from this file that were logged since your last message. + +## Session Management + +Terminal sessions are tied to your working directory. If you `cd` to a different project, goose automatically creates or switches to a session for that directory. This keeps conversations organized by project. -The hook runs `goose term log &` in the background, which writes to a local SQLite database. When you run `gt`, goose queries that database for commands since your last message. +Sessions created by `goose term` don't appear in `goose session list` - they're hidden to avoid cluttering your session history. But they're real sessions with full conversation history that you can access by ID if needed. From ee3241026fedf71a15645a8ffa51e3d0c7d25f9f Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 25 Nov 2025 12:01:34 +1100 Subject: [PATCH 10/21] session not hidden and used paths --- crates/goose-cli/src/commands/term.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 6d03cfa14bf3..1a9290ea65b8 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, Result}; -use goose::session::session_manager::SessionType; +use goose::config::paths::Paths; + use goose::session::SessionManager; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -19,8 +20,7 @@ struct TerminalConfig { impl TerminalConfig { fn config_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; - let config_dir = home.join(".config").join("goose"); + let config_dir = Paths::config_dir(); fs::create_dir_all(&config_dir)?; Ok(config_dir.join("term-sessions.json")) } @@ -64,7 +64,7 @@ async fn get_or_create_terminal_session(working_dir: PathBuf) -> Result let session = SessionManager::create_session( working_dir.clone(), session_name.clone(), - SessionType::Hidden, + Default::default(), ) .await?; @@ -201,8 +201,7 @@ Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ } fn shell_history_path(session_id: &str) -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; - let history_dir = home.join(".config").join("goose").join("shell-history"); + let history_dir = Paths::config_dir().join("shell-history"); fs::create_dir_all(&history_dir)?; Ok(history_dir.join(format!("{}.txt", session_id))) } From c6c62a011c2826d27a560f4e973eb2289f1fdb8c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 25 Nov 2025 15:10:30 +1100 Subject: [PATCH 11/21] session naming --- crates/goose-cli/src/cli.rs | 21 ++++---- crates/goose-cli/src/commands/term.rs | 72 ++++++--------------------- 2 files changed, 24 insertions(+), 69 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 240cc0cbf5b4..06ac8d75ddaa 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -857,20 +857,22 @@ enum TermCommand { Each terminal gets a persistent goose session that automatically resumes.\n\n\ Setup:\n \ echo 'eval \"$(goose term init zsh)\"' >> ~/.zshrc\n \ - source ~/.zshrc" + source ~/.zshrc\n\n\ + With --default (anything typed that isn't a command goes to goose):\n \ + echo 'eval \"$(goose term init zsh --default)\"' >> ~/.zshrc" )] Init { /// Shell type (bash, zsh, fish, powershell) #[arg(value_enum)] shell: Shell, - /// Enable command_not_found_handler (sends unknown commands to goose) + /// Make goose the default handler for unknown commands #[arg( - long = "with-command-not-found", - help = "Enable command_not_found_handler for automatic goose invocation", - long_help = "When enabled, any command not found will be automatically sent to goose for interpretation. Only supported for zsh and bash." + long = "default", + help = "Make goose the default handler for unknown commands", + long_help = "When enabled, anything you type that isn't a valid command will be sent to goose. Only supported for zsh and bash." )] - with_command_not_found: bool, + default: bool, }, /// Log a shell command (called by shell hook) @@ -1471,17 +1473,14 @@ pub async fn cli() -> anyhow::Result<()> { } Some(Command::Term { command }) => { match command { - TermCommand::Init { - shell, - with_command_not_found, - } => { + TermCommand::Init { shell, default } => { let shell_str = match shell { Shell::Bash => "bash", Shell::Zsh => "zsh", Shell::Fish => "fish", Shell::Powershell => "powershell", }; - handle_term_init(shell_str, with_command_not_found).await?; + handle_term_init(shell_str, default).await?; } TermCommand::Log { command } => { handle_term_log(command).await?; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 1a9290ea65b8..da42b469da1b 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -1,9 +1,6 @@ use anyhow::{anyhow, Result}; use goose::config::paths::Paths; - use goose::session::SessionManager; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::PathBuf; @@ -12,70 +9,29 @@ use crate::session::{build_session, SessionBuilderConfig}; const TERMINAL_SESSION_PREFIX: &str = "term:"; -#[derive(Debug, Serialize, Deserialize, Default)] -struct TerminalConfig { - /// Map from working directory path to session ID - terminal_sessions: HashMap, -} - -impl TerminalConfig { - fn config_path() -> Result { - let config_dir = Paths::config_dir(); - fs::create_dir_all(&config_dir)?; - Ok(config_dir.join("term-sessions.json")) - } - - fn load() -> Result { - let path = Self::config_path()?; - if !path.exists() { - return Ok(Self::default()); - } - let content = fs::read_to_string(&path)?; - Ok(serde_json::from_str(&content)?) - } - - fn save(&self) -> Result<()> { - let path = Self::config_path()?; - let content = serde_json::to_string_pretty(self)?; - fs::write(&path, content)?; - Ok(()) - } - - fn get_session_id(&self, working_dir: &str) -> Option<&str> { - self.terminal_sessions.get(working_dir).map(|s| s.as_str()) - } - - fn set_session_id(&mut self, working_dir: String, session_id: String) { - self.terminal_sessions.insert(working_dir, session_id); - } -} - async fn get_or_create_terminal_session(working_dir: PathBuf) -> Result { - let working_dir_str = working_dir.to_string_lossy().to_string(); - let mut config = TerminalConfig::load()?; - - if let Some(session_id) = config.get_session_id(&working_dir_str) { - if SessionManager::get_session(session_id, false).await.is_ok() { - return Ok(session_id.to_string()); - } + let session_name = format!( + "{}{}", + TERMINAL_SESSION_PREFIX, + working_dir.to_string_lossy() + ); + + // Find existing session by name + let sessions = SessionManager::list_sessions().await?; + if let Some(session) = sessions.iter().find(|s| s.name == session_name) { + return Ok(session.id.clone()); } - let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, working_dir_str); - let session = SessionManager::create_session( - working_dir.clone(), - session_name.clone(), - Default::default(), - ) - .await?; + // Create new session + let session = + SessionManager::create_session(working_dir, session_name.clone(), Default::default()) + .await?; SessionManager::update_session(&session.id) .user_provided_name(session_name) .apply() .await?; - config.set_session_id(working_dir_str, session.id.clone()); - config.save()?; - Ok(session.id) } From fe6616cc4f286162c0f6626e97d5b17521dda4ce Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 25 Nov 2025 15:44:57 +1100 Subject: [PATCH 12/21] using new alias and docs --- crates/goose-cli/src/cli.rs | 7 ++-- crates/goose-cli/src/commands/term.rs | 16 ++++++-- .../docs/guides/terminal-integration.md | 40 ++++++++++--------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 06ac8d75ddaa..f528fbad1243 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -840,7 +840,8 @@ enum Command { eval \"$(goose term init zsh)\" # Add to ~/.zshrc\n\n\ Usage:\n \ goose term run \"list files in this directory\"\n \ - gt \"create a python script\" # using alias" + @goose \"create a python script\" # using alias\n \ + @g \"quick question\" # short alias" )] Term { #[command(subcommand)] @@ -888,8 +889,8 @@ enum TermCommand { long_about = "Run a prompt in the terminal-integrated session.\n\n\ Examples:\n \ goose term run list files in this directory\n \ - goose term run create a python script that prints hello world\n \ - gt list files # using alias" + @goose list files # using alias\n \ + @g why did that fail # short alias" )] Run { /// The prompt to send to goose (multiple words allowed without quotes) diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index da42b469da1b..eaf5a63318e2 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -77,11 +77,13 @@ command_not_found_handler() {{ format!( r#"export GOOSE_SESSION_ID="{session_id}" alias gt='{goose_bin} term run' +alias @goose='{goose_bin} term run' +alias @g='{goose_bin} term run' # Log commands to goose (runs silently in background) goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return - [[ "$1" =~ ^gt($|[[:space:]]) ]] && return + [[ "$1" =~ ^(gt|@goose|@g)($|[[:space:]]) ]] && return ('{goose_bin}' term log "$1" &) 2>/dev/null }} @@ -96,11 +98,13 @@ fi{command_not_found_handler}"# format!( r#"export GOOSE_SESSION_ID="{session_id}" alias gt='{goose_bin} term run' +alias @goose='{goose_bin} term run' +alias @g='{goose_bin} term run' # Log commands to goose (runs silently in background) goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return - [[ "$1" =~ ^gt($|[[:space:]]) ]] && return + [[ "$1" =~ ^(gt|@goose|@g)($|[[:space:]]) ]] && return ('{goose_bin}' term log "$1" &) 2>/dev/null }} @@ -119,11 +123,13 @@ fi{command_not_found_handler}"# format!( r#"set -gx GOOSE_SESSION_ID "{session_id}" function gt; {goose_bin} term run $argv; end +function @goose; {goose_bin} term run $argv; end +function @g; {goose_bin} term run $argv; end # Log commands to goose function goose_preexec --on-event fish_preexec string match -q -r '^goose term' -- $argv[1]; and return - string match -q -r '^gt($|\s)' -- $argv[1]; and return + string match -q -r '^(gt|@goose|@g)($|\s)' -- $argv[1]; and return {goose_bin} term log "$argv[1]" 2>/dev/null & end"# ) @@ -132,12 +138,14 @@ end"# format!( r#"$env:GOOSE_SESSION_ID = "{session_id}" function gt {{ & '{goose_bin}' term run @args }} +function @goose {{ & '{goose_bin}' term run @args }} +function @g {{ & '{goose_bin}' term run @args }} # Log commands to goose Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ $line = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) - if ($line -notmatch '^goose term' -and $line -notmatch '^gt($|\s)') {{ + if ($line -notmatch '^goose term' -and $line -notmatch '^(gt|@goose|@g)($|\s)') {{ Start-Job -ScriptBlock {{ & '{goose_bin}' term log $using:line }} | Out-Null }} [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md index 1e6bb4a409fc..f3edc43279c5 100644 --- a/documentation/docs/guides/terminal-integration.md +++ b/documentation/docs/guides/terminal-integration.md @@ -6,7 +6,7 @@ unlisted: true The `goose term` commands let you talk to goose directly from your shell prompt. Instead of switching to a separate REPL session, you stay in your terminal and call goose when you need it. ```bash -gt "what does this error mean?" +@goose "what does this error mean?" ``` Goose responds, you read the answer, and you're back at your prompt. The conversation lives alongside your work, not in a separate window you have to manage. @@ -43,26 +43,26 @@ Invoke-Expression (goose term init powershell) Then restart your terminal or source the config. -### Advanced: Command Not Found Handler +### Default Mode -For **bash** and **zsh**, you can enable automatic goose invocation for unknown commands: +For **bash** and **zsh**, you can make goose the default handler for anything that isn't a valid command: ```bash # zsh -eval "$(goose term init zsh --with-command-not-found)" +eval "$(goose term init zsh --default)" # bash -eval "$(goose term init bash --with-command-not-found)" +eval "$(goose term init bash --default)" ``` -With this enabled, any command that doesn't exist will automatically be sent to goose: +With this enabled, anything you type that isn't a command will be sent to goose: ```bash -$ analyze_logs -🪿 Command 'analyze_logs' not found. Asking goose... +$ what files are in this directory? +🪿 Command 'what' not found. Asking goose... ``` -Goose will interpret what you meant and either suggest the correct command or help you accomplish the task. +Goose will interpret what you typed and help you accomplish the task. ## Usage @@ -71,16 +71,22 @@ Once set up, your terminal session is linked to a goose session. All commands yo To talk to goose about what you've been doing: ```bash -gt "why did that fail?" +@goose "why did that fail?" ``` -`gt` is just an alias for `goose term run`. It opens goose with your command history already loaded. +You can also use `@g` as a shorter alias: + +```bash +@g "explain this error" +``` + +Both `@goose` and `@g` are aliases for `goose term run`. They open goose with your command history already loaded. ## What Gets Logged -Every command you type gets stored with its timestamp. Goose sees commands you ran since your last message to it. +Every command you type gets stored. Goose sees commands you ran since your last message to it. -Commands starting with `goose term` or `gt` are not logged (to avoid noise). +Commands starting with `goose term`, `@goose`, or `@g` are not logged (to avoid noise). ## Performance @@ -93,14 +99,12 @@ You won't notice any delay. The logging happens asynchronously after your comman `goose term init` outputs shell code that: 1. Sets a `GOOSE_SESSION_ID` environment variable linking your terminal to a goose session -2. Creates the `gt` alias for quick access +2. Creates `@goose` and `@g` aliases for quick access 3. Installs a preexec hook that calls `goose term log` for each command -4. Optionally installs a command-not-found handler (with `--with-command-not-found`) +4. Optionally installs a command-not-found handler (with `--default`) -The hook runs `goose term log &` in the background, which appends to a local history file in `~/.config/goose/shell-history/`. When you run `gt`, goose reads commands from this file that were logged since your last message. +The hook runs `goose term log &` in the background, which appends to a local history file. When you run `@goose`, goose reads commands from this file that were logged since your last message. ## Session Management Terminal sessions are tied to your working directory. If you `cd` to a different project, goose automatically creates or switches to a session for that directory. This keeps conversations organized by project. - -Sessions created by `goose term` don't appear in `goose session list` - they're hidden to avoid cluttering your session history. But they're real sessions with full conversation history that you can access by ID if needed. From 255d863ad59a788e2f94ebef268377be767d981b Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 25 Nov 2025 17:48:38 +1100 Subject: [PATCH 13/21] dependency tidy --- Cargo.lock | 1 - crates/goose-cli/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3be9387eda2e..664de1f40ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2756,7 +2756,6 @@ dependencies = [ "clap", "cliclack", "console", - "dirs 6.0.0", "dotenvy", "etcetera", "futures", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 9dd403b1df71..7404a2f84acc 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -59,7 +59,6 @@ is-terminal = "0.4.16" anstream = "0.6.18" url = "2.5.7" open = "5.3.2" -dirs = "6.0.0" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } From 2668eb5126e7d42cc82e831af877aa93cb23f308 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 25 Nov 2025 18:00:14 +1100 Subject: [PATCH 14/21] not async --- crates/goose-cli/src/cli.rs | 2 +- crates/goose-cli/src/commands/term.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index f528fbad1243..490cff4a857b 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1484,7 +1484,7 @@ pub async fn cli() -> anyhow::Result<()> { handle_term_init(shell_str, default).await?; } TermCommand::Log { command } => { - handle_term_log(command).await?; + handle_term_log(command)?; } TermCommand::Run { prompt } => { handle_term_run(prompt).await?; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index eaf5a63318e2..e6c3c3408149 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -199,7 +199,7 @@ fn read_and_clear_shell_history(session_id: &str) -> Result> { Ok(commands) } -pub async fn handle_term_log(command: String) -> Result<()> { +pub fn handle_term_log(command: String) -> Result<()> { let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { anyhow!("GOOSE_SESSION_ID not set. Run 'eval \"$(goose term init )\"' first.") })?; From 9942fda58b35c46cfa83c9885236573700422e8a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 25 Nov 2025 18:53:59 +1100 Subject: [PATCH 15/21] remove silly comments --- crates/goose-cli/src/commands/term.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index e6c3c3408149..559a576267bb 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -16,13 +16,11 @@ async fn get_or_create_terminal_session(working_dir: PathBuf) -> Result working_dir.to_string_lossy() ); - // Find existing session by name let sessions = SessionManager::list_sessions().await?; if let Some(session) = sessions.iter().find(|s| s.name == session_name) { return Ok(session.id.clone()); } - // Create new session let session = SessionManager::create_session(working_dir, session_name.clone(), Default::default()) .await?; From ae2a6e9244d1180809b6ffb6eb44238eb5cac4e8 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 26 Nov 2025 12:45:59 +0100 Subject: [PATCH 16/21] Use messages --- crates/goose-cli/src/cli.rs | 2 +- crates/goose-cli/src/commands/term.rs | 109 ++++++++------------ crates/goose/src/session/session_manager.rs | 3 + 3 files changed, 45 insertions(+), 69 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 490cff4a857b..f528fbad1243 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1484,7 +1484,7 @@ pub async fn cli() -> anyhow::Result<()> { handle_term_init(shell_str, default).await?; } TermCommand::Log { command } => { - handle_term_log(command)?; + handle_term_log(command).await?; } TermCommand::Run { prompt } => { handle_term_run(prompt).await?; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 559a576267bb..74ad03316669 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -1,42 +1,20 @@ use anyhow::{anyhow, Result}; -use goose::config::paths::Paths; use goose::session::SessionManager; -use std::fs; -use std::io::Write; -use std::path::PathBuf; +use goose::session::SessionType; +use goose::conversation::message::{Message, MessageContent, MessageMetadata}; +use rmcp::model::Role; +use chrono; use crate::session::{build_session, SessionBuilderConfig}; -const TERMINAL_SESSION_PREFIX: &str = "term:"; - -async fn get_or_create_terminal_session(working_dir: PathBuf) -> Result { - let session_name = format!( - "{}{}", - TERMINAL_SESSION_PREFIX, - working_dir.to_string_lossy() - ); - - let sessions = SessionManager::list_sessions().await?; - if let Some(session) = sessions.iter().find(|s| s.name == session_name) { - return Ok(session.id.clone()); - } - - let session = - SessionManager::create_session(working_dir, session_name.clone(), Default::default()) - .await?; - - SessionManager::update_session(&session.id) - .user_provided_name(session_name) - .apply() - .await?; - - Ok(session.id) -} /// Handle `goose term init ` - print shell initialization script pub async fn handle_term_init(shell: &str, with_command_not_found: bool) -> Result<()> { let working_dir = std::env::current_dir()?; - let session_id = get_or_create_terminal_session(working_dir).await?; + let session = SessionManager::create_session(working_dir, + "Goose Term Session".to_string(), + SessionType::Terminal).await?; + let session_id = session.id; let goose_bin = std::env::current_exe() .map(|p| p.to_string_lossy().into_owned()) @@ -162,47 +140,21 @@ Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ Ok(()) } -fn shell_history_path(session_id: &str) -> Result { - let history_dir = Paths::config_dir().join("shell-history"); - fs::create_dir_all(&history_dir)?; - Ok(history_dir.join(format!("{}.txt", session_id))) -} - -fn append_shell_command(session_id: &str, command: &str) -> Result<()> { - let path = shell_history_path(session_id)?; - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - writeln!(file, "{}", command)?; - Ok(()) -} - -fn read_and_clear_shell_history(session_id: &str) -> Result> { - let path = shell_history_path(session_id)?; - - if !path.exists() { - return Ok(Vec::new()); - } - - let content = fs::read_to_string(&path)?; - let commands: Vec = content - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|s| s.to_string()) - .collect(); - fs::write(&path, "")?; - Ok(commands) -} - -pub fn handle_term_log(command: String) -> Result<()> { +pub async fn handle_term_log(command: String) -> Result<()> { let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { anyhow!("GOOSE_SESSION_ID not set. Run 'eval \"$(goose term init )\"' first.") })?; - append_shell_command(&session_id, &command)?; + let message = Message::new( + Role::User, + chrono::Utc::now().timestamp_millis(), + vec![MessageContent::text(command)], + ) + .with_metadata(MessageMetadata::user_only()); + + SessionManager::add_message(&session_id, &message).await?; Ok(()) } @@ -225,13 +177,34 @@ pub async fn handle_term_run(prompt: Vec) -> Result<()> { .apply() .await?; - let commands = read_and_clear_shell_history(&session_id)?; - let prompt_with_context = if commands.is_empty() { + let session = SessionManager::get_session(&session_id, true).await?; + let user_messages_after_last_assistant: Vec<&Message> = if let Some(conv) = &session.conversation { + conv.messages() + .iter() + .rev() + .take_while(|m| m.role != Role::Assistant) + .collect() + } else { + Vec::new() + }; + + if let Some(oldest_user) = user_messages_after_last_assistant.last() { + SessionManager::truncate_conversation(&session_id, oldest_user.created).await?; + } + + let prompt_with_context = if user_messages_after_last_assistant.is_empty() { prompt } else { + let history = user_messages_after_last_assistant + .iter() + .rev() // back to chronological order + .map(|m| m.as_concat_text()) + .collect::>() + .join("\n"); + format!( "\n{}\n\n\n{}", - commands.join("\n"), + history, prompt ) }; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7d63e665b45c..9b37f907b51e 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -30,6 +30,7 @@ pub enum SessionType { Scheduled, SubAgent, Hidden, + Terminal, } impl Default for SessionType { @@ -45,6 +46,7 @@ impl std::fmt::Display for SessionType { SessionType::SubAgent => write!(f, "sub_agent"), SessionType::Hidden => write!(f, "hidden"), SessionType::Scheduled => write!(f, "scheduled"), + SessionType::Terminal => write!(f, "terminal"), } } } @@ -58,6 +60,7 @@ impl std::str::FromStr for SessionType { "sub_agent" => Ok(SessionType::SubAgent), "hidden" => Ok(SessionType::Hidden), "scheduled" => Ok(SessionType::Scheduled), + "terminal" => Ok(SessionType::Terminal), _ => Err(anyhow::anyhow!("Invalid session type: {}", s)), } } From 9460b33ee964978b7acca111966b035931b4792d Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 26 Nov 2025 13:48:03 +0100 Subject: [PATCH 17/21] Named sessions --- crates/goose-cli/src/cli.rs | 29 ++- crates/goose-cli/src/commands/term.rs | 213 ++++++++++++-------- crates/goose/src/session/session_manager.rs | 53 +++-- scripts/clippy-lint.sh | 29 ++- ui/desktop/openapi.json | 3 +- ui/desktop/src/api/types.gen.ts | 2 +- 6 files changed, 199 insertions(+), 130 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index f528fbad1243..29705631ffb2 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -13,7 +13,9 @@ use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; -use crate::commands::term::{handle_term_info, handle_term_init, handle_term_log, handle_term_run}; +use crate::commands::term::{ + handle_term_info, handle_term_init, handle_term_log, handle_term_run, Shell, +}; use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -867,6 +869,9 @@ enum TermCommand { #[arg(value_enum)] shell: Shell, + #[arg(short, long, help = "Name for the terminal session")] + name: Option, + /// Make goose the default handler for unknown commands #[arg( long = "default", @@ -914,14 +919,6 @@ enum CliProviderVariant { Ollama, } -#[derive(clap::ValueEnum, Clone, Debug)] -enum Shell { - Bash, - Zsh, - Fish, - Powershell, -} - #[derive(Debug)] pub struct InputConfig { pub contents: Option, @@ -1474,14 +1471,12 @@ pub async fn cli() -> anyhow::Result<()> { } Some(Command::Term { command }) => { match command { - TermCommand::Init { shell, default } => { - let shell_str = match shell { - Shell::Bash => "bash", - Shell::Zsh => "zsh", - Shell::Fish => "fish", - Shell::Powershell => "powershell", - }; - handle_term_init(shell_str, default).await?; + TermCommand::Init { + shell, + name, + default, + } => { + handle_term_init(shell, name, default).await?; } TermCommand::Log { command } => { handle_term_log(command).await?; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 74ad03316669..877c322bb250 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -1,123 +1,116 @@ use anyhow::{anyhow, Result}; +use chrono; +use goose::conversation::message::{Message, MessageContent, MessageMetadata}; use goose::session::SessionManager; use goose::session::SessionType; -use goose::conversation::message::{Message, MessageContent, MessageMetadata}; use rmcp::model::Role; -use chrono; use crate::session::{build_session, SessionBuilderConfig}; +use clap::ValueEnum; -/// Handle `goose term init ` - print shell initialization script -pub async fn handle_term_init(shell: &str, with_command_not_found: bool) -> Result<()> { - let working_dir = std::env::current_dir()?; - let session = SessionManager::create_session(working_dir, - "Goose Term Session".to_string(), - SessionType::Terminal).await?; - let session_id = session.id; - - let goose_bin = std::env::current_exe() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "goose".to_string()); - - let command_not_found_handler = if with_command_not_found { - match shell.to_lowercase().as_str() { - "bash" => format!( - r#" +#[derive(ValueEnum, Clone, Debug)] +pub enum Shell { + Bash, + Zsh, + Fish, + #[value(alias = "pwsh")] + Powershell, +} -# Command not found handler - sends unknown commands to goose -command_not_found_handle() {{ - echo "🪿 Command '$1' not found. Asking goose..." - '{goose_bin}' term run "$@" - return 0 -}}"# - ), - "zsh" => format!( - r#" +struct ShellConfig { + script_template: &'static str, + command_not_found: Option<&'static str>, +} -# Command not found handler - sends unknown commands to goose -command_not_found_handler() {{ - echo "🪿 Command '$1' not found. Asking goose..." - '{goose_bin}' term run "$@" - return 0 -}}"# - ), - _ => String::new(), +impl Shell { + fn config(&self) -> &'static ShellConfig { + match self { + Shell::Bash => &BASH_CONFIG, + Shell::Zsh => &ZSH_CONFIG, + Shell::Fish => &FISH_CONFIG, + Shell::Powershell => &POWERSHELL_CONFIG, } - } else { - String::new() - }; + } +} - let script = match shell.to_lowercase().as_str() { - "bash" => { - format!( - r#"export GOOSE_SESSION_ID="{session_id}" +static BASH_CONFIG: ShellConfig = ShellConfig { + script_template: r#"export GOOSE_SESSION_ID="{session_id}" alias gt='{goose_bin} term run' alias @goose='{goose_bin} term run' alias @g='{goose_bin} term run' -# Log commands to goose (runs silently in background) goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return [[ "$1" =~ ^(gt|@goose|@g)($|[[:space:]]) ]] && return ('{goose_bin}' term log "$1" &) 2>/dev/null }} -# Install preexec hook for bash if [[ -z "$goose_preexec_installed" ]]; then goose_preexec_installed=1 trap 'goose_preexec "$BASH_COMMAND"' DEBUG -fi{command_not_found_handler}"# - ) - } - "zsh" => { - format!( - r#"export GOOSE_SESSION_ID="{session_id}" +fi{command_not_found_handler}"#, + command_not_found: Some( + r#" + +command_not_found_handle() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"#, + ), +}; + +static ZSH_CONFIG: ShellConfig = ShellConfig { + script_template: r#"export GOOSE_SESSION_ID="{session_id}" alias gt='{goose_bin} term run' alias @goose='{goose_bin} term run' alias @g='{goose_bin} term run' -# Log commands to goose (runs silently in background) goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return [[ "$1" =~ ^(gt|@goose|@g)($|[[:space:]]) ]] && return ('{goose_bin}' term log "$1" &) 2>/dev/null }} -# Install preexec hook for zsh autoload -Uz add-zsh-hook add-zsh-hook preexec goose_preexec -# Add goose indicator to prompt if [[ -z "$GOOSE_PROMPT_INSTALLED" ]]; then export GOOSE_PROMPT_INSTALLED=1 PROMPT='%F{{cyan}}🪿%f '$PROMPT -fi{command_not_found_handler}"# - ) - } - "fish" => { - format!( - r#"set -gx GOOSE_SESSION_ID "{session_id}" +fi{command_not_found_handler}"#, + command_not_found: Some( + r#" + +command_not_found_handler() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"#, + ), +}; + +static FISH_CONFIG: ShellConfig = ShellConfig { + script_template: r#"set -gx GOOSE_SESSION_ID "{session_id}" function gt; {goose_bin} term run $argv; end function @goose; {goose_bin} term run $argv; end function @g; {goose_bin} term run $argv; end -# Log commands to goose function goose_preexec --on-event fish_preexec string match -q -r '^goose term' -- $argv[1]; and return string match -q -r '^(gt|@goose|@g)($|\s)' -- $argv[1]; and return {goose_bin} term log "$argv[1]" 2>/dev/null & -end"# - ) - } - "powershell" | "pwsh" => { - format!( - r#"$env:GOOSE_SESSION_ID = "{session_id}" +end"#, + command_not_found: None, +}; + +static POWERSHELL_CONFIG: ShellConfig = ShellConfig { + script_template: r#"$env:GOOSE_SESSION_ID = "{session_id}" function gt {{ & '{goose_bin}' term run @args }} function @goose {{ & '{goose_bin}' term run @args }} function @g {{ & '{goose_bin}' term run @args }} -# Log commands to goose Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ $line = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) @@ -125,23 +118,69 @@ Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ Start-Job -ScriptBlock {{ & '{goose_bin}' term log $using:line }} | Out-Null }} [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() -}}"# +}}"#, + command_not_found: None, +}; + +pub async fn handle_term_init( + shell: Shell, + name: Option, + with_command_not_found: bool, +) -> Result<()> { + let config = shell.config(); + + let working_dir = std::env::current_dir()?; + let named_session = if let Some(ref name) = name { + let sessions = SessionManager::list_sessions_by_types(&[SessionType::Terminal]).await?; + sessions.into_iter().find(|s| s.name == *name) + } else { + None + }; + + let session = match named_session { + Some(s) => s, + None => { + let session = SessionManager::create_session( + working_dir, + "Goose Term Session".to_string(), + SessionType::Terminal, ) - } - _ => { - return Err(anyhow!( - "Unsupported shell: {}. Supported shells: bash, zsh, fish, powershell", - shell - )); + .await?; + + if let Some(name) = name { + SessionManager::update_session(&session.id) + .user_provided_name(name) + .apply() + .await?; + } + + session } }; + let goose_bin = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "goose".to_string()); + + let command_not_found_handler = if with_command_not_found { + config + .command_not_found + .map(|s| s.replace("{goose_bin}", &goose_bin)) + .unwrap_or_default() + } else { + String::new() + }; + + let script = config + .script_template + .replace("{session_id}", &session.id) + .replace("{goose_bin}", &goose_bin) + .replace("{command_not_found_handler}", &command_not_found_handler); + println!("{}", script); Ok(()) } - - pub async fn handle_term_log(command: String) -> Result<()> { let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { anyhow!("GOOSE_SESSION_ID not set. Run 'eval \"$(goose term init )\"' first.") @@ -178,15 +217,16 @@ pub async fn handle_term_run(prompt: Vec) -> Result<()> { .await?; let session = SessionManager::get_session(&session_id, true).await?; - let user_messages_after_last_assistant: Vec<&Message> = if let Some(conv) = &session.conversation { - conv.messages() - .iter() - .rev() - .take_while(|m| m.role != Role::Assistant) - .collect() - } else { - Vec::new() - }; + let user_messages_after_last_assistant: Vec<&Message> = + if let Some(conv) = &session.conversation { + conv.messages() + .iter() + .rev() + .take_while(|m| m.role != Role::Assistant) + .collect() + } else { + Vec::new() + }; if let Some(oldest_user) = user_messages_after_last_assistant.last() { SessionManager::truncate_conversation(&session_id, oldest_user.created).await?; @@ -204,8 +244,7 @@ pub async fn handle_term_run(prompt: Vec) -> Result<()> { format!( "\n{}\n\n\n{}", - history, - prompt + history, prompt ) }; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 9b37f907b51e..427dc0c40939 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -294,6 +294,10 @@ impl SessionManager { Self::instance().await?.list_sessions().await } + pub async fn list_sessions_by_types(types: &[SessionType]) -> Result> { + Self::instance().await?.list_sessions_by_types(types).await + } + pub async fn delete_session(id: &str) -> Result<()> { Self::instance().await?.delete_session(id).await } @@ -1124,25 +1128,40 @@ impl SessionStorage { Ok(()) } - async fn list_sessions(&self) -> Result> { - sqlx::query_as::<_, Session>( + async fn list_sessions_by_types(&self, types: &[SessionType]) -> Result> { + if types.is_empty() { + return Ok(Vec::new()); + } + + let placeholders: String = types.iter().map(|_| "?").collect::>().join(", "); + let query = format!( r#" - SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, - s.total_tokens, s.input_tokens, s.output_tokens, - s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, - s.schedule_id, s.recipe_json, s.user_recipe_values_json, - s.provider_name, s.model_config_json, - COUNT(m.id) as message_count - FROM sessions s - INNER JOIN messages m ON s.id = m.session_id - WHERE s.session_type = 'user' OR s.session_type = 'scheduled' - GROUP BY s.id - ORDER BY s.updated_at DESC - "#, - ) - .fetch_all(&self.pool) + SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, + s.total_tokens, s.input_tokens, s.output_tokens, + s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, + s.schedule_id, s.recipe_json, s.user_recipe_values_json, + s.provider_name, s.model_config_json, + COUNT(m.id) as message_count + FROM sessions s + INNER JOIN messages m ON s.id = m.session_id + WHERE s.session_type IN ({}) + GROUP BY s.id + ORDER BY s.updated_at DESC + "#, + placeholders + ); + + let mut q = sqlx::query_as::<_, Session>(&query); + for t in types { + q = q.bind(t.to_string()); + } + + q.fetch_all(&self.pool).await.map_err(Into::into) + } + + async fn list_sessions(&self) -> Result> { + self.list_sessions_by_types(&[SessionType::User, SessionType::Scheduled]) .await - .map_err(Into::into) } async fn delete_session(&self, session_id: &str) -> Result<()> { diff --git a/scripts/clippy-lint.sh b/scripts/clippy-lint.sh index afc9296debf0..0befc5b5a444 100755 --- a/scripts/clippy-lint.sh +++ b/scripts/clippy-lint.sh @@ -12,13 +12,28 @@ source "$SCRIPT_DIR/clippy-baseline.sh" echo "🔍 Running all clippy checks..." -# Run standard clippy with strict warnings -echo " → Standard clippy rules (strict)" -cargo clippy --all-targets --jobs 2 -- -D warnings - -# Run baseline rules check +FIX_MODE=0 +[[ "$1" == "--fix" ]] && FIX_MODE=1 + +run_clippy() { + if [[ "$FIX_MODE" -eq 1 ]]; then + cargo fmt + cargo clippy --all-targets --jobs 2 \ + --fix --allow-dirty --allow-staged \ + -- -D warnings + else + cargo clippy --all-targets --jobs 2 -- -D warnings + fi +} + +if [[ "$FIX_MODE" -eq 1 ]]; then + echo "🛠 Applying fixes..." +else + echo "🔍 Running clippy..." +fi + +run_clippy echo "" check_all_baseline_rules - echo "" -echo "✅ All lint checks passed!" +echo "✅ Done" diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index a7bc0d15ecbd..769f2fa7475a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4756,7 +4756,8 @@ "user", "scheduled", "sub_agent", - "hidden" + "hidden", + "terminal" ] }, "SessionsQuery": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 00f29ea9a58f..ac44b284c5a3 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -761,7 +761,7 @@ export type SessionListResponse = { sessions: Array; }; -export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden'; +export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal'; export type SessionsQuery = { limit: number; From 5016de9ee44dc4be46a576fdcacbd38343bdbde8 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 26 Nov 2025 13:58:13 +0100 Subject: [PATCH 18/21] Update documentation - thank you goose --- documentation/docs/guides/terminal-integration.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md index f3edc43279c5..1a5878b58840 100644 --- a/documentation/docs/guides/terminal-integration.md +++ b/documentation/docs/guides/terminal-integration.md @@ -107,4 +107,14 @@ The hook runs `goose term log &` in the background, which appends to a ## Session Management -Terminal sessions are tied to your working directory. If you `cd` to a different project, goose automatically creates or switches to a session for that directory. This keeps conversations organized by project. +By default a new goose session is created each time you run init and +that session lasts as long as you keep that terminal open. + +You can create a named session by passing --name: + +```bash +eval "$(goose term init zsh --name my_project)" +``` + +which will create a session with the name `my_project` if it doesn't exist yet or continues +that session if it does. \ No newline at end of file From fd46023c6cfae0ca175f374ac088fbdf9c779ead Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 26 Nov 2025 14:37:03 +0100 Subject: [PATCH 19/21] Update instructions --- documentation/docs/guides/terminal-integration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md index 1a5878b58840..d9207a8cadcd 100644 --- a/documentation/docs/guides/terminal-integration.md +++ b/documentation/docs/guides/terminal-integration.md @@ -103,7 +103,9 @@ You won't notice any delay. The logging happens asynchronously after your comman 3. Installs a preexec hook that calls `goose term log` for each command 4. Optionally installs a command-not-found handler (with `--default`) -The hook runs `goose term log &` in the background, which appends to a local history file. When you run `@goose`, goose reads commands from this file that were logged since your last message. +The hook runs `goose term log &` in the background, which appends to the goose session. +When you run `@goose`, goose reads commands from goose session any commands that happened since it +was last called and incorporates them in the next call. ## Session Management From e16c061c653798ec395d1dbd4c942853d13e5dd1 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 27 Nov 2025 12:26:52 +1100 Subject: [PATCH 20/21] drop the old gt alias to save conflict --- crates/goose-cli/src/commands/term.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 877c322bb250..cd674748d622 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -36,13 +36,12 @@ impl Shell { static BASH_CONFIG: ShellConfig = ShellConfig { script_template: r#"export GOOSE_SESSION_ID="{session_id}" -alias gt='{goose_bin} term run' alias @goose='{goose_bin} term run' alias @g='{goose_bin} term run' goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return - [[ "$1" =~ ^(gt|@goose|@g)($|[[:space:]]) ]] && return + [[ "$1" =~ ^(@goose|@g)($|[[:space:]]) ]] && return ('{goose_bin}' term log "$1" &) 2>/dev/null }} @@ -63,13 +62,12 @@ command_not_found_handle() {{ static ZSH_CONFIG: ShellConfig = ShellConfig { script_template: r#"export GOOSE_SESSION_ID="{session_id}" -alias gt='{goose_bin} term run' alias @goose='{goose_bin} term run' alias @g='{goose_bin} term run' goose_preexec() {{ [[ "$1" =~ ^goose\ term ]] && return - [[ "$1" =~ ^(gt|@goose|@g)($|[[:space:]]) ]] && return + [[ "$1" =~ ^(@goose|@g)($|[[:space:]]) ]] && return ('{goose_bin}' term log "$1" &) 2>/dev/null }} @@ -93,13 +91,12 @@ command_not_found_handler() {{ static FISH_CONFIG: ShellConfig = ShellConfig { script_template: r#"set -gx GOOSE_SESSION_ID "{session_id}" -function gt; {goose_bin} term run $argv; end function @goose; {goose_bin} term run $argv; end function @g; {goose_bin} term run $argv; end function goose_preexec --on-event fish_preexec string match -q -r '^goose term' -- $argv[1]; and return - string match -q -r '^(gt|@goose|@g)($|\s)' -- $argv[1]; and return + string match -q -r '^(@goose|@g)($|\s)' -- $argv[1]; and return {goose_bin} term log "$argv[1]" 2>/dev/null & end"#, command_not_found: None, @@ -107,14 +104,13 @@ end"#, static POWERSHELL_CONFIG: ShellConfig = ShellConfig { script_template: r#"$env:GOOSE_SESSION_ID = "{session_id}" -function gt {{ & '{goose_bin}' term run @args }} function @goose {{ & '{goose_bin}' term run @args }} function @g {{ & '{goose_bin}' term run @args }} Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ $line = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) - if ($line -notmatch '^goose term' -and $line -notmatch '^(gt|@goose|@g)($|\s)') {{ + if ($line -notmatch '^goose term' -and $line -notmatch '^(@goose|@g)($|\s)') {{ Start-Job -ScriptBlock {{ & '{goose_bin}' term log $using:line }} | Out-Null }} [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() From c6df680f6df7960f90a5995311ec4e0d0122ddfb Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 1 Dec 2025 15:00:57 +1100 Subject: [PATCH 21/21] Update documentation/docs/guides/terminal-integration.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- documentation/docs/guides/terminal-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md index d9207a8cadcd..656b8cb3697b 100644 --- a/documentation/docs/guides/terminal-integration.md +++ b/documentation/docs/guides/terminal-integration.md @@ -104,7 +104,7 @@ You won't notice any delay. The logging happens asynchronously after your comman 4. Optionally installs a command-not-found handler (with `--default`) The hook runs `goose term log &` in the background, which appends to the goose session. -When you run `@goose`, goose reads commands from goose session any commands that happened since it +When you run `@goose`, goose reads from the goose session any commands that happened since it was last called and incorporates them in the next call. ## Session Management