Files
spaceshell/DOCS/superpowers/plans/2026-06-09-spacesh-m3.md
T
2026-06-09 22:52:29 +07:00

51 KiB
Raw Blame History

spacesh M3 Implementation Plan — status detection & UI

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: Drive the M4 status primitive from real sources — Claude Code hooks (per-surface CLAUDE_CONFIG_DIR), shell OSC 133, and best-effort fallback patterns — and surface status in the GUI (panel rings, sidebar aggregate, Event Center, native macOS notifications, auto-unread).

Architecture: Pure detectors (Osc133Scanner, FallbackScanner) live in spacesh-core. The daemon's surface actor runs them on each output flush and sends StateDetected to the router, which calls the same set_state + Evt::State path as Cmd::SetState. A versioned hooks.rs writes a per-surface Claude config dir whose hooks call spacesh notify. The GUI subscribes to state events for rings/badges/feed/notifications.

Tech Stack: Rust (tokio), React/TypeScript + Tauri 2 (tauri-plugin-notification). Builds on M0M2 + M4 (status primitive set_state/state already shipped).

Spec: DOCS/superpowers/specs/2026-06-09-spacesh-m3-design.md. Base: DOCS/MAIN.md §7.

Conventions: English code/comments. cargo test --workspace is the DoD, green & non-flaky — new socket/PTY integration tests use #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + crate::test_support::serial(). Commit after each task; append: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. Do not git push.


File Structure

crates/spacesh-core/src/
  detect.rs   (new)   # Osc133Scanner + FallbackScanner (pure, over bytes/text)
  grid.rs             # + GridSurface::tail_text(lines) helper
  lib.rs              # + re-export detect
crates/spaceshd/src/
  hooks.rs    (new)   # Claude hook adapter: prepare()/cleanup(), is_agent(), settings template
  shell_integration/
    spacesh.zsh (new) # zsh OSC 133 emitter (precmd/preexec), embedded via include_str!
  surface.rs          # actor runs scanners on flush → state_tx; spawn_* gain state_tx + hooks_active
  server.rs           # state_tx funnel → ServerMsg::StateDetected → set_state + Evt::State;
                      #   spawn computes hooks/shell env; Close cleans hook dir
  main.rs             # (no change beyond existing mods)
app/src/
  layoutTypes.ts      # + SurfaceState type
  socketBridge.ts     # + State/Exit in DaemonEvt; focus()
  StatusRing.tsx (new)
  EventCenter.tsx (new)
  notify.ts      (new)
  LayoutEngine.tsx    # panel header shows StatusRing
  Sidebar.tsx         # aggregate badge from states
  App.tsx             # subscribe state/exit → rings/feed/notify/auto-unread
app/src-tauri/
  Cargo.toml          # + tauri-plugin-notification
  src/lib.rs          # register the plugin
  capabilities/default.json  # + notification permission
  tauri.conf.json     # (plugin needs no extra config)

Phase 1 — core detectors

Task 1: Osc133Scanner + FallbackScanner + grid tail

Files:

  • Create: crates/spacesh-core/src/detect.rs

  • Modify: crates/spacesh-core/src/grid.rs (add tail_text), crates/spacesh-core/src/lib.rs

  • Step 1: Write the failing tests + detectors

Create crates/spacesh-core/src/detect.rs:

//! Pure status detectors over terminal output. No I/O.
use spacesh_proto::status::SurfaceState;

/// Scans a byte stream for OSC 133 semantic-prompt markers and yields the
/// status each marker implies. Robust to escape sequences split across feeds:
/// an incomplete trailing marker is buffered until the next feed.
///
/// Markers: ESC ] 133 ; A ST (prompt) → Idle; ; C ST (command output) → Work;
///          ; D [;exit] ST (command end) → Done (exit 0) / Error (exit != 0).
/// ST is BEL (0x07) or ESC \ (0x1b 0x5c). The `B` marker (input start) is ignored.
#[derive(Default)]
pub struct Osc133Scanner {
    buf: Vec<u8>,
}

impl Osc133Scanner {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn feed(&mut self, bytes: &[u8]) -> Vec<SurfaceState> {
        self.buf.extend_from_slice(bytes);
        let mut out = Vec::new();
        let prefix: &[u8] = b"\x1b]133;";
        loop {
            // Find the next marker start.
            let Some(start) = find(&self.buf, prefix) else {
                // No marker start. Keep only a possible partial prefix at the tail.
                self.buf = keep_partial_tail(&self.buf, prefix);
                break;
            };
            // Drop anything before the marker start.
            if start > 0 {
                self.buf.drain(0..start);
            }
            // After the prefix, find the terminator (BEL or ESC \).
            let body_start = prefix.len();
            let Some((body_end, term_len)) = find_terminator(&self.buf, body_start) else {
                break; // incomplete marker; wait for more bytes
            };
            let body = &self.buf[body_start..body_end];
            if let Some(state) = classify(body) {
                out.push(state);
            }
            // Consume through the terminator.
            self.buf.drain(0..body_end + term_len);
        }
        out
    }
}

/// Classify the `133;` body (e.g. `A`, `C`, `D`, `D;0`, `D;1`).
fn classify(body: &[u8]) -> Option<SurfaceState> {
    let s = std::str::from_utf8(body).ok()?;
    let mut parts = s.split(';');
    match parts.next()? {
        "C" => Some(SurfaceState::Work),
        "A" => Some(SurfaceState::Idle),
        "D" => {
            // exit code is the next part, if present.
            match parts.next() {
                Some(code) if code != "0" && !code.is_empty() => Some(SurfaceState::Error),
                _ => Some(SurfaceState::Done),
            }
        }
        _ => None, // B and others: no status
    }
}

fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
    if needle.is_empty() || hay.len() < needle.len() {
        return None;
    }
    hay.windows(needle.len()).position(|w| w == needle)
}

/// Terminator search from `from`: returns (index_of_terminator, terminator_len).
fn find_terminator(hay: &[u8], from: usize) -> Option<(usize, usize)> {
    let mut i = from;
    while i < hay.len() {
        if hay[i] == 0x07 {
            return Some((i, 1));
        }
        if hay[i] == 0x1b && i + 1 < hay.len() && hay[i + 1] == 0x5c {
            return Some((i, 2));
        }
        i += 1;
    }
    None
}

/// Keep only the longest suffix of `buf` that is a strict prefix of `needle`
/// (a possibly-incomplete marker start), so it can complete on the next feed.
fn keep_partial_tail(buf: &[u8], needle: &[u8]) -> Vec<u8> {
    let max = needle.len().saturating_sub(1).min(buf.len());
    for n in (1..=max).rev() {
        let tail = &buf[buf.len() - n..];
        if needle.starts_with(tail) {
            return tail.to_vec();
        }
    }
    Vec::new()
}

/// Stateless best-effort heuristics over a window of recent terminal text.
pub struct FallbackScanner;

impl FallbackScanner {
    /// Returns a status implied by the tail text, or None for "no change".
    pub fn scan(text: &str) -> Option<SurfaceState> {
        let tail = text.trim_end();
        let last_line = tail.lines().last().unwrap_or("");
        // Confirmation / input prompts → waiting for the user.
        let wait_markers = ["(y/n)", "(Y/n)", "(y/N)", "[y/N]", "[Y/n]", "Press enter", "press enter", " 1.", "? "];
        if wait_markers.iter().any(|m| last_line.contains(m)) {
            return Some(SurfaceState::Wait);
        }
        // Spinner glyphs at the tail → working.
        let spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '|', '/', '-', '\\'];
        if let Some(c) = last_line.chars().rev().find(|c| !c.is_whitespace()) {
            if spinners.contains(&c) {
                return Some(SurfaceState::Work);
            }
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn osc133_c_then_d0_gives_work_then_done() {
        let mut s = Osc133Scanner::new();
        let evs = s.feed(b"\x1b]133;C\x07hello\x1b]133;D;0\x07");
        assert_eq!(evs, vec![SurfaceState::Work, SurfaceState::Done]);
    }

    #[test]
    fn osc133_d_nonzero_is_error() {
        let mut s = Osc133Scanner::new();
        assert_eq!(s.feed(b"\x1b]133;D;1\x07"), vec![SurfaceState::Error]);
    }

    #[test]
    fn osc133_a_is_idle_and_st_can_be_esc_backslash() {
        let mut s = Osc133Scanner::new();
        assert_eq!(s.feed(b"\x1b]133;A\x1b\\"), vec![SurfaceState::Idle]);
    }

    #[test]
    fn osc133_split_across_feeds_is_buffered() {
        let mut s = Osc133Scanner::new();
        assert_eq!(s.feed(b"\x1b]133;C"), vec![]); // no terminator yet
        assert_eq!(s.feed(b"\x07"), vec![SurfaceState::Work]);
    }

    #[test]
    fn osc133_split_in_prefix_is_buffered() {
        let mut s = Osc133Scanner::new();
        assert_eq!(s.feed(b"text\x1b]13"), vec![]); // partial prefix retained
        assert_eq!(s.feed(b"3;C\x07"), vec![SurfaceState::Work]);
    }

    #[test]
    fn osc133_ignores_plain_text() {
        let mut s = Osc133Scanner::new();
        assert_eq!(s.feed(b"just some output\n"), vec![]);
        assert!(s.feed(b"more\n").is_empty());
    }

    #[test]
    fn fallback_detects_confirmation_and_spinner() {
        assert_eq!(FallbackScanner::scan("Apply changes? (y/n)"), Some(SurfaceState::Wait));
        assert_eq!(FallbackScanner::scan("building ⠹"), Some(SurfaceState::Work));
        assert_eq!(FallbackScanner::scan("normal output"), None);
    }
}
  • Step 2: Add tail_text to GridSurface

In crates/spacesh-core/src/grid.rs, add this method inside impl GridSurface (it already exposes char_at and size):

    /// The visible grid as text — the last `lines` rows, trailing blanks trimmed.
    /// Used by the fallback detector.
    pub fn tail_text(&self, lines: usize) -> String {
        let size = self.size();
        let start = size.lines.saturating_sub(lines);
        let mut out = String::new();
        for line in start..size.lines {
            let mut row = String::new();
            for col in 0..size.cols {
                row.push(self.char_at(line, col));
            }
            out.push_str(row.trim_end());
            out.push('\n');
        }
        out
    }
  • Step 3: Re-export detect

crates/spacesh-core/src/lib.rs:

pub mod detect;
pub mod grid;
pub mod ops;
pub mod presets;
pub mod snapshot;

pub use detect::{FallbackScanner, Osc133Scanner};
pub use grid::GridSurface;
pub use snapshot::Snapshot;
  • Step 4: Run tests

Run: cargo test -p spacesh-core detect and cargo test -p spacesh-core Expected: PASS (7 detect tests + existing core tests).

  • Step 5: Commit
git add crates/spacesh-core/src/detect.rs crates/spacesh-core/src/grid.rs crates/spacesh-core/src/lib.rs
git commit -m "feat(core): Osc133Scanner + FallbackScanner status detectors + grid tail_text"

Phase 2 — daemon hook adapter

Task 2: Claude Code hook adapter

Files:

