docs(spec): M3 design — status detection sources + UI + native notifications

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:45:11 +07:00
parent 7f2afc3b8a
commit c0c8fe25f1
@@ -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/<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.