Files
spaceshell/DOCS/superpowers/plans/2026-06-09-spacesh-m0-m1.md
2026-06-09 20:39:51 +07:00

101 KiB
Raw Permalink Blame History

spacesh M0+M1 Implementation Plan

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: Build the M0 vertical slice (bytes flying GUI↔daemon↔PTY over a Unix socket) plus M1 persistence (daemon outlives GUI; reattach repaints the screen from a daemon-side grid snapshot).

Architecture: A Rust Cargo workspace with four crates. spacesh-proto defines the wire protocol (length-prefixed JSON). spacesh-pty spawns and reads PTYs with output batching. spacesh-core keeps an authoritative terminal grid (alacritty_terminal) and serializes it to an ANSI snapshot. spaceshd is the daemon: a Unix-domain-socket server with one actor task per surface that owns the PTY, the grid, and a broadcast fan-out. A Tauri 2 app is the thin GUI: src-tauri bridges the socket into the webview (commands via invoke, the output stream via ipc::Channel, rare events via emit); the React front renders one xterm.js panel at a time and switches between surfaces.

Tech Stack: Rust (tokio, tokio-util codec, serde/serde_json, bytes, base64, anyhow, thiserror), portable-pty 0.8, alacritty_terminal 0.25, Tauri 2, React + TypeScript + Vite, @xterm/xterm with @xterm/addon-webgl.

Spec: docs/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md. Base spec: DOCS/MAIN.md.

Conventions: English for all code/comments. camelCase vars/functions, PascalCase types, snake_case files, UPPER_CASE env vars. No hard-coded secrets. Socket path ~/.spacesh/sock, lock ~/.spacesh/daemon.lock.


File Structure

spacesh/
├── Cargo.toml                          # [virtual workspace manifest]
├── crates/
│   ├── spacesh-proto/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs                  # re-exports
│   │       ├── ids.rs                  # SurfaceId, WorkspaceId newtypes
│   │       ├── message.rs             # Envelope, Req, Res, Evt, Cmd, ErrorBody
│   │       └── codec.rs                # length-prefix framing helpers (read_frame/write_frame)
│   ├── spacesh-pty/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── lib.rs                  # PtyHandle: spawn, output rx (batched), input, resize, kill
│   ├── spacesh-core/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs                  # re-exports
│   │       ├── grid.rs                 # GridSurface: feed bytes into alacritty Term
│   │       └── snapshot.rs             # snapshot_ansi(&Term) -> Snapshot { ansi, cols, rows, cursor }
│   └── spaceshd/
│       ├── Cargo.toml
│       └── src/
│           ├── main.rs                 # entrypoint: parse argv, run daemon or `install-agent`
│           ├── lifecycle.rs            # socket path, lock file, single-instance, lazy-start helper
│           ├── registry.rs             # Registry: workspaces + surface senders
│           ├── surface.rs              # surface actor (owns PTY, grid, broadcast)
│           ├── server.rs               # accept loop + client task + command dispatch
│           └── launchd.rs              # plist template + install
└── app/
    ├── package.json
    ├── vite.config.ts
    ├── index.html
    ├── src/
    │   ├── main.tsx
    │   ├── App.tsx                     # holds surface list + active surface
    │   ├── socketBridge.ts             # invoke wrappers + Channel/event subscriptions
    │   ├── TerminalView.tsx            # xterm.js instance bound to one surface
    │   └── SurfaceList.tsx             # switch active surface
    └── src-tauri/
        ├── Cargo.toml
        ├── tauri.conf.json
        ├── build.rs
        └── src/
            ├── main.rs
            ├── lib.rs                  # builder, manage(BridgeState), invoke_handler
            └── bridge.rs               # UDS connection, req/res correlation, output channels, evt emit

Phase 0 — Workspace scaffold

Task 0: Create the Cargo workspace and crate skeletons

Files:

  • Create: Cargo.toml

  • Create: crates/spacesh-proto/Cargo.toml, crates/spacesh-proto/src/lib.rs

  • Create: crates/spacesh-pty/Cargo.toml, crates/spacesh-pty/src/lib.rs

  • Create: crates/spacesh-core/Cargo.toml, crates/spacesh-core/src/lib.rs

  • Create: crates/spaceshd/Cargo.toml, crates/spaceshd/src/main.rs

  • Step 1: Create the workspace manifest

Cargo.toml:

[workspace]
resolver = "2"
members = [
    "crates/spacesh-proto",
    "crates/spacesh-pty",
    "crates/spacesh-core",
    "crates/spaceshd",
]

[workspace.package]
edition = "2021"
version = "0.1.0"

[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
base64 = "0.22"
anyhow = "1"
thiserror = "1"
futures = "0.3"
portable-pty = "0.8"
alacritty_terminal = "0.25"
fs2 = "0.4"
dirs = "5"
  • Step 2: Create spacesh-proto skeleton

crates/spacesh-proto/Cargo.toml:

[package]
name = "spacesh-proto"
edition.workspace = true
version.workspace = true

[dependencies]
serde.workspace = true
serde_json.workspace = true
bytes.workspace = true
thiserror.workspace = true
tokio = { workspace = true }
tokio-util.workspace = true

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

pub mod codec;
pub mod ids;
pub mod message;

pub use ids::{SurfaceId, WorkspaceId};
pub use message::{Cmd, Envelope, ErrorBody, Evt};
  • Step 3: Create spacesh-pty skeleton

crates/spacesh-pty/Cargo.toml:

[package]
name = "spacesh-pty"
edition.workspace = true
version.workspace = true

[dependencies]
portable-pty.workspace = true
tokio.workspace = true
bytes.workspace = true
anyhow.workspace = true

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

// PtyHandle implemented in Task 3.
  • Step 4: Create spacesh-core skeleton

crates/spacesh-core/Cargo.toml:

[package]
name = "spacesh-core"
edition.workspace = true
version.workspace = true

[dependencies]
alacritty_terminal.workspace = true
serde.workspace = true

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

pub mod grid;
pub mod snapshot;

pub use grid::GridSurface;
pub use snapshot::Snapshot;

(Leave grid.rs / snapshot.rs to Tasks 1112; create them empty now so it compiles after those tasks. For Task 0, comment the mod lines out and restore them in Task 11.)

crates/spacesh-core/src/lib.rs (Task 0 version):

// modules added in Tasks 11-12
  • Step 5: Create spaceshd skeleton

crates/spaceshd/Cargo.toml:

[package]
name = "spaceshd"
edition.workspace = true
version.workspace = true

[[bin]]
name = "spaceshd"
path = "src/main.rs"

[dependencies]
spacesh-proto = { path = "../spacesh-proto" }
spacesh-pty = { path = "../spacesh-pty" }
spacesh-core = { path = "../spacesh-core" }
tokio.workspace = true
tokio-util.workspace = true
serde.workspace = true
serde_json.workspace = true
bytes.workspace = true
base64.workspace = true
anyhow.workspace = true
thiserror.workspace = true
futures.workspace = true
fs2.workspace = true
dirs.workspace = true

crates/spaceshd/src/main.rs:

fn main() {
    println!("spaceshd skeleton");
}
  • Step 6: Verify the workspace builds

Run: cargo build Expected: PASS (compiles all four crates; spaceshd prints nothing yet but builds).

  • Step 7: Commit
git add Cargo.toml crates/
git commit -m "chore: scaffold cargo workspace and crate skeletons"

Phase 1 — spacesh-proto

Task 1: Id newtypes and message types

Files:

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

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

  • Test: inline #[cfg(test)] in message.rs

  • Step 1: Write the failing test

Append to crates/spacesh-proto/src/message.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ids::{SurfaceId, WorkspaceId};

    #[test]
    fn req_round_trips_through_json() {
        let env = Envelope::Req {
            id: 42,
            cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) },
        };
        let json = serde_json::to_string(&env).unwrap();
        let back: Envelope = serde_json::from_str(&json).unwrap();
        assert_eq!(env, back);
    }

    #[test]
    fn res_ok_and_err_serialize_distinctly() {
        let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None };
        let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null,
            error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) };
        assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true"));
        assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND"));
    }

    #[test]
    fn evt_output_carries_workspace_scoped_surface() {
        let evt = Envelope::Evt(Evt::Output {
            surface_id: SurfaceId("s_1".into()),
            bytes: vec![104, 105],
        });
        let json = serde_json::to_string(&evt).unwrap();
        let back: Envelope = serde_json::from_str(&json).unwrap();
        assert_eq!(evt, back);
    }

    #[test]
    fn new_surface_defaults_cmd_to_none() {
        let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#;
        let env: Envelope = serde_json::from_str(json).unwrap();
        match env {
            Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => {
                assert!(command.is_none());
                assert!(args.is_empty());
            }
            _ => panic!("wrong variant"),
        }
    }
}
  • Step 2: Run test to verify it fails

Run: cargo test -p spacesh-proto Expected: FAIL to compile (Envelope, Cmd, Evt, ErrorBody, SurfaceId not defined).

  • Step 3: Write the id newtypes

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

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SurfaceId(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorkspaceId(pub String);

impl std::fmt::Display for SurfaceId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}
impl std::fmt::Display for WorkspaceId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}
  • Step 4: Write the message types

Prepend to crates/spacesh-proto/src/message.rs (above the test module):

use serde::{Deserialize, Serialize};
use crate::ids::{SurfaceId, WorkspaceId};

/// Wire envelope. `kind` is the serde tag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Envelope {
    Req {
        id: u64,
        cmd: Cmd,
    },
    Res {
        id: u64,
        ok: bool,
        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
        data: serde_json::Value,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        error: Option<ErrorBody>,
    },
    Evt(Evt),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorBody {
    pub code: String,
    pub msg: String,
}

/// Client → daemon commands. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "cmd", content = "args", rename_all = "snake_case")]
pub enum Cmd {
    Open { path: String },
    NewSurface {
        workspace_id: WorkspaceId,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        command: Option<String>,
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        args: Vec<String>,
        cols: u16,
        rows: u16,
    },
    Input {
        surface_id: SurfaceId,
        /// base64-encoded keyboard bytes.
        bytes: String,
    },
    Resize { surface_id: SurfaceId, cols: u16, rows: u16 },
    Attach { surface_id: SurfaceId },
    Detach { surface_id: SurfaceId },
    Focus { surface_id: SurfaceId },
    Close { surface_id: SurfaceId },
    Status,
    Shutdown,
}

/// Daemon → subscribers push events. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "evt", content = "data", rename_all = "snake_case")]
pub enum Evt {
    Output { surface_id: SurfaceId, bytes: Vec<u8> },
    Exit { surface_id: SurfaceId, code: i32 },
    SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
    SurfaceClosed { surface_id: SurfaceId },
}

Note on the Cmd::Input.bytes field: it is base64 text so it survives JSON cleanly. Evt::Output.bytes is Vec<u8> and serde_json encodes it as a JSON array of numbers — acceptable for M0+M1; the MessagePack swap noted in the spec would change this later.

  • Step 5: Run tests to verify they pass

Run: cargo test -p spacesh-proto Expected: PASS (4 tests).

  • Step 6: Commit