  • Create: crates/spaceshd/src/hooks.rs
  • Modify: crates/spaceshd/src/main.rs (add mod hooks;)

Version caveat: the Claude Code hook event names (Stop/Notification/UserPromptSubmit), the settings JSON shape, and CLAUDE_CONFIG_DIR are confirmed against current Claude Code but may drift. Everything is isolated in this file; a drift fix is local. If CLAUDE_CONFIG_DIR or an event name differs in the installed version, adjust only hooks.rs.

  • Step 1: Write the failing test + adapter

Create crates/spaceshd/src/hooks.rs:

//! Versioned Claude Code hook adapter. For a Claude agent surface, writes a
//! per-surface CLAUDE_CONFIG_DIR with hooks that call `spacesh notify`, and
//! returns the env to inject. Isolated so hook-format drift is a local fix.
use std::path::PathBuf;
use spacesh_proto::ids::SurfaceId;

/// Is this command a Claude Code agent we should hook? (heuristic)
pub fn is_agent(command: &str, agent_label: Option<&str>) -> bool {
    let base = std::path::Path::new(command)
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or(command);
    base == "claude" || agent_label == Some("claude")
}

/// Per-surface config dir under ~/.spacesh/hooks/<surface_id>.
fn dir_for(home: &PathBuf, sid: &SurfaceId) -> PathBuf {
    home.join(".spacesh").join("hooks").join(&sid.0)
}

/// Build the settings.json contents wiring Stop/Notification/UserPromptSubmit
/// to `spacesh notify`. `spacesh_bin` is the absolute path to the CLI.
pub fn settings_json(spacesh_bin: &str) -> String {
    let line = |state: &str| {
        format!(
            "{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}\"}}]}}"
        )
    };
    format!(
        "{{\"hooks\":{{\"Stop\":[{}],\"Notification\":[{}],\"UserPromptSubmit\":[{}]}}}}",
        line("done"), line("wait"), line("work")
    )
}

/// Prepare the per-surface hook config; return env pairs to merge into the spawn.
/// Best-effort: on any I/O error returns an empty vec (spawn proceeds without hooks).
pub fn prepare(sid: &SurfaceId, spacesh_bin: &str) -> Vec<(String, String)> {
    let Some(home) = dirs::home_dir() else { return vec![] };
    let dir = dir_for(&home, sid);
    if std::fs::create_dir_all(&dir).is_err() {
        return vec![];
    }
    if std::fs::write(dir.join("settings.json"), settings_json(spacesh_bin)).is_err() {
        return vec![];
    }
    vec![("CLAUDE_CONFIG_DIR".to_string(), dir.to_string_lossy().to_string())]
}

/// Remove the per-surface hook dir (best-effort) on close.
pub fn cleanup(sid: &SurfaceId) {
    if let Some(home) = dirs::home_dir() {
        let _ = std::fs::remove_dir_all(dir_for(&home, sid));
    }
}

/// Absolute path to the `spacesh` CLI binary, sibling of the running daemon.
pub fn spacesh_bin() -> String {
    std::env::current_exe()
        .ok()
        .map(|p| p.with_file_name("spacesh"))
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|| "spacesh".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn is_agent_matches_claude_by_command_or_label() {
        assert!(is_agent("claude", None));
        assert!(is_agent("/usr/local/bin/claude", None));
        assert!(is_agent("/bin/sh", Some("claude")));
        assert!(!is_agent("/bin/zsh", None));
        assert!(!is_agent("node", Some("codex")));
    }

    #[test]
    fn settings_json_has_three_events_with_abs_bin_and_env_var() {
        let j = settings_json("/abs/spacesh");
        assert!(j.contains("\"Stop\""));
        assert!(j.contains("\"Notification\""));
        assert!(j.contains("\"UserPromptSubmit\""));
        assert!(j.contains("/abs/spacesh notify --surface $SPACESH_SURFACE_ID --state done"));
        assert!(j.contains("--state wait"));
        assert!(j.contains("--state work"));
        // Valid JSON.
        let _: serde_json::Value = serde_json::from_str(&j).unwrap();
    }

    #[test]
    fn prepare_writes_config_and_cleanup_removes_it() {
        let sid = SurfaceId(format!("s_test_{}", std::process::id()));
        let env = prepare(&sid, "/abs/spacesh");
        assert_eq!(env.len(), 1);
        assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR");
        let dir = std::path::PathBuf::from(&env[0].1);
        assert!(dir.join("settings.json").exists());
        cleanup(&sid);
        assert!(!dir.exists());
    }
}
  • Step 2: Wire the module

In crates/spaceshd/src/main.rs, add mod hooks; with the other mod lines. (serde_json and dirs are already daemon deps.)

  • Step 3: Run tests

Run: cargo test -p spaceshd hooks Expected: PASS (3 tests).

  • Step 4: Commit
git add crates/spaceshd/src/hooks.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): versioned Claude Code hook adapter (per-surface CLAUDE_CONFIG_DIR)"

Phase 3 — daemon shell integration + actor/server detection wiring

Task 3: zsh shell-integration asset + env builder

Files:

  • Create: crates/spaceshd/src/shell_integration/spacesh.zsh
  • Modify: crates/spaceshd/src/hooks.rs (add shell_env for zsh; reuse the module for spawn-time env)

Scope note: M3 ships zsh OSC 133 integration (env-only via ZDOTDIR, the macOS default shell). bash/fish run without injected integration and rely on the fallback detector — a documented partial (see Notes).

  • Step 1: The zsh integration script

Create crates/spaceshd/src/shell_integration/spacesh.zsh:

# spacesh zsh OSC 133 integration. Sourced from a per-surface ZDOTDIR .zshrc
# after the user's ~/.zshrc. Emits semantic-prompt markers so the daemon can
# detect command start/end and exit status.
autoload -Uz add-zsh-hook 2>/dev/null

