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

14 KiB
Raw Blame History

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 commandsset_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:
    { "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:
    • Cwork
    • D;0done; D;<n≠0>error; D with no/garbled code → done (exit unknown)
    • Aidle
  • 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 sources 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.rsOsc133Scanner + 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.