From 84a19356e2ea0446a9f7bb4da254c43357a7b2f2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:52:29 +0700 Subject: [PATCH] =?UTF-8?q?docs(plan):=20M3=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20status=20detection=20sources=20+=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-09-spacesh-m3.md | 1265 +++++++++++++++++ 1 file changed, 1265 insertions(+) create mode 100644 DOCS/superpowers/plans/2026-06-09-spacesh-m3.md diff --git a/DOCS/superpowers/plans/2026-06-09-spacesh-m3.md b/DOCS/superpowers/plans/2026-06-09-spacesh-m3.md new file mode 100644 index 0000000..34c824a --- /dev/null +++ b/DOCS/superpowers/plans/2026-06-09-spacesh-m3.md @@ -0,0 +1,1265 @@ +# 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 M0–M2 + 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) `. 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`: +```rust +//! 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, +} + +impl Osc133Scanner { + pub fn new() -> Self { + Self::default() + } + + pub fn feed(&mut self, bytes: &[u8]) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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`): +```rust + /// 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`: +```rust +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** + +```bash +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`: +```rust +//! 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/. +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** + +```bash +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`: +```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`: +```rust +/// 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: +```rust + #[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** + +```bash +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: +```rust +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`: +```rust +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 { + 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: +```rust +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::(64); + let (bcast, _) = broadcast::channel::>(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 = Vec::with_capacity(FLUSH_BYTES); + let mut flush_deadline: Option = 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, + grid: &mut GridSurface, + osc: &mut Osc133Scanner, + deterministic: &mut bool, + last_state: &mut SurfaceState, + id: &SurfaceId, + bcast: &broadcast::Sender>, + state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, +) { + if pending.is_empty() { + return; + } + // Deterministic source: OSC 133 markers in this chunk. + let mut candidate: Option = 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`: +```rust + 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: +```rust + 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: +```rust + #[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`: +```rust +use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId, WorkspaceId}; +use spacesh_proto::status::SurfaceState; +``` + +(b) Add a `ServerMsg::StateDetected` variant: +```rust + /// 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`: +```rust + 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: +```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, +) { +``` +Add the arm in the `match msg`: +```rust + 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: +```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).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`): +```rust +/// 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: +```rust + 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: +```rust + crate::hooks::cleanup(&surface_id); + crate::hooks::cleanup_shell(&surface_id); +``` +And in the `Cmd::CloseWorkspace` arm, after computing `ids`, clean each: +```rust + 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`: +```rust + #[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 = + 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** + +```bash +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: +```ts +export type SurfaceState = "work" | "wait" | "done" | "error" | "idle"; +``` +And add `state` to the `SurfaceView.spec` sibling — update the `SurfaceView` interface: +```ts +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: +```ts +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 { + 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`: +```tsx +import type { SurfaceState } from "./layoutTypes"; + +const COLOR: Record = { + 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 ( + + ); +} +``` + +- [ ] **Step 3: Show the ring in LayoutEngine leaves + aggregate in Sidebar** + +In `app/src/LayoutEngine.tsx`, the `Props` already carry `running: Record`. Add a `states: Record` prop and render a small header with the ring above each leaf's TerminalView. Update the `Props` interface and the leaf branch: +```tsx +import { StatusRing } from "./StatusRing"; +import type { LayoutNode, SurfaceState } from "./layoutTypes"; + +interface Props { + workspaceId: string; + layout: LayoutNode | null; + running: Record; + states: Record; +} +``` +In the `Node` leaf branch (running case), wrap the `TerminalView` with a header: +```tsx + return ( +
+
+ + {id} +
+
+ +
+
+ ); +``` +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: +```tsx +import type { SurfaceState, WorkspaceView } from "./layoutTypes"; + +const RING: Record = { + 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 `` with: +```tsx + +``` + +- [ ] **Step 4: Type-check** + +Run: `cd app && npm run build` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +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: +```toml +tauri-plugin-notification = "2" +``` +In `app/src-tauri/src/lib.rs`, register the plugin in the builder (before `.run`): +```rust + .plugin(tauri_plugin_notification::init()) +``` +In `app/src-tauri/capabilities/default.json`, add to the `permissions` array: +```json +"notification:default" +``` + +- [ ] **Step 2: notify.ts** + +Create `app/src/notify.ts`: +```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 = {}; + +/// 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 { + 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`: +```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 = { done: "✓", wait: "⌛", error: "✕", work: "●", idle: "·", exit: "⏻" }; +const COLOR: Record = { 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 ( +
+
+ Event Center + Mark all read +
+
+ {feed.length === 0 &&
No events yet.
} + {feed.map((e) => ( +
onSelect(e.surfaceId)} + style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: "1px solid #232A33", cursor: "pointer" }}> + {ICON[e.kind]} +
+
{e.workspace} · {e.agent}
+
{e.kind} {e.time}
+
+
+ ))} +
+
+ ); +} +``` + +- [ ] **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: +```tsx +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([]); + const [workspaces, setWorkspaces] = useState([]); + const [activeId, setActiveId] = useState(null); + const [running, setRunning] = useState>({}); + const [states, setStates] = useState>({}); + const [feed, setFeed] = useState([]); + const [wizard, setWizard] = useState(false); + const feedId = useRef(0); + const activeRef = useRef(null); + const wsRef = useRef([]); + activeRef.current = activeId; + wsRef.current = workspaces; + + const refresh = useCallback(async () => { + const st = await getStatusFull(); + setGroups(st.groups); + setWorkspaces(st.workspaces); + const run: Record = {}; + const stt: Record = {}; + 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 ( +
+ setWizard(true)} /> +
+ {active && ( +
+ { if (active) void applyPreset(active.id, p, []); }} /> +
+ )} +
+ {active + ? + :
No workspace — create one to begin.
} +
+
+ setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} /> + {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} +
+ ); +} +``` + +- [ ] **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: +```json +"@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** + +```bash +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. +```