From 4bd4aa4a3685b8da150465a33069ea1331ae2479 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:10:54 +0700 Subject: [PATCH 1/8] feat(proto): SurfaceState + SetState command + State event + SurfaceView.state Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-proto/src/lib.rs | 2 ++ crates/spacesh-proto/src/message.rs | 22 +++++++++++++++ crates/spacesh-proto/src/status.rs | 39 +++++++++++++++++++++++++++ crates/spacesh-proto/src/workspace.rs | 4 +++ 4 files changed, 67 insertions(+) create mode 100644 crates/spacesh-proto/src/status.rs diff --git a/crates/spacesh-proto/src/lib.rs b/crates/spacesh-proto/src/lib.rs index cef3834..987b410 100644 --- a/crates/spacesh-proto/src/lib.rs +++ b/crates/spacesh-proto/src/lib.rs @@ -2,9 +2,11 @@ pub mod codec; pub mod ids; pub mod layout; pub mod message; +pub mod status; pub mod workspace; pub use ids::{GroupId, SurfaceId, WorkspaceId}; pub use layout::{LayoutNode, Orient}; pub use message::{Cmd, Envelope, ErrorBody, Evt}; +pub use status::SurfaceState; pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index b8187a2..dc26265 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{GroupId, SurfaceId, WorkspaceId}; use crate::layout::LayoutNode; +use crate::status::SurfaceState; use crate::workspace::{Group, WorkspaceView}; /// Wire envelope. `kind` is the serde tag. @@ -114,6 +115,7 @@ pub enum Cmd { order: Option, }, DeleteGroup { group_id: GroupId }, + SetState { surface_id: SurfaceId, state: SurfaceState }, Status, Shutdown, } @@ -131,6 +133,7 @@ pub enum Evt { WorkspaceClosed { workspace_id: WorkspaceId }, GroupsChanged { groups: Vec }, SurfaceRestarted { surface_id: SurfaceId }, + State { surface_id: SurfaceId, state: SurfaceState }, } #[cfg(test)] @@ -240,4 +243,23 @@ mod tests { let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap(); assert_eq!(back, evt); } + + #[test] + fn set_state_round_trips() { + let env = Envelope::Req { + id: 1, + cmd: Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Done }, + }; + let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); + assert_eq!(back, env); + } + + #[test] + fn state_event_round_trips() { + let evt = Envelope::Evt(Evt::State { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Wait }); + let j = serde_json::to_string(&evt).unwrap(); + assert!(j.contains(r#""evt":"state""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, evt); + } } diff --git a/crates/spacesh-proto/src/status.rs b/crates/spacesh-proto/src/status.rs new file mode 100644 index 0000000..ee14d64 --- /dev/null +++ b/crates/spacesh-proto/src/status.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +/// Ephemeral agent-activity status of a running surface (orthogonal to the +/// running/stopped process lifecycle). Defaults to `Idle`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SurfaceState { + Work, + Wait, + Done, + Error, + #[default] + Idle, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_snake_case() { + assert_eq!(serde_json::to_string(&SurfaceState::Work).unwrap(), r#""work""#); + assert_eq!(serde_json::to_string(&SurfaceState::Idle).unwrap(), r#""idle""#); + } + + #[test] + fn default_is_idle() { + assert_eq!(SurfaceState::default(), SurfaceState::Idle); + } + + #[test] + fn round_trips() { + for s in [SurfaceState::Work, SurfaceState::Wait, SurfaceState::Done, SurfaceState::Error, SurfaceState::Idle] { + let j = serde_json::to_string(&s).unwrap(); + let back: SurfaceState = serde_json::from_str(&j).unwrap(); + assert_eq!(back, s); + } + } +} diff --git a/crates/spacesh-proto/src/workspace.rs b/crates/spacesh-proto/src/workspace.rs index 23ffea7..5a0c28a 100644 --- a/crates/spacesh-proto/src/workspace.rs +++ b/crates/spacesh-proto/src/workspace.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::ids::{GroupId, SurfaceId, WorkspaceId}; use crate::layout::LayoutNode; +use crate::status::SurfaceState; /// Everything needed to (re)create a panel's process. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -50,6 +51,9 @@ pub struct SurfaceView { pub spec: SurfaceSpec, /// true = has a live actor/PTY; false = stopped (in tree, no process). pub running: bool, + /// Ephemeral agent-activity status (meaningful while running). + #[serde(default)] + pub state: SurfaceState, } /// Workspace view in `status` / `workspace_changed`: structure + per-surface state. From 635f9f4356186fbe293cd353deb8182ee180e5fa Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:13:50 +0700 Subject: [PATCH 2/8] feat(daemon): per-surface status (set_state/state), idle-on-spawn, SPACESH_SOCK override Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/lifecycle.rs | 15 +++++++++ crates/spaceshd/src/registry.rs | 46 +++++++++++++++++++++++++- crates/spaceshd/src/server.rs | 55 +++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/crates/spaceshd/src/lifecycle.rs b/crates/spaceshd/src/lifecycle.rs index 5ed2da6..26d3736 100644 --- a/crates/spaceshd/src/lifecycle.rs +++ b/crates/spaceshd/src/lifecycle.rs @@ -10,6 +10,11 @@ pub fn spacesh_dir() -> Result { } pub fn socket_path() -> Result { + if let Ok(p) = std::env::var("SPACESH_SOCK") { + if !p.is_empty() { + return Ok(PathBuf::from(p)); + } + } Ok(spacesh_dir()?.join("sock")) } @@ -67,4 +72,14 @@ mod tests { assert!(second.is_none(), "second acquire should be blocked"); drop(first); } + + #[test] + fn socket_path_honors_env_override() { + // Note: set/remove around the assertion; tests in this module run serially enough, + // but guard by restoring afterwards. + std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock"); + let p = socket_path().unwrap(); + std::env::remove_var("SPACESH_SOCK"); + assert_eq!(p, std::path::PathBuf::from("/tmp/spacesh-test-override.sock")); + } } diff --git a/crates/spaceshd/src/registry.rs b/crates/spaceshd/src/registry.rs index cf47eb6..a7a31ff 100644 --- a/crates/spaceshd/src/registry.rs +++ b/crates/spaceshd/src/registry.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId}; +use spacesh_proto::status::SurfaceState; use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; use crate::state_store::PersistState; @@ -18,6 +19,8 @@ pub struct Registry { by_path: HashMap, /// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped. live: HashMap, + /// Ephemeral per-surface status. In-memory only (never persisted). + states: HashMap, } impl Registry { @@ -111,6 +114,18 @@ impl Registry { self.live.contains_key(sid) } + // ---- surface state ---- + + pub fn set_state(&mut self, sid: &SurfaceId, state: SurfaceState) { + self.states.insert(sid.clone(), state); + } + pub fn state(&self, sid: &SurfaceId) -> SurfaceState { + self.states.get(sid).copied().unwrap_or_default() + } + pub fn drop_state(&mut self, sid: &SurfaceId) { + self.states.remove(sid); + } + // ---- groups ---- pub fn create_group(&mut self, name: String, color: String) -> GroupId { @@ -144,7 +159,11 @@ impl Registry { } fn to_view(&self, w: &Workspace) -> WorkspaceView { let surfaces = w.surfaces.iter().map(|(sid, spec)| { - (sid.clone(), SurfaceView { spec: spec.clone(), running: self.live.contains_key(sid) }) + (sid.clone(), SurfaceView { + spec: spec.clone(), + running: self.live.contains_key(sid), + state: self.state(sid), + }) }).collect(); WorkspaceView { id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), @@ -168,6 +187,7 @@ impl Registry { self.workspaces.clear(); self.by_path.clear(); self.live.clear(); + self.states.clear(); for w in state.workspaces { self.by_path.insert(w.path.clone(), w.id.clone()); self.workspaces.insert(w.id.clone(), w); @@ -243,4 +263,28 @@ mod tests { r.delete_group(&g); assert!(r.workspace(&ws).unwrap().group_id.is_none()); } + + #[test] + fn state_defaults_idle_and_can_be_set() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let sid = r.new_surface_id(); + r.add_surface_spec(&ws, sid.clone(), spec()); + assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle); + r.set_state(&sid, spacesh_proto::status::SurfaceState::Work); + assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Work); + let v = r.workspace_view(&ws).unwrap(); + assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work); + } + + #[test] + fn drop_state_resets_to_idle() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let sid = r.new_surface_id(); + r.add_surface_spec(&ws, sid.clone(), spec()); + r.set_state(&sid, spacesh_proto::status::SurfaceState::Error); + r.drop_state(&sid); + assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle); + } } diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 60759a6..3d45149 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -133,8 +133,8 @@ async fn router( } } ServerMsg::Exit { surface_id, code } => { - // Transition running -> stopped; keep panel + tree. reg.mark_stopped(&surface_id); + reg.drop_state(&surface_id); let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); broadcast_evt(&clients, &evt); } @@ -211,6 +211,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&sid, spacesh_proto::SurfaceState::Idle); reg.add_surface_spec(&workspace_id, sid.clone(), spec); // First panel of an empty workspace becomes the root leaf. if let Some(w) = reg.workspace_mut(&workspace_id) { @@ -241,6 +242,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle); reg.add_surface_spec(&ws_id, new_sid.clone(), spec); let orient = match dir { SplitDir::Right => Orient::H, SplitDir::Down => Orient::V }; if let Some(w) = reg.workspace_mut(&ws_id) { @@ -313,6 +315,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle); reg.add_surface_spec(&workspace_id, new_sid.clone(), spec); new_ids.push(new_sid); } @@ -342,6 +345,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&surface_id, spacesh_proto::SurfaceState::Idle); broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() })); let _ = out.send(ok(id, serde_json::Value::Null)).await; } @@ -467,6 +471,17 @@ async fn handle_request( } } + Cmd::SetState { surface_id, state } => { + if reg.is_running(&surface_id) { + reg.set_state(&surface_id, state); + broadcast_evt(clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state })); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } else { + // unknown or stopped surface — status is only meaningful while running. + let _ = out.send(err(id, "NOT_FOUND", "surface not running")).await; + } + } + Cmd::Status => { let (groups, workspaces) = reg.status(); let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; @@ -677,6 +692,44 @@ mod tests { assert!(w0["layout"].to_string().contains("split")); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_state_updates_status_and_emits_event() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let sock2 = sock.clone(); + tokio::spawn(async move { let _ = serve(&sock2, store).await; }); + wait_for_socket(&sock).await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + + let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + let r = req(&mut s, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + command: Some("/bin/sh".into()), + args: vec!["-c".into(), "sleep 1".into()], + cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + let surface_id = spacesh_proto::SurfaceId(sid.clone()); + + // set_state on the running surface + let r = req(&mut s, 3, Cmd::SetState { surface_id: surface_id.clone(), state: spacesh_proto::status::SurfaceState::Work }).await; + assert!(matches!(r, Envelope::Res { ok: true, .. })); + + // status reflects it + let r = req(&mut s, 4, Cmd::Status).await; + let wss = res_data(&r)["workspaces"].as_array().unwrap(); + let w0 = wss.iter().find(|w| w["id"] == ws).unwrap(); + assert_eq!(w0["surfaces"][&sid]["state"], "work"); + + // unknown surface -> NOT_FOUND + let r = req(&mut s, 5, Cmd::SetState { surface_id: spacesh_proto::SurfaceId("s_nope".into()), state: spacesh_proto::status::SurfaceState::Done }).await; + match r { Envelope::Res { ok, error, .. } => { assert!(!ok); assert_eq!(error.unwrap().code, "NOT_FOUND"); }, _ => panic!() } + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cold_restart_restores_structure_stopped() { let _serial = crate::test_support::serial(); From a9fa1bf77b09fdffcc15c9a6a8873251d5b3d5d7 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:17:15 +0700 Subject: [PATCH 3/8] =?UTF-8?q?feat(cli):=20spacesh-cli=20scaffold=20?= =?UTF-8?q?=E2=80=94=20clap=20tree,=20one-shot=20client,=20command=20mappi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 3 + crates/spacesh-cli/Cargo.toml | 20 ++++++ crates/spacesh-cli/src/cli.rs | 81 +++++++++++++++++++++ crates/spacesh-cli/src/client.rs | 68 ++++++++++++++++++ crates/spacesh-cli/src/lib.rs | 4 ++ crates/spacesh-cli/src/main.rs | 9 +++ crates/spacesh-cli/src/mapping.rs | 115 ++++++++++++++++++++++++++++++ crates/spacesh-cli/src/output.rs | 1 + 8 files changed, 301 insertions(+) create mode 100644 crates/spacesh-cli/Cargo.toml create mode 100644 crates/spacesh-cli/src/cli.rs create mode 100644 crates/spacesh-cli/src/client.rs create mode 100644 crates/spacesh-cli/src/lib.rs create mode 100644 crates/spacesh-cli/src/main.rs create mode 100644 crates/spacesh-cli/src/mapping.rs create mode 100644 crates/spacesh-cli/src/output.rs diff --git a/Cargo.toml b/Cargo.toml index 0a5cfcf..849d892 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/spacesh-pty", "crates/spacesh-core", "crates/spaceshd", + "crates/spacesh-cli", ] [workspace.package] @@ -25,3 +26,5 @@ portable-pty = "0.8" alacritty_terminal = "0.25" fs2 = "0.4" dirs = "5" +clap = { version = "4", features = ["derive"] } +clap_complete = "4" diff --git a/crates/spacesh-cli/Cargo.toml b/crates/spacesh-cli/Cargo.toml new file mode 100644 index 0000000..74cf22e --- /dev/null +++ b/crates/spacesh-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "spacesh-cli" +edition.workspace = true +version.workspace = true + +[lib] +name = "spacesh_cli" +path = "src/lib.rs" + +[[bin]] +name = "spacesh" +path = "src/main.rs" + +[dependencies] +spacesh-proto = { path = "../spacesh-proto" } +clap.workspace = true +clap_complete.workspace = true +tokio = { workspace = true } +serde_json.workspace = true +anyhow.workspace = true diff --git a/crates/spacesh-cli/src/cli.rs b/crates/spacesh-cli/src/cli.rs new file mode 100644 index 0000000..132c582 --- /dev/null +++ b/crates/spacesh-cli/src/cli.rs @@ -0,0 +1,81 @@ +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Parser, Debug)] +#[command(name = "spacesh", about = "spacesh CLI — thin client to the spacesh daemon")] +pub struct Cli { + /// Print raw JSON instead of human output. + #[arg(long, global = true)] + pub json: bool, + #[command(subcommand)] + pub cmd: Sub, +} + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] +pub enum StateArg { Work, Wait, Done, Error, Idle } + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] +pub enum DirArg { Right, Down } + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] +pub enum EdgeArg { Left, Right, Top, Bottom } + +#[derive(Subcommand, Debug)] +pub enum Sub { + Open { path: String }, + Status, + NewSurface { + workspace_id: String, + #[arg(long)] cmd: Option, + #[arg(long = "arg")] args: Vec, + #[arg(long, default_value_t = 80)] cols: u16, + #[arg(long, default_value_t = 24)] rows: u16, + }, + Split { + surface_id: String, + #[arg(long, value_enum, default_value_t = DirArg::Right)] dir: DirArg, + #[arg(long)] cmd: Option, + #[arg(long = "arg")] args: Vec, + }, + Close { surface_id: String }, + Focus { surface_id: String }, + Restart { surface_id: String }, + Notify { + #[arg(long)] surface: String, + #[arg(long, value_enum)] state: StateArg, + }, + ApplyPreset { + workspace_id: String, + #[arg(long)] preset: String, + #[arg(long = "agent")] agents: Vec, + }, + SetRatios { + workspace_id: String, + #[arg(long, value_delimiter = ',')] path: Vec, + #[arg(long, value_delimiter = ',')] ratios: Vec, + }, + Move { + surface_id: String, + #[arg(long)] target: String, + #[arg(long, value_enum)] edge: EdgeArg, + }, + CloseWorkspace { workspace_id: String }, + Group { + #[command(subcommand)] action: GroupAction, + }, + SetMeta { + workspace_id: String, + #[arg(long)] name: Option, + #[arg(long)] group: Option, + #[arg(long)] unread: Option, + #[arg(long)] order: Option, + }, + Shutdown, + Completions { shell: clap_complete::Shell }, +} + +#[derive(Subcommand, Debug)] +pub enum GroupAction { + Create { #[arg(long)] name: String, #[arg(long)] color: String }, + Set { group_id: String, #[arg(long)] name: Option, #[arg(long)] color: Option, #[arg(long)] order: Option }, + Delete { group_id: String }, +} diff --git a/crates/spacesh-cli/src/client.rs b/crates/spacesh-cli/src/client.rs new file mode 100644 index 0000000..f01aefe --- /dev/null +++ b/crates/spacesh-cli/src/client.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; +use anyhow::{anyhow, Context, Result}; +use serde_json::Value; +use spacesh_proto::codec::{read_frame, write_frame}; +use spacesh_proto::{Cmd, Envelope}; +use tokio::net::UnixStream; + +pub fn socket_path() -> PathBuf { + if let Ok(p) = std::env::var("SPACESH_SOCK") { + if !p.is_empty() { + return PathBuf::from(p); + } + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".spacesh").join("sock") +} + +/// Connect, lazy-starting the daemon if the socket is absent. +async fn connect_or_start() -> Result { + let sock = socket_path(); + if let Ok(s) = UnixStream::connect(&sock).await { + return Ok(s); + } + // Locate the daemon next to this binary and spawn it. + let exe = std::env::current_exe().context("current_exe")?; + let daemon = exe.with_file_name("spaceshd"); + let _ = std::process::Command::new(daemon).spawn(); + for _ in 0..100 { + if let Ok(s) = UnixStream::connect(&sock).await { + return Ok(s); + } + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + } + Err(anyhow!("daemon unavailable")) +} + +/// One-shot request/response. Skips any interleaved events; returns `data` on ok. +pub async fn request(cmd: Cmd) -> Result { + let mut stream = connect_or_start().await?; + send_and_read(&mut stream, cmd).await +} + +/// Best-effort status notify: connect only (no spawn); silently succeed if absent. +pub async fn notify(cmd: Cmd) -> Result<()> { + let sock = socket_path(); + let Ok(mut stream) = UnixStream::connect(&sock).await else { + return Ok(()); // no daemon — best-effort no-op + }; + let _ = send_and_read(&mut stream, cmd).await; + Ok(()) +} + +async fn send_and_read(stream: &mut UnixStream, cmd: Cmd) -> Result { + write_frame(stream, &Envelope::Req { id: 1, cmd }).await.map_err(|e| anyhow!(e.to_string()))?; + loop { + match read_frame(stream).await.map_err(|e| anyhow!(e.to_string()))? { + Some(Envelope::Res { id: 1, ok, data, error }) => { + if ok { + return Ok(data); + } + let (code, msg) = error.map(|e| (e.code, e.msg)).unwrap_or_else(|| ("ERROR".into(), "error".into())); + return Err(anyhow!("{code}: {msg}")); + } + Some(_) => continue, // events / non-matching res + None => return Err(anyhow!("connection closed")), + } + } +} diff --git a/crates/spacesh-cli/src/lib.rs b/crates/spacesh-cli/src/lib.rs new file mode 100644 index 0000000..e81b7fe --- /dev/null +++ b/crates/spacesh-cli/src/lib.rs @@ -0,0 +1,4 @@ +pub mod cli; +pub mod client; +pub mod mapping; +pub mod output; diff --git a/crates/spacesh-cli/src/main.rs b/crates/spacesh-cli/src/main.rs new file mode 100644 index 0000000..5036b31 --- /dev/null +++ b/crates/spacesh-cli/src/main.rs @@ -0,0 +1,9 @@ +use clap::Parser; +use spacesh_cli::cli::Cli; +use spacesh_cli::output; + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() { + let parsed = Cli::parse(); + std::process::exit(output::run(parsed).await); +} diff --git a/crates/spacesh-cli/src/mapping.rs b/crates/spacesh-cli/src/mapping.rs new file mode 100644 index 0000000..9c964d7 --- /dev/null +++ b/crates/spacesh-cli/src/mapping.rs @@ -0,0 +1,115 @@ +use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId}; +use spacesh_proto::message::{Cmd, Edge, PresetSlot, SplitDir}; +use spacesh_proto::status::SurfaceState; +use crate::cli::{DirArg, EdgeArg, GroupAction, StateArg, Sub}; + +pub fn state_of(a: StateArg) -> SurfaceState { + match a { + StateArg::Work => SurfaceState::Work, + StateArg::Wait => SurfaceState::Wait, + StateArg::Done => SurfaceState::Done, + StateArg::Error => SurfaceState::Error, + StateArg::Idle => SurfaceState::Idle, + } +} + +/// Map a parsed subcommand to a bus command. `Completions` has no Cmd (handled +/// before dispatch), so callers must not pass it here. +pub fn to_cmd(sub: Sub) -> Cmd { + match sub { + Sub::Open { path } => Cmd::Open { path }, + Sub::Status => Cmd::Status, + Sub::NewSurface { workspace_id, cmd, args, cols, rows } => Cmd::NewSurface { + workspace_id: WorkspaceId(workspace_id), command: cmd, args, cols, rows, + }, + Sub::Split { surface_id, dir, cmd, args } => Cmd::SplitSurface { + surface_id: SurfaceId(surface_id), + dir: match dir { DirArg::Right => SplitDir::Right, DirArg::Down => SplitDir::Down }, + command: cmd, args, + }, + Sub::Close { surface_id } => Cmd::Close { surface_id: SurfaceId(surface_id) }, + Sub::Focus { surface_id } => Cmd::Focus { surface_id: SurfaceId(surface_id) }, + Sub::Restart { surface_id } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }, + Sub::Notify { surface, state } => Cmd::SetState { surface_id: SurfaceId(surface), state: state_of(state) }, + Sub::ApplyPreset { workspace_id, preset, agents } => Cmd::ApplyPreset { + workspace_id: WorkspaceId(workspace_id), + preset_id: preset, + slots: agents.into_iter().map(|a| if a == "shell" { + PresetSlot { command: None, args: vec![] } + } else { + PresetSlot { command: Some(a), args: vec![] } + }).collect(), + }, + Sub::SetRatios { workspace_id, path, ratios } => Cmd::SetRatios { + workspace_id: WorkspaceId(workspace_id), node_path: path, ratios, + }, + Sub::Move { surface_id, target, edge } => Cmd::MoveSurface { + surface_id: SurfaceId(surface_id), + target_surface_id: SurfaceId(target), + edge: match edge { EdgeArg::Left => Edge::Left, EdgeArg::Right => Edge::Right, EdgeArg::Top => Edge::Top, EdgeArg::Bottom => Edge::Bottom }, + }, + Sub::CloseWorkspace { workspace_id } => Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }, + Sub::Group { action } => match action { + GroupAction::Create { name, color } => Cmd::CreateGroup { name, color }, + GroupAction::Set { group_id, name, color, order } => Cmd::SetGroup { group_id: GroupId(group_id), name, color, order }, + GroupAction::Delete { group_id } => Cmd::DeleteGroup { group_id: GroupId(group_id) }, + }, + Sub::SetMeta { workspace_id, name, group, unread, order } => Cmd::SetWorkspaceMeta { + workspace_id: WorkspaceId(workspace_id), + name, + // None = no change; Some("") = ungroup; Some(g) = set group. + group_id: group.map(|g| if g.is_empty() { None } else { Some(GroupId(g)) }), + unread, + order, + }, + Sub::Shutdown => Cmd::Shutdown, + Sub::Completions { .. } => unreachable!("completions handled before dispatch"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + use clap::Parser; + + fn parse(argv: &[&str]) -> Sub { + Cli::try_parse_from(argv).unwrap().cmd + } + + #[test] + fn notify_maps_to_set_state() { + let cmd = to_cmd(parse(&["spacesh", "notify", "--surface", "s_1", "--state", "done"])); + assert!(matches!(cmd, Cmd::SetState { state: SurfaceState::Done, .. })); + } + + #[test] + fn split_default_dir_is_right() { + let cmd = to_cmd(parse(&["spacesh", "split", "s_1"])); + match cmd { Cmd::SplitSurface { dir, .. } => assert_eq!(dir, SplitDir::Right), _ => panic!() } + } + + #[test] + fn set_ratios_parses_csv() { + let cmd = to_cmd(parse(&["spacesh", "set-ratios", "w_1", "--path", "0,1", "--ratios", "0.3,0.7"])); + match cmd { Cmd::SetRatios { node_path, ratios, .. } => { assert_eq!(node_path, vec![0,1]); assert_eq!(ratios, vec![0.3,0.7]); }, _ => panic!() } + } + + #[test] + fn apply_preset_shell_agent_is_empty_slot() { + let cmd = to_cmd(parse(&["spacesh", "apply-preset", "w_1", "--preset", "2lr", "--agent", "shell", "--agent", "claude"])); + match cmd { + Cmd::ApplyPreset { slots, .. } => { + assert!(slots[0].command.is_none()); + assert_eq!(slots[1].command.as_deref(), Some("claude")); + } + _ => panic!(), + } + } + + #[test] + fn set_meta_group_empty_means_ungroup() { + let cmd = to_cmd(parse(&["spacesh", "set-meta", "w_1", "--group", ""])); + match cmd { Cmd::SetWorkspaceMeta { group_id, .. } => assert_eq!(group_id, Some(None)), _ => panic!() } + } +} diff --git a/crates/spacesh-cli/src/output.rs b/crates/spacesh-cli/src/output.rs new file mode 100644 index 0000000..10954b2 --- /dev/null +++ b/crates/spacesh-cli/src/output.rs @@ -0,0 +1 @@ +pub async fn run(_c: crate::cli::Cli) -> i32 { 0 } From 3c3a8039f3c01ff8a540d100fcc07b86aa456f43 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:17:53 +0700 Subject: [PATCH 4/8] feat(cli): command dispatch, human/--json rendering, status table, completions Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-cli/src/output.rs | 86 +++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/spacesh-cli/src/output.rs b/crates/spacesh-cli/src/output.rs index 10954b2..030c6ce 100644 --- a/crates/spacesh-cli/src/output.rs +++ b/crates/spacesh-cli/src/output.rs @@ -1 +1,85 @@ -pub async fn run(_c: crate::cli::Cli) -> i32 { 0 } +use clap::CommandFactory; +use serde_json::Value; +use crate::cli::{Cli, Sub}; +use crate::{client, mapping}; + +/// Entry point: returns the process exit code. +pub async fn run(cli: Cli) -> i32 { + // Completions are local — no daemon. + if let Sub::Completions { shell } = cli.cmd { + let mut cmd = Cli::command(); + clap_complete::generate(shell, &mut cmd, "spacesh", &mut std::io::stdout()); + return 0; + } + + // notify is best-effort: never fails the caller. + if let Sub::Notify { .. } = &cli.cmd { + let _ = client::notify(mapping::to_cmd(cli.cmd)).await; + return 0; + } + + let is_status = matches!(cli.cmd, Sub::Status); + let cmd = mapping::to_cmd(cli.cmd); + match client::request(cmd).await { + Ok(data) => { + if cli.json { + println!("{}", serde_json::to_string_pretty(&data).unwrap_or_else(|_| "null".into())); + } else if is_status { + print_status(&data); + } else { + print_human(&data); + } + 0 + } + Err(e) => { + if cli.json { + println!("{}", serde_json::json!({ "ok": false, "error": e.to_string() })); + } else { + eprintln!("{e}"); + } + 1 + } + } +} + +/// Human render for non-status commands: surface the salient id, else "ok". +fn print_human(data: &Value) { + if let Some(id) = data.get("workspace_id").and_then(|v| v.as_str()) { + println!("{id}"); + } else if let Some(id) = data.get("surface_id").and_then(|v| v.as_str()) { + println!("{id}"); + } else if let Some(id) = data.get("group_id").and_then(|v| v.as_str()) { + println!("{id}"); + } else if let Some(ids) = data.get("surface_ids").and_then(|v| v.as_array()) { + for id in ids { + if let Some(s) = id.as_str() { println!("{s}"); } + } + } else { + println!("ok"); + } +} + +/// Compact table for `status`. +fn print_status(data: &Value) { + let empty = vec![]; + let workspaces = data.get("workspaces").and_then(|v| v.as_array()).unwrap_or(&empty); + if workspaces.is_empty() { + println!("(no workspaces)"); + return; + } + for w in workspaces { + let name = w.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let id = w.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let unread = w.get("unread").and_then(|v| v.as_bool()).unwrap_or(false); + println!("{} ({}){}", name, id, if unread { " *" } else { "" }); + if let Some(surfaces) = w.get("surfaces").and_then(|v| v.as_object()) { + for (sid, sv) in surfaces { + let running = sv.get("running").and_then(|v| v.as_bool()).unwrap_or(false); + let state = sv.get("state").and_then(|v| v.as_str()).unwrap_or("idle"); + let agent = sv.get("spec").and_then(|s| s.get("agent_label")).and_then(|v| v.as_str()).unwrap_or("shell"); + let life = if running { "running" } else { "stopped" }; + println!(" {sid} {agent:<8} {life:<8} {state}"); + } + } + } +} From 6f2e7885a47c3aa5d63422057348f16d6baa3f46 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:20:26 +0700 Subject: [PATCH 5/8] test(cli): wire-level integration tests via SPACESH_SOCK mock daemon Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-cli/tests/integration.rs | 76 +++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 crates/spacesh-cli/tests/integration.rs diff --git a/crates/spacesh-cli/tests/integration.rs b/crates/spacesh-cli/tests/integration.rs new file mode 100644 index 0000000..1a46a2a --- /dev/null +++ b/crates/spacesh-cli/tests/integration.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; +use spacesh_proto::codec::{read_frame, write_frame}; +use spacesh_proto::{Cmd, Envelope, SurfaceId}; +use spacesh_proto::status::SurfaceState; +use spacesh_cli::client; +use tokio::net::UnixListener; + +// These tests mutate the process-global SPACESH_SOCK env var, so they must not +// run concurrently. Serialize them on a process-wide lock (poison-tolerant). +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); +fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn tmp_sock(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + p.push(format!("spacesh-cli-{name}-{n}.sock")); + p +} + +/// One-shot mock daemon: accept one connection, read one request, send `reply`. +fn mock_daemon(sock: PathBuf, reply: Envelope) { + let listener = UnixListener::bind(&sock).unwrap(); + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + if let Ok(Some(_req)) = read_frame(&mut stream).await { + let _ = write_frame(&mut stream, &reply).await; + } + } + }); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_returns_data_from_daemon() { + let _g = serial(); + let sock = tmp_sock("req"); + std::env::set_var("SPACESH_SOCK", &sock); + mock_daemon(sock.clone(), Envelope::Res { + id: 1, ok: true, data: serde_json::json!({ "workspace_id": "w_1" }), error: None, + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; // let the listener bind + + let data = client::request(Cmd::Open { path: "/tmp".into() }).await.unwrap(); + std::env::remove_var("SPACESH_SOCK"); + let _ = std::fs::remove_file(&sock); + assert_eq!(data["workspace_id"], "w_1"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_surfaces_daemon_error() { + let _g = serial(); + let sock = tmp_sock("err"); + std::env::set_var("SPACESH_SOCK", &sock); + mock_daemon(sock.clone(), Envelope::Res { + id: 1, ok: false, data: serde_json::Value::Null, + error: Some(spacesh_proto::ErrorBody { code: "NOT_FOUND".into(), msg: "surface".into() }), + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let res = client::request(Cmd::Close { surface_id: SurfaceId("s_x".into()) }).await; + std::env::remove_var("SPACESH_SOCK"); + let _ = std::fs::remove_file(&sock); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("NOT_FOUND")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn notify_with_no_daemon_is_silent_success() { + let _g = serial(); + let sock = tmp_sock("nodaemon"); // never bound + std::env::set_var("SPACESH_SOCK", &sock); + let r = client::notify(Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: SurfaceState::Done }).await; + std::env::remove_var("SPACESH_SOCK"); + assert!(r.is_ok(), "notify must be a silent success when no daemon is listening"); +} From 6fc099bb6fa4d066daac75442901cf3fe42d2ebd Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:23:41 +0700 Subject: [PATCH 6/8] test(daemon): serialize SPACESH_SOCK-mutating lifecycle tests to fix env race Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/lifecycle.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/spaceshd/src/lifecycle.rs b/crates/spaceshd/src/lifecycle.rs index 26d3736..3412c45 100644 --- a/crates/spaceshd/src/lifecycle.rs +++ b/crates/spaceshd/src/lifecycle.rs @@ -56,6 +56,7 @@ mod tests { #[test] fn paths_live_under_spacesh_dir() { + let _serial = crate::test_support::serial(); let dir = spacesh_dir().unwrap(); assert!(socket_path().unwrap().starts_with(&dir)); assert!(lock_path().unwrap().starts_with(&dir)); @@ -75,8 +76,9 @@ mod tests { #[test] fn socket_path_honors_env_override() { - // Note: set/remove around the assertion; tests in this module run serially enough, - // but guard by restoring afterwards. + let _serial = crate::test_support::serial(); + // Note: set/remove around the assertion; serialized against the other + // env-sensitive lifecycle test via the crate's serial() lock. std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock"); let p = socket_path().unwrap(); std::env::remove_var("SPACESH_SOCK"); From 67c64e5bfed73b2c5ec08a92ea7e4a22c1321b56 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:24:40 +0700 Subject: [PATCH 7/8] chore: lock clap/clap_complete for spacesh-cli Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ed0f63b..21360d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,56 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -93,6 +143,61 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -277,6 +382,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -301,6 +412,12 @@ dependencies = [ "libc", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -398,6 +515,12 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -713,6 +836,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spacesh-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "serde_json", + "spacesh-proto", + "tokio", +] + [[package]] name = "spacesh-core" version = "0.1.0" @@ -764,6 +899,12 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -857,6 +998,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vte" version = "0.15.0" From 819e936e6d3e63cc0fef47122b0e90d6e68326ca Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:24:40 +0700 Subject: [PATCH 8/8] docs(plan): sync M4 lifecycle test with SPACESH_SOCK serial-guard fix Co-Authored-By: Claude Opus 4.8 (1M context) --- DOCS/superpowers/plans/2026-06-09-spacesh-m4.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DOCS/superpowers/plans/2026-06-09-spacesh-m4.md b/DOCS/superpowers/plans/2026-06-09-spacesh-m4.md index b1361b1..58583cf 100644 --- a/DOCS/superpowers/plans/2026-06-09-spacesh-m4.md +++ b/DOCS/superpowers/plans/2026-06-09-spacesh-m4.md @@ -275,18 +275,18 @@ pub fn socket_path() -> Result { Ok(spacesh_dir()?.join("sock")) } ``` -Add a test in `lifecycle.rs` tests module: +Add a test in `lifecycle.rs` tests module. It mutates the process-global `SPACESH_SOCK`, so it MUST hold `crate::test_support::serial()` for its duration; ALSO add the same guard to the existing `paths_live_under_spacesh_dir` test (the only other test that calls `socket_path()`), or it will intermittently observe the override var and fail: ```rust #[test] fn socket_path_honors_env_override() { - // Note: set/remove around the assertion; tests in this module run serially enough, - // but guard by restoring afterwards. + let _serial = crate::test_support::serial(); std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock"); let p = socket_path().unwrap(); std::env::remove_var("SPACESH_SOCK"); assert_eq!(p, std::path::PathBuf::from("/tmp/spacesh-test-override.sock")); } ``` +And prepend `let _serial = crate::test_support::serial();` as the first line of the existing `paths_live_under_spacesh_dir` test. - [ ] **Step 4: Initialize idle on every spawn; drop on exit; dispatch SetState**