git add crates/spacesh-proto/src/ids.rs crates/spacesh-proto/src/message.rs
git commit -m "feat(proto): envelope, commands, events, ids with serde round-trip tests"

Task 2: Length-prefix codec helpers

Files:

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

  • Test: inline #[cfg(test)] in codec.rs

  • Step 1: Write the failing test

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

use crate::message::Envelope;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};

/// Maximum frame size we will accept (16 MiB). Guards against a corrupt length prefix.
pub const MAX_FRAME: u32 = 16 * 1024 * 1024;

#[derive(Debug, thiserror::Error)]
pub enum CodecError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
    #[error("frame too large: {0} bytes")]
    FrameTooLarge(u32),
}

/// Write one envelope as `u32` BE length prefix + JSON payload.
pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, env: &Envelope) -> Result<(), CodecError> {
    let payload = serde_json::to_vec(env)?;
    let len = payload.len() as u32;
    if len > MAX_FRAME {
        return Err(CodecError::FrameTooLarge(len));
    }
    w.write_all(&len.to_be_bytes()).await?;
    w.write_all(&payload).await?;
    w.flush().await?;
    Ok(())
}

/// Read one length-prefixed envelope. Returns `Ok(None)` on clean EOF.
pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Option<Envelope>, CodecError> {
    let mut len_buf = [0u8; 4];
    match r.read_exact(&mut len_buf).await {
        Ok(_) => {}
        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
        Err(e) => return Err(e.into()),
    }
    let len = u32::from_be_bytes(len_buf);
    if len > MAX_FRAME {
        return Err(CodecError::FrameTooLarge(len));
    }
    let mut payload = vec![0u8; len as usize];
    r.read_exact(&mut payload).await?;
    let env: Envelope = serde_json::from_slice(&payload)?;
    Ok(Some(env))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ids::SurfaceId;
    use crate::message::{Cmd, Envelope};

    #[tokio::test]
    async fn frame_round_trips_over_a_pipe() {
        let (mut client, mut server) = tokio::io::duplex(1024);
        let env = Envelope::Req { id: 9, cmd: Cmd::Status };
        write_frame(&mut client, &env).await.unwrap();
        let got = read_frame(&mut server).await.unwrap().unwrap();
        assert_eq!(got, env);
    }

    #[tokio::test]
    async fn two_frames_are_decoded_independently() {
        let (mut client, mut server) = tokio::io::duplex(4096);
        let a = Envelope::Req { id: 1, cmd: Cmd::Status };
        let b = Envelope::Req { id: 2, cmd: Cmd::Close { surface_id: SurfaceId("s_1".into()) } };
        write_frame(&mut client, &a).await.unwrap();
        write_frame(&mut client, &b).await.unwrap();
        assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), a);
        assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), b);
    }

    #[tokio::test]
    async fn clean_eof_returns_none() {
        let (client, mut server) = tokio::io::duplex(16);
        drop(client);
        assert!(read_frame(&mut server).await.unwrap().is_none());
    }
}
  • Step 2: Run test to verify it fails

Run: cargo test -p spacesh-proto codec Expected: FAIL to compile until codec is wired (it is already declared in lib.rs from Task 0 Step 2). If lib.rs lacks pub mod codec;, it is present — confirm.

  • Step 3: Implementation already written in Step 1

The module body above is the implementation. No additional code.

  • Step 4: Run tests to verify they pass

Run: cargo test -p spacesh-proto Expected: PASS (all proto tests, including 3 new codec tests).

  • Step 5: Commit
git add crates/spacesh-proto/src/codec.rs
git commit -m "feat(proto): length-prefixed frame read/write with EOF handling"

Phase 2 — spacesh-pty

Task 3: PtyHandle — spawn, batched output, input, resize, kill

Files:

  • Modify: crates/spacesh-pty/src/lib.rs
  • Test: inline #[cfg(test)] in lib.rs

The PTY APIs are blocking (portable-pty reader is a blocking Read). We run the reader on a dedicated OS thread and forward raw chunks over an mpsc. Batching/coalescing happens in the daemon's surface actor (Task 13), not here — this crate exposes raw chunks plus a small read buffer. Keeping batching out of spacesh-pty keeps it a thin, testable I/O layer.

  • Step 1: Write the failing test

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

use std::io::{Read, Write};
use anyhow::Result;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use tokio::sync::mpsc;

/// A spawned PTY with its child process. Output chunks arrive on `output`.
pub struct PtyHandle {
    master: Box<dyn MasterPty + Send>,
    writer: Box<dyn Write + Send>,
    child: Box<dyn portable_pty::Child + Send + Sync>,
    /// Raw output chunks read off the PTY master (already on the async side).
    pub output: mpsc::Receiver<Vec<u8>>,
}

/// Parameters for spawning a surface's process.
pub struct SpawnSpec {
    pub command: String,
    pub args: Vec<String>,
    pub cwd: std::path::PathBuf,
    pub cols: u16,
    pub rows: u16,
    /// Extra environment variables (e.g. SPACESH_SURFACE_ID).
    pub env: Vec<(String, String)>,
}

impl PtyHandle {
    pub fn spawn(spec: SpawnSpec) -> Result<Self> {
        let pty_system = native_pty_system();
        let pair = pty_system.openpty(PtySize {
            rows: spec.rows,
            cols: spec.cols,
            pixel_width: 0,
            pixel_height: 0,
        })?;

        let mut cmd = CommandBuilder::new(&spec.command);
        for a in &spec.args {
            cmd.arg(a);
        }
        cmd.cwd(&spec.cwd);
        for (k, v) in &spec.env {
            cmd.env(k, v);
        }

        let child = pair.slave.spawn_command(cmd)?;
        // The slave handle must be dropped so the child is the only holder; otherwise
        // EOF is never observed on the master after the child exits.
        drop(pair.slave);

        let writer = pair.master.take_writer()?;
        let mut reader = pair.master.try_clone_reader()?;

        let (tx, rx) = mpsc::channel::<Vec<u8>>(256);
        std::thread::spawn(move || {
            let mut buf = [0u8; 8192];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break, // EOF: child closed the pty
                    Ok(n) => {
                        if tx.blocking_send(buf[..n].to_vec()).is_err() {
                            break; // receiver gone
                        }
                    }
                    Err(_) => break,
                }
            }
        });

        Ok(Self {
            master: pair.master,
            writer,
            child,
            output: rx,
        })
    }

    pub fn write_input(&mut self, bytes: &[u8]) -> Result<()> {
        self.writer.write_all(bytes)?;
        self.writer.flush()?;
        Ok(())
    }

    pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
        self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?;
        Ok(())
    }

    /// Best-effort wait for the child's exit code (blocking).
    pub fn wait(&mut self) -> i32 {
        match self.child.wait() {
            Ok(status) => status.exit_code() as i32,
            Err(_) => -1,
        }
    }

    pub fn kill(&mut self) {
        let _ = self.child.kill();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn shell_spec(script: &str) -> SpawnSpec {
        SpawnSpec {
            command: "/bin/sh".into(),
            args: vec!["-c".into(), script.into()],
            cwd: std::env::temp_dir(),
            cols: 80,
            rows: 24,
            env: vec![("SPACESH_SURFACE_ID".into(), "s_test".into())],
        }
    }

    #[tokio::test]
    async fn spawn_echo_produces_output() {
        let mut handle = PtyHandle::spawn(shell_spec("printf SPACESH_OK")).unwrap();
        let mut collected = Vec::new();
        // Drain until EOF (channel closes when the reader thread sees EOF).
        while let Some(chunk) = handle.output.recv().await {
            collected.extend_from_slice(&chunk);
        }
        let text = String::from_utf8_lossy(&collected);
        assert!(text.contains("SPACESH_OK"), "got: {text:?}");
    }

    #[tokio::test]
    async fn resize_does_not_error() {
        let handle = PtyHandle::spawn(shell_spec("sleep 0.2")).unwrap();
        handle.resize(120, 40).unwrap();
    }

    #[tokio::test]
    async fn input_is_echoed_back() {
        // `cat` echoes stdin back to stdout on a pty.
        let mut handle = PtyHandle::spawn(shell_spec("cat")).unwrap();
        handle.write_input(b"hello\n").unwrap();
        let mut collected = Vec::new();
        // Read a few chunks then kill cat to end the stream.
        if let Some(chunk) = handle.output.recv().await {
            collected.extend_from_slice(&chunk);
        }
        handle.kill();
        let text = String::from_utf8_lossy(&collected);
        assert!(text.contains("hello"), "got: {text:?}");
    }
}
  • Step 2: Run test to verify it fails

