Files
spaceshell/DOCS/superpowers/plans/2026-06-09-spacesh-m4.md
T
2026-06-09 22:24:40 +07:00

38 KiB
Raw Blame History

spacesh M4 Implementation Plan — CLI + status primitive

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the spacesh CLI (a one-shot client at full bus parity minus interactive panels) and the set_state/state status primitive (5 ephemeral states stored in the daemon, emitted as a state event, surfaced in status).

Architecture: New spacesh-cli crate produces the spacesh binary with a small blocking-over-async one-shot UDS client built on spacesh-proto's codec. The daemon gains an in-memory per-surface SurfaceState map (not persisted), a SetState command, and a State event. Both daemon and CLI honor SPACESH_SOCK to locate the socket (defaults to ~/.spacesh/sock), which also isolates tests.

Tech Stack: Rust — clap + clap_complete (CLI), tokio (net/rt), serde_json, anyhow; builds on the shipped M0M2 crates.

Spec: DOCS/superpowers/specs/2026-06-09-spacesh-m4-design.md. Base: DOCS/MAIN.md §5/§7.4/§10.1/§12.

Conventions: English code/comments. cargo test --workspace is the DoD and must stay green & non-flaky — new socket/PTY integration tests use #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + a serial() guard (the daemon crate already has crate::test_support::serial(); the CLI crate introduces its own). Commit after each task; append: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. Do not git push.


File Structure

Cargo.toml                          # + crates/spacesh-cli workspace member
crates/spacesh-proto/src/
  status.rs   (new)                 # SurfaceState enum
  message.rs                        # + Cmd::SetState, Evt::State
  workspace.rs                      # + SurfaceView.state
  lib.rs                            # + re-exports
crates/spaceshd/src/
  registry.rs                       # + states map (idle on spawn / drop on exit) + state in to_view
  server.rs                         # + Cmd::SetState dispatch + Evt::State; init idle on every spawn; drop on exit
  lifecycle.rs                      # + SPACESH_SOCK env override in socket_path()
crates/spacesh-cli/   (new)
  Cargo.toml
  src/
    main.rs                         # clap parse -> dispatch -> exit code
    cli.rs                          # clap derive types (Cli, Sub, arg enums)
    mapping.rs                      # Sub -> proto Cmd (pure, unit-tested)
    client.rs                       # one-shot UDS: ensure_daemon, request, notify
    output.rs                       # render res (human / --json), status table

Phase 1 — proto: status primitive

Task 1: SurfaceState + SetState/State + SurfaceView.state

Files:

  • Create: crates/spacesh-proto/src/status.rs

  • Modify: crates/spacesh-proto/src/message.rs, crates/spacesh-proto/src/workspace.rs, crates/spacesh-proto/src/lib.rs

  • Step 1: SurfaceState enum + test

Create crates/spacesh-proto/src/status.rs:

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);
        }
    }
}
  • Step 2: Add state to SurfaceView

In crates/spacesh-proto/src/workspace.rs, add the import and the field. Change the top import:

use crate::status::SurfaceState;

And add the field to SurfaceView:

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,
}
  • Step 3: Add SetState command and State event

In crates/spacesh-proto/src/message.rs, add the import:

use crate::status::SurfaceState;

Add to enum Cmd (before Status):

    SetState { surface_id: SurfaceId, state: SurfaceState },

Add to enum Evt:

    State { surface_id: SurfaceId, state: SurfaceState },
  • Step 4: Wire lib.rs

crates/spacesh-proto/src/lib.rs:

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};
  • Step 5: Add proto round-trip tests for the new variants

Append to the tests module in crates/spacesh-proto/src/message.rs:

    #[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);
    }
  • Step 6: Run tests

Run: cargo test -p spacesh-proto Expected: PASS (all proto tests incl. status + 2 new message tests). Note: the daemon will NOT compile after this task (its to_view is missing the new state field and its match cmd lacks SetState) — that is expected and fixed in Task 2. Verify proto in isolation only.

  • Step 7: Commit
git add crates/spacesh-proto/src/status.rs crates/spacesh-proto/src/message.rs crates/spacesh-proto/src/workspace.rs crates/spacesh-proto/src/lib.rs
git commit -m "feat(proto): SurfaceState + SetState command + State event + SurfaceView.state"

Phase 2 — daemon: status storage & dispatch

Task 2: states map, SetState dispatch, idle-on-spawn, drop-on-exit, SPACESH_SOCK

Files:

  • Modify: crates/spaceshd/src/registry.rs, crates/spaceshd/src/server.rs, crates/spaceshd/src/lifecycle.rs

  • Step 1: Add the states map to the registry

In crates/spaceshd/src/registry.rs:

Add the import:

use spacesh_proto::status::SurfaceState;

