Files
spaceshell/DOCS/superpowers/specs/2026-06-09-spacesh-m4-design.md
T
2026-06-09 21:58:40 +07:00

10 KiB

spacesh M4 — design spec

Slice: M4 — CLI (thin spacesh client over the command bus) + the set_state/state status primitive (proto + daemon). Reordered ahead of M3 so the agent-hook status path in M3 targets a real spacesh notify, not a mock. Builds on M0+M1+M2 (shipped). Base spec: DOCS/MAIN.md §5, §7.4, §10.1, §12. Date: 2026-06-09.


1. Scope

In (decided in brainstorm):

  • New spacesh-cli crate producing the spacesh binary — a one-shot client to the daemon UDS (connect → request → await matching res → exit). Own small blocking client over spacesh-proto codec; not the persistent streaming bridge.
  • Command set: core + M2 operations (everything scriptable; interactive attach/input/detach are excluded).
  • set_state/state primitive added to spacesh-proto and the daemon: 5 ephemeral states work/wait/done/error/idle, in-memory (NOT persisted), orthogonal to the M2 running/stopped lifecycle, new running panel starts idle. SurfaceView (status) gains a state field.
  • Lazy-start the daemon from the CLI (like the GUI, DOCS/MAIN.md §4.1); notify is best-effort — if the socket is absent it exits 0 silently without spawning a daemon.
  • --json global flag (raw data / error object); human output otherwise; status renders a compact table. Shell completions via clap_complete.

Out (later slices):

  • The status detection sources that CALL set_state — Claude Code hook adapter, OSC 133 shell integration, output-pattern fallback — are M3.
  • The status UI — panel rings, sidebar badges, Event Center wiring, native macOS notifications — is M3.
  • External notifications (Telegram/MAX), zoom, scrollback search, diff view — M5. Remote — M6. Auth — separate.

M4 ships only the status primitive (store + emit + status field) and the CLI. spacesh notify is the public entry to the primitive that M3's hooks will use.


2. Crate & transport