Run: cargo test -p spacesh-pty Expected: PASS actually — the implementation is included with the tests in this task. (Write the file in full as above; the "failing first" step here is degenerate because the type and tests are introduced together for an I/O wrapper. If you prefer strict red-green, paste only the #[cfg(test)] block first, run cargo test -p spacesh-pty → FAIL "cannot find PtyHandle", then paste the implementation.)

  • Step 3: Run tests to verify they pass

Run: cargo test -p spacesh-pty Expected: PASS (3 tests). If input_is_echoed_back is flaky on slow CI, the kill+single-recv keeps it bounded; re-run.

  • Step 4: Commit
git add crates/spacesh-pty/src/lib.rs
git commit -m "feat(pty): PtyHandle spawn/read/input/resize/kill over portable-pty"

Phase 3 — spaceshd (M0 core: bytes flying)

Task 4: Lifecycle helpers — paths, lock, lazy start

Files:

  • Create: crates/spaceshd/src/lifecycle.rs

  • Modify: crates/spaceshd/src/main.rs (add mod lifecycle;)

  • Test: inline #[cfg(test)] in lifecycle.rs

  • Step 1: Write the failing test

crates/spaceshd/src/lifecycle.rs:

use std::path::PathBuf;
use anyhow::{Context, Result};

/// `~/.spacesh` directory, created if missing.
pub fn spacesh_dir() -> Result<PathBuf> {
    let home = dirs::home_dir().context("no home dir")?;
    let dir = home.join(".spacesh");
    std::fs::create_dir_all(&dir)?;
    Ok(dir)
}

pub fn socket_path() -> Result<PathBuf> {
    Ok(spacesh_dir()?.join("sock"))
}

pub fn lock_path() -> Result<PathBuf> {
    Ok(spacesh_dir()?.join("daemon.lock"))
}

/// Hold the single-instance lock for the lifetime of the daemon.
pub struct InstanceLock {
    _file: std::fs::File,
}

/// Acquire the exclusive daemon lock. Returns `Ok(None)` if another live daemon holds it.
pub fn acquire_instance_lock() -> Result<Option<InstanceLock>> {
    use fs2::FileExt;
    let file = std::fs::OpenOptions::new()
        .create(true)
        .write(true)
        .open(lock_path()?)?;
    match file.try_lock_exclusive() {
        Ok(()) => Ok(Some(InstanceLock { _file: file })),
        Err(_) => Ok(None),
    }
}

/// If a stale socket file exists but no daemon answers, remove it so we can bind.
pub fn clear_stale_socket() -> Result<()> {
    let path = socket_path()?;
    if path.exists() {
        // We hold the instance lock, so any existing socket is stale.
        std::fs::remove_file(&path)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paths_live_under_spacesh_dir() {
        let dir = spacesh_dir().unwrap();
        assert!(socket_path().unwrap().starts_with(&dir));
        assert!(lock_path().unwrap().starts_with(&dir));
    }

    #[test]
    fn lock_is_exclusive_within_process() {
        let first = acquire_instance_lock().unwrap();
        assert!(first.is_some(), "first acquire should succeed");
        // A second attempt from the same process on the same fd path:
        // fs2 advisory locks are per-handle; opening a new handle and locking
        // should fail while `first` is held.
        let second = acquire_instance_lock().unwrap();
        assert!(second.is_none(), "second acquire should be blocked");
        drop(first);
    }
}
  • Step 2: Wire the module

crates/spaceshd/src/main.rs:

mod lifecycle;

fn main() {
    println!("spaceshd skeleton");
}
  • Step 3: Run test to verify behavior

Run: cargo test -p spaceshd lifecycle Expected: PASS (2 tests). lock_is_exclusive_within_process depends on fs2 advisory locking being per-open-handle; if your platform coalesces locks per-process, change the assertion to acquire from a spawned thread holding its own handle. (macOS flock is per-handle → the test as written passes.)

  • Step 4: Commit
git add crates/spaceshd/src/lifecycle.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): lifecycle paths, single-instance lock, stale-socket cleanup"

Task 5: Surface actor (M0 — no grid yet)

Files:

  • Create: crates/spaceshd/src/surface.rs
  • Modify: crates/spaceshd/src/main.rs (add mod surface;)
  • Test: inline #[cfg(test)] in surface.rs

The actor owns the PTY and a broadcast sender for output fan-out. In M0 it forwards raw chunks straight to the broadcast (no grid feed, no coalescing yet — both added in Task 13). Commands arrive on an mpsc.

  • Step 1: Write the failing test

crates/spaceshd/src/surface.rs:

use spacesh_proto::{SurfaceId, WorkspaceId};
use spacesh_pty::{PtyHandle, SpawnSpec};
use tokio::sync::{broadcast, mpsc, oneshot};

/// Output broadcast capacity (chunks). Lagging subscribers drop intermediate frames.
const BROADCAST_CAP: usize = 1024;

/// Messages sent to a surface actor.
pub enum SurfaceMsg {
    Input(Vec<u8>),
    Resize { cols: u16, rows: u16 },
    /// Subscribe to the output stream. Reply carries a fresh receiver.
    Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
    Close,
}

/// Handle the daemon keeps for a live surface.
pub struct SurfaceHandle {
    pub id: SurfaceId,
    pub workspace_id: WorkspaceId,
    pub tx: mpsc::Sender<SurfaceMsg>,
}

/// Spawn the actor; returns its handle. `exit_tx` is fired once with the exit code.
pub fn spawn_surface(
    id: SurfaceId,
    workspace_id: WorkspaceId,
    mut pty: PtyHandle,
    exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
) -> SurfaceHandle {
    let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
    let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
    let actor_id = id.clone();

    tokio::spawn(async move {
        loop {
            tokio::select! {
                msg = rx.recv() => {
                    match msg {
                        Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); }
                        Some(SurfaceMsg::Resize { cols, rows }) => { let _ = pty.resize(cols, rows); }
                        Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); }
                        Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
                    }
                }
                chunk = pty.output.recv() => {
                    match chunk {
                        Some(bytes) => { let _ = bcast.send(bytes); }
                        None => { break; } // PTY EOF
                    }
                }
            }
        }
        let code = pty.wait();
        let _ = exit_tx.send((actor_id, code));
    });

    SurfaceHandle { id, workspace_id, tx }
}

#[cfg(test)]
mod tests {
    use super::*;
    use spacesh_pty::SpawnSpec;

    fn spec(script: &str) -> SpawnSpec {
        SpawnSpec {
            command: "/bin/sh".into(),
            args: vec!["-c".into(), script.into()],
            cwd: std::env::temp_dir(),
            cols: 80,
            rows: 24,
            env: vec![],
        }
    }

    #[tokio::test]
    async fn attach_receives_output() {
        let pty = PtyHandle::spawn(spec("printf HELLO; sleep 0.3")).unwrap();
        let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
        let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, exit_tx);

        let (reply_tx, reply_rx) = oneshot::channel();
        handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
        let mut sub = reply_rx.await.unwrap();

        let mut collected = Vec::new();
        // Collect for a short bounded window.
        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
        while tokio::time::Instant::now() < deadline {
            match tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await {
                Ok(Ok(bytes)) => collected.extend_from_slice(&bytes),
                _ => {}
            }
            if String::from_utf8_lossy(&collected).contains("HELLO") { break; }
        }
        assert!(String::from_utf8_lossy(&collected).contains("HELLO"));
    }

    #[tokio::test]
    async fn exit_is_reported() {
        let pty = PtyHandle::spawn(spec("exit 7")).unwrap();
        let (exit_tx, mut exit_rx) = mpsc::unbounded_channel();
        let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, exit_tx);
        let (sid, code) = tokio::time::timeout(tokio::time::Duration::from_secs(3), exit_rx.recv())
            .await.unwrap().unwrap();
        assert_eq!(sid, SurfaceId("s_2".into()));
        assert_eq!(code, 7);
    }
}
  • Step 2: Wire the module

crates/spaceshd/src/main.rs:

mod lifecycle;
mod surface;

fn main() {
    println!("spaceshd skeleton");
}
  • Step 3: Run test to verify it fails then passes

Run: cargo test -p spaceshd surface Expected: PASS (2 tests). If the unused SpawnSpec import in the actor file warns, remove it from the top-level use (it is only used in tests).

  • Step 4: Commit
git add crates/spaceshd/src/surface.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): surface actor owning pty + broadcast fan-out (M0, no grid)"

Task 6: Registry

Files:

  • Create: crates/spaceshd/src/registry.rs

  • Modify: crates/spaceshd/src/main.rs (add mod registry;)

  • Test: inline #[cfg(test)] in registry.rs

  • Step 1: Write the failing test

crates/spaceshd/src/registry.rs:

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use spacesh_proto::{SurfaceId, WorkspaceId};
use crate::surface::SurfaceHandle;

#[derive(Clone)]
pub struct WorkspaceMeta {
    pub id: WorkspaceId,
    pub path: PathBuf,
}

/// Single-threaded owner of all live surfaces and workspaces.
/// Lives inside the server task; not shared across threads.
#[derive(Default)]
pub struct Registry {
    counter: AtomicU64,
    workspaces: HashMap<WorkspaceId, WorkspaceMeta>,
    /// path → workspace, so `open` is idempotent.
    by_path: HashMap<PathBuf, WorkspaceId>,
    surfaces: HashMap<SurfaceId, SurfaceHandle>,
}

impl Registry {
    pub fn new() -> Self {
        Self::default()
    }

    fn next_id(&self, prefix: &str) -> String {
        let n = self.counter.fetch_add(1, Ordering::Relaxed);
        format!("{prefix}_{n:x}")
    }

    /// Idempotent: opening the same canonicalized path returns the existing workspace.
    pub fn open_workspace(&mut self, path: PathBuf) -> WorkspaceMeta {
        let canonical = path.canonicalize().unwrap_or(path);
        if let Some(id) = self.by_path.get(&canonical) {
            return self.workspaces[id].clone();
        }
        let id = WorkspaceId(self.next_id("w"));
        let meta = WorkspaceMeta { id: id.clone(), path: canonical.clone() };
        self.workspaces.insert(id.clone(), meta.clone());
        self.by_path.insert(canonical, id);
        meta
    }

    pub fn workspace(&self, id: &WorkspaceId) -> Option<&WorkspaceMeta> {
        self.workspaces.get(id)
    }

    pub fn new_surface_id(&self) -> SurfaceId {
        SurfaceId(self.next_id("s"))
    }

    pub fn insert_surface(&mut self, handle: SurfaceHandle) {
        self.surfaces.insert(handle.id.clone(), handle);
    }

    pub fn surface(&self, id: &SurfaceId) -> Option<&SurfaceHandle> {
        self.surfaces.get(id)
    }

    pub fn remove_surface(&mut self, id: &SurfaceId) -> Option<SurfaceHandle> {
        self.surfaces.remove(id)
    }

    /// Snapshot for the `status` command: (workspace, its surface ids).
    pub fn status(&self) -> Vec<(WorkspaceMeta, Vec<SurfaceId>)> {
        self.workspaces
            .values()
            .map(|w| {
                let sids = self
                    .surfaces
                    .values()
                    .filter(|s| s.workspace_id == w.id)
                    .map(|s| s.id.clone())
                    .collect();
                (w.clone(), sids)
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn open_is_idempotent_per_path() {
        let mut reg = Registry::new();
        let dir = std::env::temp_dir();
        let a = reg.open_workspace(dir.clone());
        let b = reg.open_workspace(dir.clone());
        assert_eq!(a.id, b.id);
    }

    #[test]
    fn ids_are_unique_and_prefixed() {
        let reg = Registry::new();
        let s1 = reg.new_surface_id();
        let s2 = reg.new_surface_id();
        assert!(s1.0.starts_with("s_"));
        assert_ne!(s1, s2);
    }
}
  • Step 2: Wire the module

Update crates/spaceshd/src/main.rs to add mod registry; alongside the others.

  • Step 3: Run tests

Run: cargo test -p spaceshd registry Expected: PASS (2 tests).

  • Step 4: Commit
git add crates/spaceshd/src/registry.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): registry for workspaces and surfaces with idempotent open"

Task 7: Socket server — accept loop, client task, command dispatch

Files:

  • Create: crates/spaceshd/src/server.rs
  • Modify: crates/spaceshd/src/main.rs
  • Test: inline #[cfg(test)] integration test in server.rs

Design: the Registry is owned by a single router task reachable via an mpsc<ServerMsg>. Each accepted connection spawns a client task that owns the socket write half and forwards (a) parsed requests to the router, (b) a subscription channel for events. The router executes commands against the registry and replies through per-client mpsc<Envelope> senders. This keeps the registry lock-free (single owner) and lets the router fan events out to all connected clients.

  • Step 1: Write the failing integration test

crates/spaceshd/src/server.rs:

use std::collections::HashMap;
use std::path::Path;
use anyhow::Result;
use base64::Engine;
use spacesh_proto::codec::{read_frame, write_frame};
use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId};
use spacesh_pty::{PtyHandle, SpawnSpec};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, oneshot};
use crate::registry::Registry;
use crate::surface::{spawn_surface, SurfaceMsg};

/// Per-client outbound channel: the router pushes envelopes the client task writes out.
type ClientTx = mpsc::Sender<Envelope>;