_spacesh_precmd() {
  local code=$?
  # End previous command (D) with its exit code, then mark prompt start (A).
  print -n "\e]133;D;${code}\a\e]133;A\a"
}
_spacesh_preexec() {
  # Command output begins (C).
  print -n "\e]133;C\a"
}
add-zsh-hook precmd _spacesh_precmd 2>/dev/null
add-zsh-hook preexec _spacesh_preexec 2>/dev/null
  • Step 2: Add the shell-env builder + test

Add to crates/spaceshd/src/hooks.rs:

/// zsh OSC 133 integration script, embedded at build time.
const ZSH_INTEGRATION: &str = include_str!("shell_integration/spacesh.zsh");

/// Is this command a zsh shell we can OSC-133-integrate via ZDOTDIR?
pub fn is_zsh(command: &str) -> bool {
    std::path::Path::new(command).file_name().and_then(|s| s.to_str()) == Some("zsh")
}

/// Prepare a per-surface ZDOTDIR whose .zshrc sources the user's rc then our
/// integration. Returns env pairs (ZDOTDIR, and SPACESH_ZDOTDIR=original) to
/// inject. Best-effort: empty vec on I/O failure.
pub fn shell_env(sid: &SurfaceId) -> Vec<(String, String)> {
    let Some(home) = dirs::home_dir() else { return vec![] };
    let dir = home.join(".spacesh").join("shellint").join(&sid.0);
    if std::fs::create_dir_all(&dir).is_err() {
        return vec![];
    }
    if std::fs::write(dir.join("spacesh.zsh"), ZSH_INTEGRATION).is_err() {
        return vec![];
    }
    let orig_zdotdir = std::env::var("ZDOTDIR").unwrap_or_else(|_| home.to_string_lossy().to_string());
    // .zshrc: source user rc, then our integration.
    let zshrc = format!(
        "[ -f \"{orig}/.zshrc\" ] && source \"{orig}/.zshrc\"\nsource \"{dir}/spacesh.zsh\"\n",
        orig = orig_zdotdir,
        dir = dir.to_string_lossy(),
    );
    if std::fs::write(dir.join(".zshrc"), zshrc).is_err() {
        return vec![];
    }
    vec![("ZDOTDIR".to_string(), dir.to_string_lossy().to_string())]
}

/// Remove the per-surface shellint dir (best-effort).
pub fn cleanup_shell(sid: &SurfaceId) {
    if let Some(home) = dirs::home_dir() {
        let _ = std::fs::remove_dir_all(home.join(".spacesh").join("shellint").join(&sid.0));
    }
}

Append a test to hooks.rs tests:

    #[test]
    fn is_zsh_detects_zsh() {
        assert!(is_zsh("/bin/zsh"));
        assert!(is_zsh("zsh"));
        assert!(!is_zsh("/bin/bash"));
    }

    #[test]
    fn shell_env_writes_zdotdir_with_integration() {
        let sid = SurfaceId(format!("s_shtest_{}", std::process::id()));
        let env = shell_env(&sid);
        assert_eq!(env.len(), 1);
        assert_eq!(env[0].0, "ZDOTDIR");
        let dir = std::path::PathBuf::from(&env[0].1);
        assert!(dir.join(".zshrc").exists());
        assert!(dir.join("spacesh.zsh").exists());
        cleanup_shell(&sid);
        assert!(!dir.exists());
    }
  • Step 3: Run tests

Run: cargo test -p spaceshd hooks Expected: PASS (5 tests).

  • Step 4: Commit
git add crates/spaceshd/src/shell_integration/spacesh.zsh crates/spaceshd/src/hooks.rs
git commit -m "feat(daemon): zsh OSC 133 shell integration via per-surface ZDOTDIR"

Task 4: Actor detector wiring + StateDetected routing + spawn env

Files:

  • Modify: crates/spaceshd/src/surface.rs, crates/spaceshd/src/server.rs

This threads a state_tx funnel and a hooks_active flag through the spawn path, runs the detectors in the actor on every flush, and routes detected states through the same set_state + Evt::State path as Cmd::SetState. It also computes hook/shell env at spawn and cleans up hook dirs on close.

  • Step 1: Extend the actor with detection

In crates/spaceshd/src/surface.rs:

(a) Update imports at the top:

use spacesh_core::{snapshot::snapshot_ansi, GridSurface};
use spacesh_core::snapshot::Snapshot;
use spacesh_core::detect::{FallbackScanner, Osc133Scanner};
use spacesh_proto::{SurfaceId, WorkspaceId};
use spacesh_proto::status::SurfaceState;
use spacesh_proto::workspace::SurfaceSpec;
use spacesh_pty::{PtyHandle, SpawnSpec};
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::time::{Duration, Instant};

(b) Change spawn_from_spec to accept extra env, the hooks_active flag, and state_tx:

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)>,
) -> std::io::Result<SurfaceHandle> {
    let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
    env.extend(extra_env);
    let pty = PtyHandle::spawn(SpawnSpec {
        command: spec.command.clone(),
        args: spec.args.clone(),
        cwd: std::path::PathBuf::from(&spec.cwd),
        cols: spec.cols,
        rows: spec.rows,
        env,
    })
    .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
    Ok(spawn_surface(id, workspace_id, pty, spec.cols, spec.rows, hooks_active, state_tx, exit_tx))
}

(c) Change spawn_surface signature and add detection. Replace the function:

