Files
2026-06-09 22:52:29 +07:00

1266 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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<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`):
```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/<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**
```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<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:
```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::<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`:
```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<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`:
```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<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**
```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<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`:
```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:
```tsx
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:
```tsx
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:
```tsx
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:
```tsx
<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**
```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<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`:
```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:
```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<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:
```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.
```