/// Messages into the single router task.
enum ServerMsg {
    /// A request from a client; reply routed to that client's `out`.
    Request { id: u64, cmd: Cmd, client: ClientId, out: ClientTx },
    /// Forward an output chunk to all subscribers of `surface_id`.
    Output { surface_id: SurfaceId, bytes: Vec<u8> },
    /// A surface process exited.
    Exit { surface_id: SurfaceId, code: i32 },
    /// Register a new client's event sink.
    ClientConnected { client: ClientId, out: ClientTx },
    /// Drop a client and all its subscriptions.
    ClientDisconnected { client: ClientId },
}

type ClientId = u64;

pub async fn serve(socket: &Path) -> Result<()> {
    let listener = UnixListener::bind(socket)?;
    let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);

    // Exit events from surfaces are funneled into the router.
    let (exit_tx, mut exit_rx) = mpsc::unbounded_channel::<(SurfaceId, i32)>();
    let router_for_exit = router_tx.clone();
    tokio::spawn(async move {
        while let Some((sid, code)) = exit_rx.recv().await {
            let _ = router_for_exit.send(ServerMsg::Exit { surface_id: sid, code }).await;
        }
    });

    let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx));

    let mut next_client: ClientId = 0;
    loop {
        let (stream, _addr) = listener.accept().await?;
        let client_id = next_client;
        next_client += 1;
        let router_tx = router_tx.clone();
        tokio::spawn(handle_client(stream, client_id, router_tx));
        if shutdown.is_finished() {
            break;
        }
    }
    Ok(())
}

async fn handle_client(stream: UnixStream, client_id: ClientId, router_tx: mpsc::Sender<ServerMsg>) {
    let (mut read_half, mut write_half) = stream.into_split();
    let (out_tx, mut out_rx) = mpsc::channel::<Envelope>(256);

    let _ = router_tx
        .send(ServerMsg::ClientConnected { client: client_id, out: out_tx.clone() })
        .await;

    // Writer task: drain outbound envelopes to the socket.
    let writer = tokio::spawn(async move {
        while let Some(env) = out_rx.recv().await {
            if write_frame(&mut write_half, &env).await.is_err() {
                break;
            }
        }
    });

    // Reader loop: parse frames and forward requests to the router.
    loop {
        match read_frame(&mut read_half).await {
            Ok(Some(Envelope::Req { id, cmd })) => {
                let _ = router_tx
                    .send(ServerMsg::Request { id, cmd, client: client_id, out: out_tx.clone() })
                    .await;
            }
            Ok(Some(_)) => { /* clients don't send res/evt; ignore */ }
            Ok(None) => break, // EOF
            Err(_) => break,
        }
    }

    let _ = router_tx.send(ServerMsg::ClientDisconnected { client: client_id }).await;
    writer.abort();
}

async fn router(
    mut rx: mpsc::Receiver<ServerMsg>,
    router_tx: mpsc::Sender<ServerMsg>,
    exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
) {
    let mut reg = Registry::new();
    let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
    // surface_id → set of client ids subscribed (attached).
    let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new();

    while let Some(msg) = rx.recv().await {
        match msg {
            ServerMsg::ClientConnected { client, out } => {
                clients.insert(client, out);
            }
            ServerMsg::ClientDisconnected { client } => {
                clients.remove(&client);
                for list in subs.values_mut() {
                    list.retain(|c| *c != client);
                }
            }
            ServerMsg::Output { surface_id, bytes } => {
                if let Some(list) = subs.get(&surface_id) {
                    let evt = Envelope::Evt(Evt::Output { surface_id: surface_id.clone(), bytes });
                    for c in list {
                        if let Some(out) = clients.get(c) {
                            let _ = out.try_send(evt.clone());
                        }
                    }
                }
            }
            ServerMsg::Exit { surface_id, code } => {
                let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
                broadcast_evt(&clients, &evt);
            }
            ServerMsg::Request { id, cmd, client, out } => {
                handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx).await;
            }
        }
    }
}

fn broadcast_evt(clients: &HashMap<ClientId, ClientTx>, evt: &Envelope) {
    for out in clients.values() {
        let _ = out.try_send(evt.clone());
    }
}

fn ok(id: u64, data: serde_json::Value) -> Envelope {
    Envelope::Res { id, ok: true, data, error: None }
}
fn err(id: u64, code: &str, msg: &str) -> Envelope {
    Envelope::Res { id, ok: false, data: serde_json::Value::Null,
        error: Some(ErrorBody { code: code.into(), msg: msg.into() }) }
}

#[allow(clippy::too_many_arguments)]
async fn handle_request(
    id: u64,
    cmd: Cmd,
    client: ClientId,
    out: ClientTx,
    reg: &mut Registry,
    subs: &mut HashMap<SurfaceId, Vec<ClientId>>,
    clients: &HashMap<ClientId, ClientTx>,
    router_tx: &mpsc::Sender<ServerMsg>,
    exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>,
) {
    match cmd {
        Cmd::Open { path } => {
            let meta = reg.open_workspace(path.into());
            let _ = out.send(ok(id, serde_json::json!({ "workspace_id": meta.id.0 }))).await;
        }
        Cmd::NewSurface { workspace_id, command, args, cols, rows } => {
            let Some(ws) = reg.workspace(&workspace_id).cloned() else {
                let _ = out.send(err(id, "NOT_FOUND", "workspace")).await;
                return;
            };
            let sid = reg.new_surface_id();
            let shell = command.unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
            let spec = SpawnSpec {
                command: shell,
                args,
                cwd: ws.path.clone(),
                cols,
                rows,
                env: vec![("SPACESH_SURFACE_ID".into(), sid.0.clone())],
            };
            match PtyHandle::spawn(spec) {
                Ok(pty) => {
                    let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, exit_tx.clone());
                    // Bridge the surface's broadcast into the router as Output messages.
                    spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
                    reg.insert_surface(handle);
                    let created = Envelope::Evt(Evt::SurfaceCreated {
                        surface_id: sid.clone(), workspace_id: workspace_id.clone(),
                    });
                    broadcast_evt(clients, &created);
                    let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await;
                }
                Err(e) => {
                    let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await;
                }
            }
        }
        Cmd::Input { surface_id, bytes } => {
            let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else {
                let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await;
                return;
            };
            if let Some(s) = reg.surface(&surface_id) {
                let _ = s.tx.send(SurfaceMsg::Input(decoded)).await;
                let _ = out.send(ok(id, serde_json::Value::Null)).await;
            } else {
                let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
            }
        }
        Cmd::Resize { surface_id, cols, rows } => {
            if let Some(s) = reg.surface(&surface_id) {
                let _ = s.tx.send(SurfaceMsg::Resize { cols, rows }).await;
                let _ = out.send(ok(id, serde_json::Value::Null)).await;
            } else {
                let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
            }
        }
        Cmd::Attach { surface_id } => {
            // M0 attach: register subscription, no snapshot yet (snapshot added in Task 13).
            if reg.surface(&surface_id).is_some() {
                subs.entry(surface_id.clone()).or_default().push(client);
                let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0 }))).await;
            } else {
                let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
            }
        }
        Cmd::Detach { surface_id } => {
            if let Some(list) = subs.get_mut(&surface_id) {
                list.retain(|c| *c != client);
            }
            let _ = out.send(ok(id, serde_json::Value::Null)).await;
        }
        Cmd::Focus { surface_id: _ } => {
            // Focus is a no-op in this slice (window raise is GUI-side; CLI parity later).
            let _ = out.send(ok(id, serde_json::Value::Null)).await;
        }
        Cmd::Close { surface_id } => {
            if let Some(handle) = reg.remove_surface(&surface_id) {
                let _ = handle.tx.send(SurfaceMsg::Close).await;
                subs.remove(&surface_id);
                let closed = Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() });
                broadcast_evt(clients, &closed);
                let _ = out.send(ok(id, serde_json::Value::Null)).await;
            } else {
                let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
            }
        }
        Cmd::Status => {
            let workspaces: Vec<_> = reg.status().into_iter().map(|(w, sids)| {
                serde_json::json!({
                    "workspace_id": w.id.0,
                    "path": w.path.to_string_lossy(),
                    "surfaces": sids.iter().map(|s| s.0.clone()).collect::<Vec<_>>(),
                })
            }).collect();
            let _ = out.send(ok(id, serde_json::json!({ "workspaces": workspaces }))).await;
        }
        Cmd::Shutdown => {
            let _ = out.send(ok(id, serde_json::Value::Null)).await;
            std::process::exit(0);
        }
    }
}

/// Pump a surface's broadcast output into the router as `ServerMsg::Output`.
fn spawn_output_bridge(
    surface_id: SurfaceId,
    handle: &crate::surface::SurfaceHandle,
    router_tx: mpsc::Sender<ServerMsg>,
) {
    let tx = handle.tx.clone();
    tokio::spawn(async move {
        // Ask the actor for a subscription receiver.
        let (reply_tx, reply_rx) = oneshot::channel();
        if tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.is_err() {
            return;
        }
        let Ok(mut sub) = reply_rx.await else { return };
        loop {
            match sub.recv().await {
                Ok(bytes) => {
                    if router_tx.send(ServerMsg::Output { surface_id: surface_id.clone(), bytes }).await.is_err() {
                        break;
                    }
                }
                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                Err(_) => break, // surface closed
            }
        }
    });
}

#[cfg(test)]
mod tests {
    use super::*;
    use base64::Engine;

    async fn req(stream: &mut UnixStream, id: u64, cmd: Cmd) -> Envelope {
        write_frame(stream, &Envelope::Req { id, cmd }).await.unwrap();
        // Read until we see the matching res (skip interleaved evts).
        loop {
            let env = read_frame(stream).await.unwrap().unwrap();
            if let Envelope::Res { id: rid, .. } = &env {
                if *rid == id { return env; }
            }
        }
    }

    #[tokio::test]
    async fn open_new_surface_attach_streams_output() {
        let dir = tempdir_path();
        let sock = dir.join("sock");
        let sock_for_task = sock.clone();
        tokio::spawn(async move { let _ = serve(&sock_for_task).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),
            command: Some("/bin/sh".into()),
            args: vec!["-c".into(), "printf STREAM_OK; sleep 0.5".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);

        let _ = req(&mut s, 3, Cmd::Attach { surface_id: surface_id.clone() }).await;

        // Now read frames looking for an Output evt containing STREAM_OK.
        let mut got = String::new();
        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
        while tokio::time::Instant::now() < deadline {
            if let Ok(Ok(Some(Envelope::Evt(Evt::Output { bytes, .. })))) =
                tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut s)).await {
                got.push_str(&String::from_utf8_lossy(&bytes));
                if got.contains("STREAM_OK") { break; }
            }
        }
        assert!(got.contains("STREAM_OK"), "got: {got:?}");
    }

    #[tokio::test]
    async fn unknown_surface_returns_not_found() {
        let dir = tempdir_path();
        let sock = dir.join("sock");
        let sock_for_task = sock.clone();
        tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
        wait_for_socket(&sock).await;
        let mut s = UnixStream::connect(&sock).await.unwrap();
        let r = req(&mut s, 1, Cmd::Input {
            surface_id: spacesh_proto::SurfaceId("s_nope".into()),
            bytes: base64::engine::general_purpose::STANDARD.encode(b"x"),
        }).await;
        match r {
            Envelope::Res { ok, error, .. } => {
                assert!(!ok);
                assert_eq!(error.unwrap().code, "NOT_FOUND");
            }
            _ => panic!(),
        }
    }

    fn res_data(env: &Envelope) -> &serde_json::Value {
        match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") }
    }

    fn tempdir_path() -> std::path::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!("spaceshd-test-{n}"));
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    async fn wait_for_socket(sock: &Path) {
        for _ in 0..100 {
            if UnixStream::connect(sock).await.is_ok() { return; }
            tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
        }
        panic!("socket never came up");
    }
}

