Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101 KiB
spacesh M0+M1 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the M0 vertical slice (bytes flying GUI↔daemon↔PTY over a Unix socket) plus M1 persistence (daemon outlives GUI; reattach repaints the screen from a daemon-side grid snapshot).
Architecture: A Rust Cargo workspace with four crates. spacesh-proto defines the wire protocol (length-prefixed JSON). spacesh-pty spawns and reads PTYs with output batching. spacesh-core keeps an authoritative terminal grid (alacritty_terminal) and serializes it to an ANSI snapshot. spaceshd is the daemon: a Unix-domain-socket server with one actor task per surface that owns the PTY, the grid, and a broadcast fan-out. A Tauri 2 app is the thin GUI: src-tauri bridges the socket into the webview (commands via invoke, the output stream via ipc::Channel, rare events via emit); the React front renders one xterm.js panel at a time and switches between surfaces.
Tech Stack: Rust (tokio, tokio-util codec, serde/serde_json, bytes, base64, anyhow, thiserror), portable-pty 0.8, alacritty_terminal 0.25, Tauri 2, React + TypeScript + Vite, @xterm/xterm with @xterm/addon-webgl.
Spec: docs/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md. Base spec: DOCS/MAIN.md.
Conventions: English for all code/comments. camelCase vars/functions, PascalCase types, snake_case files, UPPER_CASE env vars. No hard-coded secrets. Socket path ~/.spacesh/sock, lock ~/.spacesh/daemon.lock.
File Structure
spacesh/
├── Cargo.toml # [virtual workspace manifest]
├── crates/
│ ├── spacesh-proto/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs # re-exports
│ │ ├── ids.rs # SurfaceId, WorkspaceId newtypes
│ │ ├── message.rs # Envelope, Req, Res, Evt, Cmd, ErrorBody
│ │ └── codec.rs # length-prefix framing helpers (read_frame/write_frame)
│ ├── spacesh-pty/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs # PtyHandle: spawn, output rx (batched), input, resize, kill
│ ├── spacesh-core/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs # re-exports
│ │ ├── grid.rs # GridSurface: feed bytes into alacritty Term
│ │ └── snapshot.rs # snapshot_ansi(&Term) -> Snapshot { ansi, cols, rows, cursor }
│ └── spaceshd/
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # entrypoint: parse argv, run daemon or `install-agent`
│ ├── lifecycle.rs # socket path, lock file, single-instance, lazy-start helper
│ ├── registry.rs # Registry: workspaces + surface senders
│ ├── surface.rs # surface actor (owns PTY, grid, broadcast)
│ ├── server.rs # accept loop + client task + command dispatch
│ └── launchd.rs # plist template + install
└── app/
├── package.json
├── vite.config.ts
├── index.html
├── src/
│ ├── main.tsx
│ ├── App.tsx # holds surface list + active surface
│ ├── socketBridge.ts # invoke wrappers + Channel/event subscriptions
│ ├── TerminalView.tsx # xterm.js instance bound to one surface
│ └── SurfaceList.tsx # switch active surface
└── src-tauri/
├── Cargo.toml
├── tauri.conf.json
├── build.rs
└── src/
├── main.rs
├── lib.rs # builder, manage(BridgeState), invoke_handler
└── bridge.rs # UDS connection, req/res correlation, output channels, evt emit
Phase 0 — Workspace scaffold
Task 0: Create the Cargo workspace and crate skeletons
Files:
-
Create:
Cargo.toml -
Create:
crates/spacesh-proto/Cargo.toml,crates/spacesh-proto/src/lib.rs -
Create:
crates/spacesh-pty/Cargo.toml,crates/spacesh-pty/src/lib.rs -
Create:
crates/spacesh-core/Cargo.toml,crates/spacesh-core/src/lib.rs -
Create:
crates/spaceshd/Cargo.toml,crates/spaceshd/src/main.rs -
Step 1: Create the workspace manifest
Cargo.toml:
[workspace]
resolver = "2"
members = [
"crates/spacesh-proto",
"crates/spacesh-pty",
"crates/spacesh-core",
"crates/spaceshd",
]
[workspace.package]
edition = "2021"
version = "0.1.0"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
base64 = "0.22"
anyhow = "1"
thiserror = "1"
futures = "0.3"
portable-pty = "0.8"
alacritty_terminal = "0.25"
fs2 = "0.4"
dirs = "5"
- Step 2: Create
spacesh-protoskeleton
crates/spacesh-proto/Cargo.toml:
[package]
name = "spacesh-proto"
edition.workspace = true
version.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
bytes.workspace = true
thiserror.workspace = true
tokio = { workspace = true }
tokio-util.workspace = true
crates/spacesh-proto/src/lib.rs:
pub mod codec;
pub mod ids;
pub mod message;
pub use ids::{SurfaceId, WorkspaceId};
pub use message::{Cmd, Envelope, ErrorBody, Evt};
- Step 3: Create
spacesh-ptyskeleton
crates/spacesh-pty/Cargo.toml:
[package]
name = "spacesh-pty"
edition.workspace = true
version.workspace = true
[dependencies]
portable-pty.workspace = true
tokio.workspace = true
bytes.workspace = true
anyhow.workspace = true
crates/spacesh-pty/src/lib.rs:
// PtyHandle implemented in Task 3.
- Step 4: Create
spacesh-coreskeleton
crates/spacesh-core/Cargo.toml:
[package]
name = "spacesh-core"
edition.workspace = true
version.workspace = true
[dependencies]
alacritty_terminal.workspace = true
serde.workspace = true
crates/spacesh-core/src/lib.rs:
pub mod grid;
pub mod snapshot;
pub use grid::GridSurface;
pub use snapshot::Snapshot;
(Leave grid.rs / snapshot.rs to Tasks 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):
// modules added in Tasks 11-12
- Step 5: Create
spaceshdskeleton
crates/spaceshd/Cargo.toml:
[package]
name = "spaceshd"
edition.workspace = true
version.workspace = true
[[bin]]
name = "spaceshd"
path = "src/main.rs"
[dependencies]
spacesh-proto = { path = "../spacesh-proto" }
spacesh-pty = { path = "../spacesh-pty" }
spacesh-core = { path = "../spacesh-core" }
tokio.workspace = true
tokio-util.workspace = true
serde.workspace = true
serde_json.workspace = true
bytes.workspace = true
base64.workspace = true
anyhow.workspace = true
thiserror.workspace = true
futures.workspace = true
fs2.workspace = true
dirs.workspace = true
crates/spaceshd/src/main.rs:
fn main() {
println!("spaceshd skeleton");
}
- Step 6: Verify the workspace builds
Run: cargo build
Expected: PASS (compiles all four crates; spaceshd prints nothing yet but builds).
- Step 7: Commit
git add Cargo.toml crates/
git commit -m "chore: scaffold cargo workspace and crate skeletons"
Phase 1 — spacesh-proto
Task 1: Id newtypes and message types
Files:
-
Create:
crates/spacesh-proto/src/ids.rs -
Create:
crates/spacesh-proto/src/message.rs -
Test: inline
#[cfg(test)]inmessage.rs -
Step 1: Write the failing test
Append to crates/spacesh-proto/src/message.rs:
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{SurfaceId, WorkspaceId};
#[test]
fn req_round_trips_through_json() {
let env = Envelope::Req {
id: 42,
cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) },
};
let json = serde_json::to_string(&env).unwrap();
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(env, back);
}
#[test]
fn res_ok_and_err_serialize_distinctly() {
let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None };
let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null,
error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) };
assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true"));
assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND"));
}
#[test]
fn evt_output_carries_workspace_scoped_surface() {
let evt = Envelope::Evt(Evt::Output {
surface_id: SurfaceId("s_1".into()),
bytes: vec![104, 105],
});
let json = serde_json::to_string(&evt).unwrap();
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(evt, back);
}
#[test]
fn new_surface_defaults_cmd_to_none() {
let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#;
let env: Envelope = serde_json::from_str(json).unwrap();
match env {
Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => {
assert!(command.is_none());
assert!(args.is_empty());
}
_ => panic!("wrong variant"),
}
}
}
- Step 2: Run test to verify it fails
Run: cargo test -p spacesh-proto
Expected: FAIL to compile (Envelope, Cmd, Evt, ErrorBody, SurfaceId not defined).
- Step 3: Write the id newtypes
crates/spacesh-proto/src/ids.rs:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SurfaceId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorkspaceId(pub String);
impl std::fmt::Display for SurfaceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::fmt::Display for WorkspaceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
- Step 4: Write the message types
Prepend to crates/spacesh-proto/src/message.rs (above the test module):
use serde::{Deserialize, Serialize};
use crate::ids::{SurfaceId, WorkspaceId};
/// Wire envelope. `kind` is the serde tag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Envelope {
Req {
id: u64,
cmd: Cmd,
},
Res {
id: u64,
ok: bool,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
data: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<ErrorBody>,
},
Evt(Evt),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorBody {
pub code: String,
pub msg: String,
}
/// Client → daemon commands. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "cmd", content = "args", rename_all = "snake_case")]
pub enum Cmd {
Open { path: String },
NewSurface {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
cols: u16,
rows: u16,
},
Input {
surface_id: SurfaceId,
/// base64-encoded keyboard bytes.
bytes: String,
},
Resize { surface_id: SurfaceId, cols: u16, rows: u16 },
Attach { surface_id: SurfaceId },
Detach { surface_id: SurfaceId },
Focus { surface_id: SurfaceId },
Close { surface_id: SurfaceId },
Status,
Shutdown,
}
/// Daemon → subscribers push events. The active subset for M0+M1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "evt", content = "data", rename_all = "snake_case")]
pub enum Evt {
Output { surface_id: SurfaceId, bytes: Vec<u8> },
Exit { surface_id: SurfaceId, code: i32 },
SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
SurfaceClosed { surface_id: SurfaceId },
}
Note on the Cmd::Input.bytes field: it is base64 text so it survives JSON cleanly. Evt::Output.bytes is Vec<u8> and serde_json encodes it as a JSON array of numbers — acceptable for M0+M1; the MessagePack swap noted in the spec would change this later.
- Step 5: Run tests to verify they pass
Run: cargo test -p spacesh-proto
Expected: PASS (4 tests).
- Step 6: Commit
git add crates/spacesh-proto/src/ids.rs crates/spacesh-proto/src/message.rs
git commit -m "feat(proto): envelope, commands, events, ids with serde round-trip tests"
Task 2: Length-prefix codec helpers
Files:
-
Create:
crates/spacesh-proto/src/codec.rs -
Test: inline
#[cfg(test)]incodec.rs -
Step 1: Write the failing test
crates/spacesh-proto/src/codec.rs:
use crate::message::Envelope;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
/// Maximum frame size we will accept (16 MiB). Guards against a corrupt length prefix.
pub const MAX_FRAME: u32 = 16 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum CodecError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("frame too large: {0} bytes")]
FrameTooLarge(u32),
}
/// Write one envelope as `u32` BE length prefix + JSON payload.
pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, env: &Envelope) -> Result<(), CodecError> {
let payload = serde_json::to_vec(env)?;
let len = payload.len() as u32;
if len > MAX_FRAME {
return Err(CodecError::FrameTooLarge(len));
}
w.write_all(&len.to_be_bytes()).await?;
w.write_all(&payload).await?;
w.flush().await?;
Ok(())
}
/// Read one length-prefixed envelope. Returns `Ok(None)` on clean EOF.
pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Option<Envelope>, CodecError> {
let mut len_buf = [0u8; 4];
match r.read_exact(&mut len_buf).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e.into()),
}
let len = u32::from_be_bytes(len_buf);
if len > MAX_FRAME {
return Err(CodecError::FrameTooLarge(len));
}
let mut payload = vec![0u8; len as usize];
r.read_exact(&mut payload).await?;
let env: Envelope = serde_json::from_slice(&payload)?;
Ok(Some(env))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::SurfaceId;
use crate::message::{Cmd, Envelope};
#[tokio::test]
async fn frame_round_trips_over_a_pipe() {
let (mut client, mut server) = tokio::io::duplex(1024);
let env = Envelope::Req { id: 9, cmd: Cmd::Status };
write_frame(&mut client, &env).await.unwrap();
let got = read_frame(&mut server).await.unwrap().unwrap();
assert_eq!(got, env);
}
#[tokio::test]
async fn two_frames_are_decoded_independently() {
let (mut client, mut server) = tokio::io::duplex(4096);
let a = Envelope::Req { id: 1, cmd: Cmd::Status };
let b = Envelope::Req { id: 2, cmd: Cmd::Close { surface_id: SurfaceId("s_1".into()) } };
write_frame(&mut client, &a).await.unwrap();
write_frame(&mut client, &b).await.unwrap();
assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), a);
assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), b);
}
#[tokio::test]
async fn clean_eof_returns_none() {
let (client, mut server) = tokio::io::duplex(16);
drop(client);
assert!(read_frame(&mut server).await.unwrap().is_none());
}
}
- Step 2: Run test to verify it fails
Run: cargo test -p spacesh-proto codec
Expected: FAIL to compile until codec is wired (it is already declared in lib.rs from Task 0 Step 2). If lib.rs lacks pub mod codec;, it is present — confirm.
- Step 3: Implementation already written in Step 1
The module body above is the implementation. No additional code.
- Step 4: Run tests to verify they pass
Run: cargo test -p spacesh-proto
Expected: PASS (all proto tests, including 3 new codec tests).
- Step 5: Commit
git add crates/spacesh-proto/src/codec.rs
git commit -m "feat(proto): length-prefixed frame read/write with EOF handling"
Phase 2 — spacesh-pty
Task 3: PtyHandle — spawn, batched output, input, resize, kill
Files:
- Modify:
crates/spacesh-pty/src/lib.rs - Test: inline
#[cfg(test)]inlib.rs
The PTY APIs are blocking (portable-pty reader is a blocking Read). We run the reader on a dedicated OS thread and forward raw chunks over an mpsc. Batching/coalescing happens in the daemon's surface actor (Task 13), not here — this crate exposes raw chunks plus a small read buffer. Keeping batching out of spacesh-pty keeps it a thin, testable I/O layer.
- Step 1: Write the failing test
crates/spacesh-pty/src/lib.rs:
use std::io::{Read, Write};
use anyhow::Result;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use tokio::sync::mpsc;
/// A spawned PTY with its child process. Output chunks arrive on `output`.
pub struct PtyHandle {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
/// Raw output chunks read off the PTY master (already on the async side).
pub output: mpsc::Receiver<Vec<u8>>,
}
/// Parameters for spawning a surface's process.
pub struct SpawnSpec {
pub command: String,
pub args: Vec<String>,
pub cwd: std::path::PathBuf,
pub cols: u16,
pub rows: u16,
/// Extra environment variables (e.g. SPACESH_SURFACE_ID).
pub env: Vec<(String, String)>,
}
impl PtyHandle {
pub fn spawn(spec: SpawnSpec) -> Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: spec.rows,
cols: spec.cols,
pixel_width: 0,
pixel_height: 0,
})?;
let mut cmd = CommandBuilder::new(&spec.command);
for a in &spec.args {
cmd.arg(a);
}
cmd.cwd(&spec.cwd);
for (k, v) in &spec.env {
cmd.env(k, v);
}
let child = pair.slave.spawn_command(cmd)?;
// The slave handle must be dropped so the child is the only holder; otherwise
// EOF is never observed on the master after the child exits.
drop(pair.slave);
let writer = pair.master.take_writer()?;
let mut reader = pair.master.try_clone_reader()?;
let (tx, rx) = mpsc::channel::<Vec<u8>>(256);
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break, // EOF: child closed the pty
Ok(n) => {
if tx.blocking_send(buf[..n].to_vec()).is_err() {
break; // receiver gone
}
}
Err(_) => break,
}
}
});
Ok(Self {
master: pair.master,
writer,
child,
output: rx,
})
}
pub fn write_input(&mut self, bytes: &[u8]) -> Result<()> {
self.writer.write_all(bytes)?;
self.writer.flush()?;
Ok(())
}
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?;
Ok(())
}
/// Best-effort wait for the child's exit code (blocking).
pub fn wait(&mut self) -> i32 {
match self.child.wait() {
Ok(status) => status.exit_code() as i32,
Err(_) => -1,
}
}
pub fn kill(&mut self) {
let _ = self.child.kill();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shell_spec(script: &str) -> SpawnSpec {
SpawnSpec {
command: "/bin/sh".into(),
args: vec!["-c".into(), script.into()],
cwd: std::env::temp_dir(),
cols: 80,
rows: 24,
env: vec![("SPACESH_SURFACE_ID".into(), "s_test".into())],
}
}
#[tokio::test]
async fn spawn_echo_produces_output() {
let mut handle = PtyHandle::spawn(shell_spec("printf SPACESH_OK")).unwrap();
let mut collected = Vec::new();
// Drain until EOF (channel closes when the reader thread sees EOF).
while let Some(chunk) = handle.output.recv().await {
collected.extend_from_slice(&chunk);
}
let text = String::from_utf8_lossy(&collected);
assert!(text.contains("SPACESH_OK"), "got: {text:?}");
}
#[tokio::test]
async fn resize_does_not_error() {
let handle = PtyHandle::spawn(shell_spec("sleep 0.2")).unwrap();
handle.resize(120, 40).unwrap();
}
#[tokio::test]
async fn input_is_echoed_back() {
// `cat` echoes stdin back to stdout on a pty.
let mut handle = PtyHandle::spawn(shell_spec("cat")).unwrap();
handle.write_input(b"hello\n").unwrap();
let mut collected = Vec::new();
// Read a few chunks then kill cat to end the stream.
if let Some(chunk) = handle.output.recv().await {
collected.extend_from_slice(&chunk);
}
handle.kill();
let text = String::from_utf8_lossy(&collected);
assert!(text.contains("hello"), "got: {text:?}");
}
}
- Step 2: Run test to verify it fails
Run: cargo test -p spacesh-pty
Expected: PASS actually — the implementation is included with the tests in this task. (Write the file in full as above; the "failing first" step here is degenerate because the type and tests are introduced together for an I/O wrapper. If you prefer strict red-green, paste only the #[cfg(test)] block first, run cargo test -p spacesh-pty → FAIL "cannot find PtyHandle", then paste the implementation.)
- Step 3: Run tests to verify they pass
Run: cargo test -p spacesh-pty
Expected: PASS (3 tests). If input_is_echoed_back is flaky on slow CI, the kill+single-recv keeps it bounded; re-run.
- Step 4: Commit
git add crates/spacesh-pty/src/lib.rs
git commit -m "feat(pty): PtyHandle spawn/read/input/resize/kill over portable-pty"
Phase 3 — spaceshd (M0 core: bytes flying)
Task 4: Lifecycle helpers — paths, lock, lazy start
Files:
-
Create:
crates/spaceshd/src/lifecycle.rs -
Modify:
crates/spaceshd/src/main.rs(addmod lifecycle;) -
Test: inline
#[cfg(test)]inlifecycle.rs -
Step 1: Write the failing test
crates/spaceshd/src/lifecycle.rs:
use std::path::PathBuf;
use anyhow::{Context, Result};
/// `~/.spacesh` directory, created if missing.
pub fn spacesh_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("no home dir")?;
let dir = home.join(".spacesh");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn socket_path() -> Result<PathBuf> {
Ok(spacesh_dir()?.join("sock"))
}
pub fn lock_path() -> Result<PathBuf> {
Ok(spacesh_dir()?.join("daemon.lock"))
}
/// Hold the single-instance lock for the lifetime of the daemon.
pub struct InstanceLock {
_file: std::fs::File,
}
/// Acquire the exclusive daemon lock. Returns `Ok(None)` if another live daemon holds it.
pub fn acquire_instance_lock() -> Result<Option<InstanceLock>> {
use fs2::FileExt;
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.open(lock_path()?)?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(InstanceLock { _file: file })),
Err(_) => Ok(None),
}
}
/// If a stale socket file exists but no daemon answers, remove it so we can bind.
pub fn clear_stale_socket() -> Result<()> {
let path = socket_path()?;
if path.exists() {
// We hold the instance lock, so any existing socket is stale.
std::fs::remove_file(&path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_live_under_spacesh_dir() {
let dir = spacesh_dir().unwrap();
assert!(socket_path().unwrap().starts_with(&dir));
assert!(lock_path().unwrap().starts_with(&dir));
}
#[test]
fn lock_is_exclusive_within_process() {
let first = acquire_instance_lock().unwrap();
assert!(first.is_some(), "first acquire should succeed");
// A second attempt from the same process on the same fd path:
// fs2 advisory locks are per-handle; opening a new handle and locking
// should fail while `first` is held.
let second = acquire_instance_lock().unwrap();
assert!(second.is_none(), "second acquire should be blocked");
drop(first);
}
}
- Step 2: Wire the module
crates/spaceshd/src/main.rs:
mod lifecycle;
fn main() {
println!("spaceshd skeleton");
}
- Step 3: Run test to verify behavior
Run: cargo test -p spaceshd lifecycle
Expected: PASS (2 tests). lock_is_exclusive_within_process depends on fs2 advisory locking being per-open-handle; if your platform coalesces locks per-process, change the assertion to acquire from a spawned thread holding its own handle. (macOS flock is per-handle → the test as written passes.)
- Step 4: Commit
git add crates/spaceshd/src/lifecycle.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): lifecycle paths, single-instance lock, stale-socket cleanup"
Task 5: Surface actor (M0 — no grid yet)
Files:
- Create:
crates/spaceshd/src/surface.rs - Modify:
crates/spaceshd/src/main.rs(addmod surface;) - Test: inline
#[cfg(test)]insurface.rs
The actor owns the PTY and a broadcast sender for output fan-out. In M0 it forwards raw chunks straight to the broadcast (no grid feed, no coalescing yet — both added in Task 13). Commands arrive on an mpsc.
- Step 1: Write the failing test
crates/spaceshd/src/surface.rs:
use spacesh_proto::{SurfaceId, WorkspaceId};
use spacesh_pty::{PtyHandle, SpawnSpec};
use tokio::sync::{broadcast, mpsc, oneshot};
/// Output broadcast capacity (chunks). Lagging subscribers drop intermediate frames.
const BROADCAST_CAP: usize = 1024;
/// Messages sent to a surface actor.
pub enum SurfaceMsg {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
/// Subscribe to the output stream. Reply carries a fresh receiver.
Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
Close,
}
/// Handle the daemon keeps for a live surface.
pub struct SurfaceHandle {
pub id: SurfaceId,
pub workspace_id: WorkspaceId,
pub tx: mpsc::Sender<SurfaceMsg>,
}
/// Spawn the actor; returns its handle. `exit_tx` is fired once with the exit code.
pub fn spawn_surface(
id: SurfaceId,
workspace_id: WorkspaceId,
mut pty: PtyHandle,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
) -> SurfaceHandle {
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
let actor_id = id.clone();
tokio::spawn(async move {
loop {
tokio::select! {
msg = rx.recv() => {
match msg {
Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); }
Some(SurfaceMsg::Resize { cols, rows }) => { let _ = pty.resize(cols, rows); }
Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); }
Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
}
}
chunk = pty.output.recv() => {
match chunk {
Some(bytes) => { let _ = bcast.send(bytes); }
None => { break; } // PTY EOF
}
}
}
}
let code = pty.wait();
let _ = exit_tx.send((actor_id, code));
});
SurfaceHandle { id, workspace_id, tx }
}
#[cfg(test)]
mod tests {
use super::*;
use spacesh_pty::SpawnSpec;
fn spec(script: &str) -> SpawnSpec {
SpawnSpec {
command: "/bin/sh".into(),
args: vec!["-c".into(), script.into()],
cwd: std::env::temp_dir(),
cols: 80,
rows: 24,
env: vec![],
}
}
#[tokio::test]
async fn attach_receives_output() {
let pty = PtyHandle::spawn(spec("printf HELLO; sleep 0.3")).unwrap();
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, exit_tx);
let (reply_tx, reply_rx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
let mut sub = reply_rx.await.unwrap();
let mut collected = Vec::new();
// Collect for a short bounded window.
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await {
Ok(Ok(bytes)) => collected.extend_from_slice(&bytes),
_ => {}
}
if String::from_utf8_lossy(&collected).contains("HELLO") { break; }
}
assert!(String::from_utf8_lossy(&collected).contains("HELLO"));
}
#[tokio::test]
async fn exit_is_reported() {
let pty = PtyHandle::spawn(spec("exit 7")).unwrap();
let (exit_tx, mut exit_rx) = mpsc::unbounded_channel();
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, exit_tx);
let (sid, code) = tokio::time::timeout(tokio::time::Duration::from_secs(3), exit_rx.recv())
.await.unwrap().unwrap();
assert_eq!(sid, SurfaceId("s_2".into()));
assert_eq!(code, 7);
}
}
- Step 2: Wire the module
crates/spaceshd/src/main.rs:
mod lifecycle;
mod surface;
fn main() {
println!("spaceshd skeleton");
}
- Step 3: Run test to verify it fails then passes
Run: cargo test -p spaceshd surface
Expected: PASS (2 tests). If the unused SpawnSpec import in the actor file warns, remove it from the top-level use (it is only used in tests).
- Step 4: Commit
git add crates/spaceshd/src/surface.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): surface actor owning pty + broadcast fan-out (M0, no grid)"
Task 6: Registry
Files:
-
Create:
crates/spaceshd/src/registry.rs -
Modify:
crates/spaceshd/src/main.rs(addmod registry;) -
Test: inline
#[cfg(test)]inregistry.rs -
Step 1: Write the failing test
crates/spaceshd/src/registry.rs:
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use spacesh_proto::{SurfaceId, WorkspaceId};
use crate::surface::SurfaceHandle;
#[derive(Clone)]
pub struct WorkspaceMeta {
pub id: WorkspaceId,
pub path: PathBuf,
}
/// Single-threaded owner of all live surfaces and workspaces.
/// Lives inside the server task; not shared across threads.
#[derive(Default)]
pub struct Registry {
counter: AtomicU64,
workspaces: HashMap<WorkspaceId, WorkspaceMeta>,
/// path → workspace, so `open` is idempotent.
by_path: HashMap<PathBuf, WorkspaceId>,
surfaces: HashMap<SurfaceId, SurfaceHandle>,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
fn next_id(&self, prefix: &str) -> String {
let n = self.counter.fetch_add(1, Ordering::Relaxed);
format!("{prefix}_{n:x}")
}
/// Idempotent: opening the same canonicalized path returns the existing workspace.
pub fn open_workspace(&mut self, path: PathBuf) -> WorkspaceMeta {
let canonical = path.canonicalize().unwrap_or(path);
if let Some(id) = self.by_path.get(&canonical) {
return self.workspaces[id].clone();
}
let id = WorkspaceId(self.next_id("w"));
let meta = WorkspaceMeta { id: id.clone(), path: canonical.clone() };
self.workspaces.insert(id.clone(), meta.clone());
self.by_path.insert(canonical, id);
meta
}
pub fn workspace(&self, id: &WorkspaceId) -> Option<&WorkspaceMeta> {
self.workspaces.get(id)
}
pub fn new_surface_id(&self) -> SurfaceId {
SurfaceId(self.next_id("s"))
}
pub fn insert_surface(&mut self, handle: SurfaceHandle) {
self.surfaces.insert(handle.id.clone(), handle);
}
pub fn surface(&self, id: &SurfaceId) -> Option<&SurfaceHandle> {
self.surfaces.get(id)
}
pub fn remove_surface(&mut self, id: &SurfaceId) -> Option<SurfaceHandle> {
self.surfaces.remove(id)
}
/// Snapshot for the `status` command: (workspace, its surface ids).
pub fn status(&self) -> Vec<(WorkspaceMeta, Vec<SurfaceId>)> {
self.workspaces
.values()
.map(|w| {
let sids = self
.surfaces
.values()
.filter(|s| s.workspace_id == w.id)
.map(|s| s.id.clone())
.collect();
(w.clone(), sids)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_is_idempotent_per_path() {
let mut reg = Registry::new();
let dir = std::env::temp_dir();
let a = reg.open_workspace(dir.clone());
let b = reg.open_workspace(dir.clone());
assert_eq!(a.id, b.id);
}
#[test]
fn ids_are_unique_and_prefixed() {
let reg = Registry::new();
let s1 = reg.new_surface_id();
let s2 = reg.new_surface_id();
assert!(s1.0.starts_with("s_"));
assert_ne!(s1, s2);
}
}
- Step 2: Wire the module
Update crates/spaceshd/src/main.rs to add mod registry; alongside the others.
- Step 3: Run tests
Run: cargo test -p spaceshd registry
Expected: PASS (2 tests).
- Step 4: Commit
git add crates/spaceshd/src/registry.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): registry for workspaces and surfaces with idempotent open"
Task 7: Socket server — accept loop, client task, command dispatch
Files:
- Create:
crates/spaceshd/src/server.rs - Modify:
crates/spaceshd/src/main.rs - Test: inline
#[cfg(test)]integration test inserver.rs
Design: the Registry is owned by a single router task reachable via an mpsc<ServerMsg>. Each accepted connection spawns a client task that owns the socket write half and forwards (a) parsed requests to the router, (b) a subscription channel for events. The router executes commands against the registry and replies through per-client mpsc<Envelope> senders. This keeps the registry lock-free (single owner) and lets the router fan events out to all connected clients.
- Step 1: Write the failing integration test
crates/spaceshd/src/server.rs:
use std::collections::HashMap;
use std::path::Path;
use anyhow::Result;
use base64::Engine;
use spacesh_proto::codec::{read_frame, write_frame};
use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId};
use spacesh_pty::{PtyHandle, SpawnSpec};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, oneshot};
use crate::registry::Registry;
use crate::surface::{spawn_surface, SurfaceMsg};
/// Per-client outbound channel: the router pushes envelopes the client task writes out.
type ClientTx = mpsc::Sender<Envelope>;
/// Messages into the single router task.
enum ServerMsg {
/// A request from a client; reply routed to that client's `out`.
Request { id: u64, cmd: Cmd, client: ClientId, out: ClientTx },
/// Forward an output chunk to all subscribers of `surface_id`.
Output { surface_id: SurfaceId, bytes: Vec<u8> },
/// A surface process exited.
Exit { surface_id: SurfaceId, code: i32 },
/// Register a new client's event sink.
ClientConnected { client: ClientId, out: ClientTx },
/// Drop a client and all its subscriptions.
ClientDisconnected { client: ClientId },
}
type ClientId = u64;
pub async fn serve(socket: &Path) -> Result<()> {
let listener = UnixListener::bind(socket)?;
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
// Exit events from surfaces are funneled into the router.
let (exit_tx, mut exit_rx) = mpsc::unbounded_channel::<(SurfaceId, i32)>();
let router_for_exit = router_tx.clone();
tokio::spawn(async move {
while let Some((sid, code)) = exit_rx.recv().await {
let _ = router_for_exit.send(ServerMsg::Exit { surface_id: sid, code }).await;
}
});
let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx));
let mut next_client: ClientId = 0;
loop {
let (stream, _addr) = listener.accept().await?;
let client_id = next_client;
next_client += 1;
let router_tx = router_tx.clone();
tokio::spawn(handle_client(stream, client_id, router_tx));
if shutdown.is_finished() {
break;
}
}
Ok(())
}
async fn handle_client(stream: UnixStream, client_id: ClientId, router_tx: mpsc::Sender<ServerMsg>) {
let (mut read_half, mut write_half) = stream.into_split();
let (out_tx, mut out_rx) = mpsc::channel::<Envelope>(256);
let _ = router_tx
.send(ServerMsg::ClientConnected { client: client_id, out: out_tx.clone() })
.await;
// Writer task: drain outbound envelopes to the socket.
let writer = tokio::spawn(async move {
while let Some(env) = out_rx.recv().await {
if write_frame(&mut write_half, &env).await.is_err() {
break;
}
}
});
// Reader loop: parse frames and forward requests to the router.
loop {
match read_frame(&mut read_half).await {
Ok(Some(Envelope::Req { id, cmd })) => {
let _ = router_tx
.send(ServerMsg::Request { id, cmd, client: client_id, out: out_tx.clone() })
.await;
}
Ok(Some(_)) => { /* clients don't send res/evt; ignore */ }
Ok(None) => break, // EOF
Err(_) => break,
}
}
let _ = router_tx.send(ServerMsg::ClientDisconnected { client: client_id }).await;
writer.abort();
}
async fn router(
mut rx: mpsc::Receiver<ServerMsg>,
router_tx: mpsc::Sender<ServerMsg>,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
) {
let mut reg = Registry::new();
let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
// surface_id → set of client ids subscribed (attached).
let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
ServerMsg::ClientConnected { client, out } => {
clients.insert(client, out);
}
ServerMsg::ClientDisconnected { client } => {
clients.remove(&client);
for list in subs.values_mut() {
list.retain(|c| *c != client);
}
}
ServerMsg::Output { surface_id, bytes } => {
if let Some(list) = subs.get(&surface_id) {
let evt = Envelope::Evt(Evt::Output { surface_id: surface_id.clone(), bytes });
for c in list {
if let Some(out) = clients.get(c) {
let _ = out.try_send(evt.clone());
}
}
}
}
ServerMsg::Exit { surface_id, code } => {
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
broadcast_evt(&clients, &evt);
}
ServerMsg::Request { id, cmd, client, out } => {
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx).await;
}
}
}
}
fn broadcast_evt(clients: &HashMap<ClientId, ClientTx>, evt: &Envelope) {
for out in clients.values() {
let _ = out.try_send(evt.clone());
}
}
fn ok(id: u64, data: serde_json::Value) -> Envelope {
Envelope::Res { id, ok: true, data, error: None }
}
fn err(id: u64, code: &str, msg: &str) -> Envelope {
Envelope::Res { id, ok: false, data: serde_json::Value::Null,
error: Some(ErrorBody { code: code.into(), msg: msg.into() }) }
}
#[allow(clippy::too_many_arguments)]
async fn handle_request(
id: u64,
cmd: Cmd,
client: ClientId,
out: ClientTx,
reg: &mut Registry,
subs: &mut HashMap<SurfaceId, Vec<ClientId>>,
clients: &HashMap<ClientId, ClientTx>,
router_tx: &mpsc::Sender<ServerMsg>,
exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>,
) {
match cmd {
Cmd::Open { path } => {
let meta = reg.open_workspace(path.into());
let _ = out.send(ok(id, serde_json::json!({ "workspace_id": meta.id.0 }))).await;
}
Cmd::NewSurface { workspace_id, command, args, cols, rows } => {
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await;
return;
};
let sid = reg.new_surface_id();
let shell = command.unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
let spec = SpawnSpec {
command: shell,
args,
cwd: ws.path.clone(),
cols,
rows,
env: vec![("SPACESH_SURFACE_ID".into(), sid.0.clone())],
};
match PtyHandle::spawn(spec) {
Ok(pty) => {
let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, exit_tx.clone());
// Bridge the surface's broadcast into the router as Output messages.
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
reg.insert_surface(handle);
let created = Envelope::Evt(Evt::SurfaceCreated {
surface_id: sid.clone(), workspace_id: workspace_id.clone(),
});
broadcast_evt(clients, &created);
let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await;
}
Err(e) => {
let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await;
}
}
}
Cmd::Input { surface_id, bytes } => {
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else {
let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await;
return;
};
if let Some(s) = reg.surface(&surface_id) {
let _ = s.tx.send(SurfaceMsg::Input(decoded)).await;
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Cmd::Resize { surface_id, cols, rows } => {
if let Some(s) = reg.surface(&surface_id) {
let _ = s.tx.send(SurfaceMsg::Resize { cols, rows }).await;
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Cmd::Attach { surface_id } => {
// M0 attach: register subscription, no snapshot yet (snapshot added in Task 13).
if reg.surface(&surface_id).is_some() {
subs.entry(surface_id.clone()).or_default().push(client);
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0 }))).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Cmd::Detach { surface_id } => {
if let Some(list) = subs.get_mut(&surface_id) {
list.retain(|c| *c != client);
}
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::Focus { surface_id: _ } => {
// Focus is a no-op in this slice (window raise is GUI-side; CLI parity later).
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::Close { surface_id } => {
if let Some(handle) = reg.remove_surface(&surface_id) {
let _ = handle.tx.send(SurfaceMsg::Close).await;
subs.remove(&surface_id);
let closed = Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() });
broadcast_evt(clients, &closed);
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Cmd::Status => {
let workspaces: Vec<_> = reg.status().into_iter().map(|(w, sids)| {
serde_json::json!({
"workspace_id": w.id.0,
"path": w.path.to_string_lossy(),
"surfaces": sids.iter().map(|s| s.0.clone()).collect::<Vec<_>>(),
})
}).collect();
let _ = out.send(ok(id, serde_json::json!({ "workspaces": workspaces }))).await;
}
Cmd::Shutdown => {
let _ = out.send(ok(id, serde_json::Value::Null)).await;
std::process::exit(0);
}
}
}
/// Pump a surface's broadcast output into the router as `ServerMsg::Output`.
fn spawn_output_bridge(
surface_id: SurfaceId,
handle: &crate::surface::SurfaceHandle,
router_tx: mpsc::Sender<ServerMsg>,
) {
let tx = handle.tx.clone();
tokio::spawn(async move {
// Ask the actor for a subscription receiver.
let (reply_tx, reply_rx) = oneshot::channel();
if tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.is_err() {
return;
}
let Ok(mut sub) = reply_rx.await else { return };
loop {
match sub.recv().await {
Ok(bytes) => {
if router_tx.send(ServerMsg::Output { surface_id: surface_id.clone(), bytes }).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break, // surface closed
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
async fn req(stream: &mut UnixStream, id: u64, cmd: Cmd) -> Envelope {
write_frame(stream, &Envelope::Req { id, cmd }).await.unwrap();
// Read until we see the matching res (skip interleaved evts).
loop {
let env = read_frame(stream).await.unwrap().unwrap();
if let Envelope::Res { id: rid, .. } = &env {
if *rid == id { return env; }
}
}
}
#[tokio::test]
async fn open_new_surface_attach_streams_output() {
let dir = tempdir_path();
let sock = dir.join("sock");
let sock_for_task = sock.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut s, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "printf STREAM_OK; sleep 0.5".into()],
cols: 80, rows: 24,
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
let surface_id = spacesh_proto::SurfaceId(sid);
let _ = req(&mut s, 3, Cmd::Attach { surface_id: surface_id.clone() }).await;
// Now read frames looking for an Output evt containing STREAM_OK.
let mut got = String::new();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(Envelope::Evt(Evt::Output { bytes, .. })))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut s)).await {
got.push_str(&String::from_utf8_lossy(&bytes));
if got.contains("STREAM_OK") { break; }
}
}
assert!(got.contains("STREAM_OK"), "got: {got:?}");
}
#[tokio::test]
async fn unknown_surface_returns_not_found() {
let dir = tempdir_path();
let sock = dir.join("sock");
let sock_for_task = sock.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Input {
surface_id: spacesh_proto::SurfaceId("s_nope".into()),
bytes: base64::engine::general_purpose::STANDARD.encode(b"x"),
}).await;
match r {
Envelope::Res { ok, error, .. } => {
assert!(!ok);
assert_eq!(error.unwrap().code, "NOT_FOUND");
}
_ => panic!(),
}
}
fn res_data(env: &Envelope) -> &serde_json::Value {
match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") }
}
fn tempdir_path() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("spaceshd-test-{n}"));
std::fs::create_dir_all(&p).unwrap();
p
}
async fn wait_for_socket(sock: &Path) {
for _ in 0..100 {
if UnixStream::connect(sock).await.is_ok() { return; }
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
}
panic!("socket never came up");
}
}
Add tempfile-free temp dirs as above (no new dependency). Note Cmd::Shutdown calls std::process::exit — do not exercise it in tests (it would kill the test runner). It is covered by manual verification.
- Step 2: Wire the module
crates/spaceshd/src/main.rs:
mod lifecycle;
mod registry;
mod server;
mod surface;
fn main() {
println!("spaceshd skeleton");
}
- Step 3: Run tests
Run: cargo test -p spaceshd server
Expected: PASS (2 tests). The streaming test depends on the output bridge subscribing before the child writes; sleep 0.5 in the script gives margin.
- Step 4: Commit
git add crates/spaceshd/src/server.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): socket server with router task, command dispatch, event fan-out (M0)"
Task 8: Daemon entrypoint with single-instance startup
Files:
-
Modify:
crates/spaceshd/src/main.rs -
Step 1: Write the entrypoint
crates/spaceshd/src/main.rs:
mod launchd;
mod lifecycle;
mod registry;
mod server;
mod surface;
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
let arg = std::env::args().nth(1);
match arg.as_deref() {
Some("install-agent") => {
launchd::install_agent()?;
println!("launchd agent installed");
Ok(())
}
Some("--help") | Some("-h") => {
println!("spaceshd [install-agent]");
Ok(())
}
_ => run_daemon().await,
}
}
async fn run_daemon() -> Result<()> {
let Some(_lock) = lifecycle::acquire_instance_lock()? else {
eprintln!("another spaceshd is already running");
return Ok(());
};
lifecycle::clear_stale_socket()?;
let sock = lifecycle::socket_path()?;
eprintln!("spaceshd listening on {}", sock.display());
server::serve(&sock).await
}
(launchd::install_agent is created in Task 16; to keep Task 8 compiling on its own, add a temporary stub now and replace it in Task 16.)
Temporary stub — crates/spaceshd/src/launchd.rs:
use anyhow::Result;
pub fn install_agent() -> Result<()> {
anyhow::bail!("install-agent implemented in Task 16")
}
- Step 2: Build and smoke-run the daemon manually
Run: cargo build -p spaceshd
Expected: PASS.
Manual smoke test (own terminal):
cargo run -p spaceshd &
sleep 1
ls -l ~/.spacesh/sock # socket exists
kill %1
rm -f ~/.spacesh/sock
Expected: socket file present while running.
- Step 3: Commit
git add crates/spaceshd/src/main.rs crates/spaceshd/src/launchd.rs
git commit -m "feat(daemon): entrypoint with single-instance lock and lazy socket bind"
Phase 4 — Tauri app (M0: bytes flying in the GUI)
Task 9: Tauri scaffold + bridge state
Files:
- Create:
app/package.json,app/vite.config.ts,app/index.html,app/tsconfig.json - Create:
app/src-tauri/Cargo.toml,app/src-tauri/tauri.conf.json,app/src-tauri/build.rs - Create:
app/src-tauri/src/main.rs,app/src-tauri/src/lib.rs
This task scaffolds the app and proves it launches. The bridge logic is Task 10's bridge.rs. Use the official scaffolder if preferred (npm create tauri-app@latest), but the files below are the minimum that integrates with our crates.
- Step 1: Frontend manifest
app/package.json:
{
"name": "spacesh-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-webgl": "^0.18.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
app/vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: { port: 1420, strictPort: true },
});
app/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>spacesh</title>
<link rel="stylesheet" href="/node_modules/@xterm/xterm/css/xterm.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
app/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}
- Step 2: Tauri Rust side manifest and config
app/src-tauri/Cargo.toml:
[package]
name = "spacesh-app"
version = "0.1.0"
edition = "2021"
[lib]
name = "spacesh_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
spacesh-proto = { path = "../../crates/spacesh-proto" }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
anyhow = "1"
dirs = "5"
app/src-tauri/build.rs:
fn main() {
tauri_build::build()
}
app/src-tauri/tauri.conf.json:
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh",
"version": "0.1.0",
"identifier": "xyz.spacesh.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
"security": { "csp": null }
}
}
- Step 3: Minimal Rust entry that launches a window
app/src-tauri/src/main.rs:
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
spacesh_app_lib::run();
}
app/src-tauri/src/lib.rs (Task 9 version — expanded in Task 10):
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running spacesh");
}
- Step 4: Minimal React entry
app/src/main.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<div style={{ color: "#ddd", fontFamily: "monospace", padding: 16 }}>
spacesh — scaffold OK
</div>
</React.StrictMode>
);
- Step 5: Install and launch
Run:
cd app && npm install && npm run tauri dev
Expected: a window opens showing "spacesh — scaffold OK". Close it.
- Step 6: Commit
git add app/
git commit -m "chore(app): scaffold tauri 2 + react + vite, window launches"
Task 10: Bridge (UDS ↔ webview) + React terminal
Files:
- Create:
app/src-tauri/src/bridge.rs - Modify:
app/src-tauri/src/lib.rs - Create:
app/src/socketBridge.ts,app/src/TerminalView.tsx,app/src/SurfaceList.tsx,app/src/App.tsx - Modify:
app/src/main.tsx
Bridge design (scheme B): one persistent UDS connection. A writer task owns the write half; a reader task demuxes frames — Res go to a pending-request map (oneshot per id), Evt::Output go to the registered per-surface Channel, other Evt are emitted to the webview. The daemon must be auto-spawned if the socket is absent.
- Step 1: Implement the bridge
app/src-tauri/src/bridge.rs:
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
use base64::Engine;
use serde_json::Value;
use spacesh_proto::codec::{read_frame, write_frame};
use spacesh_proto::{Cmd, Envelope, Evt, SurfaceId};
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter};
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{mpsc, oneshot, Mutex};
pub struct Bridge {
next_id: AtomicU64,
/// Outbound frames to the daemon.
tx: mpsc::Sender<Envelope>,
/// Pending request id → reply slot.
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
/// surface id → output channel into the webview.
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
}
fn socket_path() -> Result<PathBuf> {
Ok(dirs::home_dir().context("no home")?.join(".spacesh").join("sock"))
}
async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
if let Ok(s) = UnixStream::connect(sock).await {
return Ok(s);
}
// Lazy start: spawn the daemon binary, then poll for the socket.
let exe = std::env::current_exe()?;
let daemon = exe.with_file_name("spaceshd");
let _ = std::process::Command::new(daemon).spawn();
for _ in 0..100 {
if let Ok(s) = UnixStream::connect(sock).await {
return Ok(s);
}
tokio::time::sleep(tokio::time::Duration::from_millis(30)).await;
}
anyhow::bail!("daemon did not come up")
}
impl Bridge {
pub async fn connect(app: AppHandle) -> Result<Self> {
let sock = socket_path()?;
let stream = ensure_daemon(&sock).await?;
let (read_half, write_half) = stream.into_split();
let (tx, rx) = mpsc::channel::<Envelope>(256);
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
spawn_writer(write_half, rx);
spawn_reader(read_half, app, pending.clone(), out_channels.clone());
Ok(Self { next_id: AtomicU64::new(1), tx, pending, out_channels })
}
pub async fn request(&self, cmd: Cmd) -> Result<Envelope> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (reply_tx, reply_rx) = oneshot::channel();
self.pending.lock().await.insert(id, reply_tx);
self.tx.send(Envelope::Req { id, cmd }).await?;
Ok(reply_rx.await?)
}
pub async fn register_output(&self, surface_id: String, channel: Channel<Vec<u8>>) {
self.out_channels.lock().await.insert(surface_id, channel);
}
pub async fn unregister_output(&self, surface_id: &str) {
self.out_channels.lock().await.remove(surface_id);
}
}
fn spawn_writer(mut write_half: OwnedWriteHalf, mut rx: mpsc::Receiver<Envelope>) {
tokio::spawn(async move {
while let Some(env) = rx.recv().await {
if write_frame(&mut write_half, &env).await.is_err() {
break;
}
}
});
}
fn spawn_reader(
mut read_half: OwnedReadHalf,
app: AppHandle,
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
) {
tokio::spawn(async move {
loop {
match read_frame(&mut read_half).await {
Ok(Some(env)) => match env {
Envelope::Res { id, .. } => {
if let Some(slot) = pending.lock().await.remove(&id) {
let _ = slot.send(env);
}
}
Envelope::Evt(Evt::Output { surface_id, bytes }) => {
if let Some(ch) = out_channels.lock().await.get(&surface_id.0) {
let _ = ch.send(bytes);
}
}
Envelope::Evt(other) => {
// exit / surface_created / surface_closed → emit to webview.
let _ = app.emit("spacesh:evt", &other);
}
Envelope::Req { .. } => {}
},
Ok(None) | Err(_) => break,
}
}
});
}
// ---- Tauri commands ----
type BridgeState<'a> = tauri::State<'a, Bridge>;
fn data_of(env: Envelope) -> Result<Value, String> {
match env {
Envelope::Res { ok: true, data, .. } => Ok(data),
Envelope::Res { ok: false, error, .. } => {
Err(error.map(|e| format!("{}: {}", e.code, e.msg)).unwrap_or_else(|| "error".into()))
}
_ => Err("unexpected reply".into()),
}
}
#[tauri::command]
pub async fn open(state: BridgeState<'_>, path: String) -> Result<Value, String> {
data_of(state.request(Cmd::Open { path }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn new_surface(
state: BridgeState<'_>,
workspace_id: String,
command: Option<String>,
args: Vec<String>,
cols: u16,
rows: u16,
) -> Result<Value, String> {
let cmd = Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(workspace_id),
command,
args,
cols,
rows,
};
data_of(state.request(cmd).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn input(state: BridgeState<'_>, surface_id: String, data: Vec<u8>) -> Result<Value, String> {
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
data_of(state.request(Cmd::Input { surface_id: SurfaceId(surface_id), bytes: b64 }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn resize(state: BridgeState<'_>, surface_id: String, cols: u16, rows: u16) -> Result<Value, String> {
data_of(state.request(Cmd::Resize { surface_id: SurfaceId(surface_id), cols, rows }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn attach(state: BridgeState<'_>, surface_id: String, on_output: Channel<Vec<u8>>) -> Result<Value, String> {
state.register_output(surface_id.clone(), on_output).await;
data_of(state.request(Cmd::Attach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn detach(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
state.unregister_output(&surface_id).await;
data_of(state.request(Cmd::Detach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn status(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Status).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn close_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}
- Step 2: Wire the bridge into the builder
app/src-tauri/src/lib.rs:
mod bridge;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let handle = app.handle().clone();
// Connect the bridge on a tokio runtime, then manage it.
tauri::async_runtime::block_on(async move {
let bridge = bridge::Bridge::connect(handle.clone())
.await
.expect("failed to connect to spaceshd");
handle.manage(bridge);
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
bridge::open,
bridge::new_surface,
bridge::input,
bridge::resize,
bridge::attach,
bridge::detach,
bridge::status,
bridge::close_surface,
])
.run(tauri::generate_context!())
.expect("error while running spacesh");
}
- Step 3: Frontend socket bridge
app/src/socketBridge.ts:
import { invoke, Channel } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
export interface WorkspaceStatus {
workspace_id: string;
path: string;
surfaces: string[];
}
export async function openWorkspace(path: string): Promise<string> {
const data = await invoke<{ workspace_id: string }>("open", { path });
return data.workspace_id;
}
export async function newSurface(
workspaceId: string,
cols: number,
rows: number,
command?: string,
args: string[] = []
): Promise<string> {
const data = await invoke<{ surface_id: string }>("new_surface", {
workspaceId,
command: command ?? null,
args,
cols,
rows,
});
return data.surface_id;
}
export async function sendInput(surfaceId: string, data: Uint8Array): Promise<void> {
await invoke("input", { surfaceId, data: Array.from(data) });
}
export async function resizeSurface(surfaceId: string, cols: number, rows: number): Promise<void> {
await invoke("resize", { surfaceId, cols, rows });
}
export interface AttachResult {
snapshot: string;
cols: number;
rows: number;
}
export async function attachSurface(
surfaceId: string,
onOutput: (bytes: Uint8Array) => void
): Promise<AttachResult> {
const channel = new Channel<number[]>();
channel.onmessage = (msg) => onOutput(new Uint8Array(msg));
return await invoke<AttachResult>("attach", { surfaceId, onOutput: channel });
}
export async function detachSurface(surfaceId: string): Promise<void> {
await invoke("detach", { surfaceId });
}
export async function getStatus(): Promise<WorkspaceStatus[]> {
const data = await invoke<{ workspaces: WorkspaceStatus[] }>("status");
return data.workspaces;
}
export type DaemonEvt =
| { evt: "exit"; data: { surface_id: string; code: number } }
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
| { evt: "surface_closed"; data: { surface_id: string } };
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
}
- Step 4: TerminalView
app/src/TerminalView.tsx:
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { WebglAddon } from "@xterm/addon-webgl";
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
export function TerminalView({ surfaceId }: { surfaceId: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const term = new Terminal({ fontFamily: "monospace", fontSize: 13, convertEol: false });
try {
term.loadAddon(new WebglAddon());
} catch {
// webgl unavailable → fall back to canvas/dom renderer silently
}
term.open(ref.current);
// Input → daemon.
const inputDisposable = term.onData((data) => {
void sendInput(surfaceId, encoder.encode(data));
});
let disposed = false;
// Attach: fresh xterm instance, write snapshot, then stream live output.
void attachSurface(surfaceId, (bytes) => {
if (!disposed) term.write(decoder.decode(bytes));
}).then((res) => {
if (disposed) return;
if (res.snapshot) term.write(res.snapshot);
if (res.cols && res.rows) {
term.resize(res.cols, res.rows);
void resizeSurface(surfaceId, res.cols, res.rows);
}
});
return () => {
disposed = true;
inputDisposable.dispose();
void detachSurface(surfaceId);
term.dispose();
};
}, [surfaceId]);
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
}
- Step 5: SurfaceList
app/src/SurfaceList.tsx:
export function SurfaceList({
surfaces,
active,
onSelect,
}: {
surfaces: string[];
active: string | null;
onSelect: (id: string) => void;
}) {
return (
<div style={{ width: 160, background: "#1a1a1a", color: "#ccc", padding: 8 }}>
<div style={{ opacity: 0.6, fontSize: 11, marginBottom: 8 }}>SURFACES</div>
{surfaces.map((id) => (
<div
key={id}
onClick={() => onSelect(id)}
style={{
padding: "4px 6px",
cursor: "pointer",
borderRadius: 4,
background: id === active ? "#333" : "transparent",
fontFamily: "monospace",
fontSize: 12,
}}
>
{id}
</div>
))}
</div>
);
}
- Step 6: App wiring
app/src/App.tsx:
import { useEffect, useState } from "react";
import { TerminalView } from "./TerminalView";
import { SurfaceList } from "./SurfaceList";
import { openWorkspace, newSurface, getStatus, onDaemonEvent } from "./socketBridge";
export function App() {
const [surfaces, setSurfaces] = useState<string[]>([]);
const [active, setActive] = useState<string | null>(null);
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
useEffect(() => {
void (async () => {
const ws = await getStatus();
const flat = ws.flatMap((w) => w.surfaces);
setSurfaces(flat);
if (flat.length) setActive(flat[0]);
})();
const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "surface_created") {
setSurfaces((s) => [...s, evt.data.surface_id]);
} else if (evt.evt === "surface_closed" || evt.evt === "exit") {
// exit leaves the surface visible; surface_closed removes it.
if (evt.evt === "surface_closed") {
setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id));
}
}
});
return () => {
void unlisten.then((f) => f());
};
}, []);
async function handleNewSurface() {
let ws = workspaceId;
if (!ws) {
ws = await openWorkspace(".");
setWorkspaceId(ws);
}
const id = await newSurface(ws, 80, 24);
setActive(id);
}
return (
<div style={{ display: "flex", height: "100vh", background: "#000" }}>
<div style={{ display: "flex", flexDirection: "column", width: 160 }}>
<button onClick={handleNewSurface} style={{ margin: 8 }}>
+ surface
</button>
<SurfaceList surfaces={surfaces} active={active} onSelect={setActive} />
</div>
<div style={{ flex: 1 }}>
{active ? <TerminalView key={active} surfaceId={active} /> : <div style={{ color: "#666", padding: 16 }}>no surface</div>}
</div>
</div>
);
}
app/src/main.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "@xterm/xterm/css/xterm.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
- Step 7: Manual end-to-end verification (M0)
Run:
cd app && npm run tauri dev
Steps: click "+ surface" → a shell appears in the terminal → type echo hi → see output. This proves bytes fly GUI↔daemon↔PTY.
Expected: interactive shell works.
- Step 8: Commit
git add app/
git commit -m "feat(app): UDS bridge (channel/invoke/emit) + xterm.js terminal, M0 e2e works"
Phase 5 — spacesh-core (grid + snapshot)
Task 11: GridSurface — feed bytes into an alacritty Term
Files:
- Create:
crates/spacesh-core/src/grid.rs - Modify:
crates/spacesh-core/src/lib.rs(enablemod grid;) - Test: inline
#[cfg(test)]ingrid.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). Ifcargo buildreports a signature mismatch, runcargo doc -p alacritty_terminal --openand adjust the three call sites (constructor,advance, cell iteration) — the wrapper isolates them.
- Step 1: Write the failing test
crates/spacesh-core/src/grid.rs:
use alacritty_terminal::event::VoidListener;
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line, Point};
use alacritty_terminal::term::{Config, Term};
use alacritty_terminal::vte::ansi::Processor;
/// Fixed-size terminal dimensions for the daemon-side grid.
#[derive(Clone, Copy)]
pub struct GridSize {
pub cols: usize,
pub lines: usize,
}
impl Dimensions for GridSize {
fn total_lines(&self) -> usize {
self.lines
}
fn screen_lines(&self) -> usize {
self.lines
}
fn columns(&self) -> usize {
self.cols
}
}
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
pub struct GridSurface {
term: Term<VoidListener>,
parser: Processor,
size: GridSize,
}
impl GridSurface {
pub fn new(cols: u16, rows: u16) -> Self {
let size = GridSize { cols: cols as usize, lines: rows as usize };
let term = Term::new(Config::default(), &size, VoidListener);
Self { term, parser: Processor::new(), size }
}
pub fn feed(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.term, bytes);
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.size = GridSize { cols: cols as usize, lines: rows as usize };
self.term.resize(self.size);
}
pub fn size(&self) -> GridSize {
self.size
}
/// Read the visible character at (line, col) — used by tests and the snapshot writer.
pub fn char_at(&self, line: usize, col: usize) -> char {
let point = Point::new(Line(line as i32), Column(col));
self.term.grid()[point].c
}
pub fn term(&self) -> &Term<VoidListener> {
&self.term
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feeding_plain_text_lands_in_the_grid() {
let mut g = GridSurface::new(20, 5);
g.feed(b"hello");
assert_eq!(g.char_at(0, 0), 'h');
assert_eq!(g.char_at(0, 4), 'o');
}
#[test]
fn carriage_return_and_newline_move_the_cursor() {
let mut g = GridSurface::new(20, 5);
g.feed(b"ab\r\ncd");
assert_eq!(g.char_at(0, 0), 'a');
assert_eq!(g.char_at(1, 0), 'c');
}
}
- Step 2: Enable the module
crates/spacesh-core/src/lib.rs:
pub mod grid;
pub mod snapshot;
pub use grid::GridSurface;
pub use snapshot::Snapshot;
(snapshot is added in Task 12; if building Task 11 alone, temporarily comment the snapshot lines and restore them in Task 12.)
- Step 3: Run tests
Run: cargo test -p spacesh-core grid
Expected: PASS (2 tests). Fix the three pinned call sites if the API differs (see API pin note).
- Step 4: Commit
git add crates/spacesh-core/src/grid.rs crates/spacesh-core/src/lib.rs
git commit -m "feat(core): GridSurface feeding PTY bytes into alacritty term"
Task 12: snapshot_ansi — serialize the grid to an ANSI dump
Files:
-
Create:
crates/spacesh-core/src/snapshot.rs -
Test: inline
#[cfg(test)]insnapshot.rs -
Step 1: Write the failing test
crates/spacesh-core/src/snapshot.rs:
use serde::Serialize;
use alacritty_terminal::index::Point;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::vte::ansi::Color;
use crate::grid::GridSurface;
/// Serializable snapshot returned by `attach`.
#[derive(Debug, Clone, Serialize)]
pub struct Snapshot {
/// ANSI byte dump suitable for `xterm.write()`.
pub ansi: String,
pub cols: u16,
pub rows: u16,
/// 1-based cursor position.
pub cursor_row: u16,
pub cursor_col: u16,
}
fn sgr_for_color(c: Color, foreground: bool) -> String {
let base = if foreground { 38 } else { 48 };
match c {
Color::Named(named) => {
// Map common named colors to SGR; default fg/bg reset for the rest.
use alacritty_terminal::vte::ansi::NamedColor;
let code = match named {
NamedColor::Black => Some(if foreground { 30 } else { 40 }),
NamedColor::Red => Some(if foreground { 31 } else { 41 }),
NamedColor::Green => Some(if foreground { 32 } else { 42 }),
NamedColor::Yellow => Some(if foreground { 33 } else { 43 }),
NamedColor::Blue => Some(if foreground { 34 } else { 44 }),
NamedColor::Magenta => Some(if foreground { 35 } else { 45 }),
NamedColor::Cyan => Some(if foreground { 36 } else { 46 }),
NamedColor::White => Some(if foreground { 37 } else { 47 }),
NamedColor::BrightBlack => Some(if foreground { 90 } else { 100 }),
NamedColor::BrightRed => Some(if foreground { 91 } else { 101 }),
NamedColor::BrightGreen => Some(if foreground { 92 } else { 102 }),
NamedColor::BrightYellow => Some(if foreground { 93 } else { 103 }),
NamedColor::BrightBlue => Some(if foreground { 94 } else { 104 }),
NamedColor::BrightMagenta => Some(if foreground { 95 } else { 105 }),
NamedColor::BrightCyan => Some(if foreground { 96 } else { 106 }),
NamedColor::BrightWhite => Some(if foreground { 97 } else { 107 }),
_ => None, // Foreground/Background/Cursor etc. → use reset.
};
match code {
Some(n) => format!("{n}"),
None => format!("{}", if foreground { 39 } else { 49 }),
}
}
Color::Indexed(i) => format!("{base};5;{i}"),
Color::Spec(rgb) => format!("{base};2;{};{};{}", rgb.r, rgb.g, rgb.b),
}
}
/// Serialize the visible grid into an ANSI dump.
pub fn snapshot_ansi(g: &GridSurface) -> Snapshot {
let size = g.size();
let term = g.term();
let grid = term.grid();
let mut out = String::new();
out.push_str("\x1b[2J\x1b[H"); // clear + home
let cols = size.cols;
let lines = size.lines;
// Track the last emitted attributes to avoid redundant SGR sequences.
let mut last: Option<(Color, Color, Flags)> = None;
for line in 0..lines {
for col in 0..cols {
let point = Point::new(alacritty_terminal::index::Line(line as i32), alacritty_terminal::index::Column(col));
let cell = &grid[point];
let cur = (cell.fg, cell.bg, cell.flags);
if last != Some(cur) {
let mut codes: Vec<String> = vec!["0".into()]; // reset, then re-apply
if cell.flags.contains(Flags::BOLD) { codes.push("1".into()); }
if cell.flags.contains(Flags::DIM) { codes.push("2".into()); }
if cell.flags.contains(Flags::ITALIC) { codes.push("3".into()); }
if cell.flags.contains(Flags::UNDERLINE) { codes.push("4".into()); }
if cell.flags.contains(Flags::INVERSE) { codes.push("7".into()); }
codes.push(sgr_for_color(cell.fg, true));
codes.push(sgr_for_color(cell.bg, false));
out.push_str(&format!("\x1b[{}m", codes.join(";")));
last = Some(cur);
}
out.push(cell.c);
}
out.push_str("\r\n");
}
out.push_str("\x1b[0m"); // reset attributes at end
let cursor = grid.cursor.point;
let cursor_row = (cursor.line.0 as i64 + 1).clamp(1, lines as i64) as u16;
let cursor_col = (cursor.column.0 as i64 + 1).clamp(1, cols as i64) as u16;
out.push_str(&format!("\x1b[{cursor_row};{cursor_col}H"));
Snapshot {
ansi: out,
cols: cols as u16,
rows: lines as u16,
cursor_row,
cursor_col,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_contains_fed_text_and_is_deterministic() {
let mut g = GridSurface::new(10, 3);
g.feed(b"hi");
let a = snapshot_ansi(&g);
let b = snapshot_ansi(&g);
assert_eq!(a.ansi, b.ansi, "snapshot must be deterministic");
assert!(a.ansi.contains("hi"));
assert!(a.ansi.starts_with("\x1b[2J\x1b[H"));
assert_eq!(a.cols, 10);
assert_eq!(a.rows, 3);
}
#[test]
fn cursor_is_one_based_after_input() {
let mut g = GridSurface::new(10, 3);
g.feed(b"abc");
let s = snapshot_ansi(&g);
// After 'abc' the cursor sits at column 4 (1-based) on row 1.
assert_eq!(s.cursor_row, 1);
assert_eq!(s.cursor_col, 4);
}
}
- Step 2: Run tests
Run: cargo test -p spacesh-core
Expected: PASS (grid + snapshot tests). If Flags, Color, or NamedColor paths differ in 0.25, adjust the imports per cargo doc.
- Step 3: Commit
git add crates/spacesh-core/src/snapshot.rs crates/spacesh-core/src/lib.rs
git commit -m "feat(core): deterministic ANSI snapshot of the grid for reattach repaint"
Phase 6 — spaceshd (M1: grid + attach snapshot)
Task 13: Integrate grid + coalescing into the surface actor; snapshot on attach
Files:
- Modify:
crates/spaceshd/src/surface.rs - Test: inline
#[cfg(test)]additions insurface.rs
Changes: the actor now owns a GridSurface, feeds every output chunk into it, coalesces output before broadcasting (flush every ~6 ms or at 16 KiB), and answers a new Snapshot request by serializing the grid in the same actor turn as the subscription — guaranteeing the snapshot/stream ordering from the spec.
- Step 1: Extend the message enum and actor
Replace crates/spaceshd/src/surface.rs actor section with this (the test module from Task 5 stays; new tests appended in Step 2):
use spacesh_core::{snapshot::snapshot_ansi, GridSurface};
use spacesh_core::snapshot::Snapshot;
use spacesh_proto::{SurfaceId, WorkspaceId};
use spacesh_pty::PtyHandle;
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::time::{Duration, Instant};
const BROADCAST_CAP: usize = 1024;
const FLUSH_INTERVAL: Duration = Duration::from_millis(6);
const FLUSH_BYTES: usize = 16 * 1024;
pub enum SurfaceMsg {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
/// Attach with snapshot: subscribe AND capture the grid in one actor turn.
AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver<Vec<u8>>)> },
Close,
}
pub struct SurfaceHandle {
pub id: SurfaceId,
pub workspace_id: WorkspaceId,
pub tx: mpsc::Sender<SurfaceMsg>,
}
pub fn spawn_surface(
id: SurfaceId,
workspace_id: WorkspaceId,
mut pty: PtyHandle,
cols: u16,
rows: u16,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
) -> SurfaceHandle {
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
let actor_id = id.clone();
tokio::spawn(async move {
let mut grid = GridSurface::new(cols, rows);
let mut pending: Vec<u8> = Vec::with_capacity(FLUSH_BYTES);
let mut flush_deadline: Option<Instant> = None;
// Helper closure can't borrow across awaits cleanly; inline the flush logic.
loop {
// Copy the deadline into an owned local so the timer future doesn't
// hold a borrow of `flush_deadline` across the select! (other arms mutate it).
let next_flush = flush_deadline;
let timer = async move {
match next_flush {
Some(d) => tokio::time::sleep_until(d).await,
None => std::future::pending::<()>().await,
}
};
tokio::select! {
msg = rx.recv() => {
match msg {
Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); }
Some(SurfaceMsg::Resize { cols, rows }) => {
grid.resize(cols, rows);
let _ = pty.resize(cols, rows);
}
Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); }
Some(SurfaceMsg::AttachSnapshot { reply }) => {
// Subscribe + snapshot ONLY — do not touch `pending`.
// This arm is atomic within the single actor (no await, no flush
// can interleave), so subscribing before snapshotting guarantees the
// new receiver gets exactly the output emitted AFTER this snapshot.
// Any accumulated `pending` (not yet fed to the grid) is left alone:
// the normal 6ms/16KiB flush path delivers it to ALL subscribers —
// including this new one — exactly once, and it is NOT in the snapshot.
// No gap, no double-render. (Broadcasting pending here would re-send
// already-snapshotted bytes to the new client via the bridge path.)
let sub = bcast.subscribe();
let snap = snapshot_ansi(&grid);
let _ = reply.send((snap, sub));
}
Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
}
}
chunk = pty.output.recv() => {
match chunk {
Some(bytes) => {
pending.extend_from_slice(&bytes);
if flush_deadline.is_none() {
flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
}
if pending.len() >= FLUSH_BYTES {
grid.feed(&pending);
let _ = bcast.send(std::mem::take(&mut pending));
flush_deadline = None;
}
}
None => {
// Final flush on EOF.
if !pending.is_empty() {
grid.feed(&pending);
let _ = bcast.send(std::mem::take(&mut pending));
}
break;
}
}
}
_ = timer => {
if !pending.is_empty() {
grid.feed(&pending);
let _ = bcast.send(std::mem::take(&mut pending));
}
flush_deadline = None;
}
}
}
let code = pty.wait();
let _ = exit_tx.send((actor_id, code));
});
SurfaceHandle { id, workspace_id, tx }
}
Caller update: spawn_surface now takes cols, rows. Update crates/spaceshd/src/server.rs Cmd::NewSurface to pass them:
let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, cols, rows, exit_tx.clone());
Attach update (server): replace the M0 Cmd::Attach arm in server.rs with a snapshot-backed one:
Cmd::Attach { surface_id } => {
if let Some(s) = reg.surface(&surface_id) {
let (reply_tx, reply_rx) = oneshot::channel();
if s.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() {
if let Ok((snap, _sub)) = reply_rx.await {
subs.entry(surface_id.clone()).or_default().push(client);
let _ = out.send(ok(id, serde_json::json!({
"snapshot": snap.ansi,
"cols": snap.cols,
"rows": snap.rows,
"cursor_row": snap.cursor_row,
"cursor_col": snap.cursor_col,
}))).await;
return;
}
}
let _ = out.send(err(id, "INTERNAL", "attach failed")).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Note: the output bridge (Task 7's spawn_output_bridge) still subscribes via SurfaceMsg::Attach and pumps all broadcast traffic into the router; the router fans only to clients in subs. The AttachSnapshot subscription receiver _sub is dropped because the client receives output through the router/bridge path, not directly — the snapshot's role is the one-shot repaint, and the bridge guarantees subsequent output flows. This keeps a single fan-out path while preserving the ordering guarantee (snapshot taken in the same actor turn the bridge's broadcast continues from).
- Step 2: Update Task 5 tests for the new signature and add the snapshot test
In surface.rs tests, update both spawn_surface(...) calls to pass 80, 24:
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);
Append a new test:
#[tokio::test]
async fn attach_snapshot_reflects_prior_output() {
let pty = PtyHandle::spawn(spec("printf SNAPME; sleep 0.5")).unwrap();
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);
// Give the child time to write and the actor time to flush into the grid.
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let (reply_tx, reply_rx) = oneshot::channel();
handle.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.unwrap();
let (snap, _sub) = reply_rx.await.unwrap();
assert!(snap.ansi.contains("SNAPME"), "snapshot: {:?}", snap.ansi);
}
- Step 3: Run tests
Run: cargo test -p spaceshd
Expected: PASS (surface + registry + lifecycle + server tests).
- Step 4: Commit
git add crates/spaceshd/src/surface.rs crates/spaceshd/src/server.rs
git commit -m "feat(daemon): grid feed + output coalescing + snapshot-on-attach (M1)"
Task 14: Reattach integration test (same screen after reconnect)
Files:
-
Modify:
crates/spaceshd/src/server.rs(append a test) -
Step 1: Write the test
Append to the tests module in server.rs:
#[tokio::test]
async fn reattach_returns_snapshot_with_prior_output() {
let dir = tempdir_path();
let sock = dir.join("sock");
let sock_for_task = sock.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
wait_for_socket(&sock).await;
// First client: open, new surface that prints a marker, attach, then disconnect.
let surface_id;
{
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut s, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "printf REPAINT_ME; sleep 2".into()],
cols: 80, rows: 24,
}).await;
surface_id = spacesh_proto::SurfaceId(res_data(&r)["surface_id"].as_str().unwrap().to_string());
// Give the actor time to flush output into the grid.
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
// disconnect by dropping `s`
}
// Second client: attach to the same surface, expect snapshot to contain the marker.
let mut s2 = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s2, 1, Cmd::Attach { surface_id: surface_id.clone() }).await;
let snap = res_data(&r)["snapshot"].as_str().unwrap();
assert!(snap.contains("REPAINT_ME"), "snapshot was: {snap:?}");
}
- Step 2: Run tests
Run: cargo test -p spaceshd server
Expected: PASS (3 server tests including reattach).
- Step 3: Commit
git add crates/spaceshd/src/server.rs
git commit -m "test(daemon): reattach after disconnect repaints prior output from snapshot"
Phase 7 — app (M1 reattach in the GUI)
Task 15: Reattach flow + reconnect handling in the bridge
Files:
- Modify:
app/src-tauri/src/bridge.rs(reconnect on EOF) - Modify:
app/src/TerminalView.tsx(already writes snapshot — verify cursor handling)
The TerminalView from Task 10 already: creates a fresh xterm per surface, writes res.snapshot, then streams. The remaining M1 piece is making the bridge survive a daemon restart is out of scope (daemon outlives GUI, not vice-versa) — but the GUI must survive its own reload, which it does because attach re-runs on mount. This task hardens reattach and adds a reconnect guard so a transient socket drop doesn't wedge the bridge.
- Step 1: Add a reconnect note + guard (no behavior change to the happy path)
In app/src-tauri/src/bridge.rs, change spawn_reader's terminal branch to emit a disconnect event so the UI can re-attach:
Ok(None) | Err(_) => {
let _ = app.emit("spacesh:disconnected", ());
break;
}
- Step 2: Re-attach the active surface on reconnect in the front
In app/src/App.tsx, add inside the useEffect:
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => {
// Force a remount of the active TerminalView by toggling the key.
setActive((cur) => cur);
void getStatus().then((ws) => {
const flat = ws.flatMap((w) => w.surfaces);
setSurfaces(flat);
});
});
return () => {
void unlisten.then((f) => f());
void reconnect.then((f) => f());
};
Add to app/src/socketBridge.ts:
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
return listen(name, () => handler());
}
- Step 3: Manual verification (M1 killer feature)
Run cd app && npm run tauri dev. Steps:
- Click "+ surface", run a long command like
top. - Close the app window (the daemon keeps running — verify
ls ~/.spacesh/sockstill works andpgrep spaceshdshows it alive). - Relaunch
npm run tauri dev. - The surface is still listed; selecting it repaints the screen from the snapshot and
topis still updating.
Expected: agent (process) survived the GUI; screen restored on reattach.
- Step 4: Commit
git add app/
git commit -m "feat(app): reattach repaint + disconnect guard (M1)"
Phase 8 — launchd
Task 16: launchd user-agent install
Files:
-
Modify:
crates/spaceshd/src/launchd.rs(replace the Task 8 stub) -
Test: inline
#[cfg(test)]inlaunchd.rs -
Step 1: Write the failing test
crates/spaceshd/src/launchd.rs:
use anyhow::{Context, Result};
use std::path::PathBuf;
const LABEL: &str = "xyz.spacesh.daemon";
fn plist_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("no home")?;
Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
}
/// Render the launchd plist. `run_at_load` defaults to false in this slice.
pub fn render_plist(exe: &str, run_at_load: bool) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<{run_at_load}/>
</dict>
</plist>
"#,
run_at_load = if run_at_load { "true" } else { "false" }
)
}
pub fn install_agent() -> Result<()> {
let exe = std::env::current_exe()?;
let plist = render_plist(&exe.to_string_lossy(), false);
let path = plist_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, plist)?;
// Load it (best-effort; ignore "already loaded").
let _ = std::process::Command::new("launchctl")
.arg("load")
.arg(&path)
.status();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plist_has_label_keepalive_and_exe() {
let p = render_plist("/usr/local/bin/spaceshd", false);
assert!(p.contains("xyz.spacesh.daemon"));
assert!(p.contains("/usr/local/bin/spaceshd"));
assert!(p.contains("<key>KeepAlive</key>\n <true/>"));
assert!(p.contains("<key>RunAtLoad</key>\n <false/>"));
}
#[test]
fn run_at_load_toggles() {
assert!(render_plist("x", true).contains("<key>RunAtLoad</key>\n <true/>"));
}
}
- Step 2: Run tests
Run: cargo test -p spaceshd launchd
Expected: PASS (2 tests). (install_agent itself touches the real filesystem/launchctl — exercised manually, not in tests.)
- Step 3: Manual verification
Run:
cargo build -p spaceshd
./target/debug/spaceshd install-agent
launchctl list | grep spacesh # shows the agent
Expected: the agent is registered; killing spaceshd (pkill spaceshd) results in launchd respawning it.
Cleanup:
launchctl unload ~/Library/LaunchAgents/xyz.spacesh.daemon.plist
rm ~/Library/LaunchAgents/xyz.spacesh.daemon.plist
- Step 4: Commit
git add crates/spaceshd/src/launchd.rs
git commit -m "feat(daemon): launchd user-agent install with KeepAlive"
Definition of Done (slice acceptance)
Run the full suite and the manual checks:
cargo test— all crate tests pass.cargo build— workspace + app build clean.- M0: in the app, create a surface, type into it, see live output. Bytes fly GUI↔daemon↔PTY.
- M1: start a long-running process in a surface, kill the GUI, confirm
spaceshd(and the child) stay alive, relaunch the GUI, reselect the surface, confirm the screen repaints from the snapshot and the process is still live. - launchd:
spaceshd install-agentregisters the KeepAlive agent;pkill spaceshdtriggers 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 theGridSurface/Snapshotinterface, not the crate. - Single fan-out path: all output to clients flows daemon broadcast →
spawn_output_bridge→ router → per-client socket. TheAttachSnapshotreceiver 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 = 16KiBmatch 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.