From c0c8fe25f1b6607bed263f2c3588ae52c9c2cfea Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:45:11 +0700 Subject: [PATCH] =?UTF-8?q?docs(spec):=20M3=20design=20=E2=80=94=20status?= =?UTF-8?q?=20detection=20sources=20+=20UI=20+=20native=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-09-spacesh-m3-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 DOCS/superpowers/specs/2026-06-09-spacesh-m3-design.md diff --git a/DOCS/superpowers/specs/2026-06-09-spacesh-m3-design.md b/DOCS/superpowers/specs/2026-06-09-spacesh-m3-design.md new file mode 100644 index 0000000..99fcfe3 --- /dev/null +++ b/DOCS/superpowers/specs/2026-06-09-spacesh-m3-design.md @@ -0,0 +1,156 @@ +# spacesh M3 — design spec + +> Slice: **M3 — statuses** (detection sources that drive the `set_state` primitive, plus the status UI and native notifications). +> Builds on M0+M1+M2+M4 (shipped). The `set_state`/`state` primitive and `spacesh notify` already exist (M4). Base spec: `DOCS/MAIN.md` §7, §8.5, §13. +> Date: 2026-06-09. + +--- + +## 1. Scope + +M4 already ships the status *primitive*: in-memory per-surface `SurfaceState` (`work/wait/done/error/idle`), `Cmd::SetState`, `Evt::State`, `SurfaceView.state`, and `spacesh notify`. M3 adds the **sources** that set status and the **UI** that surfaces it. + +**In (decided in brainstorm):** +- **Claude Code hook adapter** — per-surface env isolation via `CLAUDE_CONFIG_DIR`: when the daemon spawns a Claude agent panel, it writes a per-surface settings file with hooks that call `spacesh notify`. Mapping: `Stop→done`, `Notification→wait`, `UserPromptSubmit→work`. Versioned adapter (event names/format isolated). +- **OSC 133** for shell panels — inject shell integration (so the shell emits markers) + a daemon-side scanner that derives `work` (C), `done`/`error` (D + exit code), `idle` (A). +- **Fallback patterns** — best-effort heuristics over the grid tail for agents without hooks/OSC 133 (spinner→work, confirmation prompt→wait). Isolated, minority case. +- **Status UI** — per-panel status rings, sidebar aggregate badge, GUI-ephemeral Event Center feed with mark-read. +- **Native macOS notifications** (Tauri notification plugin), GUI-side, on `done/wait/error` only when the window is unfocused; click → focus the panel; configurable state set. +- **GUI-driven auto-unread** — on `done/wait/error` for a non-active workspace, the GUI sets `unread` via `set_workspace_meta`. + +**Out (later / not this slice):** +- External notifications (Telegram/MAX) — **M5** (daemon-side subscriber). +- Daemon-authoritative event log — not now (Event Center is GUI-ephemeral). +- No new protocol commands and no new persistence: status is ephemeral (M4), the feed is GUI-memory. + +--- + +## 2. Components & placement + +The primitive (`set_state`/`state`/registry `states` map) is done (M4). M3 adds sources (write the primitive) and UI. + +``` +crates/spacesh-core/src/ + detect.rs (new) # PURE detectors: Osc133Scanner (feed bytes → state events), + # FallbackScanner (grid-tail text → best-effort wait/work). Unit-testable, no I/O. +crates/spaceshd/src/ + hooks.rs (new) # versioned Claude Code hook adapter: write per-surface CLAUDE_CONFIG_DIR/settings.json, + # build env, "is this a claude agent?" heuristic, cleanup on close + shell_integration/ # zsh/bash assets emitting OSC 133 (embedded via include_str!) + surface.rs # actor holds Osc133Scanner + (optional) FallbackScanner; runs them on each output + # flush; on a status change sends router StateDetected{surface_id, state} + server.rs # spawn: agent panel → hooks env; shell panel → shell-integration env; + # router: StateDetected → reg.set_state + broadcast Evt::State (same path as Cmd::SetState) +app/src/ + StatusRing.tsx (new)# status ring (color by SurfaceState) in the LayoutEngine panel header + EventCenter.tsx(new)# GUI-ephemeral feed of state/exit + mark-read + notify.ts (new) # Tauri notification on state when unfocused, click → focus + Sidebar.tsx # workspace aggregate badge from panel states; auto-unread + App.tsx # subscribe to state events → feed, rings, unread, notifications +``` + +- **Detectors live in `spacesh-core`** (pure functions over bytes/text) → deterministic unit tests. +- **Spawn adapters live in `spaceshd`** (hooks env, shell-integration env) next to the PTY spawner. +- **Internal path does not go over the socket:** actor → router `StateDetected` (a private router message, not on the wire) → the same `reg.set_state` + `Evt::State` as the external `Cmd::SetState`. One way to set status. +- **No new protocol commands** — `set_state`/`state` already exist (M4). + +--- + +## 3. Claude Code hook adapter + +**When:** spawning an agent panel whose command is Claude Code (heuristic: `command`/`agent_label == "claude"`, or a config "agent commands" list). Shell and other commands are skipped (they use OSC 133). + +**`hooks::prepare(surface_id) -> Vec<(String, String)>`** (env pairs to merge into `SpawnSpec.env`): +- Writes `~/.spacesh/hooks//settings.json` (atomic) with hooks that call the CLI: + ```jsonc + { "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "/spacesh notify --surface $SPACESH_SURFACE_ID --state done" }]}], + "Notification": [{ "hooks": [{ "type": "command", "command": "/spacesh notify --surface $SPACESH_SURFACE_ID --state wait" }]}], + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "/spacesh notify --surface $SPACESH_SURFACE_ID --state work" }]}] + }} + ``` +- `` is the absolute path to the `spacesh` binary (sibling of the daemon) — the agent's PATH is not guaranteed. +- Returns env: `CLAUDE_CONFIG_DIR=~/.spacesh/hooks/` (`SPACESH_SURFACE_ID` is already injected since M0). +- Cleanup: on panel `close`/exit, remove `~/.spacesh/hooks//` (best-effort). + +**State mapping** (isolated in `hooks.rs`, versioned — `DOCS/MAIN.md` §13 risk #1): `Stop→done`, `Notification→wait`, `UserPromptSubmit→work`. Error is not derived from Claude hooks (no reliable event) — error comes from shell OSC 133 (exit≠0) and fallback. `idle` is the M4 initial state and the post-reattach default. + +**Versioning/drift:** event names and the settings shape live as constants + a template in `hooks.rs`; verify against the installed `claude --version` at implementation time. Hooks call `spacesh notify`, which is best-effort (M4: never fails the agent). + +**Isolation:** `CLAUDE_CONFIG_DIR` gives a per-panel config dir; the user's global `~/.claude/settings.json` is untouched. (A project-local `.claude/settings.json` in cwd is still merged by Claude; our additive status hooks coexist.) + +> **Implementer caveat:** the exact Claude Code hook event set, settings schema, and `CLAUDE_CONFIG_DIR` semantics must be confirmed against the installed Claude Code version (consult the claude-code-guide / current docs). Keep everything in `hooks.rs` so a drift fix is localized; if `CLAUDE_CONFIG_DIR` or an event name differs, adjust only the adapter. + +--- + +## 4. Internal detection (OSC 133 + fallback) + +**OSC 133 (deterministic for shell):** stream markers `ESC ] 133 ; A ST` (prompt start), `;B` (input start), `;C` (command output start), `;D[;exit] ST` (end, with code). +- `core::detect::Osc133Scanner` — a state machine over flushed bytes, robust to an escape sequence split across chunks (keeps a tail buffer of the unfinished escape). Emits: + - `C` → `work` + - `D;0` → `done`; `D;` → `error`; `D` with no/garbled code → `done` (exit unknown) + - `A` → `idle` +- Pure; unit-tested: feed marker byte sequences → expected state sequence. + +**Shell integration (so the shell EMITS OSC 133):** assets `shell_integration/spacesh.zsh|bash` (embedded via `include_str!`) using `precmd`/`preexec` (zsh) and `PROMPT_COMMAND`/`trap DEBUG` (bash) to print A/B/C/D markers with `$?`. Injected at shell-panel spawn **via env, without touching the user's rc**: +- zsh: `ZDOTDIR=~/.spacesh/shellint//` whose `.zshrc` first `source`s the original `~/.zshrc`, then the spacesh script; the original `ZDOTDIR` is preserved/forwarded. +- bash: provide a spacesh rc (via `BASH_ENV` for the non-interactive path and a spacesh rcfile that sources `~/.bashrc` + the script for the interactive path). +- Exact per-shell mechanism lives in the spawn wiring; assets are versioned. Best-effort: if it can't be prepared, the shell runs without integration → fallback applies. + +**Fallback (best-effort, agents without hooks/OSC 133):** `core::detect::FallbackScanner` over a window of the last grid lines: +- spinner glyphs (`⠋⠙⠹…`, `|/-\`) at the tail → `work`; +- confirmation/input patterns (`(y/n)`, `Press enter`, `❯ 1.`) → `wait`; +- otherwise no change. Isolated, flagged best-effort. Applied only when no deterministic source is active for the panel (no hooks and no OSC 133 seen). + +**Actor wiring (approach A):** on each output flush the surface actor: `term.feed(bytes)` (as today) → `Osc133Scanner.feed(bytes)`; if the panel is marked `needs_fallback`, run `FallbackScanner` over a grid-tail snapshot. On a **change** in the resulting status (dedup: don't resend the same) → `router_tx.send(StateDetected { surface_id, state })`. The router does `reg.set_state` + broadcast `Evt::State` (identical to `Cmd::SetState`). Source priority: deterministic (hooks/OSC 133) override fallback. + +--- + +## 5. UI + +The GUI subscribes to `state` events (add `State`/`Exit` to the front's `DaemonEvt` mapping) and keeps the `running` map from M2. + +- **Status rings (`StatusRing.tsx`):** a ring in each LayoutEngine panel header, colored by `SurfaceState` (work=`$st-work`, wait=`$st-wait`, done=`$st-done`, error=`$st-error`, idle=`$st-idle`); a `stopped` panel shows gray "stopped" (lifecycle overrides status). Optional CSS spinner for `work`. Replaces the M1/M2 placeholder. +- **Sidebar aggregate badge:** per-workspace ring/dot from the aggregate of its running panels' states (priority error > wait > work > done > idle), plus the unread dot (M2). Replaces the M2 placeholder ring. +- **Event Center (`EventCenter.tsx`, GUI-ephemeral):** right-column feed (per the mockup): each `state ∈ {done,wait,error}` and `exit` appends `workspace · agent · status · time` with a status icon and unread dot. Tabs All/Unread/Errors. "Mark all read" / clicking an entry → `focus` the panel + clear the workspace unread. In-memory ring (~200), empty after a GUI restart (current statuses still come from `status`). +- **Native notifications (`notify.ts`, `tauri-plugin-notification`):** on `state ∈ {done,wait,error}`, if the window is **not focused** (`getCurrentWindow().isFocused()` is false), send an OS notification `workspace · agent: `; click → raise the window + `focus` the panel. The state set and on/off are settings (default `done/wait/error`). Dedup the same consecutive state. +- **Auto-unread (GUI-driven):** on `state ∈ {done,wait,error}` for a **non-active** workspace → `setWorkspaceMeta(unread=true)`; selecting a workspace clears unread (already M2). The active workspace never sets unread. +- **Tauri:** add `tauri-plugin-notification`, grant it in capabilities, and forward `State`/`Exit` events to the webview (the bridge already emits other events — extend the front mapping). + +--- + +## 6. Errors & testing + +**Errors / edge-cases:** +- Hook adapter: failure to write `CLAUDE_CONFIG_DIR`/settings → log, spawn proceeds (panel without auto-status; don't crash). `spacesh` binary not found for substitution → log, skip writing hooks. +- Shell integration: failure to prepare ZDOTDIR/rc → spawn without integration (no OSC 133 → fallback). Never break the shell. +- OSC 133 scanner: unfinished escape at a chunk boundary is buffered (tested on a split boundary); a `D` with no/garbled code → treated as `done`. +- Source conflict: deterministic (hooks/OSC 133) overrides fallback; dedup — don't re-emit the same status. +- `set_state`/`StateDetected` for a non-running panel → ignored (the M4 primitive already does this). +- Notifications: no OS permission → silently skip; dedup bursts. +- Hook dir cleanup on exit is best-effort; an orphan dir is non-critical. + +**Tests:** + +| Level | What | +|---|---| +| `spacesh-core/detect` (unit) | Osc133Scanner: A/B/C/D → work/idle/done(`D;0`)/error(`D;1`); escape split across chunks; garbage ignored. FallbackScanner: spinner→work, `(y/n)`/`❯ 1.`→wait, plain text→no change | +| `spaceshd/hooks` (unit) | settings.json has the 3 events with the absolute `spacesh` path + `$SPACESH_SURFACE_ID`; env carries `CLAUDE_CONFIG_DIR`; "is claude?" heuristic; cleanup removes the dir | +| `spaceshd` (integration, serial + multi_thread) | a panel whose PTY prints OSC 133 markers (`printf '\e]133;C\a'; …; printf '\e]133;D;0\a'`) → `status` shows work→done and an `Evt::State` is pushed; an agent panel's env carries `CLAUDE_CONFIG_DIR` | +| `app` (manual) | claude panel: ring changes work/wait/done; shell `false` → error ring; minimize window → native notification on done, click → focus; Event Center accumulates, mark-read works; a non-active workspace gets unread | + +**Budgets:** the actor detector runs only on flush (already coalesced 6ms/16KB from M0) — no separate scrollback pass; fallback scans only the last N grid lines, not the whole grid. + +**Boundary:** Telegram/MAX external notifications are M5 (daemon subscriber); here only native macOS. No new persistence (status ephemeral, feed GUI-ephemeral). + +--- + +## 7. Implementation order (within the slice) + +1. `spacesh-core/detect.rs` — `Osc133Scanner` + `FallbackScanner` (pure) + tests. +2. `spaceshd/hooks.rs` — Claude hook adapter (settings write, env, heuristic, cleanup) + tests. +3. `spaceshd` shell-integration assets + spawn wiring (agent → hooks env; shell → shell-integration env). +4. `spaceshd/surface.rs` + `server.rs` — actor detector wiring, `StateDetected` router message → `set_state` + `Evt::State`; OSC 133 integration test. +5. `app` — forward `State`/`Exit` to webview; `StatusRing`, sidebar aggregate; `EventCenter`; `notify.ts` + tauri-plugin-notification + capability; auto-unread. + +End of slice: deterministic per-panel status (Claude hooks + shell OSC 133, fallback for the rest) shown as rings/badges, an Event Center feed, native macOS notifications when away, and auto-unread — all on the M4 primitive.