Files
spaceshell/DOCS/superpowers/specs/2026-06-09-spacesh-m3-design.md
2026-06-09 22:45:11 +07:00

157 lines
14 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 — 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/<surface_id>/settings.json` (atomic) with hooks that call the CLI:
```jsonc
{ "hooks": {
"Stop": [{ "hooks": [{ "type": "command", "command": "<ABS>/spacesh notify --surface $SPACESH_SURFACE_ID --state done" }]}],
"Notification": [{ "hooks": [{ "type": "command", "command": "<ABS>/spacesh notify --surface $SPACESH_SURFACE_ID --state wait" }]}],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "<ABS>/spacesh notify --surface $SPACESH_SURFACE_ID --state work" }]}]
}}
```
- `<ABS>` 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/<surface_id>` (`SPACESH_SURFACE_ID` is already injected since M0).
- Cleanup: on panel `close`/exit, remove `~/.spacesh/hooks/<surface_id>/` (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;<n≠0>` → `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/<surface_id>/` 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: <status>`; 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.