Add the field to struct Registry:

    /// Ephemeral per-surface status. In-memory only (never persisted).
    states: HashMap<SurfaceId, SurfaceState>,

Add these methods inside impl Registry (near the live-actor section):

    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);
    }

Update to_view to include the state:

    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),
                state: self.state(sid),
            })
        }).collect();
        WorkspaceView {
            id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
            group_id: w.group_id.clone(), order: w.order, unread: w.unread,
            layout: w.layout.clone(), surfaces,
        }
    }

Also clear states in restore (cold start): add self.states.clear(); next to self.live.clear();.

  • Step 2: Add registry unit tests

Append to the tests module in registry.rs:

    #[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);
    }
  • Step 3: SPACESH_SOCK override in lifecycle

In crates/spaceshd/src/lifecycle.rs, replace socket_path:

pub fn socket_path() -> Result<PathBuf> {
    if let Ok(p) = std::env::var("SPACESH_SOCK") {
        if !p.is_empty() {
            return Ok(PathBuf::from(p));
        }
    }
    Ok(spacesh_dir()?.join("sock"))
}

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:

    #[test]
    fn socket_path_honors_env_override() {
        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

In crates/spaceshd/src/server.rs:

(a) After EACH place that calls reg.set_live(handle) for a newly spawned surface — there are four: NewSurface, SplitSurface, ApplyPreset (inside the loop), RestartSurface — add an idle init right after set_live. For the single-surface handlers:

                    reg.set_live(handle);
                    reg.set_state(&sid, spacesh_proto::status::SurfaceState::Idle);

(use new_sid in SplitSurface, surface_id in RestartSurface, the loop's new_sid in ApplyPreset).

(b) In the router's ServerMsg::Exit arm, drop the state alongside mark_stopped:

            ServerMsg::Exit { surface_id, code } => {
                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);
            }

(c) Add the Cmd::SetState arm to handle_request (place it near Cmd::RestartSurface):

        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;
            }
        }
  • Step 5: Add a daemon integration test for SetState

Append to the tests module in server.rs (mirror the existing integration-test scaffolding — tempdir_path, wait_for_socket, req, res_data, the store construction):

    #[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<dyn crate::state_store::StateStore> =
            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!() }
    }
  • Step 6: Run the full suite (3×)

Run: cargo test --workspace > /tmp/m4.log 2>&1; echo EXIT=$? — three times, all 0. Expected: proto + daemon (incl. new tests) green.

  • Step 7: Commit
git add crates/spaceshd/src/registry.rs crates/spaceshd/src/server.rs crates/spaceshd/src/lifecycle.rs
git commit -m "feat(daemon): per-surface status (set_state/state), idle-on-spawn, SPACESH_SOCK override"

Phase 3 — spacesh-cli

Task 3: CLI scaffold — crate, clap tree, client, mapping (with unit tests)

Files:

  • Modify: Cargo.toml (workspace members)

  • Create: crates/spacesh-cli/Cargo.toml, crates/spacesh-cli/src/main.rs, cli.rs, mapping.rs, client.rs

  • Step 1: Add to workspace + crate manifest

In the root Cargo.toml [workspace] members, add "crates/spacesh-cli". Add to [workspace.dependencies]:

clap = { version = "4", features = ["derive"] }
clap_complete = "4"

Create crates/spacesh-cli/Cargo.toml (lib + bin so integration tests can call the real client/mapping):

[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
  • Step 2: clap types

Create crates/spacesh-cli/src/cli.rs:

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<String>,
        #[arg(long = "arg")] args: Vec<String>,
        #[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<String>,
        #[arg(long = "arg")] args: Vec<String>,
    },
    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<String>,
    },
    SetRatios {
        workspace_id: String,
        #[arg(long, value_delimiter = ',')] path: Vec<u32>,
        #[arg(long, value_delimiter = ',')] ratios: Vec<f32>,
    },
    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<String>,
        #[arg(long)] group: Option<String>,
        #[arg(long)] unread: Option<bool>,
        #[arg(long)] order: Option<u32>,
    },
    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<String>, #[arg(long)] color: Option<String>, #[arg(long)] order: Option<u32> },
    Delete { group_id: String },
}
  • Step 3: Write the mapping + its failing tests

Create crates/spacesh-cli/src/mapping.rs:

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!() }
    }
}
  • Step 4: One-shot client

Create crates/spacesh-cli/src/client.rs:

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<UnixStream> {
    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<Value> {
    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<Value> {
    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")),
        }
    }
}
  • Step 5: lib.rs (module roots) + thin main.rs

Create crates/spacesh-cli/src/lib.rs:

pub mod cli;
pub mod client;
pub mod mapping;
pub mod output;

Create crates/spacesh-cli/src/main.rs:

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);
}

