diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs
index e693b90..6ea05e2 100644
--- a/crates/spaceshd/src/main.rs
+++ b/crates/spaceshd/src/main.rs
@@ -7,6 +7,7 @@ mod lifecycle;
mod persist;
mod registry;
mod server;
+mod snapshot_store;
mod state_store;
mod surface;
diff --git a/crates/spaceshd/src/snapshot_store.rs b/crates/spaceshd/src/snapshot_store.rs
new file mode 100644
index 0000000..fdacf9d
--- /dev/null
+++ b/crates/spaceshd/src/snapshot_store.rs
@@ -0,0 +1,123 @@
+use std::path::PathBuf;
+use spacesh_core::snapshot::Snapshot;
+use spacesh_proto::SurfaceId;
+
+/// Stores one visible-screen snapshot per surface as `
/.json`.
+pub trait SnapshotStore: Send + Sync {
+ fn save(&self, sid: &SurfaceId, snap: &Snapshot);
+ fn load(&self, sid: &SurfaceId) -> Option;
+ fn remove(&self, sid: &SurfaceId);
+}
+
+/// Writer command: persist or delete a surface's snapshot. Shared by the
+/// router ticker, the close/remove paths, and each actor's on-exit dump, so a
+/// single channel type flows everywhere.
+pub enum SnapshotMsg {
+ Save(SurfaceId, Snapshot),
+ Remove(SurfaceId),
+}
+
+/// A no-op store for tests and contexts that do not persist snapshots.
+pub struct NullSnapshotStore;
+impl SnapshotStore for NullSnapshotStore {
+ fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {}
+ fn load(&self, _sid: &SurfaceId) -> Option { None }
+ fn remove(&self, _sid: &SurfaceId) {}
+}
+
+/// JSON file store. Filenames are the surface id (e.g. `s_1f.json`); ids are
+/// `^[a-z]_[0-9a-f]+$` so they are always safe path components.
+pub struct JsonSnapshotStore {
+ dir: PathBuf,
+}
+
+impl JsonSnapshotStore {
+ pub fn new(dir: PathBuf) -> Self {
+ let _ = std::fs::create_dir_all(&dir);
+ Self { dir }
+ }
+ fn path(&self, sid: &SurfaceId) -> PathBuf {
+ self.dir.join(format!("{}.json", sid.0))
+ }
+}
+
+impl SnapshotStore for JsonSnapshotStore {
+ fn save(&self, sid: &SurfaceId, snap: &Snapshot) {
+ let path = self.path(sid);
+ let tmp = path.with_extension("json.tmp");
+ let Ok(bytes) = serde_json::to_vec(snap) else { return };
+ if std::fs::write(&tmp, &bytes).is_err() { return; }
+ if std::fs::File::open(&tmp).and_then(|f| f.sync_all()).is_err() { return; }
+ let _ = std::fs::rename(&tmp, &path);
+ }
+ fn load(&self, sid: &SurfaceId) -> Option {
+ let bytes = std::fs::read(self.path(sid)).ok()?;
+ serde_json::from_slice(&bytes).ok()
+ }
+ fn remove(&self, sid: &SurfaceId) {
+ let _ = std::fs::remove_file(self.path(sid));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tmp_dir(name: &str) -> PathBuf {
+ let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let p = std::env::temp_dir().join(format!("spacesh-snap-{name}-{n}"));
+ std::fs::create_dir_all(&p).unwrap();
+ p
+ }
+
+ fn sample() -> Snapshot {
+ Snapshot { ansi: "\u{1b}[mhello".into(), cols: 80, rows: 24, cursor_row: 1, cursor_col: 6 }
+ }
+
+ #[test]
+ fn save_then_load_round_trips() {
+ let dir = tmp_dir("roundtrip");
+ let store = JsonSnapshotStore::new(dir.clone());
+ let sid = SurfaceId("s_1".into());
+ store.save(&sid, &sample());
+ assert_eq!(store.load(&sid), Some(sample()));
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn missing_loads_none() {
+ let store = JsonSnapshotStore::new(tmp_dir("missing"));
+ assert_eq!(store.load(&SurfaceId("s_none".into())), None);
+ }
+
+ #[test]
+ fn corrupt_loads_none() {
+ let dir = tmp_dir("corrupt");
+ let store = JsonSnapshotStore::new(dir.clone());
+ let sid = SurfaceId("s_2".into());
+ std::fs::write(dir.join("s_2.json"), b"{ not json").unwrap();
+ assert_eq!(store.load(&sid), None);
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn remove_deletes_file() {
+ let dir = tmp_dir("remove");
+ let store = JsonSnapshotStore::new(dir.clone());
+ let sid = SurfaceId("s_3".into());
+ store.save(&sid, &sample());
+ assert!(store.load(&sid).is_some());
+ store.remove(&sid);
+ assert_eq!(store.load(&sid), None);
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn null_store_is_inert() {
+ let store = NullSnapshotStore;
+ let sid = SurfaceId("s_4".into());
+ store.save(&sid, &sample());
+ assert_eq!(store.load(&sid), None);
+ store.remove(&sid);
+ }
+}