From 1f699736060ed5fea3567e3cf52013939f239a94 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Mon, 15 Jun 2026 15:18:55 +0700 Subject: [PATCH] docs: session persistence implementation plan + spec sync to leaner design Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-15-session-persistence.md | 1279 +++++++++++++++++ 1 file changed, 1279 insertions(+) create mode 100644 DOCS/superpowers/plans/2026-06-15-session-persistence.md diff --git a/DOCS/superpowers/plans/2026-06-15-session-persistence.md b/DOCS/superpowers/plans/2026-06-15-session-persistence.md new file mode 100644 index 0000000..abb094f --- /dev/null +++ b/DOCS/superpowers/plans/2026-06-15-session-persistence.md @@ -0,0 +1,1279 @@ +# Session Persistence (resurrect + resume) 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:** After a daemon restart (reboot / battery / `kill -9`) the user can bring each panel back: it shows its last on-screen state and offers a one-click **Resume** that respawns the agent with its session-continue flag (e.g. `claude --continue`). + +**Architecture:** The daemon already persists structure (`state.json`) and already shows stopped panels with a restart overlay; `RestartSurface` already respawns a stopped surface from its spec. This plan adds (1) periodic on-disk snapshots of each surface's visible screen, (2) a `[resume]` config map producing resume args, (3) a `resume` flag on `RestartSurface`, and (4) painting the saved screen behind the overlay plus a Resume button. We reuse `spacesh_core::snapshot::snapshot_ansi` (the live-reattach serializer) for the on-disk snapshot. + +**Tech Stack:** Rust (tokio actors, serde, alacritty_terminal grid), Tauri 2 bridge, React/TS + xterm.js. + +**Spec:** `docs/superpowers/specs/2026-06-15-session-persistence-design.md` + +--- + +## Orientation (read before starting) + +Key existing code this plan builds on: + +- `crates/spacesh-core/src/snapshot.rs` — `Snapshot { ansi, cols, rows, cursor_row, cursor_col }` (derives `Serialize` only) and `snapshot_ansi(&GridSurface) -> Snapshot`. +- `crates/spaceshd/src/state_store.rs` — `JsonStateStore` pattern: atomic write (temp → `sync_all` → rename), corrupt-file tolerance. Mirror this for snapshots. +- `crates/spaceshd/src/surface.rs` — surface actor. `spawn_from_spec` → `spawn_surface_deferred` → `run_actor`; eager `spawn_surface` for tests. `SurfaceMsg` enum. `run_actor` owns `grid: GridSurface` and exits via `exit_tx.send((id, code))` after `pty.wait()`. +- `crates/spaceshd/src/server.rs` — `serve(socket, store, event_store)`, the `router` single-task loop over `ServerMsg`, `handle_request`, `RestartSurface` handler, the stopped-`Attach` branch, and ~12 `serve(...)` callsites in `#[cfg(test)]`. +- `crates/spaceshd/src/config.rs` — `Config` with `#[serde(default)]` sub-tables. +- `crates/spacesh-proto/src/message.rs` — `Cmd::RestartSurface { surface_id }`. +- `app/src/LayoutEngine.tsx` — `Leaf` renders the `running[id] === false` overlay ("Process exited" + Restart button). +- `app/src/socketBridge.ts` — `restartSurface`, `AttachResult`. `app/src-tauri/src/bridge.rs` — `restart_surface`, `attach` invoke handlers. + +Build/test commands: `cargo test -p spacesh-core`, `cargo test -p spacesh-proto`, `cargo test -p spaceshd`, and `cd app && npx tsc --noEmit`. + +--- + +## Task 1: `Snapshot` gains `Deserialize` + +**Files:** +- Modify: `crates/spacesh-core/src/snapshot.rs` +- Test: same file (`#[cfg(test)]` module) + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `crates/spacesh-core/src/snapshot.rs`: + +```rust +#[test] +fn snapshot_round_trips_through_json() { + let mut g = GridSurface::new(20, 4); + g.feed(b"hello"); + let snap = snapshot_ansi(&g); + let json = serde_json::to_string(&snap).unwrap(); + let back: Snapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(back.ansi, snap.ansi); + assert_eq!((back.cols, back.rows), (snap.cols, snap.rows)); + assert_eq!((back.cursor_row, back.cursor_col), (snap.cursor_row, snap.cursor_col)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p spacesh-core snapshot_round_trips_through_json` +Expected: FAIL — `Snapshot` does not implement `Deserialize` (compile error `the trait bound Snapshot: Deserialize<'_> is not satisfied`). + +- [ ] **Step 3: Add the derive** + +In `crates/spacesh-core/src/snapshot.rs`, change the `Snapshot` derive and the `serde` import: + +```rust +use serde::{Deserialize, Serialize}; +``` + +```rust +/// Serializable snapshot returned by `attach` and persisted to disk. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +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, +} +``` + +(`PartialEq` is added so tests can compare snapshots directly.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p spacesh-core snapshot_round_trips_through_json` +Expected: PASS. Also run `cargo test -p spacesh-core` — all green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/spacesh-core/src/snapshot.rs +git commit -m "feat(core): Snapshot derives Deserialize + PartialEq for disk persistence" +``` + +--- + +## Task 2: `snapshot_store` — per-surface disk store + +**Files:** +- Create: `crates/spaceshd/src/snapshot_store.rs` +- Modify: `crates/spaceshd/src/main.rs` (add `mod snapshot_store;`) +- Test: in the new file's `#[cfg(test)]` module + +- [ ] **Step 1: Register the module** + +In `crates/spaceshd/src/main.rs`, add to the module list (keep alphabetical near `state_store`): + +```rust +mod snapshot_store; +``` + +- [ ] **Step 2: Write the failing test** + +Create `crates/spaceshd/src/snapshot_store.rs` with the test module first (it will not compile until Step 3 adds the types — that is the failing state): + +```rust +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 let Ok(f) = std::fs::File::open(&tmp) { let _ = f.sync_all(); } + 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); + } +} +``` + +- [ ] **Step 3: Run tests to verify they pass** + +The module body above already contains the implementation, so this task writes test + impl together (the store is pure I/O with no logic worth a red-then-green split beyond compilation). + +Run: `cargo test -p spaceshd snapshot_store` +Expected: PASS — 5 tests (`save_then_load_round_trips`, `missing_loads_none`, `corrupt_loads_none`, `remove_deletes_file`, `null_store_is_inert`). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/snapshot_store.rs crates/spaceshd/src/main.rs +git commit -m "feat(daemon): per-surface JSON snapshot store (atomic write, corrupt-tolerant)" +``` + +--- + +## Task 3: Resume config + snapshot interval + +**Files:** +- Modify: `crates/spaceshd/src/config.rs` +- Test: same file (`#[cfg(test)]` module) + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `crates/spaceshd/src/config.rs`: + +```rust +#[test] +fn resume_args_user_then_default_then_none() { + let mut c = Config::default(); + // built-in defaults present without any config + assert_eq!(c.resume_args("claude").as_deref(), Some(&["--continue".to_string()][..])); + assert_eq!(c.resume_args("codex").as_deref(), Some(&["resume".to_string()][..])); + // a path is reduced to its basename before lookup + assert_eq!(c.resume_args("/usr/local/bin/claude").as_deref(), Some(&["--continue".to_string()][..])); + // unknown command → None + assert_eq!(c.resume_args("bash"), None); + // user override wins over the default + c.resume.commands.insert("claude".into(), vec!["--resume".into(), "last".into()]); + assert_eq!(c.resume_args("claude"), Some(vec!["--resume".into(), "last".into()])); +} + +#[test] +fn snapshot_interval_defaults_to_5s() { + let c = Config::default(); + assert_eq!(c.snapshot_interval_secs(), 5); +} + +#[test] +fn parses_resume_table_and_interval() { + let dir = std::env::temp_dir().join(format!("spacesh-cfg-resume-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write(&path, + "snapshot_interval_secs = 10\n[resume.commands]\ngemini = [\"--resume\"]\n").unwrap(); + let c = Config::from_path(&path); + assert_eq!(c.snapshot_interval_secs(), 10); + assert_eq!(c.resume_args("gemini"), Some(vec!["--resume".into()])); + let _ = std::fs::remove_file(&path); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p spaceshd resume_args_user_then_default_then_none` +Expected: FAIL — compile error: no field `resume`, no method `resume_args`/`snapshot_interval_secs`. + +- [ ] **Step 3: Implement config additions** + +In `crates/spaceshd/src/config.rs`, add the struct and a default table, and extend `Config`: + +```rust +/// Built-in resume args for known agents, used when config has no override. +/// (command basename, resume args) +const DEFAULT_RESUME: &[(&str, &[&str])] = &[ + ("claude", &["--continue"]), + ("codex", &["resume"]), +]; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ResumeConfig { + /// command basename -> args that continue its previous session. + #[serde(default)] + pub commands: std::collections::HashMap>, +} +``` + +Add the fields to `Config`: + +```rust +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Config { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_shell: Option, + #[serde(default)] + pub terminal: TerminalConfig, + #[serde(default)] + pub appearance: AppearanceConfig, + #[serde(default)] + pub resume: ResumeConfig, + /// How often (seconds) the daemon dumps changed grids to disk. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot_interval_secs: Option, +} +``` + +Add the resolver methods in the `impl Config` block: + +```rust +/// Resume args for a command, by basename: user map → built-in default → None. +pub fn resume_args(&self, command: &str) -> Option> { + let base = std::path::Path::new(command) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| command.to_string()); + if let Some(args) = self.resume.commands.get(&base) { + return Some(args.clone()); + } + DEFAULT_RESUME.iter() + .find(|(name, _)| *name == base) + .map(|(_, args)| args.iter().map(|s| s.to_string()).collect()) +} + +/// Snapshot dump cadence in seconds (config → default 5, clamped to [1, 3600]). +pub fn snapshot_interval_secs(&self) -> u64 { + self.snapshot_interval_secs.unwrap_or(5).clamp(1, 3600) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p spaceshd config` +Expected: PASS — including the three new tests and the existing config tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/spaceshd/src/config.rs +git commit -m "feat(daemon): [resume] config map + snapshot_interval_secs with built-in defaults" +``` + +--- + +## Task 4: Actor `Snapshot` message + dirty flag + on-exit dump + +**Files:** +- Modify: `crates/spaceshd/src/surface.rs` +- Test: same file (`#[cfg(test)]` module) + +This adds a snapshot channel threaded through every spawn entry point. The +channel carries `SnapshotMsg` (defined in Task 2) to the writer (Task 5); here +the actor only ever sends `SnapshotMsg::Save(id, snap)` on exit and answers +on-demand `SurfaceMsg::Snapshot` requests. Add the import at the top of +`surface.rs`: `use crate::snapshot_store::SnapshotMsg;`. + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module in `crates/spaceshd/src/surface.rs`. Note the existing test helper `spawn_surface(...)` signature gains a trailing `snapshot_tx`; these tests use it. + +```rust +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_msg_returns_grid_and_tracks_dirty() { + let _serial = crate::test_support::serial(); + let pty = PtyHandle::spawn(spec("printf DIRTYME; sleep 0.4")).unwrap(); + let (state_tx, _s) = mpsc::unbounded_channel(); + let (exit_tx, _e) = mpsc::unbounded_channel(); + let (snap_tx, _snap_rx) = mpsc::unbounded_channel(); + let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx); + + // Give the child time to print. + tokio::time::sleep(Duration::from_millis(150)).await; + let (reply_tx, reply_rx) = oneshot::channel(); + handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.unwrap(); + let (snap, dirty) = reply_rx.await.unwrap(); + assert!(snap.ansi.contains("DIRTYME"), "snapshot: {:?}", snap.ansi); + assert!(dirty, "first snapshot after output should be dirty"); + + // Immediately snapshot again with no new output → not dirty. + let (reply_tx, reply_rx) = oneshot::channel(); + handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.unwrap(); + let (_snap2, dirty2) = reply_rx.await.unwrap(); + assert!(!dirty2, "second snapshot with no new output should be clean"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn final_snapshot_sent_on_exit() { + let _serial = crate::test_support::serial(); + let pty = PtyHandle::spawn(spec("printf BYE")).unwrap(); // exits immediately + let (state_tx, _s) = mpsc::unbounded_channel(); + let (exit_tx, _e) = mpsc::unbounded_channel(); + let (snap_tx, mut snap_rx) = mpsc::unbounded_channel(); + let _handle = spawn_surface(SurfaceId("s_x".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx, snap_tx); + + let msg = tokio::time::timeout(Duration::from_secs(2), snap_rx.recv()).await.unwrap().unwrap(); + match msg { + crate::snapshot_store::SnapshotMsg::Save(sid, snap) => { + assert_eq!(sid.0, "s_x"); + assert!(snap.ansi.contains("BYE"), "final snapshot: {:?}", snap.ansi); + } + _ => panic!("expected a Save message on exit"), + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p spaceshd snapshot_msg_returns_grid_and_tracks_dirty` +Expected: FAIL — compile error: `SurfaceMsg::Snapshot` variant missing and `spawn_surface` takes too few arguments. + +- [ ] **Step 3: Add the message variant and snapshot channel** + +In `crates/spaceshd/src/surface.rs`: + +Add the variant to `SurfaceMsg`: + +```rust +pub enum SurfaceMsg { + Input(Vec), + Resize { cols: u16, rows: u16 }, + Attach { reply: oneshot::Sender>> }, + /// Attach with snapshot: subscribe AND capture the grid in one actor turn. + AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver>)> }, + /// On-demand snapshot without subscribing; bool = dirty since last snapshot. + Snapshot { reply: oneshot::Sender<(Snapshot, bool)> }, + Close, +} +``` + +Thread a `snapshot_tx: mpsc::UnboundedSender` parameter through `spawn_from_spec`, `spawn_surface`, `spawn_surface_deferred`, and `run_actor`. For each, add the parameter (last position) and pass it down. + +`spawn_from_spec` signature + body: + +```rust +#[allow(clippy::too_many_arguments)] +pub fn spawn_from_spec( + id: SurfaceId, + workspace_id: WorkspaceId, + spec: &SurfaceSpec, + extra_env: Vec<(String, String)>, + hooks_active: bool, + state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, + snapshot_tx: mpsc::UnboundedSender, +) -> std::io::Result { + let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())]; + env.extend(extra_env); + let spawn_spec = SpawnSpec { + command: spec.command.clone(), + args: spec.args.clone(), + cwd: std::path::PathBuf::from(&spec.cwd), + cols: spec.cols, + rows: spec.rows, + env, + }; + Ok(spawn_surface_deferred(id, workspace_id, spawn_spec, spec.cols, spec.rows, hooks_active, state_tx, exit_tx, snapshot_tx)) +} +``` + +`spawn_surface` (eager, test path): + +```rust +#[allow(clippy::too_many_arguments)] +pub fn spawn_surface( + id: SurfaceId, + workspace_id: WorkspaceId, + pty: PtyHandle, + cols: u16, + rows: u16, + hooks_active: bool, + state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, + snapshot_tx: mpsc::UnboundedSender, +) -> SurfaceHandle { + let (tx, rx) = mpsc::channel::(64); + let (bcast, _) = broadcast::channel::>(BROADCAST_CAP); + tokio::spawn(run_actor(id.clone(), pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, Vec::new(), snapshot_tx)); + SurfaceHandle { id, workspace_id, tx } +} +``` + +`spawn_surface_deferred`: add `snapshot_tx: mpsc::UnboundedSender` as the final parameter; inside the pre-spawn loop, answer the new message with the empty grid; and pass `snapshot_tx` into `run_actor`. In the pre-spawn `select!`, add: + +```rust +Some(SurfaceMsg::Snapshot { reply }) => { + let snap = snapshot_ansi(&GridSurface::new(cols, rows)); + let _ = reply.send((snap, false)); +} +``` + +and change the spawn call: + +```rust +Ok(pty) => run_actor(actor_id, pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, prebuf, snapshot_tx).await, +``` + +`run_actor`: add `snapshot_tx: mpsc::UnboundedSender` as the final parameter. Introduce a `dirty` flag, set it when output arrives, clear it on a snapshot, answer the new message, and send the final snapshot on exit. The relevant edits inside `run_actor`'s grid block: + +Declare alongside the other loop locals: + +```rust +let mut dirty = false; +``` + +In the `SurfaceMsg::AttachSnapshot` arm, after building `snap`, also clear dirty (the screen has just been handed out fresh): + +```rust +Some(SurfaceMsg::AttachSnapshot { reply }) => { + let sub = bcast.subscribe(); + let snap = snapshot_ansi(&grid); + dirty = false; + let _ = reply.send((snap, sub)); +} +``` + +Add the new arm next to it: + +```rust +Some(SurfaceMsg::Snapshot { reply }) => { + let snap = snapshot_ansi(&grid); + let was_dirty = dirty; + dirty = false; + let _ = reply.send((snap, was_dirty)); +} +``` + +In the PTY output arm, when bytes arrive (the `Some(bytes) =>` branch), set `dirty = true;` after extending `pending`: + +```rust +Some(bytes) => { + pending.extend_from_slice(&bytes); + dirty = true; + if flush_deadline.is_none() { + flush_deadline = Some(Instant::now() + FLUSH_INTERVAL); + } + if pending.len() >= FLUSH_BYTES { + flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx); + flush_deadline = None; + } +} +``` + +Replace the exit tail of the block (currently `let code = pty.wait(); let _ = exit_tx.send((actor_id, code));`) with a final snapshot first: + +```rust + let final_snap = snapshot_ansi(&grid); + let _ = snapshot_tx.send(SnapshotMsg::Save(actor_id.clone(), final_snap)); + let code = pty.wait(); + let _ = exit_tx.send((actor_id, code)); + } +} +``` + +> Note: `actor_id` is currently moved into `detect_id`/used once; clone as needed so it is available for both the snapshot send and `exit_tx`. If the compiler reports a move, change the earlier `let detect_id = id;` / `let actor_id = id.clone();` setup so both `actor_id` (cloneable) and `detect_id` exist, and use `actor_id.clone()` for the snapshot send. + +Update the existing in-file tests `attach_receives_output` and `attach_snapshot_reflects_prior_output` (and any other `spawn_surface(...)` callers in this file's tests) to pass a snapshot sender. Add `let (snap_tx, _snap_rx) = mpsc::unbounded_channel();` before each `spawn_surface` call and append `, snap_tx` to the call. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p spaceshd -- surface` +Expected: PASS — the two new tests plus the pre-existing surface tests (now passing the extra arg). + +- [ ] **Step 5: Commit** + +```bash +git add crates/spaceshd/src/surface.rs +git commit -m "feat(daemon): actor Snapshot message + dirty tracking + final snapshot on exit" +``` + +--- + +## Task 5: Snapshot writer task + +**Files:** +- Modify: `crates/spaceshd/src/snapshot_store.rs` +- Test: same file (`#[cfg(test)]` module) + +The writer owns the store and serializes all disk writes off the router/actor +hot paths. It accepts saves and removes over one channel. + +- [ ] **Step 1: Write the failing test** + +Add to `crates/spaceshd/src/snapshot_store.rs` (`SnapshotMsg` was already defined in Task 2; this task adds only the writer + its test). The test needs tokio: + +```rust +/// Spawn the writer task; returns the sender used by the router and actors. +pub fn spawn_writer(store: std::sync::Arc) -> tokio::sync::mpsc::UnboundedSender { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + match msg { + SnapshotMsg::Save(sid, snap) => store.save(&sid, &snap), + SnapshotMsg::Remove(sid) => store.remove(&sid), + } + } + }); + tx +} +``` + +Test: + +```rust +#[tokio::test] +async fn writer_saves_and_removes() { + let dir = tmp_dir("writer"); + let store: std::sync::Arc = std::sync::Arc::new(JsonSnapshotStore::new(dir.clone())); + let tx = spawn_writer(store.clone()); + let sid = SurfaceId("s_w".into()); + + tx.send(SnapshotMsg::Save(sid.clone(), sample())).unwrap(); + // Poll until the writer has flushed (bounded). + let mut saved = None; + for _ in 0..50 { + if let Some(s) = store.load(&sid) { saved = Some(s); break; } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_eq!(saved, Some(sample())); + + tx.send(SnapshotMsg::Remove(sid.clone())).unwrap(); + let mut gone = false; + for _ in 0..50 { + if store.load(&sid).is_none() { gone = true; break; } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(gone, "writer should have removed the snapshot file"); + let _ = std::fs::remove_dir_all(dir); +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +Implementation is included above (the writer is a thin loop). Run: +`cargo test -p spaceshd writer_saves_and_removes` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/spaceshd/src/snapshot_store.rs +git commit -m "feat(daemon): snapshot writer task (Save/Remove over one channel)" +``` + +--- + +## Task 6: Server wiring — store param, ticker, stopped-Attach reads disk, remove on close + +**Files:** +- Modify: `crates/spaceshd/src/server.rs` +- Modify: `crates/spaceshd/src/main.rs` +- Test: `crates/spaceshd/src/server.rs` (`#[cfg(test)]`) + +- [ ] **Step 1: Thread the snapshot store into `serve` and `router`** + +In `crates/spaceshd/src/server.rs`: + +Add imports near the other `use crate::...` lines: + +```rust +use crate::snapshot_store::{SnapshotStore, SnapshotMsg, spawn_writer}; +``` + +Change `serve` to accept the store, build the writer + ticker, and pass both the writer sender and an `Arc` clone (for reads) into `router`: + +```rust +pub async fn serve( + socket: &Path, + store: Arc, + event_store: Arc, + snapshot_store: Arc, +) -> Result<()> { + let listener = UnixListener::bind(socket)?; + let (router_tx, router_rx) = mpsc::channel::(256); + + // ... existing exit_tx / state_tx bridges unchanged ... + + let snapshot_tx = spawn_writer(snapshot_store.clone()); + + // Periodic snapshot tick → router. + let tick_router = router_tx.clone(); + let interval_secs = crate::config::Config::load().snapshot_interval_secs(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(interval_secs)); + tick.tick().await; // consume the immediate first tick + loop { + tick.tick().await; + if tick_router.send(ServerMsg::SnapshotTick).await.is_err() { break; } + } + }); + + let persister = persist::spawn(store.clone(), Duration::from_millis(500)); + let initial = store.load().unwrap_or_default(); + let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500)); + let event_initial = event_store.load().unwrap_or_default(); + let started_at_ms = now_millis(); + let shutdown = tokio::spawn(router( + router_rx, router_tx.clone(), exit_tx, state_tx, + persister, initial, event_persister, event_initial, + started_at_ms, snapshot_store, snapshot_tx, + )); + + // ... existing accept loop unchanged ... +} +``` + +Add `SnapshotTick` to the `ServerMsg` enum (around line 23): + +```rust +enum ServerMsg { + // ... existing variants ... + SnapshotTick, +} +``` + +Change `router`'s signature to take the two new params (final positions): + +```rust +async fn router( + mut rx: mpsc::Receiver, + router_tx: mpsc::Sender, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, + state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, + persister: Persister, + initial: crate::state_store::PersistState, + event_persister: EventPersister, + event_initial: crate::event_log::EventLogState, + started_at_ms: u64, + snapshot_store: Arc, + snapshot_tx: mpsc::UnboundedSender, +) { +``` + +- [ ] **Step 2: Handle `SnapshotTick` and thread the snapshot sender to spawns** + +In the `router` match loop, add the tick arm. It snapshots each live surface and forwards dirty ones to the writer: + +```rust +ServerMsg::SnapshotTick => { + let ids: Vec = reg.live_ids(); + for sid in ids { + let Some(handle) = reg.live(&sid) else { continue }; + let (reply_tx, reply_rx) = oneshot::channel(); + if handle.tx.send(SurfaceMsg::Snapshot { reply: reply_tx }).await.is_err() { continue; } + if let Ok((snap, dirty)) = reply_rx.await { + if dirty { + let _ = snapshot_tx.send(SnapshotMsg::Save(sid.clone(), snap)); + } + } + } +} +``` + +This needs a `live_ids()` accessor on `Registry`. In `crates/spaceshd/src/registry.rs` add: + +```rust +/// Ids of all currently-live surfaces. +pub fn live_ids(&self) -> Vec { + self.live.keys().cloned().collect() +} +``` + +Pass `snapshot_tx.clone()` into every `spawn_from_spec(...)` call inside `handle_request`. There are four callsites (NewSurface, SplitSurface, ApplyPreset, RestartSurface). Each currently ends `..., state_tx.clone(), exit_tx.clone())`; change to `..., state_tx.clone(), exit_tx.clone(), snapshot_tx.clone())`. To make `snapshot_tx` reachable inside `handle_request`, add it as a parameter to `handle_request` and pass it from the `ServerMsg::Request` arm: + +```rust +ServerMsg::Request { id, cmd, client, out } => { + handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, + &router_tx, &exit_tx, &state_tx, &persister, + &mut event_log, &event_persister, started_at_ms, &mut config, + &snapshot_store, &snapshot_tx).await; +} +``` + +and in `handle_request`'s signature add the two trailing params: + +```rust + snapshot_store: &Arc, + snapshot_tx: &mpsc::UnboundedSender, +``` + +- [ ] **Step 3: Stopped-`Attach` returns the disk snapshot; close/remove deletes it** + +In the `Cmd::Attach` handler, replace the stopped-panel branch (the `else` that returns the empty snapshot) with a disk read: + +```rust +} else { + // stopped panel: no live stream. Paint the last on-disk screen if we have one. + match snapshot_store.load(&surface_id) { + Some(snap) => { + 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, "stopped": true, + }))).await; + } + None => { + let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await; + } + } +} +``` + +In the `Cmd::Close` handler and `Cmd::CloseWorkspace` handler, after the surface(s) are removed, drop their snapshot files. For `Close { surface_id }` add, right after `reg.remove_surface(&surface_id)` (or wherever the removal happens): + +```rust +let _ = snapshot_tx.send(SnapshotMsg::Remove(surface_id.clone())); +``` + +For `CloseWorkspace { workspace_id }`, the handler already collects `let ids = reg.close_workspace(&workspace_id);`. After the existing cleanup loop, add: + +```rust +for sid in &ids { let _ = snapshot_tx.send(SnapshotMsg::Remove(sid.clone())); } +``` + +- [ ] **Step 4: Update `main.rs` to build and pass the store** + +In `crates/spaceshd/src/main.rs`, in `run_daemon`, after the event store is built: + +```rust +let snapshots_dir = lifecycle::spacesh_dir()?.join("snapshots"); +let snapshot_store: std::sync::Arc = + std::sync::Arc::new(snapshot_store::JsonSnapshotStore::new(snapshots_dir)); +eprintln!("spaceshd listening on {}", sock.display()); +server::serve(&sock, store, event_store, snapshot_store).await +``` + +- [ ] **Step 5: Fix all `serve(...)` test callsites** + +In `crates/spaceshd/src/server.rs`'s `#[cfg(test)]` module there are ~12 calls of the form `serve(&sockX, store, event_store)` (and `..._b` variants). Append a `NullSnapshotStore` argument to each. Add this import inside the test module: + +```rust +use crate::snapshot_store::NullSnapshotStore; +``` + +and change each call, e.g.: + +```rust +tokio::spawn(async move { + let _ = serve(&sock_for_task, store2, event_store, std::sync::Arc::new(NullSnapshotStore)).await; +}); +``` + +Apply the same `, std::sync::Arc::new(NullSnapshotStore)` insertion before `.await` to **every** `serve(...)` call in the test module (~12 sites, including the `_b` second-daemon ones). Compilation will fail until all are updated — use the compiler errors as the checklist. + +- [ ] **Step 6: Write the stopped-Attach integration test** + +Add a new test in the `server.rs` test module. It starts a daemon with a real `JsonSnapshotStore` over a temp dir, opens a workspace + surface, lets it print, forces a snapshot tick by waiting (or by closing the surface so the on-exit final snapshot lands), then re-attaches a fresh client and asserts the disk snapshot comes back for the stopped surface. + +```rust +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn stopped_attach_returns_disk_snapshot() { + let _serial = crate::test_support::serial(); + let dir = unique_tmp_dir("stopped-snap"); // use the module's existing temp-dir helper + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store: std::sync::Arc = + std::sync::Arc::new(crate::event_store::JsonEventStore::new(dir.join("events.json"))); + let snap_store: std::sync::Arc = + std::sync::Arc::new(crate::snapshot_store::JsonSnapshotStore::new(dir.join("snapshots"))); + let sock2 = sock.clone(); + tokio::spawn(async move { let _ = serve(&sock2, store, event_store, snap_store).await; }); + wait_for_socket(&sock).await; // module helper + + let mut c = connect(&sock).await; // module helper + let ws = open_workspace(&mut c, dir.to_str().unwrap()).await; // adapt to existing helpers + let sid = new_surface(&mut c, &ws, Some("/bin/sh"), vec!["-c".into(), "printf SNAPDISK; sleep 0.2".into()]).await; + + // Let it print and exit; the actor sends a final snapshot on exit. + tokio::time::sleep(Duration::from_millis(500)).await; + + // Fresh client attaches to the now-stopped surface. + let mut c2 = connect(&sock).await; + let r = req(&mut c2, 99, Cmd::Attach { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await; + let data = res_data(&r); + assert_eq!(data["stopped"], serde_json::json!(true)); + assert!(data["snapshot"].as_str().unwrap().contains("SNAPDISK"), "snapshot: {:?}", data["snapshot"]); + let _ = std::fs::remove_dir_all(dir); +} +``` + +> Adapt the helper calls (`unique_tmp_dir`, `wait_for_socket`, `connect`, `open_workspace`/`new_surface`, `req`, `res_data`) to the exact helpers already used by the neighbouring tests (see `reattach_returns_snapshot_with_prior_output` for the established pattern). The assertions are the contract: `stopped == true` and the ANSI contains the printed marker. + +- [ ] **Step 7: Run tests** + +Run: `cargo test -p spaceshd` +Expected: PASS — all daemon tests including the new `stopped_attach_returns_disk_snapshot`. Watch for any missed `serve(...)` callsite (compile error) and fix. + +- [ ] **Step 8: Commit** + +```bash +git add crates/spaceshd/src/server.rs crates/spaceshd/src/main.rs crates/spaceshd/src/registry.rs +git commit -m "feat(daemon): periodic snapshot ticker + stopped-attach reads disk snapshot + cleanup on close" +``` + +--- + +## Task 7: Protocol — `RestartSurface` gains `resume` + +**Files:** +- Modify: `crates/spacesh-proto/src/message.rs` +- Test: same file (`#[cfg(test)]`) + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `crates/spacesh-proto/src/message.rs`: + +```rust +#[test] +fn restart_surface_resume_defaults_false_and_round_trips() { + // Legacy frame without `resume` decodes to false. + let legacy = r#"{"kind":"req","id":5,"cmd":{"cmd":"restart_surface","args":{"surface_id":"s_1"}}}"#; + let env: Envelope = serde_json::from_str(legacy).unwrap(); + match env { + Envelope::Req { cmd: Cmd::RestartSurface { resume, .. }, .. } => assert!(!resume), + _ => panic!("wrong variant"), + } + // resume=true round-trips. + let e = Envelope::Req { id: 6, cmd: Cmd::RestartSurface { surface_id: SurfaceId("s_1".into()), resume: true } }; + let back: Envelope = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap(); + assert_eq!(back, e); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p spacesh-proto restart_surface_resume` +Expected: FAIL — `Cmd::RestartSurface` has no `resume` field. + +- [ ] **Step 3: Add the field** + +In `crates/spacesh-proto/src/message.rs`, change the variant: + +```rust +RestartSurface { + surface_id: SurfaceId, + #[serde(default)] + resume: bool, +}, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p spacesh-proto` — all green. + +> This breaks the daemon and Tauri callers that construct `Cmd::RestartSurface`. They are fixed in Tasks 8 and 9; if you build the whole workspace now it will fail to compile there — that is expected and resolved by the next tasks. + +- [ ] **Step 5: Commit** + +```bash +git add crates/spacesh-proto/src/message.rs +git commit -m "feat(proto): RestartSurface gains resume flag (defaults false)" +``` + +--- + +## Task 8: Server honors `resume` + +**Files:** +- Modify: `crates/spaceshd/src/server.rs` +- Test: same file (`#[cfg(test)]`) + +- [ ] **Step 1: Write the failing test for the pure helper** + +Add a unit test (no process spawn) for a helper that swaps args when resuming: + +```rust +#[test] +fn resume_spec_swaps_args_when_mapped() { + use spacesh_proto::workspace::SurfaceSpec; + let spec = SurfaceSpec { + command: "claude".into(), args: vec!["--foo".into()], cwd: "/tmp".into(), + agent_label: Some("claude".into()), cols: 80, rows: 24, autostart: false, + }; + let cfg = crate::config::Config::default(); + // resume=false → original args + let plain = resume_spec(&spec, false, &cfg); + assert_eq!(plain.args, vec!["--foo".to_string()]); + // resume=true with a default mapping → resume args + let resumed = resume_spec(&spec, true, &cfg); + assert_eq!(resumed.args, vec!["--continue".to_string()]); + // resume=true for an unmapped command → original args (graceful fallback) + let mut shell = spec.clone(); + shell.command = "bash".into(); + let resumed_shell = resume_spec(&shell, true, &cfg); + assert_eq!(resumed_shell.args, shell.args); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p spaceshd resume_spec_swaps_args_when_mapped` +Expected: FAIL — `resume_spec` not defined. + +- [ ] **Step 3: Implement the helper and use it in the handler** + +Add the helper near `spawn_env` in `crates/spaceshd/src/server.rs`: + +```rust +/// Build the spawn spec for a (re)start. When `resume` and the command has a +/// resume mapping, its args are replaced with the resume args; otherwise the +/// original spec args are kept. +fn resume_spec( + spec: &spacesh_proto::workspace::SurfaceSpec, + resume: bool, + cfg: &crate::config::Config, +) -> spacesh_proto::workspace::SurfaceSpec { + let mut out = spec.clone(); + if resume { + if let Some(args) = cfg.resume_args(&spec.command) { + out.args = args; + } + } + out +} +``` + +Update the `Cmd::RestartSurface` handler to destructure `resume` and spawn from the resume spec: + +```rust +Cmd::RestartSurface { surface_id, resume } => { + if reg.is_running(&surface_id) { + let _ = out.send(ok(id, serde_json::Value::Null)).await; return; // already running + } + let Some(spec) = reg.surface_spec(&surface_id) else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; + }; + let spec = resume_spec(&spec, resume, config); + let ws_id = reg.workspace_of(&surface_id).unwrap(); + let (env, hooks_active) = spawn_env(&surface_id, &spec); + match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone(), snapshot_tx.clone()) { + Ok(handle) => { + spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone()); + reg.set_live(handle); + reg.set_state(&surface_id, spacesh_proto::SurfaceState::Idle); + broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() })); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; } + } +} +``` + +> `config` is the `&mut Config` already in scope in `handle_request`; pass it as `&*config` / `config` to `resume_spec` (which takes `&Config`). Adjust the borrow as the compiler requires (e.g. `resume_spec(&spec, resume, config)` where `config: &mut Config` coerces to `&Config`). + +Note: the `snapshot_tx.clone()` added to this `spawn_from_spec` call is the same one threaded in Task 6 Step 2 — ensure all four spawn callsites carry it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p spaceshd resume_spec_swaps_args_when_mapped` +Expected: PASS. Then `cargo test -p spaceshd` — all green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/spaceshd/src/server.rs +git commit -m "feat(daemon): RestartSurface honors resume — swap to resume_args when mapped" +``` + +--- + +## Task 9: Tauri bridge + socketBridge resume arg + +**Files:** +- Modify: `app/src-tauri/src/bridge.rs` +- Modify: `app/src/socketBridge.ts` +- Test: `cd app && npx tsc --noEmit` + +- [ ] **Step 1: Update the Tauri command** + +In `app/src-tauri/src/bridge.rs`, change `restart_surface` to accept and forward `resume`: + +```rust +#[tauri::command] +pub async fn restart_surface(state: BridgeState<'_>, surface_id: String, resume: bool) -> Result { + data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id), resume }).await.map_err(|e| e.to_string())?) +} +``` + +(Any other place in `bridge.rs` constructing `Cmd::RestartSurface` must pass `resume`. The version-handshake/attach code does not; only this handler builds it.) + +- [ ] **Step 2: Update the JS binding and AttachResult** + +In `app/src/socketBridge.ts`: + +```ts +export interface AttachResult { + snapshot: string; + cols: number; + rows: number; + cursor_row?: number; + cursor_col?: number; + stopped?: boolean; +} + +export async function restartSurface(surfaceId: string, resume = false): Promise { + await invoke("restart_surface", { surfaceId, resume }); +} +``` + +- [ ] **Step 3: Verify types compile** + +Run: `cd app && npx tsc --noEmit` +Expected: PASS (no type errors). Note: existing callers of `restartSurface(id)` remain valid because `resume` defaults to `false`. + +Also build the Rust side: `cargo check -p spaceshd` and `cargo check --manifest-path app/src-tauri/Cargo.toml` (or `cargo check` in `app/src-tauri`). +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add app/src-tauri/src/bridge.rs app/src/socketBridge.ts +git commit -m "feat(app): plumb resume flag through restart_surface bridge + binding" +``` + +--- + +## Task 10: Stopped overlay — paint last screen + Resume button + +**Files:** +- Modify: `app/src/LayoutEngine.tsx` +- Test: `cd app && npx tsc --noEmit` + manual check + +- [ ] **Step 1: Add a read-only snapshot painter component** + +In `app/src/LayoutEngine.tsx`, add a small component that fetches the stopped surface's disk snapshot via `attachSurface` and paints it into a dimmed, read-only xterm. Import what is needed at the top of the file: + +```tsx +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { attachSurface } from "./socketBridge"; +``` + +(Confirm against `TerminalView.tsx` for the exact xterm import path and theme/font options it uses; mirror them so the dimmed preview matches the live terminal's look. Reuse the same `font`/`palette` props already threaded into `Leaf`.) + +```tsx +function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font: TermFont; palette: TermPalette }) { + const hostRef = useRef(null); + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const term = new Terminal({ + fontFamily: font.family, + fontSize: font.size, + theme: palette, + cursorBlink: false, + disableStdin: true, + convertEol: false, + scrollback: 0, + }); + term.open(host); + let disposed = false; + void attachSurface(surfaceId, () => {}).then((res) => { + if (!disposed && res.snapshot) term.write(res.snapshot); + }); + return () => { disposed = true; term.dispose(); }; + }, [surfaceId, font, palette]); + return
; +} +``` + +> Use the exact `TermFont`/`TermPalette` types already defined/imported in this file for the `font`/`palette` props (see `Leaf`'s props). If `TerminalView` wraps `Terminal` construction in a helper, prefer reusing that helper instead of constructing `Terminal` directly. + +- [ ] **Step 2: Render the snapshot + Resume button in the stopped branch** + +Replace the `if (running[id] === false) { ... }` block in `Leaf` with one that layers the snapshot behind centered controls and adds a Resume button. Keep the existing `RotateCw`/`Minimize2` imports; add `Play` from lucide-react at the file's icon import. + +```tsx +if (running[id] === false) { + return card( +
+ +
+
Stopped
+
+ + + {zoomed === id && ( + + )} +
+
+
+ ); +} +``` + +- [ ] **Step 3: Verify types compile** + +Run: `cd app && npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 4: Manual verification** + +Build and run (`make reinstall` then launch, or `make dev`). Steps: +1. Open a workspace, add a `claude` (or shell) panel, let it print output. +2. Quit the GUI and `pkill -x spaceshd` (simulate reboot), then relaunch the app. +3. The panel shows its **last screen dimmed** with **Resume** + **Restart fresh**. +4. Click **Resume** → the agent relaunches (for claude/codex with its continue flag) and the live terminal returns. + +Confirm keypress→echo still feels instant and no prompt-duplication regression on focus switches. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/LayoutEngine.tsx +git commit -m "feat(app): stopped panel paints last screen + Resume/Restart fresh controls" +``` + +--- + +## Final verification + +- [ ] Run the full suite: + +```bash +cargo test +cd app && npx tsc --noEmit +``` + +Expected: all Rust tests pass; tsc clean. + +- [ ] Dispatch a final code review over the whole branch, then use **superpowers:finishing-a-development-branch** to merge. + +## Notes / gotchas + +- **Snapshot tick blocks the router briefly** while it awaits each live actor's reply. Visible-screen snapshots are tiny and the await is per-surface and sequential; with a 5s cadence this is negligible. Do not move the disk write into the router — it stays in the writer task. +- **Resume is best-effort.** A new process is started; the literal in-flight process cannot survive a daemon death. For agents without a resume mapping, Resume == Restart fresh (original args). +- **`actor_id` move in `run_actor`:** the final-snapshot send needs `actor_id` before `exit_tx` consumes it — clone as the compiler directs. +- **Do not silently skip any `serve(...)` test callsite** (Task 6 Step 5): the compiler enumerates them; fix every one.