Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
spacesh M4 — design spec
Slice: M4 — CLI (thin
spaceshclient over the command bus) + theset_state/statestatus primitive (proto + daemon). Reordered ahead of M3 so the agent-hook status path in M3 targets a realspacesh 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-clicrate producing thespaceshbinary — a one-shot client to the daemon UDS (connect → request → await matchingres→ exit). Own small blocking client overspacesh-protocodec; not the persistent streaming bridge. - Command set: core + M2 operations (everything scriptable; interactive
attach/input/detachare excluded). set_state/stateprimitive added tospacesh-protoand the daemon: 5 ephemeral stateswork/wait/done/error/idle, in-memory (NOT persisted), orthogonal to the M2running/stoppedlifecycle, new running panel startsidle.SurfaceView(status) gains astatefield.- Lazy-start the daemon from the CLI (like the GUI,
DOCS/MAIN.md§4.1);notifyis best-effort — if the socket is absent it exits 0 silently without spawning a daemon. --jsonglobal flag (rawdata/ error object); human output otherwise;statusrenders a compact table. Shell completions viaclap_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(perDOCS/MAIN.md). The daemon binaryspaceshdis 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 anyevt/non-matchingresuntil the matchingres; onok:truereturndata, onok:falsereturn an error carryingcode/msg.notify(surface_id, state)— best-effort: try-connect only (no spawn); on success sendSetStateand read theres(ignore result); on connect failure returnOk(()).ensure_daemon()— connect; if it fails, locatespaceshdnext to the current exe, spawn it, poll-connect with a timeout (same shape asapp/src-tauri'sensure_daemon).
- Depends only on
spacesh-proto(types + codec). Nospacesh-core/spacesh-pty. - Add
crates/spacesh-clito 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 }SurfaceViewgainsstate: SurfaceState(defaults toIdle).
daemon
- Registry holds
states: HashMap<SurfaceId, SurfaceState>— in-memory, not inPersistState. - A new running surface (
new_surface/split_surface/apply_preset/restart_surface) initializes its state toIdle. Cmd::SetState: if the surface exists AND isrunning→ store the state, emitEvt::State, replyok. Unknown or non-running surface →NOT_FOUND(the CLInotifyswallows this — best-effort). State is meaningful only while running.Exit(running→stopped): remove the surface's entry fromstates(status is moot for a stopped panel; the UI rendersstopped).restart_surfacere-initializes toIdle.status:SurfaceView.statecomes fromstates(orIdleif absent).Evt::Statefans 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 responsedata(or{ "ok": false, "error": {…} }on failure). Human mode is default;statusrenders a compact table.- Lazy-start: before a request, connect; if no socket, locate
spaceshdnext to the binary, spawn, poll for readiness. Exception:notifyconnects best-effort and exits 0 silently if absent (no spawn) — a hook must not orphan a daemon or break the agent. - Exit codes:
0ok;1error (res ok:falseor transport);notifyalways0; clap arg errors use clap's default2. Errors print to stderr ascode: msg. set-meta --group ""maps to "ungroup" (Some(None)); omitting--groupmeans "no change" — mirrors the daemon'sOption<Option<GroupId>>.focusis currently a daemon no-op (window raise is GUI-side); the CLI sends it anyway for parity.- Completions via
clap_complete::generatefrom the same clap command tree.
5. Errors & testing
Errors / edge-cases:
- Transport: connect failure after lazy-start timeout → stderr
daemon unavailable, exit 1; fornotify→ 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 (exceptnotify). 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)
spacesh-proto—SurfaceState,Cmd::SetState,Evt::State,SurfaceView.state+ serde tests.spaceshd— registrystatesmap (idle on spawn, set/get, drop on exit, restart→idle),SetStatedispatch +Evt::State,statuscarries state + tests.spacesh-cliscaffold — crate, clap command tree,client.rs(request + ensure_daemon + notify) + unit tests for parsing.spacesh-cli— wire every subcommand to itsCmd, human +--jsonrendering,statustable, completions.spacesh-cli— integration tests (daemon on temp socket);notifybest-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.