pub fn spawn_surface(
    id: SurfaceId,
    workspace_id: WorkspaceId,
    mut pty: PtyHandle,
    cols: u16,
    rows: u16,
    hooks_active: bool,
    state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
    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();
    let detect_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;
        let mut osc = Osc133Scanner::new();
        // `deterministic` suppresses fallback once a reliable source is seen
        // (hooks active, or any OSC 133 marker observed).
        let mut deterministic = hooks_active;
        let mut last_state = SurfaceState::Idle;

        loop {
            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 }) => {
                            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 {
                                flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
                                flush_deadline = None;
                            }
                        }
                        None => {
                            flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
                            break;
                        }
                    }
                }
                _ = timer => {
                    flush(&mut pending, &mut grid, &mut osc, &mut deterministic, &mut last_state, &detect_id, &bcast, &state_tx);
                    flush_deadline = None;
                }
            }
        }
        let code = pty.wait();
        let _ = exit_tx.send((actor_id, code));
    });

    SurfaceHandle { id, workspace_id, tx }
}

/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
/// state change (if any). No-op when pending is empty.
#[allow(clippy::too_many_arguments)]
fn flush(
    pending: &mut Vec<u8>,
    grid: &mut GridSurface,
    osc: &mut Osc133Scanner,
    deterministic: &mut bool,
    last_state: &mut SurfaceState,
    id: &SurfaceId,
    bcast: &broadcast::Sender<Vec<u8>>,
    state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
) {
    if pending.is_empty() {
        return;
    }
    // Deterministic source: OSC 133 markers in this chunk.
    let mut candidate: Option<SurfaceState> = None;
    for st in osc.feed(pending) {
        *deterministic = true;
        candidate = Some(st);
    }
    grid.feed(pending);
    // Best-effort fallback only when no deterministic source is active.
    if candidate.is_none() && !*deterministic {
        candidate = FallbackScanner::scan(&grid.tail_text(6));
    }
    if let Some(st) = candidate {
        if st != *last_state {
            *last_state = st;
            let _ = state_tx.send((id.clone(), st));
        }
    }
    let _ = bcast.send(std::mem::take(pending));
}

(d) Update the four spawn_surface(...) calls in surface.rs tests to the new signature — add a dummy state_tx and hooks_active:

        let (state_tx, _state_rx) = mpsc::unbounded_channel();
        let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);

Apply the same shape (its own state_tx) to attach_receives_output, exit_is_reported, attach_snapshot_reflects_prior_output. For spawn_from_spec_runs_the_command, pass the new args:

        let (state_tx, _state_rx) = mpsc::unbounded_channel();
        let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, vec![], false, state_tx, exit_tx).unwrap();

Add a new actor test for OSC 133 detection:

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn osc133_output_drives_state_detection() {
        let _serial = crate::test_support::serial();
        let pty = PtyHandle::spawn(spec("printf '\\033]133;C\\007'; printf working; printf '\\033]133;D;0\\007'; sleep 0.3")).unwrap();
        let (state_tx, mut state_rx) = mpsc::unbounded_channel();
        let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
        let _h = spawn_surface(SurfaceId("s_o".into()), WorkspaceId("w_1".into()), pty, 80, 24, false, state_tx, exit_tx);
        let mut seen = Vec::new();
        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
        while tokio::time::Instant::now() < deadline {
            if let Ok(Some((_, st))) = tokio::time::timeout(tokio::time::Duration::from_millis(100), state_rx.recv()).await {
                seen.push(st);
                if seen.contains(&SurfaceState::Done) { break; }
            }
        }
        assert!(seen.contains(&SurfaceState::Work), "states: {seen:?}");
        assert!(seen.contains(&SurfaceState::Done), "states: {seen:?}");
    }
  • Step 2: Funnel state_tx + StateDetected in the server

In crates/spaceshd/src/server.rs:

(a) Imports — add SurfaceState:

use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId, WorkspaceId};
use spacesh_proto::status::SurfaceState;

(b) Add a ServerMsg::StateDetected variant:

    /// A status change detected internally (OSC 133 / fallback) by a surface actor.
    StateDetected { surface_id: SurfaceId, state: SurfaceState },

(c) In serve, create the state_tx funnel next to exit_tx, and pass state_tx into router:

    let (state_tx, mut state_rx) = mpsc::unbounded_channel::<(SurfaceId, SurfaceState)>();
    let router_for_state = router_tx.clone();
    tokio::spawn(async move {
        while let Some((sid, st)) = state_rx.recv().await {
            let _ = router_for_state.send(ServerMsg::StateDetected { surface_id: sid, state: st }).await;
        }
    });

    let persister = persist::spawn(store.clone(), Duration::from_millis(500));
    let initial = store.load().unwrap_or_default();
    let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx, state_tx, persister, initial));

(d) Add state_tx to the router signature and the StateDetected arm:

async fn router(
    mut rx: mpsc::Receiver<ServerMsg>,
    router_tx: mpsc::Sender<ServerMsg>,
    exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
    state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
    persister: Persister,
    initial: crate::state_store::PersistState,
) {

Add the arm in the match msg:

            ServerMsg::StateDetected { surface_id, state } => {
                if reg.is_running(&surface_id) {
                    reg.set_state(&surface_id, state);
                    broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id, state }));
                }
            }

Pass &state_tx into handle_request (add a parameter) so spawn handlers can clone it for new surfaces. Update the call:

            ServerMsg::Request { id, cmd, client, out } => {
                handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &state_tx, &persister).await;
            }

And the handle_request signature gains state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, (after exit_tx).

(e) Update the FOUR spawn sites in handle_request (NewSurface, SplitSurface, ApplyPreset loop, RestartSurface) to compute env + hooks_active and pass state_tx. Use this helper (add near emit_layout):