(output::run is created in Task 4. To make Task 3 compile on its own, create a temporary output.rs with a stub pub async fn run(_c: crate::cli::Cli) -> i32 { 0 }, replaced fully in Task 4. The modules in mapping.rs/client.rs/cli.rs/output.rs reference each other via crate::... — correct under the lib root.)

Temporary crates/spacesh-cli/src/output.rs:

pub async fn run(_c: crate::cli::Cli) -> i32 { 0 }
  • Step 6: Run mapping tests

Run: cargo test -p spacesh-cli mapping Expected: PASS (5 tests). Also cargo build -p spacesh-cli succeeds.

  • Step 7: Commit
git add Cargo.toml crates/spacesh-cli/
git commit -m "feat(cli): spacesh-cli scaffold — clap tree, one-shot client, command mapping"

Task 4: dispatch, output rendering, completions

Files:

  • Modify: crates/spacesh-cli/src/output.rs (replace the stub)

  • Step 1: Implement run + rendering

Replace crates/spacesh-cli/src/output.rs:

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}");
            }
        }
    }
}
  • Step 2: Build + manual smoke (optional)

Run: cargo build -p spacesh-cli Expected: PASS. Manual: cargo run -p spacesh-cli -- completions zsh | head prints a completion script (no daemon needed).

  • Step 3: Commit
git add crates/spacesh-cli/src/output.rs
git commit -m "feat(cli): command dispatch, human/--json rendering, status table, completions"

Task 5: CLI integration tests against a mock daemon

Files:

  • Create: crates/spacesh-cli/tests/integration.rs

Because spacesh-cli exposes a spacesh_cli lib (Task 3), these tests call the REAL client::request / client::notify against a tiny in-test UDS server that speaks the framing protocol, isolated via a unique SPACESH_SOCK per test (so they run safely in parallel — no serial guard needed). Cross-crate spawning of the real spaceshd binary is intentionally avoided; the daemon's own integration tests + the manual check cover end-to-end.

  • Step 1: Write the integration tests

Create crates/spacesh-cli/tests/integration.rs:

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");
}

Note: mapping/output are unit-tested in Task 3 (mapping) and Task 4; client is now exercised for real here.

  • Step 2: Run tests (3×) for non-flakiness

Run: cargo test --workspace > /tmp/m4b.log 2>&1; echo EXIT=$? — three times, all 0. Expected: all crates green including the CLI tests.

  • Step 3: Manual end-to-end (real daemon + CLI)

Run:

cargo build
SOCK=$(mktemp -u /tmp/spacesh-e2e-XXXX.sock)
SPACESH_SOCK=$SOCK ./target/debug/spaceshd &
sleep 1
SPACESH_SOCK=$SOCK ./target/debug/spacesh open /tmp
SPACESH_SOCK=$SOCK ./target/debug/spacesh status
# grab a workspace id from status, create a surface, set state, observe in status:
# SPACESH_SOCK=$SOCK ./target/debug/spacesh new-surface <ws>
# SPACESH_SOCK=$SOCK ./target/debug/spacesh notify --surface <sid> --state work
# SPACESH_SOCK=$SOCK ./target/debug/spacesh status   # shows state=work
SPACESH_SOCK=$SOCK ./target/debug/spacesh shutdown
rm -f $SOCK

Expected: open prints a workspace id, status lists it, notify flips the surface state shown by a subsequent status.

  • Step 4: Commit
git add crates/spacesh-cli/tests/integration.rs
git commit -m "test(cli): wire-level integration tests via SPACESH_SOCK mock daemon"

Definition of Done

  • cargo test --workspace — green & non-flaky across 3 consecutive runs.
  • cargo build clean (CLI binary spacesh builds).
  • Manual (Task 5 Step 3): real spaceshd + spacesh over SPACESH_SOCKopen/status/new-surface/notify work; notify with no daemon exits 0; completions zsh prints a script.

Notes for the implementer

  • Tasks 12 compile together. Adding SurfaceView.state and Cmd::SetState breaks the daemon until Task 2 updates to_view and the match. Verify proto alone after Task 1 (cargo test -p spacesh-proto); run the workspace suite after Task 2.
  • Edge/SplitDir/PresetSlot/GroupId live in spacesh_proto::message / ::ids (added in M2) — import paths in mapping.rs are exact; if a re-export is missing, use the full module path.
  • Status is ephemeral: never add state to PersistState/Workspace. It lives only in the registry's in-memory states map and is dropped on exit.
  • notify is best-effort: no lazy-start, swallow connect errors, always exit 0. Do not "improve" it to surface errors — a failing hook must not break the agent.
  • Out of slice: status detection sources (Claude Code hook adapter, OSC 133, fallback patterns) and the status UI (rings/badges/Event Center, native notifications) are M3, built on this primitive and spacesh notify.