Add tempfile-free temp dirs as above (no new dependency). Note Cmd::Shutdown calls std::process::exit — do not exercise it in tests (it would kill the test runner). It is covered by manual verification.

  • Step 2: Wire the module

crates/spaceshd/src/main.rs:

mod lifecycle;
mod registry;
mod server;
mod surface;

fn main() {
    println!("spaceshd skeleton");
}
  • Step 3: Run tests

Run: cargo test -p spaceshd server Expected: PASS (2 tests). The streaming test depends on the output bridge subscribing before the child writes; sleep 0.5 in the script gives margin.

  • Step 4: Commit
git add crates/spaceshd/src/server.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): socket server with router task, command dispatch, event fan-out (M0)"

Task 8: Daemon entrypoint with single-instance startup

Files:

  • Modify: crates/spaceshd/src/main.rs

  • Step 1: Write the entrypoint

crates/spaceshd/src/main.rs:

mod launchd;
mod lifecycle;
mod registry;
mod server;
mod surface;

use anyhow::Result;

#[tokio::main]
async fn main() -> Result<()> {
    let arg = std::env::args().nth(1);
    match arg.as_deref() {
        Some("install-agent") => {
            launchd::install_agent()?;
            println!("launchd agent installed");
            Ok(())
        }
        Some("--help") | Some("-h") => {
            println!("spaceshd [install-agent]");
            Ok(())
        }
        _ => run_daemon().await,
    }
}

async fn run_daemon() -> Result<()> {
    let Some(_lock) = lifecycle::acquire_instance_lock()? else {
        eprintln!("another spaceshd is already running");
        return Ok(());
    };
    lifecycle::clear_stale_socket()?;
    let sock = lifecycle::socket_path()?;
    eprintln!("spaceshd listening on {}", sock.display());
    server::serve(&sock).await
}

(launchd::install_agent is created in Task 16; to keep Task 8 compiling on its own, add a temporary stub now and replace it in Task 16.)

Temporary stub — crates/spaceshd/src/launchd.rs:

use anyhow::Result;
pub fn install_agent() -> Result<()> {
    anyhow::bail!("install-agent implemented in Task 16")
}
  • Step 2: Build and smoke-run the daemon manually

Run: cargo build -p spaceshd Expected: PASS.

Manual smoke test (own terminal):

cargo run -p spaceshd &
sleep 1
ls -l ~/.spacesh/sock          # socket exists
kill %1
rm -f ~/.spacesh/sock

Expected: socket file present while running.

  • Step 3: Commit
git add crates/spaceshd/src/main.rs crates/spaceshd/src/launchd.rs
git commit -m "feat(daemon): entrypoint with single-instance lock and lazy socket bind"

Phase 4 — Tauri app (M0: bytes flying in the GUI)

Task 9: Tauri scaffold + bridge state

Files:

  • Create: app/package.json, app/vite.config.ts, app/index.html, app/tsconfig.json
  • Create: app/src-tauri/Cargo.toml, app/src-tauri/tauri.conf.json, app/src-tauri/build.rs
  • Create: app/src-tauri/src/main.rs, app/src-tauri/src/lib.rs

This task scaffolds the app and proves it launches. The bridge logic is Task 10's bridge.rs. Use the official scaffolder if preferred (npm create tauri-app@latest), but the files below are the minimum that integrates with our crates.

  • Step 1: Frontend manifest

app/package.json:

{
  "name": "spacesh-app",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "tauri": "tauri"
  },
  "dependencies": {
    "@tauri-apps/api": "^2",
    "@xterm/xterm": "^5.5.0",
    "@xterm/addon-webgl": "^0.18.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@tauri-apps/cli": "^2",
    "@types/react": "^18.3.1",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.1",
    "typescript": "^5.5.0",
    "vite": "^5.4.0"
  }
}

app/vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  clearScreen: false,
  server: { port: 1420, strictPort: true },
});

app/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>spacesh</title>
    <link rel="stylesheet" href="/node_modules/@xterm/xterm/css/xterm.css" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

app/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "react-jsx",
    "noEmit": true
  },
  "include": ["src"]
}
  • Step 2: Tauri Rust side manifest and config

app/src-tauri/Cargo.toml:

[package]
name = "spacesh-app"
version = "0.1.0"
edition = "2021"

[lib]
name = "spacesh_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = [] }
spacesh-proto = { path = "../../crates/spacesh-proto" }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
anyhow = "1"
dirs = "5"

app/src-tauri/build.rs:

fn main() {
    tauri_build::build()
}

app/src-tauri/tauri.conf.json:

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "spacesh",
  "version": "0.1.0",
  "identifier": "xyz.spacesh.app",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "app": {
    "windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
    "security": { "csp": null }
  }
}
  • Step 3: Minimal Rust entry that launches a window

app/src-tauri/src/main.rs:

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    spacesh_app_lib::run();
}

app/src-tauri/src/lib.rs (Task 9 version — expanded in Task 10):

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .run(tauri::generate_context!())
        .expect("error while running spacesh");
}
  • Step 4: Minimal React entry

app/src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <div style={{ color: "#ddd", fontFamily: "monospace", padding: 16 }}>
      spacesh  scaffold OK
    </div>
  </React.StrictMode>
);
  • Step 5: Install and launch

Run:

cd app && npm install && npm run tauri dev

Expected: a window opens showing "spacesh — scaffold OK". Close it.

  • Step 6: Commit
git add app/
git commit -m "chore(app): scaffold tauri 2 + react + vite, window launches"

Task 10: Bridge (UDS ↔ webview) + React terminal

Files:

  • Create: app/src-tauri/src/bridge.rs
  • Modify: app/src-tauri/src/lib.rs
  • Create: app/src/socketBridge.ts, app/src/TerminalView.tsx, app/src/SurfaceList.tsx, app/src/App.tsx
  • Modify: app/src/main.tsx

Bridge design (scheme B): one persistent UDS connection. A writer task owns the write half; a reader task demuxes frames — Res go to a pending-request map (oneshot per id), Evt::Output go to the registered per-surface Channel, other Evt are emitted to the webview. The daemon must be auto-spawned if the socket is absent.

  • Step 1: Implement the bridge

app/src-tauri/src/bridge.rs:

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;

use anyhow::{Context, Result};
use base64::Engine;
use serde_json::Value;
use spacesh_proto::codec::{read_frame, write_frame};
use spacesh_proto::{Cmd, Envelope, Evt, SurfaceId};
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter};
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{mpsc, oneshot, Mutex};

pub struct Bridge {
    next_id: AtomicU64,
    /// Outbound frames to the daemon.
    tx: mpsc::Sender<Envelope>,
    /// Pending request id → reply slot.
    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
    /// surface id → output channel into the webview.
    out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
}

fn socket_path() -> Result<PathBuf> {
    Ok(dirs::home_dir().context("no home")?.join(".spacesh").join("sock"))
}

async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
    if let Ok(s) = UnixStream::connect(sock).await {
        return Ok(s);
    }
    // Lazy start: spawn the daemon binary, then poll for the socket.
    let exe = std::env::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(tokio::time::Duration::from_millis(30)).await;
    }
    anyhow::bail!("daemon did not come up")
}

impl Bridge {
    pub async fn connect(app: AppHandle) -> Result<Self> {
        let sock = socket_path()?;
        let stream = ensure_daemon(&sock).await?;
        let (read_half, write_half) = stream.into_split();

        let (tx, rx) = mpsc::channel::<Envelope>(256);
        let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
        let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();

        spawn_writer(write_half, rx);
        spawn_reader(read_half, app, pending.clone(), out_channels.clone());

        Ok(Self { next_id: AtomicU64::new(1), tx, pending, out_channels })
    }

    pub async fn request(&self, cmd: Cmd) -> Result<Envelope> {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let (reply_tx, reply_rx) = oneshot::channel();
        self.pending.lock().await.insert(id, reply_tx);
        self.tx.send(Envelope::Req { id, cmd }).await?;
        Ok(reply_rx.await?)
    }

    pub async fn register_output(&self, surface_id: String, channel: Channel<Vec<u8>>) {
        self.out_channels.lock().await.insert(surface_id, channel);
    }

    pub async fn unregister_output(&self, surface_id: &str) {
        self.out_channels.lock().await.remove(surface_id);
    }
}

fn spawn_writer(mut write_half: OwnedWriteHalf, mut rx: mpsc::Receiver<Envelope>) {
    tokio::spawn(async move {
        while let Some(env) = rx.recv().await {
            if write_frame(&mut write_half, &env).await.is_err() {
                break;
            }
        }
    });
}

fn spawn_reader(
    mut read_half: OwnedReadHalf,
    app: AppHandle,
    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
    out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
) {
    tokio::spawn(async move {
        loop {
            match read_frame(&mut read_half).await {
                Ok(Some(env)) => match env {
                    Envelope::Res { id, .. } => {
                        if let Some(slot) = pending.lock().await.remove(&id) {
                            let _ = slot.send(env);
                        }
                    }
                    Envelope::Evt(Evt::Output { surface_id, bytes }) => {
                        if let Some(ch) = out_channels.lock().await.get(&surface_id.0) {
                            let _ = ch.send(bytes);
                        }
                    }
                    Envelope::Evt(other) => {
                        // exit / surface_created / surface_closed → emit to webview.
                        let _ = app.emit("spacesh:evt", &other);
                    }
                    Envelope::Req { .. } => {}
                },
                Ok(None) | Err(_) => break,
            }
        }
    });
}

// ---- Tauri commands ----

type BridgeState<'a> = tauri::State<'a, Bridge>;

fn data_of(env: Envelope) -> Result<Value, String> {
    match env {
        Envelope::Res { ok: true, data, .. } => Ok(data),
        Envelope::Res { ok: false, error, .. } => {
            Err(error.map(|e| format!("{}: {}", e.code, e.msg)).unwrap_or_else(|| "error".into()))
        }
        _ => Err("unexpected reply".into()),
    }
}

