aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFranz Bettag <franz@bett.ag>2026-04-28 19:11:11 +0200
committerGitHub <noreply@github.com>2026-04-28 20:11:11 +0300
commit5baf231e076ed96bb2aaba245eb53ad0e4501d76 (patch)
treea89d28ad26b26f8b8e519106dc304d4e54d3b39e
parent22d7bef5680a1d55c28ff6246518d96487e95d5e (diff)
Add named persistent sessions (#57)
* feat: add named persistent sessions * feat: add session management commands * fix: tighten named session stop and override precedence * fix: handle session help and stop test race * test: isolate default session env test --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
-rw-r--r--CONFIGURATION.md9
-rw-r--r--README.md12
-rw-r--r--SOCKET_API.md18
-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
-rw-r--r--tests/cli_wrapper.rs259
12 files changed, 1102 insertions, 31 deletions
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index a494d0cc..06afdb48 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -6,6 +6,15 @@ herdr reads config from:
~/.config/herdr/config.toml
```
+Named sessions share this config file. Sessions are runtime/socket namespaces, not workspace replacements; per-session sockets and persistent runtime state are separate:
+
+```text
+~/.config/herdr/session.json
+~/.config/herdr/sessions/<name>/session.json
+```
+
+Use `herdr session list`, `herdr session stop <name>`, and `herdr session delete <name>` to inspect and manage named session namespaces.
+
print the full default config with:
```bash
diff --git a/README.md b/README.md
index 0ff1ef93..8ecaea54 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,17 @@ herdr update
herdr
```
-by default herdr launches or attaches to a background session server. `ctrl+b q` detaches the client. agents keep running. use `herdr server stop` to stop the server. use `--no-session` for the old single-process mode.
+by default herdr launches or attaches to one background session server. `ctrl+b q` detaches the client. agents keep running. use `herdr server stop` to stop the default server. use `--no-session` for the old single-process mode.
+
+named sessions are runtime/socket namespaces for separate persistent herdr servers. they do not replace workspaces; each named session has its own panes, tabs, workspaces, sockets, and session state while sharing the same global config file.
+
+```bash
+herdr --session work
+herdr --session side-project
+herdr session list
+herdr session stop work
+herdr session delete side-project
+```
1. press `n` to create a workspace
2. run an agent in the root pane
diff --git a/SOCKET_API.md b/SOCKET_API.md
index b9da2efb..ea834e14 100644
--- a/SOCKET_API.md
+++ b/SOCKET_API.md
@@ -23,13 +23,21 @@ important difference: `pane.run` and `wait agent-status` are **cli conveniences*
- request/response: send one json request per line, read one json response per line
- subscriptions: send `events.subscribe`, receive an ack, then keep the same connection open and continue reading pushed events
+named sessions are runtime/socket namespaces, not replacements for herdr workspaces. each named session has its own server sockets and persistent runtime state while config remains global.
+
socket path resolution order:
-1. `HERDR_SOCKET_PATH`
-2. `$XDG_RUNTIME_DIR/herdr.sock`
-3. `$XDG_CONFIG_HOME/herdr/herdr.sock`
-4. `$HOME/.config/herdr/herdr.sock`
-5. `/tmp/herdr.sock`
+1. explicit `herdr --session <name>`:
+ `$XDG_CONFIG_HOME/herdr/sessions/<name>/herdr.sock` or `$HOME/.config/herdr/sessions/<name>/herdr.sock`
+2. `HERDR_SOCKET_PATH`
+3. `HERDR_SESSION=<name>`:
+ `$XDG_CONFIG_HOME/herdr/sessions/<name>/herdr.sock` or `$HOME/.config/herdr/sessions/<name>/herdr.sock`
+4. default session path:
+ `$XDG_CONFIG_HOME/herdr/herdr.sock` or `$HOME/.config/herdr/herdr.sock`
+
+this means `HERDR_SOCKET_PATH` remains an exact low-level socket override, but an explicit cli `--session <name>` still wins when a command runs inside a pane that inherited `HERDR_SOCKET_PATH`.
+
+session names may contain ASCII letters, numbers, `.`, `_`, and `-`. `default` is reserved for the default session. use `herdr session list`, `herdr session stop <name>`, and `herdr session delete <name>` to inspect and manage session namespaces. `session delete` refuses running sessions and does not delete the default session.
## request and response envelopes
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");
+ }
+}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index caad5b39..03bfaad4 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -29,6 +29,19 @@ struct SpawnedHerdr {
child: Box<dyn Child + Send + Sync>,
}
+struct SpawnedServerProcess {
+ child: std::process::Child,
+}
+
+impl Drop for SpawnedServerProcess {
+ fn drop(&mut self) {
+ let pid = self.child.id();
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+}
+
impl Drop for SpawnedHerdr {
fn drop(&mut self) {
let pid = self.child.process_id();
@@ -71,6 +84,91 @@ fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> Sp
spawn_herdr_with_path(config_home, runtime_dir, socket_path, None)
}
+fn app_dir_name() -> &'static str {
+ if cfg!(debug_assertions) {
+ "herdr-dev"
+ } else {
+ "herdr"
+ }
+}
+
+fn named_session_socket(config_home: &Path, session: &str) -> PathBuf {
+ config_home
+ .join(app_dir_name())
+ .join("sessions")
+ .join(session)
+ .join("herdr.sock")
+}
+
+fn spawn_named_server(
+ config_home: &Path,
+ runtime_dir: &Path,
+ session: &str,
+) -> SpawnedServerProcess {
+ fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join(app_dir_name()).join("config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command
+ .args(["--session", session, "server"])
+ .env("XDG_CONFIG_HOME", config_home)
+ .env("XDG_RUNTIME_DIR", runtime_dir)
+ .env_remove("HERDR_SOCKET_PATH")
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV")
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null());
+
+ let child = command.spawn().unwrap();
+ register_spawned_herdr_pid(Some(child.id()));
+ SpawnedServerProcess { child }
+}
+
+fn run_named_cli(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> std::process::Output {
+ run_named_cli_with_socket_override(config_home, runtime_dir, args, None)
+}
+
+fn run_named_cli_with_socket_override(
+ config_home: &Path,
+ runtime_dir: &Path,
+ args: &[&str],
+ socket_override: Option<&Path>,
+) -> std::process::Output {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command
+ .args(args)
+ .env("XDG_CONFIG_HOME", config_home)
+ .env("XDG_RUNTIME_DIR", runtime_dir)
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV");
+ if let Some(socket_override) = socket_override {
+ command.env("HERDR_SOCKET_PATH", socket_override);
+ } else {
+ command.env_remove("HERDR_SOCKET_PATH");
+ }
+ command.output().unwrap()
+}
+
+fn run_named_cli_json(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> serde_json::Value {
+ let output = run_named_cli(config_home, runtime_dir, args);
+ assert!(
+ output.status.success(),
+ "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
+ args.join(" "),
+ output.status.code(),
+ String::from_utf8_lossy(&output.stderr),
+ String::from_utf8_lossy(&output.stdout)
+ );
+ serde_json::from_slice(&output.stdout).unwrap()
+}
+
fn spawn_herdr_with_path(
config_home: &Path,
runtime_dir: &Path,
@@ -472,6 +570,7 @@ fn help_commands_exit_successfully() {
&["tab", "-h"],
&["pane", "-h"],
&["wait", "-h"],
+ &["session", "-h"],
&["integration", "-h"],
];
@@ -512,6 +611,166 @@ fn removed_show_changelog_flag_fails_before_nested_guard() {
}
#[test]
+fn named_sessions_use_separate_servers_and_workspace_state() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+
+ let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha");
+ let beta = spawn_named_server(&config_home, &runtime_dir, "beta");
+
+ wait_for_socket(
+ &named_session_socket(&config_home, "alpha"),
+ Duration::from_secs(5),
+ );
+ wait_for_socket(
+ &named_session_socket(&config_home, "beta"),
+ Duration::from_secs(5),
+ );
+
+ run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "alpha",
+ "workspace",
+ "create",
+ "--label",
+ "alpha-ws",
+ "--no-focus",
+ ],
+ );
+ run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &[
+ "--session",
+ "beta",
+ "workspace",
+ "create",
+ "--label",
+ "beta-ws",
+ "--no-focus",
+ ],
+ );
+
+ let alpha_list = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "alpha", "workspace", "list"],
+ );
+ let beta_list = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "beta", "workspace", "list"],
+ );
+
+ let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|workspace| workspace["label"].as_str().unwrap())
+ .collect();
+ let beta_labels: Vec<_> = beta_list["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|workspace| workspace["label"].as_str().unwrap())
+ .collect();
+
+ assert_eq!(alpha_labels, vec!["alpha-ws"]);
+ assert_eq!(beta_labels, vec!["beta-ws"]);
+
+ let beta_via_explicit_session = run_named_cli_with_socket_override(
+ &config_home,
+ &runtime_dir,
+ &["--session", "beta", "workspace", "list"],
+ Some(&named_session_socket(&config_home, "alpha")),
+ );
+ assert!(
+ beta_via_explicit_session.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&beta_via_explicit_session.stderr)
+ );
+ let beta_via_explicit_session: serde_json::Value =
+ serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap();
+ let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|workspace| workspace["label"].as_str().unwrap())
+ .collect();
+ assert_eq!(labels_via_explicit, vec!["beta-ws"]);
+
+ let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list"]);
+ let sessions = sessions["sessions"].as_array().unwrap();
+ let default_session = sessions
+ .iter()
+ .find(|session| session["name"] == "default")
+ .unwrap();
+ let alpha_session = sessions
+ .iter()
+ .find(|session| session["name"] == "alpha")
+ .unwrap();
+ let beta_session = sessions
+ .iter()
+ .find(|session| session["name"] == "beta")
+ .unwrap();
+ assert_eq!(default_session["default"], true);
+ assert_eq!(default_session["running"], false);
+ assert_eq!(alpha_session["running"], true);
+ assert_eq!(beta_session["running"], true);
+ assert!(alpha_session["socket_path"]
+ .as_str()
+ .unwrap()
+ .ends_with("/sessions/alpha/herdr.sock"));
+ assert!(beta_session["session_dir"]
+ .as_str()
+ .unwrap()
+ .ends_with("/sessions/beta"));
+
+ let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]);
+ assert_eq!(delete_running.status.code(), Some(1));
+ assert!(
+ String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"),
+ "stderr: {}",
+ String::from_utf8_lossy(&delete_running.stderr)
+ );
+
+ let delete_default = run_named_cli(
+ &config_home,
+ &runtime_dir,
+ &["session", "delete", "default"],
+ );
+ assert_eq!(delete_default.status.code(), Some(1));
+ assert!(
+ String::from_utf8_lossy(&delete_default.stderr).contains("default session"),
+ "stderr: {}",
+ String::from_utf8_lossy(&delete_default.stderr)
+ );
+
+ let stopped_alpha =
+ run_named_cli_json(&config_home, &runtime_dir, &["session", "stop", "alpha"]);
+ assert_eq!(stopped_alpha["stopped"], true);
+ assert_eq!(stopped_alpha["session"]["running"], false);
+
+ let deleted_alpha =
+ run_named_cli_json(&config_home, &runtime_dir, &["session", "delete", "alpha"]);
+ assert_eq!(deleted_alpha["deleted"], true);
+ assert!(!config_home
+ .join(app_dir_name())
+ .join("sessions")
+ .join("alpha")
+ .exists());
+
+ let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]);
+ drop(alpha);
+ drop(beta);
+ cleanup_test_base(&base);
+}
+
+#[test]
fn integration_commands_honor_socket_override_when_server_is_missing() {
let base = unique_test_dir();
let home_dir = base.join("home");