/// Compute spawn env (hooks for claude agents, zsh integration for zsh shells)
/// and whether a deterministic hook source is active.
fn spawn_env(sid: &SurfaceId, spec: &spacesh_proto::workspace::SurfaceSpec) -> (Vec<(String, String)>, bool) {
    if crate::hooks::is_agent(&spec.command, spec.agent_label.as_deref()) {
        let env = crate::hooks::prepare(sid, &crate::hooks::spacesh_bin());
        let active = !env.is_empty();
        (env, active)
    } else if crate::hooks::is_zsh(&spec.command) {
        (crate::hooks::shell_env(sid), false)
    } else {
        (vec![], false)
    }
}

At each spawn site, replace crate::surface::spawn_from_spec(sid.clone(), ws_id.clone(), &spec, exit_tx.clone()) (and equivalents) with:

                    let (env, hooks_active) = spawn_env(&sid, &spec);
                    match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {

(Use the right id/workspace var per site: NewSurface/ApplyPreset use workspace_id; SplitSurface uses ws_id and new_sid; RestartSurface uses surface_id and the resolved ws_id. Match the existing variable names in each arm.)

(f) Hook/shell cleanup on close: in the Cmd::Close arm, after reg.remove_surface(&surface_id), add:

                crate::hooks::cleanup(&surface_id);
                crate::hooks::cleanup_shell(&surface_id);

And in the Cmd::CloseWorkspace arm, after computing ids, clean each:

            for sid in &ids { crate::hooks::cleanup(sid); crate::hooks::cleanup_shell(sid); subs.remove(sid); }

(Replace the existing for sid in &ids { subs.remove(sid); } loop.)

  • Step 3: Add a server integration test for OSC 133 → status

Append to the tests module in server.rs:

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn osc133_in_pty_sets_status_over_socket() {
        let _serial = crate::test_support::serial();
        let dir = tempdir_path();
        let sock = dir.join("sock");
        let store: std::sync::Arc<dyn crate::state_store::StateStore> =
            std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
        let sock2 = sock.clone();
        tokio::spawn(async move { let _ = serve(&sock2, store).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.clone()),
            command: Some("/bin/sh".into()),
            args: vec!["-c".into(), "printf '\\033]133;C\\007'; printf hi; printf '\\033]133;D;0\\007'; sleep 1".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.clone());
        let _ = req(&mut s, 3, Cmd::Attach { surface_id }).await;

        // Wait for a State event to flow (Work then Done).
        let mut saw_done = false;
        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
        while tokio::time::Instant::now() < deadline {
            if let Ok(Ok(Some(Envelope::Evt(Evt::State { state, .. })))) =
                tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut s)).await {
                if state == spacesh_proto::status::SurfaceState::Done { saw_done = true; break; }
            }
        }
        assert!(saw_done, "expected a Done state event from OSC 133");
    }
  • Step 4: Run the full suite (3×)

Run: cargo test --workspace > /tmp/m3.log 2>&1; echo EXIT=$? — three times, all 0. Expected: core + daemon green incl. the new detection tests.

  • Step 5: Commit
git add crates/spaceshd/src/surface.rs crates/spaceshd/src/server.rs
git commit -m "feat(daemon): actor OSC133/fallback detection → set_state, hook/shell spawn env, cleanup"

Phase 4 — app: status UI & notifications

Task 5: Forward state events + StatusRing + Sidebar aggregate

Files:

  • Modify: app/src/layoutTypes.ts, app/src/socketBridge.ts, app/src/LayoutEngine.tsx, app/src/Sidebar.tsx

  • Create: app/src/StatusRing.tsx

  • Step 1: Types + bridge events

In app/src/layoutTypes.ts, add:

export type SurfaceState = "work" | "wait" | "done" | "error" | "idle";

And add state to the SurfaceView.spec sibling — update the SurfaceView interface:

export interface SurfaceView {
  spec: { command: string; args: string[]; cwd: string; agent_label: string | null; cols: number; rows: number; autostart: boolean };
  running: boolean;
  state: SurfaceState;
}

In app/src/socketBridge.ts, extend DaemonEvt and add a focus helper:

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 } }
  | { evt: "state"; data: { surface_id: string; state: import("./layoutTypes").SurfaceState } }
  | { evt: "layout_changed"; data: { workspace_id: string } }
  | { evt: "workspace_changed"; data: unknown }
  | { evt: "groups_changed"; data: unknown };

export async function focusSurface(surfaceId: string): Promise<void> {
  await invoke("focus", { surfaceId });
}

(The bridge already forwards daemon events as spacesh:evt; State/Exit ride the same channel — confirm bridge.rs emits all non-output Evt variants. They do: the reader emits every Evt except Output to the webview.)

  • Step 2: StatusRing component

Create app/src/StatusRing.tsx:

import type { SurfaceState } from "./layoutTypes";

const COLOR: Record<SurfaceState, string> = {
  work: "#4C8DFF",
  wait: "#F2B84B",
  done: "#3FB950",
  error: "#F4544E",
  idle: "#5A6573",
};

export function StatusRing({ state, running }: { state: SurfaceState; running: boolean }) {
  const color = running ? COLOR[state] : "#5A6573";
  return (
    <span
      title={running ? state : "stopped"}
      style={{
        display: "inline-block",
        width: 10,
        height: 10,
        borderRadius: "50%",
        border: `2px solid ${color}`,
        boxSizing: "border-box",
        opacity: running ? 1 : 0.5,
      }}
    />
  );
}
  • Step 3: Show the ring in LayoutEngine leaves + aggregate in Sidebar

In app/src/LayoutEngine.tsx, the Props already carry running: Record<string, boolean>. Add a states: Record<string, SurfaceState> prop and render a small header with the ring above each leaf's TerminalView. Update the Props interface and the leaf branch:

import { StatusRing } from "./StatusRing";
import type { LayoutNode, SurfaceState } from "./layoutTypes";

interface Props {
  workspaceId: string;
  layout: LayoutNode | null;
  running: Record<string, boolean>;
  states: Record<string, SurfaceState>;
}