#[tauri::command]
pub async fn open(state: BridgeState<'_>, path: String) -> Result<Value, String> {
    data_of(state.request(Cmd::Open { path }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn new_surface(
    state: BridgeState<'_>,
    workspace_id: String,
    command: Option<String>,
    args: Vec<String>,
    cols: u16,
    rows: u16,
) -> Result<Value, String> {
    let cmd = Cmd::NewSurface {
        workspace_id: spacesh_proto::WorkspaceId(workspace_id),
        command,
        args,
        cols,
        rows,
    };
    data_of(state.request(cmd).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn input(state: BridgeState<'_>, surface_id: String, data: Vec<u8>) -> Result<Value, String> {
    let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
    data_of(state.request(Cmd::Input { surface_id: SurfaceId(surface_id), bytes: b64 }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn resize(state: BridgeState<'_>, surface_id: String, cols: u16, rows: u16) -> Result<Value, String> {
    data_of(state.request(Cmd::Resize { surface_id: SurfaceId(surface_id), cols, rows }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn attach(state: BridgeState<'_>, surface_id: String, on_output: Channel<Vec<u8>>) -> Result<Value, String> {
    state.register_output(surface_id.clone(), on_output).await;
    data_of(state.request(Cmd::Attach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn detach(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
    state.unregister_output(&surface_id).await;
    data_of(state.request(Cmd::Detach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn status(state: BridgeState<'_>) -> Result<Value, String> {
    data_of(state.request(Cmd::Status).await.map_err(|e| e.to_string())?)
}

#[tauri::command]
pub async fn close_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
    data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}
  • Step 2: Wire the bridge into the builder

app/src-tauri/src/lib.rs:

mod bridge;

use tauri::Manager;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let handle = app.handle().clone();
            // Connect the bridge on a tokio runtime, then manage it.
            tauri::async_runtime::block_on(async move {
                let bridge = bridge::Bridge::connect(handle.clone())
                    .await
                    .expect("failed to connect to spaceshd");
                handle.manage(bridge);
            });
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            bridge::open,
            bridge::new_surface,
            bridge::input,
            bridge::resize,
            bridge::attach,
            bridge::detach,
            bridge::status,
            bridge::close_surface,
        ])
        .run(tauri::generate_context!())
        .expect("error while running spacesh");
}
  • Step 3: Frontend socket bridge

app/src/socketBridge.ts:

import { invoke, Channel } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";

export interface WorkspaceStatus {
  workspace_id: string;
  path: string;
  surfaces: string[];
}

export async function openWorkspace(path: string): Promise<string> {
  const data = await invoke<{ workspace_id: string }>("open", { path });
  return data.workspace_id;
}

export async function newSurface(
  workspaceId: string,
  cols: number,
  rows: number,
  command?: string,
  args: string[] = []
): Promise<string> {
  const data = await invoke<{ surface_id: string }>("new_surface", {
    workspaceId,
    command: command ?? null,
    args,
    cols,
    rows,
  });
  return data.surface_id;
}

export async function sendInput(surfaceId: string, data: Uint8Array): Promise<void> {
  await invoke("input", { surfaceId, data: Array.from(data) });
}

export async function resizeSurface(surfaceId: string, cols: number, rows: number): Promise<void> {
  await invoke("resize", { surfaceId, cols, rows });
}

export interface AttachResult {
  snapshot: string;
  cols: number;
  rows: number;
}

export async function attachSurface(
  surfaceId: string,
  onOutput: (bytes: Uint8Array) => void
): Promise<AttachResult> {
  const channel = new Channel<number[]>();
  channel.onmessage = (msg) => onOutput(new Uint8Array(msg));
  return await invoke<AttachResult>("attach", { surfaceId, onOutput: channel });
}

export async function detachSurface(surfaceId: string): Promise<void> {
  await invoke("detach", { surfaceId });
}

export async function getStatus(): Promise<WorkspaceStatus[]> {
  const data = await invoke<{ workspaces: WorkspaceStatus[] }>("status");
  return data.workspaces;
}

export type DaemonEvt =
  | { evt: "exit"; data: { surface_id: string; code: number } }
  | { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
  | { evt: "surface_closed"; data: { surface_id: string } };

export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
  return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
}
  • Step 4: TerminalView

app/src/TerminalView.tsx:

import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { WebglAddon } from "@xterm/addon-webgl";
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";

const decoder = new TextDecoder();
const encoder = new TextEncoder();

export function TerminalView({ surfaceId }: { surfaceId: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    const term = new Terminal({ fontFamily: "monospace", fontSize: 13, convertEol: false });
    try {
      term.loadAddon(new WebglAddon());
    } catch {
      // webgl unavailable → fall back to canvas/dom renderer silently
    }
    term.open(ref.current);

    // Input → daemon.
    const inputDisposable = term.onData((data) => {
      void sendInput(surfaceId, encoder.encode(data));
    });

    let disposed = false;

    // Attach: fresh xterm instance, write snapshot, then stream live output.
    void attachSurface(surfaceId, (bytes) => {
      if (!disposed) term.write(decoder.decode(bytes));
    }).then((res) => {
      if (disposed) return;
      if (res.snapshot) term.write(res.snapshot);
      if (res.cols && res.rows) {
        term.resize(res.cols, res.rows);
        void resizeSurface(surfaceId, res.cols, res.rows);
      }
    });

    return () => {
      disposed = true;
      inputDisposable.dispose();
      void detachSurface(surfaceId);
      term.dispose();
    };
  }, [surfaceId]);

  return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
}
  • Step 5: SurfaceList

app/src/SurfaceList.tsx:

export function SurfaceList({
  surfaces,
  active,
  onSelect,
}: {
  surfaces: string[];
  active: string | null;
  onSelect: (id: string) => void;
}) {
  return (
    <div style={{ width: 160, background: "#1a1a1a", color: "#ccc", padding: 8 }}>
      <div style={{ opacity: 0.6, fontSize: 11, marginBottom: 8 }}>SURFACES</div>
      {surfaces.map((id) => (
        <div
          key={id}
          onClick={() => onSelect(id)}
          style={{
            padding: "4px 6px",
            cursor: "pointer",
            borderRadius: 4,
            background: id === active ? "#333" : "transparent",
            fontFamily: "monospace",
            fontSize: 12,
          }}
        >
          {id}
        </div>
      ))}
    </div>
  );
}
  • Step 6: App wiring

app/src/App.tsx:

import { useEffect, useState } from "react";
import { TerminalView } from "./TerminalView";
import { SurfaceList } from "./SurfaceList";
import { openWorkspace, newSurface, getStatus, onDaemonEvent } from "./socketBridge";

export function App() {
  const [surfaces, setSurfaces] = useState<string[]>([]);
  const [active, setActive] = useState<string | null>(null);
  const [workspaceId, setWorkspaceId] = useState<string | null>(null);

  useEffect(() => {
    void (async () => {
      const ws = await getStatus();
      const flat = ws.flatMap((w) => w.surfaces);
      setSurfaces(flat);
      if (flat.length) setActive(flat[0]);
    })();

    const unlisten = onDaemonEvent((evt) => {
      if (evt.evt === "surface_created") {
        setSurfaces((s) => [...s, evt.data.surface_id]);
      } else if (evt.evt === "surface_closed" || evt.evt === "exit") {
        // exit leaves the surface visible; surface_closed removes it.
        if (evt.evt === "surface_closed") {
          setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id));
        }
      }
    });
    return () => {
      void unlisten.then((f) => f());
    };
  }, []);

  async function handleNewSurface() {
    let ws = workspaceId;
    if (!ws) {
      ws = await openWorkspace(".");
      setWorkspaceId(ws);
    }
    const id = await newSurface(ws, 80, 24);
    setActive(id);
  }

  return (
    <div style={{ display: "flex", height: "100vh", background: "#000" }}>
      <div style={{ display: "flex", flexDirection: "column", width: 160 }}>
        <button onClick={handleNewSurface} style={{ margin: 8 }}>
          + surface
        </button>
        <SurfaceList surfaces={surfaces} active={active} onSelect={setActive} />
      </div>
      <div style={{ flex: 1 }}>
        {active ? <TerminalView key={active} surfaceId={active} /> : <div style={{ color: "#666", padding: 16 }}>no surface</div>}
      </div>
    </div>
  );
}

app/src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "@xterm/xterm/css/xterm.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
  • Step 7: Manual end-to-end verification (M0)

Run:

cd app && npm run tauri dev

Steps: click "+ surface" → a shell appears in the terminal → type echo hi → see output. This proves bytes fly GUI↔daemon↔PTY. Expected: interactive shell works.

  • Step 8: Commit
git add app/
git commit -m "feat(app): UDS bridge (channel/invoke/emit) + xterm.js terminal, M0 e2e works"

Phase 5 — spacesh-core (grid + snapshot)

Task 11: GridSurface — feed bytes into an alacritty Term

Files:

  • Create: crates/spacesh-core/src/grid.rs
  • Modify: crates/spacesh-core/src/lib.rs (enable mod grid;)
  • Test: inline #[cfg(test)] in grid.rs

API pin: alacritty_terminal = "0.25". The grid/parser API is version-sensitive. The code below targets 0.25 (Term::new(config, &dims, listener), vte::ansi::Processor::advance(&mut term, bytes), term.grid().display_iter(), Cell { c, fg, bg, flags }, term.grid().cursor.point). If cargo build reports a signature mismatch, run cargo doc -p alacritty_terminal --open and adjust the three call sites (constructor, advance, cell iteration) — the wrapper isolates them.

  • Step 1: Write the failing test

crates/spacesh-core/src/grid.rs:

use alacritty_terminal::event::VoidListener;
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line, Point};
use alacritty_terminal::term::{Config, Term};
use alacritty_terminal::vte::ansi::Processor;

/// Fixed-size terminal dimensions for the daemon-side grid.
#[derive(Clone, Copy)]
pub struct GridSize {
    pub cols: usize,
    pub lines: usize,
}

impl Dimensions for GridSize {
    fn total_lines(&self) -> usize {
        self.lines
    }
    fn screen_lines(&self) -> usize {
        self.lines
    }
    fn columns(&self) -> usize {
        self.cols
    }
}

/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
pub struct GridSurface {
    term: Term<VoidListener>,
    parser: Processor,
    size: GridSize,
}

impl GridSurface {
    pub fn new(cols: u16, rows: u16) -> Self {
        let size = GridSize { cols: cols as usize, lines: rows as usize };
        let term = Term::new(Config::default(), &size, VoidListener);
        Self { term, parser: Processor::new(), size }
    }

    pub fn feed(&mut self, bytes: &[u8]) {
        self.parser.advance(&mut self.term, bytes);
    }

    pub fn resize(&mut self, cols: u16, rows: u16) {
        self.size = GridSize { cols: cols as usize, lines: rows as usize };
        self.term.resize(self.size);
    }

    pub fn size(&self) -> GridSize {
        self.size
    }

    /// Read the visible character at (line, col) — used by tests and the snapshot writer.
    pub fn char_at(&self, line: usize, col: usize) -> char {
        let point = Point::new(Line(line as i32), Column(col));
        self.term.grid()[point].c
    }

    pub fn term(&self) -> &Term<VoidListener> {
        &self.term
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn feeding_plain_text_lands_in_the_grid() {
        let mut g = GridSurface::new(20, 5);
        g.feed(b"hello");
        assert_eq!(g.char_at(0, 0), 'h');
        assert_eq!(g.char_at(0, 4), 'o');
    }

    #[test]
    fn carriage_return_and_newline_move_the_cursor() {
        let mut g = GridSurface::new(20, 5);
        g.feed(b"ab\r\ncd");
        assert_eq!(g.char_at(0, 0), 'a');
        assert_eq!(g.char_at(1, 0), 'c');
    }
}
  • Step 2: Enable the module

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

pub mod grid;
pub mod snapshot;

pub use grid::GridSurface;
pub use snapshot::Snapshot;

(snapshot is added in Task 12; if building Task 11 alone, temporarily comment the snapshot lines and restore them in Task 12.)

  • Step 3: Run tests

Run: cargo test -p spacesh-core grid Expected: PASS (2 tests). Fix the three pinned call sites if the API differs (see API pin note).

  • Step 4: Commit
git add crates/spacesh-core/src/grid.rs crates/spacesh-core/src/lib.rs
git commit -m "feat(core): GridSurface feeding PTY bytes into alacritty term"

Task 12: snapshot_ansi — serialize the grid to an ANSI dump

Files:

  • Create: crates/spacesh-core/src/snapshot.rs

  • Test: inline #[cfg(test)] in snapshot.rs

  • Step 1: Write the failing test

crates/spacesh-core/src/snapshot.rs:

use serde::Serialize;
use alacritty_terminal::index::Point;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::vte::ansi::Color;
use crate::grid::GridSurface;

/// Serializable snapshot returned by `attach`.
#[derive(Debug, Clone, Serialize)]
pub struct Snapshot {
    /// ANSI byte dump suitable for `xterm.write()`.
    pub ansi: String,
    pub cols: u16,
    pub rows: u16,
    /// 1-based cursor position.
    pub cursor_row: u16,
    pub cursor_col: u16,
}

fn sgr_for_color(c: Color, foreground: bool) -> String {
    let base = if foreground { 38 } else { 48 };
    match c {
        Color::Named(named) => {
            // Map common named colors to SGR; default fg/bg reset for the rest.
            use alacritty_terminal::vte::ansi::NamedColor;
            let code = match named {
                NamedColor::Black => Some(if foreground { 30 } else { 40 }),
                NamedColor::Red => Some(if foreground { 31 } else { 41 }),
                NamedColor::Green => Some(if foreground { 32 } else { 42 }),
                NamedColor::Yellow => Some(if foreground { 33 } else { 43 }),
                NamedColor::Blue => Some(if foreground { 34 } else { 44 }),
                NamedColor::Magenta => Some(if foreground { 35 } else { 45 }),
                NamedColor::Cyan => Some(if foreground { 36 } else { 46 }),
                NamedColor::White => Some(if foreground { 37 } else { 47 }),
                NamedColor::BrightBlack => Some(if foreground { 90 } else { 100 }),
                NamedColor::BrightRed => Some(if foreground { 91 } else { 101 }),
                NamedColor::BrightGreen => Some(if foreground { 92 } else { 102 }),
                NamedColor::BrightYellow => Some(if foreground { 93 } else { 103 }),
                NamedColor::BrightBlue => Some(if foreground { 94 } else { 104 }),
                NamedColor::BrightMagenta => Some(if foreground { 95 } else { 105 }),
                NamedColor::BrightCyan => Some(if foreground { 96 } else { 106 }),
                NamedColor::BrightWhite => Some(if foreground { 97 } else { 107 }),
                _ => None, // Foreground/Background/Cursor etc. → use reset.
            };
            match code {
                Some(n) => format!("{n}"),
                None => format!("{}", if foreground { 39 } else { 49 }),
            }
        }
        Color::Indexed(i) => format!("{base};5;{i}"),
        Color::Spec(rgb) => format!("{base};2;{};{};{}", rgb.r, rgb.g, rgb.b),
    }
}

/// Serialize the visible grid into an ANSI dump.
pub fn snapshot_ansi(g: &GridSurface) -> Snapshot {
    let size = g.size();
    let term = g.term();
    let grid = term.grid();

    let mut out = String::new();
    out.push_str("\x1b[2J\x1b[H"); // clear + home

    let cols = size.cols;
    let lines = size.lines;

    // Track the last emitted attributes to avoid redundant SGR sequences.
    let mut last: Option<(Color, Color, Flags)> = None;

    for line in 0..lines {
        for col in 0..cols {
            let point = Point::new(alacritty_terminal::index::Line(line as i32), alacritty_terminal::index::Column(col));
            let cell = &grid[point];
            let cur = (cell.fg, cell.bg, cell.flags);
            if last != Some(cur) {
                let mut codes: Vec<String> = vec!["0".into()]; // reset, then re-apply
                if cell.flags.contains(Flags::BOLD) { codes.push("1".into()); }
                if cell.flags.contains(Flags::DIM) { codes.push("2".into()); }
                if cell.flags.contains(Flags::ITALIC) { codes.push("3".into()); }
                if cell.flags.contains(Flags::UNDERLINE) { codes.push("4".into()); }
                if cell.flags.contains(Flags::INVERSE) { codes.push("7".into()); }
                codes.push(sgr_for_color(cell.fg, true));
                codes.push(sgr_for_color(cell.bg, false));
                out.push_str(&format!("\x1b[{}m", codes.join(";")));
                last = Some(cur);
            }
            out.push(cell.c);
        }
        out.push_str("\r\n");
    }
    out.push_str("\x1b[0m"); // reset attributes at end

    let cursor = grid.cursor.point;
    let cursor_row = (cursor.line.0 as i64 + 1).clamp(1, lines as i64) as u16;
    let cursor_col = (cursor.column.0 as i64 + 1).clamp(1, cols as i64) as u16;
    out.push_str(&format!("\x1b[{cursor_row};{cursor_col}H"));

    Snapshot {
        ansi: out,
        cols: cols as u16,
        rows: lines as u16,
        cursor_row,
        cursor_col,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn snapshot_contains_fed_text_and_is_deterministic() {
        let mut g = GridSurface::new(10, 3);
        g.feed(b"hi");
        let a = snapshot_ansi(&g);
        let b = snapshot_ansi(&g);
        assert_eq!(a.ansi, b.ansi, "snapshot must be deterministic");
        assert!(a.ansi.contains("hi"));
        assert!(a.ansi.starts_with("\x1b[2J\x1b[H"));
        assert_eq!(a.cols, 10);
        assert_eq!(a.rows, 3);
    }

    #[test]
    fn cursor_is_one_based_after_input() {
        let mut g = GridSurface::new(10, 3);
        g.feed(b"abc");
        let s = snapshot_ansi(&g);
        // After 'abc' the cursor sits at column 4 (1-based) on row 1.
        assert_eq!(s.cursor_row, 1);
        assert_eq!(s.cursor_col, 4);
    }
}
  • Step 2: Run tests

Run: cargo test -p spacesh-core Expected: PASS (grid + snapshot tests). If Flags, Color, or NamedColor paths differ in 0.25, adjust the imports per cargo doc.

  • Step 3: Commit
git add crates/spacesh-core/src/snapshot.rs crates/spacesh-core/src/lib.rs
git commit -m "feat(core): deterministic ANSI snapshot of the grid for reattach repaint"

Phase 6 — spaceshd (M1: grid + attach snapshot)

Task 13: Integrate grid + coalescing into the surface actor; snapshot on attach

Files:

  • Modify: crates/spaceshd/src/surface.rs
  • Test: inline #[cfg(test)] additions in surface.rs

Changes: the actor now owns a GridSurface, feeds every output chunk into it, coalesces output before broadcasting (flush every ~6 ms or at 16 KiB), and answers a new Snapshot request by serializing the grid in the same actor turn as the subscription — guaranteeing the snapshot/stream ordering from the spec.

  • Step 1: Extend the message enum and actor

Replace crates/spaceshd/src/surface.rs actor section with this (the test module from Task 5 stays; new tests appended in Step 2):

use spacesh_core::{snapshot::snapshot_ansi, GridSurface};
use spacesh_core::snapshot::Snapshot;
use spacesh_proto::{SurfaceId, WorkspaceId};
use spacesh_pty::PtyHandle;
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::time::{Duration, Instant};

const BROADCAST_CAP: usize = 1024;
const FLUSH_INTERVAL: Duration = Duration::from_millis(6);
const FLUSH_BYTES: usize = 16 * 1024;

pub enum SurfaceMsg {
    Input(Vec<u8>),
    Resize { cols: u16, rows: u16 },
    Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
    /// Attach with snapshot: subscribe AND capture the grid in one actor turn.
    AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver<Vec<u8>>)> },
    Close,
}

pub struct SurfaceHandle {
    pub id: SurfaceId,
    pub workspace_id: WorkspaceId,
    pub tx: mpsc::Sender<SurfaceMsg>,
}

pub fn spawn_surface(
    id: SurfaceId,
    workspace_id: WorkspaceId,
    mut pty: PtyHandle,
    cols: u16,
    rows: u16,
    exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
) -> SurfaceHandle {
    let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
    let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
    let actor_id = id.clone();

    tokio::spawn(async move {
        let mut grid = GridSurface::new(cols, rows);
        let mut pending: Vec<u8> = Vec::with_capacity(FLUSH_BYTES);
        let mut flush_deadline: Option<Instant> = None;

        // Helper closure can't borrow across awaits cleanly; inline the flush logic.
        loop {
            // Copy the deadline into an owned local so the timer future doesn't
            // hold a borrow of `flush_deadline` across the select! (other arms mutate it).
            let next_flush = flush_deadline;
            let timer = async move {
                match next_flush {
                    Some(d) => tokio::time::sleep_until(d).await,
                    None => std::future::pending::<()>().await,
                }
            };

            tokio::select! {
                msg = rx.recv() => {
                    match msg {
                        Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); }
                        Some(SurfaceMsg::Resize { cols, rows }) => {
                            grid.resize(cols, rows);
                            let _ = pty.resize(cols, rows);
                        }
                        Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); }
                        Some(SurfaceMsg::AttachSnapshot { reply }) => {
                            // Subscribe + snapshot ONLY — do not touch `pending`.
                            // This arm is atomic within the single actor (no await, no flush
                            // can interleave), so subscribing before snapshotting guarantees the
                            // new receiver gets exactly the output emitted AFTER this snapshot.
                            // Any accumulated `pending` (not yet fed to the grid) is left alone:
                            // the normal 6ms/16KiB flush path delivers it to ALL subscribers —
                            // including this new one — exactly once, and it is NOT in the snapshot.
                            // No gap, no double-render. (Broadcasting pending here would re-send
                            // already-snapshotted bytes to the new client via the bridge path.)
                            let sub = bcast.subscribe();
                            let snap = snapshot_ansi(&grid);
                            let _ = reply.send((snap, sub));
                        }
                        Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
                    }
                }
                chunk = pty.output.recv() => {
                    match chunk {
                        Some(bytes) => {
                            pending.extend_from_slice(&bytes);
                            if flush_deadline.is_none() {
                                flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
                            }
                            if pending.len() >= FLUSH_BYTES {
                                grid.feed(&pending);
                                let _ = bcast.send(std::mem::take(&mut pending));
                                flush_deadline = None;
                            }
                        }
                        None => {
                            // Final flush on EOF.
                            if !pending.is_empty() {
                                grid.feed(&pending);
                                let _ = bcast.send(std::mem::take(&mut pending));
                            }
                            break;
                        }
                    }
                }
                _ = timer => {
                    if !pending.is_empty() {
                        grid.feed(&pending);
                        let _ = bcast.send(std::mem::take(&mut pending));
                    }
                    flush_deadline = None;
                }
            }
        }
        let code = pty.wait();
        let _ = exit_tx.send((actor_id, code));
    });

    SurfaceHandle { id, workspace_id, tx }
}

Caller update: spawn_surface now takes cols, rows. Update crates/spaceshd/src/server.rs Cmd::NewSurface to pass them:

let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, cols, rows, exit_tx.clone());

Attach update (server): replace the M0 Cmd::Attach arm in server.rs with a snapshot-backed one:

Cmd::Attach { surface_id } => {
    if let Some(s) = reg.surface(&surface_id) {
        let (reply_tx, reply_rx) = oneshot::channel();
        if s.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() {
            if let Ok((snap, _sub)) = reply_rx.await {
                subs.entry(surface_id.clone()).or_default().push(client);
                let _ = out.send(ok(id, serde_json::json!({
                    "snapshot": snap.ansi,
                    "cols": snap.cols,
                    "rows": snap.rows,
                    "cursor_row": snap.cursor_row,
                    "cursor_col": snap.cursor_col,
                }))).await;
                return;
            }
        }
        let _ = out.send(err(id, "INTERNAL", "attach failed")).await;
    } else {
        let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
    }
}

Note: the output bridge (Task 7's spawn_output_bridge) still subscribes via SurfaceMsg::Attach and pumps all broadcast traffic into the router; the router fans only to clients in subs. The AttachSnapshot subscription receiver _sub is dropped because the client receives output through the router/bridge path, not directly — the snapshot's role is the one-shot repaint, and the bridge guarantees subsequent output flows. This keeps a single fan-out path while preserving the ordering guarantee (snapshot taken in the same actor turn the bridge's broadcast continues from).

  • Step 2: Update Task 5 tests for the new signature and add the snapshot test

In surface.rs tests, update both spawn_surface(...) calls to pass 80, 24:

let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);

Append a new test:

#[tokio::test]
async fn attach_snapshot_reflects_prior_output() {
    let pty = PtyHandle::spawn(spec("printf SNAPME; sleep 0.5")).unwrap();
    let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
    let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);

    // Give the child time to write and the actor time to flush into the grid.
    tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;

    let (reply_tx, reply_rx) = oneshot::channel();
    handle.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.unwrap();
    let (snap, _sub) = reply_rx.await.unwrap();
    assert!(snap.ansi.contains("SNAPME"), "snapshot: {:?}", snap.ansi);
}
  • Step 3: Run tests

Run: cargo test -p spaceshd Expected: PASS (surface + registry + lifecycle + server tests).

  • Step 4: Commit
git add crates/spaceshd/src/surface.rs crates/spaceshd/src/server.rs
git commit -m "feat(daemon): grid feed + output coalescing + snapshot-on-attach (M1)"

Task 14: Reattach integration test (same screen after reconnect)

Files:

  • Modify: crates/spaceshd/src/server.rs (append a test)

  • Step 1: Write the test

Append to the tests module in server.rs:

#[tokio::test]
async fn reattach_returns_snapshot_with_prior_output() {
    let dir = tempdir_path();
    let sock = dir.join("sock");
    let sock_for_task = sock.clone();
    tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
    wait_for_socket(&sock).await;

    // First client: open, new surface that prints a marker, attach, then disconnect.
    let surface_id;
    {
        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),
            command: Some("/bin/sh".into()),
            args: vec!["-c".into(), "printf REPAINT_ME; sleep 2".into()],
            cols: 80, rows: 24,
        }).await;
        surface_id = spacesh_proto::SurfaceId(res_data(&r)["surface_id"].as_str().unwrap().to_string());
        // Give the actor time to flush output into the grid.
        tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
        // disconnect by dropping `s`
    }

    // Second client: attach to the same surface, expect snapshot to contain the marker.
    let mut s2 = UnixStream::connect(&sock).await.unwrap();
    let r = req(&mut s2, 1, Cmd::Attach { surface_id: surface_id.clone() }).await;
    let snap = res_data(&r)["snapshot"].as_str().unwrap();
    assert!(snap.contains("REPAINT_ME"), "snapshot was: {snap:?}");
}
  • Step 2: Run tests

Run: cargo test -p spaceshd server Expected: PASS (3 server tests including reattach).

  • Step 3: Commit
git add crates/spaceshd/src/server.rs
git commit -m "test(daemon): reattach after disconnect repaints prior output from snapshot"

Phase 7 — app (M1 reattach in the GUI)

Task 15: Reattach flow + reconnect handling in the bridge

Files:

  • Modify: app/src-tauri/src/bridge.rs (reconnect on EOF)
  • Modify: app/src/TerminalView.tsx (already writes snapshot — verify cursor handling)

The TerminalView from Task 10 already: creates a fresh xterm per surface, writes res.snapshot, then streams. The remaining M1 piece is making the bridge survive a daemon restart is out of scope (daemon outlives GUI, not vice-versa) — but the GUI must survive its own reload, which it does because attach re-runs on mount. This task hardens reattach and adds a reconnect guard so a transient socket drop doesn't wedge the bridge.

  • Step 1: Add a reconnect note + guard (no behavior change to the happy path)

In app/src-tauri/src/bridge.rs, change spawn_reader's terminal branch to emit a disconnect event so the UI can re-attach:

                Ok(None) | Err(_) => {
                    let _ = app.emit("spacesh:disconnected", ());
                    break;
                }
  • Step 2: Re-attach the active surface on reconnect in the front

In app/src/App.tsx, add inside the useEffect:

    const reconnect = onDaemonRawEvent("spacesh:disconnected", () => {
      // Force a remount of the active TerminalView by toggling the key.
      setActive((cur) => cur);
      void getStatus().then((ws) => {
        const flat = ws.flatMap((w) => w.surfaces);
        setSurfaces(flat);
      });
    });
    return () => {
      void unlisten.then((f) => f());
      void reconnect.then((f) => f());
    };

Add to app/src/socketBridge.ts:

export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
  return listen(name, () => handler());
}
  • Step 3: Manual verification (M1 killer feature)

