aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs30
-rw-r--r--src/cli.rs113
-rw-r--r--src/logging.rs4
-rw-r--r--src/main.rs30
-rw-r--r--src/persist/io.rs2
-rw-r--r--src/server/autodetect.rs73
-rw-r--r--src/server/headless.rs14
-rw-r--r--src/session.rs569
8 files changed, 810 insertions, 25 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index e97d94f6..ff53e6ad 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -95,11 +95,7 @@ impl EventHub {
}
pub fn socket_path() -> PathBuf {
- if let Ok(path) = std::env::var(SOCKET_PATH_ENV_VAR) {
- return PathBuf::from(path);
- }
-
- crate::config::config_dir().join("herdr.sock")
+ crate::session::active_api_socket_path()
}
pub struct ServerHandle {
@@ -1064,6 +1060,8 @@ mod tests {
fn socket_path_prefers_explicit_env_override() {
let _guard = env_lock().lock().unwrap();
let unique = format!("/tmp/herdr-test-{}.sock", std::process::id());
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
std::env::set_var(SOCKET_PATH_ENV_VAR, &unique);
assert_eq!(socket_path(), PathBuf::from(&unique));
std::env::remove_var(SOCKET_PATH_ENV_VAR);
@@ -1075,6 +1073,8 @@ mod tests {
let config_home = unique_test_path("socket-default-config-home");
let runtime_dir = unique_test_path("socket-default-runtime");
std::env::remove_var(SOCKET_PATH_ENV_VAR);
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir);
@@ -1088,6 +1088,26 @@ mod tests {
}
#[test]
+ fn socket_path_uses_named_session_dir() {
+ let _guard = env_lock().lock().unwrap();
+ let config_home = unique_test_path("socket-named-config-home");
+ std::env::remove_var(SOCKET_PATH_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
+ std::env::set_var(crate::session::SESSION_ENV_VAR, "work");
+ std::env::set_var("XDG_CONFIG_HOME", &config_home);
+
+ let expected = config_home
+ .join(crate::config::app_dir_name())
+ .join("sessions")
+ .join("work")
+ .join("herdr.sock");
+ assert_eq!(socket_path(), expected);
+
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ std::env::remove_var("XDG_CONFIG_HOME");
+ }
+
+ #[test]
fn restrict_socket_permissions_sets_user_only_mode() {
let dir = unique_test_path("socket-perms");
fs::create_dir_all(&dir).unwrap();
diff --git a/src/cli.rs b/src/cli.rs
index 52fba0bd..138815fa 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -37,6 +37,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
"pane" => run_pane_command(&args[2..])?,
"wait" => run_wait_command(&args[2..])?,
"integration" => run_integration_command(&args[2..])?,
+ "session" => run_session_command(&args[2..])?,
_ => return Ok(CommandOutcome::NotCli),
};
@@ -330,6 +331,27 @@ fn run_integration_command(args: &[String]) -> std::io::Result<i32> {
}
}
+fn run_session_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ print_session_help();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "list" => session_list(&args[1..]),
+ "stop" => session_stop(&args[1..]),
+ "delete" => session_delete(&args[1..]),
+ "help" | "--help" | "-h" => {
+ print_session_help();
+ Ok(0)
+ }
+ _ => {
+ print_session_help();
+ Ok(2)
+ }
+ }
+}
+
fn server_stop(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr server stop");
@@ -351,6 +373,76 @@ fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
})?)
}
+fn session_list(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr session list");
+ return Ok(2);
+ }
+
+ let sessions = crate::session::list_sessions()?;
+ _print_json(&serde_json::json!({
+ "sessions": sessions,
+ }));
+ Ok(0)
+}
+
+fn session_stop(args: &[String]) -> std::io::Result<i32> {
+ let Some(name) = args.first() else {
+ eprintln!("usage: herdr session stop <name>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr session stop <name>");
+ return Ok(2);
+ }
+
+ let target = match crate::session::parse_target_name(name) {
+ Ok(target) => target,
+ Err(message) => {
+ print_session_error("invalid_session_name", &message);
+ return Ok(1);
+ }
+ };
+ match crate::session::stop_session(target.as_deref()) {
+ Ok(session) => {
+ _print_json(&serde_json::json!({
+ "stopped": true,
+ "session": session,
+ }));
+ Ok(0)
+ }
+ Err(message) => {
+ print_session_error("session_stop_failed", &message);
+ Ok(1)
+ }
+ }
+}
+
+fn session_delete(args: &[String]) -> std::io::Result<i32> {
+ let Some(name) = args.first() else {
+ eprintln!("usage: herdr session delete <name>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr session delete <name>");
+ return Ok(2);
+ }
+
+ match crate::session::delete_session(name) {
+ Ok(session) => {
+ _print_json(&serde_json::json!({
+ "deleted": true,
+ "session": session,
+ }));
+ Ok(0)
+ }
+ Err(message) => {
+ print_session_error("session_delete_failed", &message);
+ Ok(1)
+ }
+ }
+}
+
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr workspace list");
@@ -1225,6 +1317,19 @@ fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result<u64> {
.map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}")))
}
+fn print_session_error(code: &str, message: &str) {
+ eprintln!(
+ "{}",
+ serde_json::to_string(&serde_json::json!({
+ "error": {
+ "code": code,
+ "message": message,
+ }
+ }))
+ .unwrap()
+ );
+}
+
fn print_server_help() {
eprintln!("herdr server commands:");
eprintln!(" herdr server run as headless server");
@@ -1293,6 +1398,14 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall opencode");
}
+fn print_session_help() {
+ eprintln!("herdr session commands:");
+ eprintln!(" herdr session list");
+ eprintln!(" herdr session stop <name>");
+ eprintln!(" herdr session delete <name>");
+ eprintln!(" use 'default' as <name> to target the default session for stop");
+}
+
fn _print_json<T: Serialize>(value: &T) {
println!("{}", serde_json::to_string(value).unwrap());
}
diff --git a/src/logging.rs b/src/logging.rs
index 2377091e..2754062b 100644
--- a/src/logging.rs
+++ b/src/logging.rs
@@ -11,7 +11,7 @@ const DEFAULT_RETAINED_LOG_FILES: usize = 0;
pub(crate) fn init_file_logging(file_name: &str) {
let Ok(make_writer) = RotatingFileMakeWriter::new(
- crate::config::config_dir(),
+ crate::session::data_dir(),
file_name,
DEFAULT_MAX_LOG_BYTES,
DEFAULT_RETAINED_LOG_FILES,
@@ -31,7 +31,7 @@ pub(crate) fn init_file_logging(file_name: &str) {
}
pub(crate) fn help_log_paths_summary() -> String {
- let dir = crate::config::config_dir();
+ let dir = crate::session::data_dir();
format!(
"{} (plus herdr-client.log, herdr-server.log)",
dir.join("herdr.log").display()
diff --git a/src/main.rs b/src/main.rs
index dbc77060..cf5842c4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -38,6 +38,7 @@ mod raw_input;
mod release_notes;
mod selection;
mod server;
+mod session;
mod sound;
mod terminal_notify;
mod terminal_theme;
@@ -160,7 +161,15 @@ fn random_nested_message() -> &'static str {
}
fn main() -> io::Result<()> {
- let args: Vec<String> = std::env::args().collect();
+ let raw_args: Vec<String> = std::env::args().collect();
+ let args = match session::configure_from_args(&raw_args) {
+ Ok(args) => args,
+ Err(err) => {
+ eprintln!("error: {err}");
+ eprintln!("run 'herdr --help' for usage");
+ std::process::exit(2);
+ }
+ };
if let cli::CommandOutcome::Handled(code) = cli::maybe_run(&args)? {
std::process::exit(code);
@@ -189,7 +198,17 @@ fn main() -> io::Result<()> {
if args.iter().any(|a| a == "--help" || a == "-h") {
println!("herdr — terminal workspace manager for AI coding agents");
println!();
- println!("Usage: herdr [options] [command]");
+ println!("Usage: herdr [options]");
+ println!(" herdr --session <name> [options]");
+ println!(" herdr update");
+ println!(" herdr server stop");
+ println!(" herdr server reload-config");
+ println!(" herdr workspace <subcommand> ...");
+ println!(" herdr tab <subcommand> ...");
+ println!(" herdr pane <subcommand> ...");
+ println!(" herdr wait <subcommand> ...");
+ println!(" herdr session <subcommand> ...");
+ println!(" herdr integration <subcommand> ...");
println!();
println!("Common commands:");
println!(
@@ -230,6 +249,10 @@ fn main() -> io::Result<()> {
);
println!(
" {:<32} {}",
+ "herdr session <subcommand>", "Manage named persistent sessions"
+ );
+ println!(
+ " {:<32} {}",
"herdr integration <subcommand>", "Manage built-in agent integrations"
);
println!();
@@ -242,6 +265,7 @@ fn main() -> io::Result<()> {
println!();
println!("Options:");
println!(" --no-session Run monolithically (no server/client, escape hatch)");
+ println!(" --session <name> Use or create a named persistent session");
println!(" --default-config Print default configuration and exit");
println!(" --version, -V Print version and exit");
println!(" --help, -h Show this help");
@@ -266,6 +290,7 @@ fn main() -> io::Result<()> {
// Reject unknown flags
let known_flags = [
"--no-session",
+ "--session",
"--version",
"-V",
"--default-config",
@@ -287,6 +312,7 @@ fn main() -> io::Result<()> {
"workspace",
"pane",
"wait",
+ "session",
"integration",
]
.contains(&arg.as_str())
diff --git a/src/persist/io.rs b/src/persist/io.rs
index f4acd662..8cd25dfb 100644
--- a/src/persist/io.rs
+++ b/src/persist/io.rs
@@ -5,7 +5,7 @@ use tracing::warn;
use super::snapshot::{parse_snapshot, snapshot_file_version, SessionSnapshot, SNAPSHOT_VERSION};
fn session_path() -> PathBuf {
- crate::config::config_dir().join("session.json")
+ crate::session::data_dir().join("session.json")
}
pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> {
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index c4a07a2d..8f695467 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -12,6 +12,7 @@ use std::io;
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::path::Path;
+use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
@@ -85,8 +86,9 @@ fn is_server_listening_at(socket_path: &Path) -> bool {
/// The server process is fully detached:
/// - Runs in its own session (setsid) so it survives the client exiting
/// - Stdin/stdout/stderr are redirected to /dev/null
-/// - Inherits all relevant environment variables
-/// (`HERDR_SOCKET_PATH`, `HERDR_CLIENT_SOCKET_PATH`, `XDG_CONFIG_HOME`, etc.)
+/// - Inherits relevant environment variables (`XDG_CONFIG_HOME`, `HERDR_SESSION`,
+/// socket overrides, etc.), except inherited socket overrides are cleared when
+/// this CLI invocation explicitly selected a session.
///
/// Returns the PID of the spawned server process.
pub fn spawn_server_daemon() -> io::Result<u32> {
@@ -99,7 +101,21 @@ pub fn spawn_server_daemon() -> io::Result<u32> {
info!(exe = %exe.display(), "spawning server daemon");
- let child = Command::new(&exe)
+ let mut command = build_server_daemon_command(exe);
+
+ let child = command.spawn().map_err(|err: io::Error| {
+ io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}"))
+ })?;
+
+ let pid = child.id();
+ info!(pid, "server daemon spawned");
+
+ Ok(pid)
+}
+
+fn build_server_daemon_command(exe: PathBuf) -> Command {
+ let mut command = Command::new(&exe);
+ command
.arg("server")
// Create a new process group so the server survives the parent's exit
// and doesn't receive SIGHUP when the client's terminal closes.
@@ -107,16 +123,15 @@ pub fn spawn_server_daemon() -> io::Result<u32> {
// Redirect stdio to /dev/null
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .spawn()
- .map_err(|err: io::Error| {
- io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}"))
- })?;
+ .stderr(std::process::Stdio::null());
- let pid = child.id();
- info!(pid, "server daemon spawned");
+ if crate::session::explicit_session_requested() {
+ command
+ .env_remove(crate::api::SOCKET_PATH_ENV_VAR)
+ .env_remove("HERDR_CLIENT_SOCKET_PATH");
+ }
- Ok(pid)
+ command
}
// ---------------------------------------------------------------------------
@@ -187,7 +202,14 @@ pub fn auto_detect_launch() -> io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
+ use std::ffi::OsStr;
use std::os::unix::net::UnixListener;
+ use std::sync::{Mutex, OnceLock};
+
+ fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
@@ -205,6 +227,35 @@ mod tests {
}
#[test]
+ fn server_daemon_command_clears_socket_overrides_for_explicit_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock");
+ std::env::set_var("HERDR_CLIENT_SOCKET_PATH", "/tmp/inherited-client.sock");
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
+ let args = vec![
+ "herdr".to_string(),
+ "--session".to_string(),
+ "work".to_string(),
+ ];
+ crate::session::configure_from_args(&args).unwrap();
+
+ let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test"));
+ let envs: Vec<_> = command.get_envs().collect();
+
+ assert!(envs.iter().any(|(key, value)| {
+ *key == OsStr::new(crate::api::SOCKET_PATH_ENV_VAR) && value.is_none()
+ }));
+ assert!(envs.iter().any(|(key, value)| {
+ *key == OsStr::new("HERDR_CLIENT_SOCKET_PATH") && value.is_none()
+ }));
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ std::env::remove_var("HERDR_CLIENT_SOCKET_PATH");
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
+ }
+
+ #[test]
fn is_server_listening_returns_true_for_live_socket() {
let dir = unique_test_dir("live");
std::fs::create_dir_all(&dir).unwrap();
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 5f9aad2c..62cf3ba1 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -152,12 +152,16 @@ fn toast_message_from_state_change(
/// Returns the path for the client protocol socket.
///
/// Contract-aligned override behavior:
-/// 1. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by
+/// 1. If CLI `--session <name>` is active, use that session's client socket.
+/// 2. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by
/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`).
/// This keeps JSON API and client socket overrides consistent.
-/// 2. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback).
-/// 3. Otherwise, use the app config directory.
+/// 3. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback).
+/// 4. Otherwise, use the active session data directory.
pub fn client_socket_path() -> PathBuf {
+ if crate::session::explicit_session_requested() {
+ return crate::session::client_socket_path_for(crate::session::active_name().as_deref());
+ }
client_socket_path_from_overrides(
std::env::var(api::SOCKET_PATH_ENV_VAR).ok().as_deref(),
std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(),
@@ -176,7 +180,7 @@ fn client_socket_path_from_overrides(
return PathBuf::from(client_socket_override);
}
- config::config_dir().join("herdr-client.sock")
+ crate::session::client_socket_path_for(crate::session::active_name().as_deref())
}
fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
@@ -2178,6 +2182,8 @@ mod tests {
#[test]
fn client_socket_path_defaults_to_config_dir() {
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
let path = client_socket_path_from_overrides(None, None);
assert_eq!(path, config::config_dir().join("herdr-client.sock"));
}
diff --git a/src/session.rs b/src/session.rs
new file mode 100644
index 00000000..feeb9740
--- /dev/null
+++ b/src/session.rs
@@ -0,0 +1,569 @@
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{Duration, Instant};
+
+pub const SESSION_ENV_VAR: &str = "HERDR_SESSION";
+pub const DEFAULT_SESSION_NAME: &str = "default";
+
+const MAX_SESSION_NAME_LEN: usize = 64;
+const STOP_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
+const STOP_WAIT_POLL: Duration = Duration::from_millis(25);
+
+static EXPLICIT_SESSION_REQUESTED: AtomicBool = AtomicBool::new(false);
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct SessionInfo {
+ pub name: String,
+ pub default: bool,
+ pub running: bool,
+ pub socket_path: String,
+ pub session_dir: String,
+}
+
+pub fn configure_from_args(args: &[String]) -> Result<Vec<String>, String> {
+ let mut cleaned = Vec::with_capacity(args.len());
+ if let Some(program) = args.first() {
+ cleaned.push(program.clone());
+ }
+
+ let mut requested_session = None;
+ let mut index = 1;
+ while index < args.len() {
+ let arg = &args[index];
+ if arg == "--session" {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --session".to_string());
+ };
+ requested_session = Some(value.clone());
+ index += 2;
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("--session=") {
+ requested_session = Some(value.to_string());
+ index += 1;
+ continue;
+ }
+
+ cleaned.push(arg.clone());
+ index += 1;
+ }
+
+ if let Some(session) = requested_session {
+ let session = normalize_name(&session)?;
+ if let Some(session) = session {
+ std::env::set_var(SESSION_ENV_VAR, session);
+ } else {
+ std::env::remove_var(SESSION_ENV_VAR);
+ }
+ EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
+ } else if std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR).is_some() {
+ EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
+ } else if let Ok(session) = std::env::var(SESSION_ENV_VAR) {
+ if normalize_name(&session)?.is_none() {
+ std::env::remove_var(SESSION_ENV_VAR);
+ }
+ EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
+ } else {
+ EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
+ }
+
+ Ok(cleaned)
+}
+
+pub fn active_name() -> Option<String> {
+ std::env::var(SESSION_ENV_VAR)
+ .ok()
+ .filter(|name| name != DEFAULT_SESSION_NAME)
+ .filter(|name| validate_name(name).is_ok())
+}
+
+pub fn explicit_session_requested() -> bool {
+ EXPLICIT_SESSION_REQUESTED.load(Ordering::Relaxed)
+}
+
+#[cfg(test)]
+pub(crate) fn clear_explicit_session_for_test() {
+ EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
+}
+
+pub fn data_dir() -> PathBuf {
+ data_dir_for(active_name().as_deref())
+}
+
+pub fn data_dir_for(name: Option<&str>) -> PathBuf {
+ let config_dir = crate::config::config_dir();
+ match name {
+ Some(name) => config_dir.join("sessions").join(name),
+ None => config_dir,
+ }
+}
+
+pub fn api_socket_path_for(name: Option<&str>) -> PathBuf {
+ data_dir_for(name).join("herdr.sock")
+}
+
+pub fn active_api_socket_path() -> PathBuf {
+ if explicit_session_requested() {
+ return api_socket_path_for(active_name().as_deref());
+ }
+ if let Ok(path) = std::env::var(crate::api::SOCKET_PATH_ENV_VAR) {
+ return PathBuf::from(path);
+ }
+ api_socket_path_for(active_name().as_deref())
+}
+
+pub fn client_socket_path_for(name: Option<&str>) -> PathBuf {
+ data_dir_for(name).join("herdr-client.sock")
+}
+
+pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
+ let mut sessions = vec![session_info(None)];
+ let sessions_dir = crate::config::config_dir().join("sessions");
+ let entries = match std::fs::read_dir(&sessions_dir) {
+ Ok(entries) => entries,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions),
+ Err(err) => return Err(err),
+ };
+
+ let mut names = Vec::new();
+ for entry in entries {
+ let entry = entry?;
+ if !entry.file_type()?.is_dir() {
+ continue;
+ }
+ let Some(name) = entry.file_name().to_str().map(str::to_string) else {
+ continue;
+ };
+ if name != DEFAULT_SESSION_NAME && validate_name(&name).is_ok() {
+ names.push(name);
+ }
+ }
+ names.sort();
+ sessions.extend(names.iter().map(|name| session_info(Some(name))));
+ Ok(sessions)
+}
+
+pub fn session_info(name: Option<&str>) -> SessionInfo {
+ let default = name.is_none();
+ let display_name = name.unwrap_or(DEFAULT_SESSION_NAME).to_string();
+ let socket_path = api_socket_path_for(name);
+ let session_dir = data_dir_for(name);
+ SessionInfo {
+ name: display_name,
+ default,
+ running: is_running_at(&socket_path),
+ socket_path: socket_path.display().to_string(),
+ session_dir: session_dir.display().to_string(),
+ }
+}
+
+pub fn parse_target_name(name: &str) -> Result<Option<String>, String> {
+ normalize_name(name)
+}
+
+pub fn stop_session(name: Option<&str>) -> Result<SessionInfo, String> {
+ stop_session_with_timeout(name, STOP_WAIT_TIMEOUT)
+}
+
+fn stop_session_with_timeout(name: Option<&str>, timeout: Duration) -> Result<SessionInfo, String> {
+ let socket_path = api_socket_path_for(name);
+ let request = serde_json::json!({
+ "id": "cli:session:stop",
+ "method": "server.stop",
+ "params": {}
+ });
+ let mut stream = UnixStream::connect(&socket_path).map_err(|err| {
+ format!(
+ "session {} is not running or cannot be reached at {}: {err}",
+ name.unwrap_or(DEFAULT_SESSION_NAME),
+ socket_path.display()
+ )
+ })?;
+ stream
+ .write_all(request.to_string().as_bytes())
+ .map_err(|err| err.to_string())?;
+ stream.write_all(b"\n").map_err(|err| err.to_string())?;
+ stream.flush().map_err(|err| err.to_string())?;
+
+ let mut line = String::new();
+ BufReader::new(stream)
+ .read_line(&mut line)
+ .map_err(|err| err.to_string())?;
+ let response: serde_json::Value = serde_json::from_str(&line).map_err(|err| err.to_string())?;
+ if let Some(error) = response.get("error") {
+ return Err(error.to_string());
+ }
+ if !wait_until_stopped(&socket_path, timeout) {
+ return Err(format!(
+ "session {} did not stop within {}ms; socket is still reachable at {}",
+ name.unwrap_or(DEFAULT_SESSION_NAME),
+ timeout.as_millis(),
+ socket_path.display()
+ ));
+ }
+ Ok(session_info(name))
+}
+
+pub fn delete_session(name: &str) -> Result<SessionInfo, String> {
+ if name == DEFAULT_SESSION_NAME {
+ return Err("deleting the default session is not supported".to_string());
+ }
+ validate_name(name)?;
+ let socket_path = api_socket_path_for(Some(name));
+ if is_running_at(&socket_path) {
+ return Err(format!(
+ "session {name} is running; stop it before deleting"
+ ));
+ }
+ let info = session_info(Some(name));
+ let dir = data_dir_for(Some(name));
+ match std::fs::remove_dir_all(&dir) {
+ Ok(()) => Ok(info),
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(info),
+ Err(err) => Err(err.to_string()),
+ }
+}
+
+fn is_running_at(socket_path: &Path) -> bool {
+ socket_path.exists() && UnixStream::connect(socket_path).is_ok()
+}
+
+fn wait_until_stopped(socket_path: &Path, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if !is_running_at(socket_path) {
+ return true;
+ }
+ std::thread::sleep(STOP_WAIT_POLL);
+ }
+ !is_running_at(socket_path)
+}
+
+pub fn validate_name(name: &str) -> Result<(), String> {
+ if name.is_empty() {
+ return Err("session name cannot be empty".to_string());
+ }
+ if name.len() > MAX_SESSION_NAME_LEN {
+ return Err(format!(
+ "session name cannot be longer than {MAX_SESSION_NAME_LEN} bytes"
+ ));
+ }
+ if name == "." || name == ".." {
+ return Err("session name cannot be . or ..".to_string());
+ }
+ if !name
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
+ {
+ return Err(
+ "session name may only contain ASCII letters, numbers, '.', '_' and '-'".to_string(),
+ );
+ }
+ Ok(())
+}
+
+fn normalize_name(name: &str) -> Result<Option<String>, String> {
+ if name == DEFAULT_SESSION_NAME {
+ return Ok(None);
+ }
+ validate_name(name)?;
+ Ok(Some(name.to_string()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::{Mutex, OnceLock};
+
+ fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
+
+ #[test]
+ fn configure_from_args_removes_global_session_option() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ let args = vec![
+ "herdr".to_string(),
+ "--session".to_string(),
+ "work".to_string(),
+ "workspace".to_string(),
+ "list".to_string(),
+ ];
+
+ let cleaned = configure_from_args(&args).unwrap();
+
+ assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("work"));
+ assert!(explicit_session_requested());
+ assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ }
+
+ #[test]
+ fn configure_from_args_accepts_equals_form() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ let args = vec![
+ "herdr".to_string(),
+ "server".to_string(),
+ "stop".to_string(),
+ "--session=api".to_string(),
+ ];
+
+ let cleaned = configure_from_args(&args).unwrap();
+
+ assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("api"));
+ assert!(explicit_session_requested());
+ assert_eq!(cleaned, vec!["herdr", "server", "stop"]);
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ }
+
+ #[test]
+ fn configure_from_args_maps_default_session_name_to_default_path() {
+ let _guard = env_lock().lock().unwrap();
+ let config_home =
+ std::env::temp_dir().join(format!("herdr-session-default-{}", std::process::id()));
+ std::env::set_var("XDG_CONFIG_HOME", &config_home);
+ std::env::set_var(SESSION_ENV_VAR, "work");
+ clear_explicit_session_for_test();
+ std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock");
+ let args = vec![
+ "herdr".to_string(),
+ "--session".to_string(),
+ DEFAULT_SESSION_NAME.to_string(),
+ "workspace".to_string(),
+ "list".to_string(),
+ ];
+
+ let cleaned = configure_from_args(&args).unwrap();
+
+ assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
+ assert!(std::env::var(SESSION_ENV_VAR).is_err());
+ assert!(explicit_session_requested());
+ assert_eq!(
+ active_api_socket_path(),
+ config_home
+ .join(crate::config::app_dir_name())
+ .join("herdr.sock")
+ );
+ std::env::remove_var("XDG_CONFIG_HOME");
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ }
+
+ #[test]
+ fn env_session_does_not_mark_session_explicit() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::set_var(SESSION_ENV_VAR, "env-session");
+ EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
+ let args = vec![
+ "herdr".to_string(),
+ "workspace".to_string(),
+ "list".to_string(),
+ ];
+
+ let cleaned = configure_from_args(&args).unwrap();
+
+ assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
+ assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("env-session"));
+ assert!(!explicit_session_requested());
+ std::env::remove_var(SESSION_ENV_VAR);
+ }
+
+ #[test]
+ fn env_default_session_name_uses_default_path() {
+ let _guard = env_lock().lock().unwrap();
+ let config_home =
+ std::env::temp_dir().join(format!("herdr-env-session-default-{}", std::process::id()));
+ std::env::set_var("XDG_CONFIG_HOME", &config_home);
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ std::env::set_var(SESSION_ENV_VAR, DEFAULT_SESSION_NAME);
+ EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
+ let args = vec![
+ "herdr".to_string(),
+ "workspace".to_string(),
+ "list".to_string(),
+ ];
+
+ let cleaned = configure_from_args(&args).unwrap();
+
+ assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
+ assert!(std::env::var(SESSION_ENV_VAR).is_err());
+ assert!(!explicit_session_requested());
+ assert_eq!(
+ active_api_socket_path(),
+ config_home
+ .join(crate::config::app_dir_name())
+ .join("herdr.sock")
+ );
+ std::env::remove_var("XDG_CONFIG_HOME");
+ std::env::remove_var(SESSION_ENV_VAR);
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ clear_explicit_session_for_test();
+ }
+
+ #[test]
+ fn explicit_session_socket_ignores_inherited_socket_override() {
+ let _guard = env_lock().lock().unwrap();
+ let config_home =
+ std::env::temp_dir().join(format!("herdr-session-precedence-{}", std::process::id()));
+ std::env::set_var("XDG_CONFIG_HOME", &config_home);
+ std::env::set_var(SESSION_ENV_VAR, "work");
+ EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
+ std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock");
+
+ let path = active_api_socket_path();
+
+ assert_eq!(
+ path,
+ config_home
+ .join(crate::config::app_dir_name())
+ .join("sessions")
+ .join("work")
+ .join("herdr.sock")
+ );
+ std::env::remove_var("XDG_CONFIG_HOME");
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ }
+
+ #[test]
+ fn env_socket_override_wins_without_explicit_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::set_var(SESSION_ENV_VAR, "work");
+ clear_explicit_session_for_test();
+ std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/explicit.sock");
+
+ assert_eq!(
+ active_api_socket_path(),
+ PathBuf::from("/tmp/explicit.sock")
+ );
+
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ }
+
+ #[test]
+ fn env_socket_override_skips_invalid_env_session_validation_without_explicit_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::set_var(SESSION_ENV_VAR, "bad/name");
+ clear_explicit_session_for_test();
+ std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/herdr.sock");
+ let args = vec![
+ "herdr".to_string(),
+ "workspace".to_string(),
+ "list".to_string(),
+ ];
+
+ let cleaned = configure_from_args(&args).unwrap();
+
+ assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
+ assert!(!explicit_session_requested());
+ assert_eq!(active_api_socket_path(), PathBuf::from("/tmp/herdr.sock"));
+ assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("bad/name"));
+
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ }
+
+ #[test]
+ fn stop_session_fails_when_socket_remains_reachable_after_timeout() {
+ let _guard = env_lock().lock().unwrap();
+ let config_home = PathBuf::from(format!("/tmp/hs-stop-{}", std::process::id()));
+ std::env::set_var("XDG_CONFIG_HOME", &config_home);
+ let session_name = "slow";
+ let socket_path = api_socket_path_for(Some(session_name));
+ std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
+ let _ = std::fs::remove_file(&socket_path);
+ let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap();
+ listener.set_nonblocking(true).unwrap();
+ let keep_running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
+ let keep_running_for_thread = keep_running.clone();
+ let handle = std::thread::spawn(move || {
+ while keep_running_for_thread.load(Ordering::Relaxed) {
+ match listener.accept() {
+ Ok((mut stream, _)) => {
+ if let Ok(reader_stream) = stream.try_clone() {
+ let mut request = String::new();
+ let _ = BufReader::new(reader_stream).read_line(&mut request);
+ }
+ let _ = stream.write_all(b"{\"id\":\"cli:session:stop\",\"result\":{}}\n");
+ let _ = stream.flush();
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ Err(_) => break,
+ }
+ }
+ });
+
+ let err = stop_session_with_timeout(Some(session_name), Duration::from_millis(75))
+ .expect_err("still-running session should fail");
+
+ assert!(err.contains("did not stop"), "{err}");
+ assert!(
+ err.contains(socket_path.to_string_lossy().as_ref()),
+ "{err}"
+ );
+ keep_running.store(false, Ordering::Relaxed);
+ handle.join().unwrap();
+ let _ = std::fs::remove_dir_all(&config_home);
+ std::env::remove_var("XDG_CONFIG_HOME");
+ }
+
+ #[test]
+ fn invalid_names_are_rejected() {
+ let _guard = env_lock().lock().unwrap();
+ assert!(validate_name("../prod").is_err());
+ assert!(validate_name("").is_err());
+ assert!(validate_name("work session").is_err());
+ }
+
+ #[test]
+ fn parse_default_target_name_maps_to_default_session() {
+ assert_eq!(parse_target_name(DEFAULT_SESSION_NAME).unwrap(), None);
+ assert_eq!(parse_target_name("work").unwrap(), Some("work".to_string()));
+ }
+
+ #[test]
+ fn delete_default_session_is_rejected() {
+ assert!(delete_session(DEFAULT_SESSION_NAME).is_err());
+ }
+
+ #[test]
+ fn list_sessions_skips_reserved_default_directory() {
+ let _guard = env_lock().lock().unwrap();
+ let config_home =
+ std::env::temp_dir().join(format!("herdr-session-list-{}", std::process::id()));
+ let sessions_dir = config_home
+ .join(crate::config::app_dir_name())
+ .join("sessions");
+ std::fs::create_dir_all(sessions_dir.join(DEFAULT_SESSION_NAME)).unwrap();
+ std::fs::create_dir_all(sessions_dir.join("work")).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &config_home);
+ std::env::remove_var(SESSION_ENV_VAR);
+ clear_explicit_session_for_test();
+
+ let sessions = list_sessions().unwrap();
+ let names: Vec<_> = sessions
+ .iter()
+ .map(|session| session.name.as_str())
+ .collect();
+
+ assert_eq!(names, vec![DEFAULT_SESSION_NAME, "work"]);
+ std::fs::remove_dir_all(&config_home).unwrap();
+ std::env::remove_var("XDG_CONFIG_HOME");
+ }
+}