# 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`: ```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`: ```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`: ```rust 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`: ```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`: ```rust // PtyHandle implemented in Task 3. ``` - [ ] **Step 4: Create `spacesh-core` skeleton** `crates/spacesh-core/Cargo.toml`: ```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`: ```rust pub mod grid; pub mod snapshot; pub use grid::GridSurface; pub use snapshot::Snapshot; ``` (Leave `grid.rs` / `snapshot.rs` to Tasks 11–12; 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): ```rust // modules added in Tasks 11-12 ``` - [ ] **Step 5: Create `spaceshd` skeleton** `crates/spaceshd/Cargo.toml`: ```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`: ```rust 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** ```bash 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`: ```rust #[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`: ```rust 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): ```rust 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, }, 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, #[serde(default, skip_serializing_if = "Vec::is_empty")] args: Vec, 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 }, 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` 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** ```bash 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`: ```rust 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: &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: &mut R) -> Result, 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** ```bash 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`: ```rust 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, writer: Box, child: Box, /// Raw output chunks read off the PTY master (already on the async side). pub output: mpsc::Receiver>, } /// Parameters for spawning a surface's process. pub struct SpawnSpec { pub command: String, pub args: Vec, 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 { 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::>(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** ```bash 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`: ```rust use std::path::PathBuf; use anyhow::{Context, Result}; /// `~/.spacesh` directory, created if missing. pub fn spacesh_dir() -> Result { 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 { Ok(spacesh_dir()?.join("sock")) } pub fn lock_path() -> Result { 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> { 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`: ```rust 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** ```bash 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`: ```rust 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), Resize { cols: u16, rows: u16 }, /// Subscribe to the output stream. Reply carries a fresh receiver. Attach { reply: oneshot::Sender>> }, Close, } /// Handle the daemon keeps for a live surface. pub struct SurfaceHandle { pub id: SurfaceId, pub workspace_id: WorkspaceId, pub tx: mpsc::Sender, } /// 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::(64); let (bcast, _) = broadcast::channel::>(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`: ```rust 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** ```bash 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`: ```rust 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, /// path → workspace, so `open` is idempotent. by_path: HashMap, surfaces: HashMap, } 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 { self.surfaces.remove(id) } /// Snapshot for the `status` command: (workspace, its surface ids). pub fn status(&self) -> Vec<(WorkspaceMeta, Vec)> { 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** ```bash 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`. 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` 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`: ```rust 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; /// 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 }, /// 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::(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) { let (mut read_half, mut write_half) = stream.into_split(); let (out_tx, mut out_rx) = mpsc::channel::(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, router_tx: mpsc::Sender, exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, ) { let mut reg = Registry::new(); let mut clients: HashMap = HashMap::new(); // surface_id → set of client ids subscribed (attached). let mut subs: HashMap> = 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, 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>, clients: &HashMap, router_tx: &mpsc::Sender, 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::>(), }) }).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, ) { 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`: ```rust 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** ```bash 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`: ```rust 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`: ```rust 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): ```bash 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** ```bash 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`: ```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`: ```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`: ```html spacesh
``` `app/tsconfig.json`: ```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`: ```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`: ```rust fn main() { tauri_build::build() } ``` `app/src-tauri/tauri.conf.json`: ```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`: ```rust #![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): ```rust #[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`: ```tsx import React from "react"; import ReactDOM from "react-dom/client"; ReactDOM.createRoot(document.getElementById("root")!).render(
spacesh — scaffold OK
); ``` - [ ] **Step 5: Install and launch** Run: ```bash cd app && npm install && npm run tauri dev ``` Expected: a window opens showing "spacesh — scaffold OK". Close it. - [ ] **Step 6: Commit** ```bash 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 `emit`ted 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`: ```rust 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, /// Pending request id → reply slot. pending: Arc>>>, /// surface id → output channel into the webview. out_channels: Arc>>>>, } fn socket_path() -> Result { Ok(dirs::home_dir().context("no home")?.join(".spacesh").join("sock")) } async fn ensure_daemon(sock: &PathBuf) -> Result { 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 { let sock = socket_path()?; let stream = ensure_daemon(&sock).await?; let (read_half, write_half) = stream.into_split(); let (tx, rx) = mpsc::channel::(256); let pending: Arc>>> = Arc::default(); let out_channels: Arc>>>> = 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 { 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>) { 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) { 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>>>, out_channels: Arc>>>>, ) { 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 { 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 { 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, args: Vec, cols: u16, rows: u16, ) -> Result { 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) -> Result { 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 { 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>) -> Result { 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 { 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 { 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 { 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`: ```rust 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`: ```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 { 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 { 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 { await invoke("input", { surfaceId, data: Array.from(data) }); } export async function resizeSurface(surfaceId: string, cols: number, rows: number): Promise { 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 { const channel = new Channel(); channel.onmessage = (msg) => onOutput(new Uint8Array(msg)); return await invoke("attach", { surfaceId, onOutput: channel }); } export async function detachSurface(surfaceId: string): Promise { await invoke("detach", { surfaceId }); } export async function getStatus(): Promise { 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("spacesh:evt", (e) => handler(e.payload)); } ``` - [ ] **Step 4: TerminalView** `app/src/TerminalView.tsx`: ```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(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
; } ``` - [ ] **Step 5: SurfaceList** `app/src/SurfaceList.tsx`: ```tsx export function SurfaceList({ surfaces, active, onSelect, }: { surfaces: string[]; active: string | null; onSelect: (id: string) => void; }) { return (
SURFACES
{surfaces.map((id) => (
onSelect(id)} style={{ padding: "4px 6px", cursor: "pointer", borderRadius: 4, background: id === active ? "#333" : "transparent", fontFamily: "monospace", fontSize: 12, }} > {id}
))}
); } ``` - [ ] **Step 6: App wiring** `app/src/App.tsx`: ```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([]); const [active, setActive] = useState(null); const [workspaceId, setWorkspaceId] = useState(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 (
{active ? :
no surface
}
); } ``` `app/src/main.tsx`: ```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( ); ``` - [ ] **Step 7: Manual end-to-end verification (M0)** Run: ```bash 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** ```bash 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`: ```rust 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, 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 { &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`: ```rust 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** ```bash 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`: ```rust 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 = 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** ```bash 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): ```rust 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), Resize { cols: u16, rows: u16 }, Attach { reply: oneshot::Sender>> }, /// Attach with snapshot: subscribe AND capture the grid in one actor turn. AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver>)> }, Close, } pub struct SurfaceHandle { pub id: SurfaceId, pub workspace_id: WorkspaceId, pub tx: mpsc::Sender, } 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::(64); let (bcast, _) = broadcast::channel::>(BROADCAST_CAP); let actor_id = id.clone(); tokio::spawn(async move { let mut grid = GridSurface::new(cols, rows); let mut pending: Vec = Vec::with_capacity(FLUSH_BYTES); let mut flush_deadline: Option = 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: ```rust 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: ```rust 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`: ```rust let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx); ``` ```rust let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx); ``` Append a new test: ```rust #[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** ```bash 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`: ```rust #[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** ```bash 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: ```rust 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`: ```tsx 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`: ```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** ```bash 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`: ```rust use anyhow::{Context, Result}; use std::path::PathBuf; const LABEL: &str = "xyz.spacesh.daemon"; fn plist_path() -> Result { 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#" Label {LABEL} ProgramArguments {exe} KeepAlive RunAtLoad <{run_at_load}/> "#, 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("KeepAlive\n ")); assert!(p.contains("RunAtLoad\n ")); } #[test] fn run_at_load_toggles() { assert!(render_plist("x", true).contains("RunAtLoad\n ")); } } ``` - [ ] **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: ```bash 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: ```bash launchctl unload ~/Library/LaunchAgents/xyz.spacesh.daemon.plist rm ~/Library/LaunchAgents/xyz.spacesh.daemon.plist ``` - [ ] **Step 4: Commit** ```bash 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 11–12 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. ```