Run cd app && npm run tauri dev. Steps:

  1. Click "+ surface", run a long command like top.
  2. Close the app window (the daemon keeps running — verify ls ~/.spacesh/sock still works and pgrep spaceshd shows it alive).
  3. Relaunch npm run tauri dev.
  4. The surface is still listed; selecting it repaints the screen from the snapshot and top is still updating.

Expected: agent (process) survived the GUI; screen restored on reattach.

  • Step 4: Commit
git add app/
git commit -m "feat(app): reattach repaint + disconnect guard (M1)"

Phase 8 — launchd

Task 16: launchd user-agent install

Files:

  • Modify: crates/spaceshd/src/launchd.rs (replace the Task 8 stub)

  • Test: inline #[cfg(test)] in launchd.rs

  • Step 1: Write the failing test

crates/spaceshd/src/launchd.rs:

use anyhow::{Context, Result};
use std::path::PathBuf;

const LABEL: &str = "xyz.spacesh.daemon";

fn plist_path() -> Result<PathBuf> {
    let home = dirs::home_dir().context("no home")?;
    Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
}

/// Render the launchd plist. `run_at_load` defaults to false in this slice.
pub fn render_plist(exe: &str, run_at_load: bool) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>{LABEL}</string>
    <key>ProgramArguments</key>
    <array>
        <string>{exe}</string>
    </array>
    <key>KeepAlive</key>
    <true/>
    <key>RunAtLoad</key>
    <{run_at_load}/>