crates/spacesh-cli/
├── Cargo.toml            # clap, clap_complete, tokio (rt-multi-thread), spacesh-proto, anyhow, serde_json
└── src/
    ├── main.rs           # clap parse -> dispatch -> process exit code
    ├── client.rs         # one-shot UDS: ensure_daemon (lazy-start), request(cmd)->Value, notify (best-effort)
    ├── commands.rs       # subcommand enum + mapping to Cmd + result rendering
    └── output.rs         # human renderers (status table) + --json passthrough
  • Binary name: spacesh (per DOCS/MAIN.md). The daemon binary spaceshd is a sibling; the CLI spawns it during lazy-start.
  • client.rs:
    • request(cmd: Cmd) -> Result<serde_json::Value> — connect (lazy-start if absent), write_frame(Req{id,cmd}), read frames skipping any evt/non-matching res until the matching res; on ok:true return data, on ok:false return an error carrying code/msg.
    • notify(surface_id, state) — best-effort: try-connect only (no spawn); on success send SetState and read the res (ignore result); on connect failure return Ok(()).
    • ensure_daemon() — connect; if it fails, locate spaceshd next to the current exe, spawn it, poll-connect with a timeout (same shape as app/src-tauri's ensure_daemon).
  • Depends only on spacesh-proto (types + codec). No spacesh-core/spacesh-pty.
  • Add crates/spacesh-cli to the root workspace members.

3. Status primitive — set_state / state

proto

New SurfaceState enum (e.g. crates/spacesh-proto/src/status.rs, re-exported from lib.rs):

#[serde(rename_all = "snake_case")]
pub enum SurfaceState { Work, Wait, Done, Error, Idle }
  • Cmd::SetState { surface_id: SurfaceId, state: SurfaceState }
  • Evt::State { surface_id: SurfaceId, state: SurfaceState }
  • SurfaceView gains state: SurfaceState (defaults to Idle).

daemon

  • Registry holds states: HashMap<SurfaceId, SurfaceState>in-memory, not in PersistState.
  • A new running surface (new_surface / split_surface / apply_preset / restart_surface) initializes its state to Idle.
  • Cmd::SetState: if the surface exists AND is running → store the state, emit Evt::State, reply ok. Unknown or non-running surface → NOT_FOUND (the CLI notify swallows this — best-effort). State is meaningful only while running.
  • Exit (running→stopped): remove the surface's entry from states (status is moot for a stopped panel; the UI renders stopped). restart_surface re-initializes to Idle.
  • status: SurfaceView.state comes from states (or Idle if absent).
  • Evt::State fans out to all clients via the existing single broadcast path.

boundary with M3

M4 provides only the primitive: storage + state event + the state field in status. The callers (notify from hooks, OSC 133, fallback patterns) and the UI (rings/badges/Event Center/native notifications) are M3, built on top.


4. CLI commands

spacesh <subcommand>. Each maps to one bus Cmd.

Subcommand Args / flags Cmd Human output
open <path> Open workspace_id
status [--json] Status table: workspace · group · panels (id, agent, running/stopped, state)
new-surface <ws> --cmd, --arg…, --cols=80, --rows=24 NewSurface surface_id
split <surface> --dir right|down, --cmd, --arg… SplitSurface surface_id
close <surface> Close ok
focus <surface> Focus ok
restart <surface> RestartSurface ok
notify --surface <id> --state work|wait|done|error|idle SetState silent (best-effort)
apply-preset <ws> --preset <id> --agent <a>… (slots in order) ApplyPreset surface_ids
set-ratios <ws> --path 0,1 --ratios 0.3,0.7 SetRatios ok
move <surface> --target <id> --edge left|right|top|bottom MoveSurface ok
close-workspace <ws> CloseWorkspace ok
group create --name --color CreateGroup group_id
group set <id> --name --color --order SetGroup ok
group delete <id> DeleteGroup ok
set-meta <ws> --name --group <id|""> --unread <bool> --order <n> SetWorkspaceMeta ok
shutdown Shutdown ok
completions <shell> bash|zsh|fish completion script (clap_complete)

Behavior:

  • --json (global): prints the raw response data (or { "ok": false, "error": {…} } on failure). Human mode is default; status renders a compact table.
  • Lazy-start: before a request, connect; if no socket, locate spaceshd next to the binary, spawn, poll for readiness. Exception: notify connects best-effort and exits 0 silently if absent (no spawn) — a hook must not orphan a daemon or break the agent.
  • Exit codes: 0 ok; 1 error (res ok:false or transport); notify always 0; clap arg errors use clap's default 2. Errors print to stderr as code: msg.
  • set-meta --group "" maps to "ungroup" (Some(None)); omitting --group means "no change" — mirrors the daemon's Option<Option<GroupId>>.
  • focus is currently a daemon no-op (window raise is GUI-side); the CLI sends it anyway for parity.
  • Completions via clap_complete::generate from the same clap command tree.

5. Errors & testing

Errors / edge-cases:

  • Transport: connect failure after lazy-start timeout → stderr daemon unavailable, exit 1; for notify → exit 0 silently.
  • Invalid args (bad --state/--edge/--dir, non-numeric --ratios/--path) → clap enum/value-parser validation, exit 2.
  • Daemon ok:false → stderr <code>: <msg>, exit 1 (except notify). With --json, print { "ok": false, "error": {…} } to stdout, exit 1.
  • Lazy-start race: the daemon's M0 single-instance lock resolves concurrent spawns; the CLI just polls the ready socket.

Tests:

Level What
spacesh-proto (unit) SurfaceState + Cmd::SetState / Evt::State serde round-trip; SurfaceView.state default
spaceshd (unit, registry) new-surface → state idle; set_state on running stores; on stopped/unknown → not found; exit removes state; restart → idle; status view carries state
spaceshd (integration, serial-guard + multi_thread) over socket: set_state on running → status shows it + a state evt is pushed; mismatched surface → NOT_FOUND
spacesh-cli (unit) clap parsing of every subcommand → correct Cmd; human/--json rendering; --ratios/--path/enum parsers
spacesh-cli (integration, serial-guard + multi_thread) spin a daemon on a temp socket → spacesh open / new-surface / status --json / notify work; notify with no daemon → exit 0; status with no daemon → lazy-start succeeds
manual hook-style spacesh notify --surface $ID --state done flips state in status; spacesh completions zsh prints a script

Budgets: one-shot CLI call < 50 ms against a live daemon (connect + req + res); lazy-start poll cadence as in M0.

Test robustness: any new socket/PTY integration test (daemon or CLI) uses #[tokio::test(flavor = "multi_thread", worker_threads = 2)] and the crate::test_support::serial() guard, matching M1/M2. The CLI integration tests bind their own daemon on a temp socket and must serialize likewise (introduce a test_support::serial() in the CLI crate, or run those tests single-threaded).


6. Implementation order (within the slice)

  1. spacesh-protoSurfaceState, Cmd::SetState, Evt::State, SurfaceView.state + serde tests.
  2. spaceshd — registry states map (idle on spawn, set/get, drop on exit, restart→idle), SetState dispatch + Evt::State, status carries state + tests.
  3. spacesh-cli scaffold — crate, clap command tree, client.rs (request + ensure_daemon + notify) + unit tests for parsing.
  4. spacesh-cli — wire every subcommand to its Cmd, human + --json rendering, status table, completions.
  5. spacesh-cli — integration tests (daemon on temp socket); notify best-effort + lazy-start paths.

End of slice: a working spacesh CLI at full bus parity (minus interactive panels), and a real spacesh notify driving the in-memory status primitive — the foundation M3's detection sources and status UI build on.