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

3087 lines
101 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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):
```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<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**
```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: 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**
```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<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**
```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<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`:
```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<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`:
```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<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**
```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<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`:
```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<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`:
```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
<!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`:
```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(
<React.StrictMode>
<div style={{ color: "#ddd", fontFamily: "monospace", padding: 16 }}>
spacesh scaffold OK
</div>
</React.StrictMode>
);
```
- [ ] **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<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`:
```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<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`:
```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`:
```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`:
```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`:
```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:
```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<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`:
```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<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**
```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<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:
```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<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:
```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 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.
```