In the Node leaf branch (running case), wrap the TerminalView with a header:

    return (
      <div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 7, padding: "3px 8px", background: "#0A0D12", borderBottom: "1px solid #232A33" }}>
          <StatusRing state={states[id] ?? "idle"} running={true} />
          <span style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{id}</span>
        </div>
        <div style={{ flex: 1, minHeight: 0 }}>
          <TerminalView key={id} surfaceId={id} />
        </div>
      </div>
    );

Thread states through the recursive Node calls (add states to its props and pass it down, same as running).

In app/src/Sidebar.tsx, replace the static gray ring with an aggregate. Add a helper and use it in the workspace row:

import type { SurfaceState, WorkspaceView } from "./layoutTypes";

const RING: Record<SurfaceState | "stopped", string> = {
  error: "#F4544E", wait: "#F2B84B", work: "#4C8DFF", done: "#3FB950", idle: "#5A6573", stopped: "#5A6573",
};

function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
  const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
  const running = Object.values(w.surfaces).filter((s) => s.running);
  if (running.length === 0) return "stopped";
  for (const st of order) {
    if (running.some((s) => s.state === st)) return st;
  }
  return "idle";
}

In the workspace row, replace the ring <span> with:

      <span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${RING[aggregate(w)]}`, boxSizing: "border-box" }} />
  • Step 4: Type-check

Run: cd app && npm run build Expected: PASS.

  • Step 5: Commit
git add app/src/layoutTypes.ts app/src/socketBridge.ts app/src/StatusRing.tsx app/src/LayoutEngine.tsx app/src/Sidebar.tsx
git commit -m "feat(app): status rings on panels + sidebar aggregate badge from state events"

Task 6: Event Center, native notifications, auto-unread, App wiring

Files:

  • Create: app/src/EventCenter.tsx, app/src/notify.ts

  • Modify: app/src/App.tsx, app/src-tauri/Cargo.toml, app/src-tauri/src/lib.rs, app/src-tauri/capabilities/default.json

  • Step 1: Add the notification plugin (Rust side)

In app/src-tauri/Cargo.toml [dependencies], add:

tauri-plugin-notification = "2"

In app/src-tauri/src/lib.rs, register the plugin in the builder (before .run):

        .plugin(tauri_plugin_notification::init())

In app/src-tauri/capabilities/default.json, add to the permissions array:

"notification:default"
  • Step 2: notify.ts

Create app/src/notify.ts:

import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { SurfaceState } from "./layoutTypes";

const NOTIFY_STATES: SurfaceState[] = ["done", "wait", "error"];
let lastBySurface: Record<string, SurfaceState> = {};

/// Fire a native notification for a status change when the window is unfocused.
export async function maybeNotify(surfaceId: string, agent: string, workspace: string, state: SurfaceState): Promise<void> {
  if (!NOTIFY_STATES.includes(state)) return;
  if (lastBySurface[surfaceId] === state) return; // dedup repeats
  lastBySurface[surfaceId] = state;

  const focused = await getCurrentWindow().isFocused().catch(() => true);
  if (focused) return;

  let granted = await isPermissionGranted();
  if (!granted) granted = (await requestPermission()) === "granted";
  if (!granted) return;

  sendNotification({ title: `${workspace} · ${agent}`, body: `${state}` });
}
  • Step 3: EventCenter.tsx

Create app/src/EventCenter.tsx:

import type { SurfaceState } from "./layoutTypes";

export interface FeedEntry {
  id: number;
  surfaceId: string;
  workspace: string;
  agent: string;
  kind: SurfaceState | "exit";
  time: string;
}

const ICON: Record<string, string> = { done: "✓", wait: "⌛", error: "✕", work: "●", idle: "·", exit: "⏻" };
const COLOR: Record<string, string> = { done: "#3FB950", wait: "#F2B84B", error: "#F4544E", work: "#4C8DFF", idle: "#5A6573", exit: "#5A6573" };

export function EventCenter({ feed, onMarkRead, onSelect }: { feed: FeedEntry[]; onMarkRead: () => void; onSelect: (surfaceId: string) => void }) {
  return (
    <div style={{ width: 300, background: "#13171F", height: "100%", padding: 14, boxSizing: "border-box", display: "flex", flexDirection: "column", borderLeft: "1px solid #232A33" }}>
      <div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
        <span style={{ fontFamily: "Inter", fontSize: 13, fontWeight: 700, color: "#E6EDF3", flex: 1 }}>Event Center</span>
        <span onClick={onMarkRead} style={{ fontSize: 11, color: "#4C8DFF", cursor: "pointer" }}>Mark all read</span>
      </div>
      <div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
        {feed.length === 0 && <div style={{ color: "#5A6573", fontSize: 12 }}>No events yet.</div>}
        {feed.map((e) => (
          <div key={e.id} onClick={() => onSelect(e.surfaceId)}
            style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: "1px solid #232A33", cursor: "pointer" }}>
            <span style={{ color: COLOR[e.kind] }}>{ICON[e.kind]}</span>
            <div style={{ flex: 1 }}>
              <div style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{e.workspace} · {e.agent}</div>
              <div style={{ fontFamily: "Inter", fontSize: 12, color: "#E6EDF3" }}>{e.kind} <span style={{ color: "#5A6573" }}>{e.time}</span></div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 4: Wire App.tsx

Update app/src/App.tsx to: keep a states map, append feed entries on state/exit, fire notifications, set auto-unread, and render EventCenter + pass states to LayoutEngine. Replace the file:

import { useEffect, useState, useCallback, useRef } from "react";
import { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar";
import { PresetPicker } from "./PresetPicker";
import { Wizard } from "./Wizard";
import { EventCenter, type FeedEntry } from "./EventCenter";
import { maybeNotify } from "./notify";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface } from "./socketBridge";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";

export function App() {
  const [groups, setGroups] = useState<Group[]>([]);
  const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
  const [activeId, setActiveId] = useState<string | null>(null);
  const [running, setRunning] = useState<Record<string, boolean>>({});
  const [states, setStates] = useState<Record<string, SurfaceState>>({});
  const [feed, setFeed] = useState<FeedEntry[]>([]);
  const [wizard, setWizard] = useState(false);
  const feedId = useRef(0);
  const activeRef = useRef<string | null>(null);
  const wsRef = useRef<WorkspaceView[]>([]);
  activeRef.current = activeId;
  wsRef.current = workspaces;

  const refresh = useCallback(async () => {
    const st = await getStatusFull();
    setGroups(st.groups);
    setWorkspaces(st.workspaces);
    const run: Record<string, boolean> = {};
    const stt: Record<string, SurfaceState> = {};
    st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; stt[id] = sv.state; }));
    setRunning(run);
    setStates(stt);
    if (!activeRef.current && st.workspaces.length) setActiveId(st.workspaces[0].id);
  }, []);

  const wsOf = (surfaceId: string): WorkspaceView | undefined =>
    wsRef.current.find((w) => surfaceId in w.surfaces);

  useEffect(() => {
    void refresh();
    const unlisten = onDaemonEvent((evt) => {
      if (evt.evt === "state") {
        const { surface_id, state } = evt.data;
        setStates((m) => ({ ...m, [surface_id]: state }));
        const w = wsOf(surface_id);
        const agent = w?.surfaces[surface_id]?.spec.agent_label ?? "shell";
        if (["done", "wait", "error"].includes(state)) {
          setFeed((f) => [{ id: feedId.current++, surfaceId: surface_id, workspace: w?.name ?? "?", agent, kind: state, time: "now" }, ...f].slice(0, 200));
          if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true });
          void maybeNotify(surface_id, agent, w?.name ?? "?", state);
        }
        void refresh();
      } else if (evt.evt === "exit") {
        const w = wsOf(evt.data.surface_id);
        setFeed((f) => [{ id: feedId.current++, surfaceId: evt.data.surface_id, workspace: w?.name ?? "?", agent: w?.surfaces[evt.data.surface_id]?.spec.agent_label ?? "shell", kind: "exit", time: "now" }, ...f].slice(0, 200));
        void refresh();
      } else {
        void refresh();
      }
    });
    const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); });
    return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
  }, [refresh]);

  const active = workspaces.find((w) => w.id === activeId) ?? null;

  function selectWorkspace(id: string) {
    setActiveId(id);
    void setWorkspaceMeta(id, { unread: false });
  }

  return (
    <div style={{ display: "flex", height: "100vh", background: "#0E1116" }}>
      <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} />
      <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
        {active && (
          <div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
            <PresetPicker selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
          </div>
        )}
        <div style={{ flex: 1, minHeight: 0 }}>
          {active
            ? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} />
            : <div style={{ color: "#666", padding: 24 }}>No workspace  create one to begin.</div>}
        </div>
      </div>
      <EventCenter feed={feed} onMarkRead={() => setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} />
      {wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
    </div>
  );
}
  • Step 2 (verify build)

Run: cd app && npm install && npm run build && cd src-tauri && cargo build Expected: both PASS. (npm install picks up @tauri-apps/plugin-notification — add it: cd app && npm install @tauri-apps/plugin-notification@^2.)

Add the JS dep to app/package.json dependencies:

"@tauri-apps/plugin-notification": "^2"
  • Step 3: Confirm workspace still green

Run: cd /Users/vasyansk/Developers/MyProject/IaaC/Realmanual/spacesh && cargo test --workspace > /tmp/v.log 2>&1; echo EXIT=$? Expected: 0 (crates untouched by app).

  • Step 4: Commit
git add app/
git commit -m "feat(app): Event Center, native notifications, auto-unread, state wiring in App"

Definition of Done

  • cargo test --workspace — green & non-flaky across 3 consecutive runs.
  • cd app && npm run build and cd app/src-tauri && cargo build — both clean.
  • Manual (npm run tauri dev): a claude panel's ring changes work→wait→done as the agent runs (hooks); a zsh panel shows work/done/error via OSC 133; sh -c 'false' (fallback off → no false status, acceptable); minimize the window and finish a task → native notification, click → focus; Event Center accumulates entries, Mark all read clears; a non-active workspace gets an unread dot on done/wait/error.

Notes for the implementer

  • Spawn-path signature change. spawn_surface/spawn_from_spec gain hooks_active + state_tx; update all call sites (4 in server.rs, the tests in surface.rs). The daemon won't compile until server.rs is updated too — implement Task 4 as a unit and run the suite at its end.
  • One status path. Internal detection (StateDetected) and external Cmd::SetState both end at reg.set_state + Evt::State. Don't add a third path. Deterministic sources (hooks/OSC 133) suppress fallback via the deterministic flag.
  • Test robustness. New socket/PTY tests use #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + crate::test_support::serial().
  • Claude hook format is version-sensitive — it's isolated in hooks.rs; verify event names/CLAUDE_CONFIG_DIR against the installed claude at integration time and adjust only that file. The unit tests assert the template shape, not live Claude behavior (which is the manual check).
  • Documented partial: shell OSC 133 integration ships for zsh only (env-only via ZDOTDIR, macOS default). bash/fish panels run without injected integration and rely on the fallback detector; adding a bash rc-injection path is a clean follow-up.
  • Out of slice: Telegram/MAX external notifications (M5, daemon subscriber), daemon-authoritative event log, remote (M6). Status stays ephemeral; the Event Center feed is GUI-memory.