Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
spacesh M3 — design spec
Slice: M3 — statuses (detection sources that drive the
set_stateprimitive, plus the status UI and native notifications). Builds on M0+M1+M2+M4 (shipped). Theset_state/stateprimitive andspacesh notifyalready 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 callspacesh 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/erroronly when the window is unfocused; click → focus the panel; configurable state set. - GUI-driven auto-unread — on
done/wait/errorfor a non-active workspace, the GUI setsunreadviaset_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 samereg.set_state+Evt::Stateas the externalCmd::SetState. One way to set status. - No new protocol commands —
set_state/statealready 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 thespaceshbinary (sibling of the daemon) — the agent's PATH is not guaranteed.- Returns env:
CLAUDE_CONFIG_DIR=~/.spacesh/hooks/<surface_id>(SPACESH_SURFACE_IDis 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_DIRsemantics must be confirmed against the installed Claude Code version (consult the claude-code-guide / current docs). Keep everything inhooks.rsso a drift fix is localized; ifCLAUDE_CONFIG_DIRor 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→workD;0→done;D;<n≠0>→error;Dwith 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.zshrcfirstsources the original~/.zshrc, then the spacesh script; the originalZDOTDIRis preserved/forwarded. - bash: provide a spacesh rc (via
BASH_ENVfor 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 bySurfaceState(work=$st-work, wait=$st-wait, done=$st-done, error=$st-error, idle=$st-idle); astoppedpanel shows gray "stopped" (lifecycle overrides status). Optional CSS spinner forwork. 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): eachstate ∈ {done,wait,error}andexitappendsworkspace · agent · status · timewith a status icon and unread dot. Tabs All/Unread/Errors. "Mark all read" / clicking an entry →focusthe panel + clear the workspace unread. In-memory ring (~200), empty after a GUI restart (current statuses still come fromstatus). - Native notifications (
notify.ts,tauri-plugin-notification): onstate ∈ {done,wait,error}, if the window is not focused (getCurrentWindow().isFocused()is false), send an OS notificationworkspace · agent: <status>; click → raise the window +focusthe panel. The state set and on/off are settings (defaultdone/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 forwardState/Exitevents 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).spaceshbinary 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
Dwith no/garbled code → treated asdone. - Source conflict: deterministic (hooks/OSC 133) overrides fallback; dedup — don't re-emit the same status.
set_state/StateDetectedfor 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)
spacesh-core/detect.rs—Osc133Scanner+FallbackScanner(pure) + tests.spaceshd/hooks.rs— Claude hook adapter (settings write, env, heuristic, cleanup) + tests.spaceshdshell-integration assets + spawn wiring (agent → hooks env; shell → shell-integration env).spaceshd/surface.rs+server.rs— actor detector wiring,StateDetectedrouter message →set_state+Evt::State; OSC 133 integration test.app— forwardState/Exitto 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.