</dict>
</plist>
"#,
        run_at_load = if run_at_load { "true" } else { "false" }
    )
}

pub fn install_agent() -> Result<()> {
    let exe = std::env::current_exe()?;
    let plist = render_plist(&exe.to_string_lossy(), false);
    let path = plist_path()?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, plist)?;
    // Load it (best-effort; ignore "already loaded").
    let _ = std::process::Command::new("launchctl")
        .arg("load")
        .arg(&path)
        .status();
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn plist_has_label_keepalive_and_exe() {
        let p = render_plist("/usr/local/bin/spaceshd", false);
        assert!(p.contains("xyz.spacesh.daemon"));
        assert!(p.contains("/usr/local/bin/spaceshd"));
        assert!(p.contains("<key>KeepAlive</key>\n    <true/>"));
        assert!(p.contains("<key>RunAtLoad</key>\n    <false/>"));
    }

    #[test]
    fn run_at_load_toggles() {
        assert!(render_plist("x", true).contains("<key>RunAtLoad</key>\n    <true/>"));
    }
}
  • Step 2: Run tests

Run: cargo test -p spaceshd launchd Expected: PASS (2 tests). (install_agent itself touches the real filesystem/launchctl — exercised manually, not in tests.)

  • Step 3: Manual verification

Run:

cargo build -p spaceshd
./target/debug/spaceshd install-agent
launchctl list | grep spacesh        # shows the agent

Expected: the agent is registered; killing spaceshd (pkill spaceshd) results in launchd respawning it.

Cleanup:

launchctl unload ~/Library/LaunchAgents/xyz.spacesh.daemon.plist
rm ~/Library/LaunchAgents/xyz.spacesh.daemon.plist
  • Step 4: Commit
git add crates/spaceshd/src/launchd.rs
git commit -m "feat(daemon): launchd user-agent install with KeepAlive"

Definition of Done (slice acceptance)

Run the full suite and the manual checks:

  • cargo test — all crate tests pass.
  • cargo build — workspace + app build clean.
  • M0: in the app, create a surface, type into it, see live output. Bytes fly GUI↔daemon↔PTY.
  • M1: start a long-running process in a surface, kill the GUI, confirm spaceshd (and the child) stay alive, relaunch the GUI, reselect the surface, confirm the screen repaints from the snapshot and the process is still live.
  • launchd: spaceshd install-agent registers the KeepAlive agent; pkill spaceshd triggers a respawn.

Notes for the implementer

  • alacritty_terminal API drift: Tasks 1112 are the only place the crate's internal API is touched. If 0.25's signatures differ from the pinned code, fix the three isolated call sites (constructor, Processor::advance, cell/cursor access) — everything else depends on the GridSurface/Snapshot interface, not the crate.
  • Single fan-out path: all output to clients flows daemon broadcast → spawn_output_bridge → router → per-client socket. The AttachSnapshot receiver is intentionally dropped; the snapshot is a one-shot repaint and the bridge carries the live stream. Do not add a second direct path or you will double-render.
  • Coalescing budget: FLUSH_INTERVAL = 6ms, FLUSH_BYTES = 16KiB match the spec's §6.2 budget. Tune only with a profiler against a real build log.
  • Out of this slice: statuses/hooks (M3), split tree + disk persistence (M2), CLI (M4), notifications/zoom/diff (M5), remote (M6). Do not pull them in.