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:
@@ -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.
|
||||||
Reference in New Issue
Block a user