diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index a0b9c01..0000000 --- a/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -target/ -worktrees/ -.git/ -.github/ -.claude/ -docs/ -web/node_modules/ -web/dist/ -*.md -.env* diff --git a/CLAUDE.md b/CLAUDE.md index 405994e..806a325 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,33 +1,57 @@ # Maestro -A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about. tmux for persistence, WebSocket for transport, daemon-per-host federated by a small always-on registry. +A terminal-session orchestrator for one machine. One daemon per host owns every +interactive shell or agent (Claude Code, opencode, plain bash) running on it; +clients — CLI, TUI, browser — attach over a plain HTTP + WebSocket API. tmux for +persistence, WebSocket for transport. -## Where the spec lives +## Architecture -`docs/superpowers/specs/2026-05-24-maestro-design.md` is the source of truth for architecture, wire protocol, session model, auth, repo layout, and scope boundary. **Read it before writing any code in this repo.** +``` +maestro daemon tmux wrapper + session API on 127.0.0.1:7070 + ├── GET/POST /api/sessions list / spawn + ├── DELETE /api/sessions/:id kill + ├── GET /api/info hostname, version, home dir, spawn presets + ├── GET /api/dirs directory listing (spawn autocomplete) + ├── GET /api/recent-dirs spawn history + ├── GET /api/gitroot enclosing repo of a path + ├── WS /api/events Snapshot + session deltas + └── WS /api/attach/:id raw PTY bytes + +maestro list/spawn/attach/kill CLI client +maestro tui ratatui client +maestro-web serves the SPA, reverse-proxies /api/* to the daemon +``` + +**No authentication layer, no registry, no peer-to-peer.** Whoever can reach the +daemon's bind address can drive every session on the host — same trust model as +tmux itself. The operator picks the perimeter: `127.0.0.1` for local-only, a +WireGuard/LAN address to share with their own devices, never a public IP. + +Every config field has a default, so `~/.config/maestro/config.toml` is +optional. ## Where the work is -Linear project **Maestro** under team **Naps62**. Issues `NAP-211..218` cover the prototype; each has `blockedBy` set so the dependency chain is explicit. **`NAP-211` is the only unblocked starting point.** +Linear project **Maestro** under team **Naps62**. -`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from "available now" (status backlog/pending, not blocked). +`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from +"available now" (status backlog/pending, not blocked). ## Repo layout Cargo workspace. NAP-245 unified the per-binary crates behind a single `crates/maestro/` umbrella that ships the `maestro` binary with feature gates -(`cli` / `daemon` / `registry`, default = all three). The library crates kept -their existing shape; only the per-binary `main.rs` files were retired. +(`cli` / `daemon` / `tui`, default = all three). ``` crates/ -├── maestro/ # umbrella binary `maestro` (cli + daemon + registry, feature-gated) (NAP-245) +├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245) ├── maestro-protocol/ # shared wire types (NAP-211) ├── maestro-config/ # shared on-disk config schema (NAP-233) ├── maestro-names/ # orchestra-themed session-handle generator (#23) -├── maestro-cli/ # `maestro list/spawn/attach/kill/policy` dispatch (NAP-216) -├── maestro-daemon/ # `maestro daemon` — tmux wrapper + WS endpoints (NAP-212, NAP-214) -├── maestro-registry/ # `maestro registry` — phone book + HTTP API (NAP-213, NAP-215) +├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216) +├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214) ├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249) ├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic) ├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225) @@ -37,18 +61,13 @@ web/ # browser SPA (NAP-218) See `docs/INSTALL.md` for end-user install instructions. +The dated design docs under `docs/superpowers/specs/` predate the single-host +refactor; they carry banners marking what no longer applies. + ## Conventions - **TDD by default.** Failing test first, then minimal impl. Commit test + impl together. - **Frequent commits.** One logical unit per commit; don't pile work up. - **Conventional commits with `maestro` scope.** Example: `feat(maestro): add Hello message round-trip test`. -- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit. (Add to CI as part of NAP-211.) +- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit. - **Integration tests use real `tmux` and `git`.** Don't mock them. Isolate via temp `$HOME` and `TMUX_TMPDIR`. - -## Network assumption - -Maestro is network-agnostic. The operator chooses bind addresses; the perimeter is whatever VPN (Wireguard, etc.) or private LAN they've set up. Maestro does not terminate TLS itself or police the network. See spec §Auth. - -## Status - -Empty repo. Spec approved and committed. **NAP-211 is the natural start.** diff --git a/DESIGN.md b/DESIGN.md index aa1fca9..282b754 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -54,7 +54,7 @@ Tints/glow derive from the accent at low alpha: `--accent-tint` (0.12), ## Typography **Monospace is the UI typeface** (brand identity), system-stack only (offline- -safe, zero web-font dependency, embedded clean into the registry binary): +safe, zero web-font dependency, embedded clean into the web binary): - `--font-mono`: `ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", "Roboto Mono", Menlo, Consolas, monospace` — chrome, labels, data, ids, the wordmark. - `--font-sans`: `system-ui` stack — reserved for the few prose strings (empty-state sentences). @@ -86,9 +86,8 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`. ## Layout & structure Mobile-first, structural responsiveness (not fluid type). Single `≤520px` -breakpoint stacks the header (full-width search, wrapped actions) and drops the -daemon chips to their own wrapped line. `≥600px` switches the spawn modal from -bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as +breakpoint stacks the header (full-width search, wrapped actions). `≥600px` +switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as terminal atmosphere (very low contrast, not decoration). ## Components @@ -96,10 +95,10 @@ terminal atmosphere (very low contrast, not decoration). Three surfaces, all DOM built in TS, styled by class: - **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle, - Spawn); status bar (`N sessions · M live` tally + daemon chips); session tree - (collapsible folders with counts, leaf rows with status dot, name, `@daemon` - + command meta, exited badge, reveal-on-hover kill). Empty states are - distinct and teach: waiting-for-daemon, no-sessions, no-search-match. + Spawn); status bar (`N sessions · M live` tally); session tree (collapsible + directory folders with counts, leaf rows with status dot, name, command meta, + exited badge, reveal-on-hover kill). Empty states are distinct and teach: + no-sessions, no-search-match. - **Attach** — header (back, `session `, live/closed/error state pill) over a framed dark xterm with a green cursor. - **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus @@ -112,5 +111,5 @@ status — never decoration. ## Dev review aids (DEV-only, stripped from production) `web/src/mock.ts` + query params seed a representative catalog without a running -registry: `?mock` (seed sessions/daemons), `?mock&spawn` (open the spawn -modal), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`. +daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal), +`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ba63735..0000000 --- a/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# syntax=docker/dockerfile:1.7 -# -# Image for the Maestro registry. -# -# NAP-245 retired the per-binary crates: the registry now ships as the -# `registry` subcommand of the unified `maestro` binary. This image builds that -# binary with only the `registry` feature enabled (no cli/daemon/tui), so it -# stays small and pulls only maestro-registry + protocol + config. -# -# Build: docker build -t maestro-registry . -# Run: docker run --rm -p 8080:8080 -e MAESTRO_TOKEN= maestro-registry -# -# On Dokploy: point at this Dockerfile, expose port 8080 (or whatever you -# set MAESTRO_REGISTRY_BIND_ADDR to), and set MAESTRO_TOKEN as a secret env. -# No persistent volume is required when MAESTRO_TOKEN is provided. - -FROM rust:1-bookworm AS builder -WORKDIR /build -COPY . . -RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/build/target \ - cargo build --release -p maestro --no-default-features --features registry \ - && cp /build/target/release/maestro /maestro-registry - -FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY --from=builder /maestro-registry /usr/local/bin/maestro-registry - -# Default to all-interfaces so Dokploy / Traefik can reach the service. -# Override via env on the platform. -ENV MAESTRO_REGISTRY_BIND_ADDR=0.0.0.0:8080 -EXPOSE 8080 - -# The on-disk binary is the unified `maestro`; run its `registry` subcommand. -ENTRYPOINT ["/usr/local/bin/maestro-registry", "registry"] diff --git a/Justfile b/Justfile index 473bc89..368911d 100644 --- a/Justfile +++ b/Justfile @@ -8,7 +8,7 @@ web: # Run the web bundle in dev mode against a local daemon — Vite serves the # frontend with HMR, and `/api/*` is proxied directly to the daemon at -# 127.0.0.1:7070 (or wherever $MAESTRO_REGISTRY points; see vite.config.ts). +# 127.0.0.1:7070 (see vite.config.ts). web-dev: pnpm -C web install pnpm -C web dev diff --git a/PRODUCT.md b/PRODUCT.md index 87c4bac..aa09c27 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -7,22 +7,21 @@ product ## Users A single technical operator (a developer) managing every interactive shell and -agent — Claude Code, opencode, plain bash — running across multiple machines. -The job-to-be-done: from any device (laptop, desktop, **phone**), get an -accurate at-a-glance picture of all live sessions across all hosts, then attach -to one and drive its terminal. Usage is often glance-and-go: checking on a +agent — Claude Code, opencode, plain bash — running on a machine. The +job-to-be-done: from any device (laptop, desktop, **phone**), get an accurate +at-a-glance picture of all live sessions on that host, then attach to one and +drive its terminal. Usage is often glance-and-go: checking on a long-running agent, killing a stray session, or hopping into a shell mid-task. Mobile-first is a hard requirement — touch targets, viewport-fit safe areas, and bottom-sheet modals already reflect this. ## Product Purpose -Maestro is a unified terminal-session orchestrator: one place to see and attach -to every interactive session running across every machine the operator cares -about. tmux for persistence, WebSocket for transport, a daemon-per-host -federated by a small always-on registry. The web UI is the browser face of -that system — it renders the cross-host session catalog and provides the attach -terminal (xterm.js). Success = the operator trusts the picture is accurate and +Maestro is a terminal-session orchestrator: one place to see and attach to +every interactive session running on a machine. tmux for persistence, +WebSocket for transport, one daemon per host. The web UI is the browser face of +that system — it renders the session catalog and provides the attach terminal +(xterm.js). Success = the operator trusts the picture is accurate and current, and can get from "open the page" to "typing in the right session" in seconds, on any device. @@ -51,14 +50,14 @@ efficient, calm / uncluttered, with earned moments of delight. ## Design Principles 1. **The scan is the product.** Optimize for instant comprehension of "what's - running, where, and is it healthy" across many sessions and hosts. Status - legibility beats everything else. + running, where, and is it healthy" across many sessions. Status legibility + beats everything else. 2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300; hierarchy and grouping carry the weight, not color-shouting every row. 3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the way; honor keyboard navigation, don't make the operator hunt. 4. **Monospace as identity.** Treat the mono/sans pairing as deliberate brand - typography, not just terminal output. Ids, hosts, counts, and state read as + typography, not just terminal output. Ids, paths, counts, and state read as precise technical data. 5. **Earned delight.** Personality lives in motion and micro-detail (live-status pulse, attach transitions, cursor cues), never in noise — always craft. diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index b42af94..4aed6eb 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -137,9 +137,9 @@ impl DaemonSection { /// `[web]` — `maestro-web` binary settings (NAP-241). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct WebSection { - /// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a - /// perimeter-facing address (LAN/WG) to share with other devices on a - /// trusted segment; do NOT bind a public address without adding auth. + /// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a LAN/VPN + /// address to share with your own devices on a trusted segment; there is no + /// auth layer, so never bind a public address. #[serde(default, skip_serializing_if = "Option::is_none")] pub bind_addr: Option, /// HTTP base URL of the co-located daemon `maestro-web` proxies to. diff --git a/crates/maestro-daemon/src/dirlist.rs b/crates/maestro-daemon/src/dirlist.rs index 42dc291..cae1c96 100644 --- a/crates/maestro-daemon/src/dirlist.rs +++ b/crates/maestro-daemon/src/dirlist.rs @@ -13,7 +13,7 @@ //! - relative `a/b` → home + `a/b` //! - absolute `/a/b` → as-is //! -//! The pure [`list_dir`] is unit-tested in isolation; the registry-client task +//! The pure [`list_dir`] is unit-tested in isolation; the HTTP handler //! just wraps its `Result` in an `RpcResult` for the wire. use std::path::{Path, PathBuf}; diff --git a/crates/maestro-daemon/src/gitroot.rs b/crates/maestro-daemon/src/gitroot.rs index 07dfca1..70c11af 100644 --- a/crates/maestro-daemon/src/gitroot.rs +++ b/crates/maestro-daemon/src/gitroot.rs @@ -12,7 +12,7 @@ //! to its worktree toplevel. //! //! It's a pure filesystem walk with no `git` subprocess — same shape as -//! [`crate::dirlist`]: unit-tested in isolation, and the registry-client task / +//! [`crate::dirlist`]: unit-tested in isolation, and the HTTP handler / //! HTTP layer (#26) just wraps its `Option` for the wire. use std::path::{Path, PathBuf}; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 970d64e..23b7b34 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -201,6 +201,7 @@ pub fn state_for( // WS /api/attach/:id — raw PTY bytes for one session // --------------------------------------------------------------------------- +#[allow(clippy::result_large_err)] async fn attach_ws( Path(id): Path, State(s): State, diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index a03f841..10dbfca 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -2,7 +2,7 @@ //! worktrees, and PTY bridges. //! //! Owns the daemon's authoritative in-memory map of live sessions and is the -//! single point through which the registry client and the per-session WS +//! single point through which the API handlers and the per-session WS //! handler interact with tmux. use std::collections::{HashMap, HashSet}; @@ -23,8 +23,8 @@ use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord}; use crate::tmux::{Tmux, TmuxError}; use crate::worktree::{WorktreeError, WorktreeManager}; -/// Capacity of the session-events broadcast channel. The registry client -/// listens; if it lags by this much we just drop frames (the registry already +/// Capacity of the session-events broadcast channel. The `/api/events` +/// publisher listens; if it lags by this much we just drop frames (clients /// reconciles on `Hello` via the snapshot). Sourced from the shared /// [`maestro_config::BroadcastTuning`] so all broadcast channels track one /// definition (NAP-273). @@ -467,7 +467,7 @@ impl SessionManager { /// Run a blocking `tmux` subprocess call on the blocking thread pool so it /// never stalls a tokio worker. `tmux` is a fork+exec that can take tens of /// milliseconds on a loaded host, and `send_keys` rides the per-keystroke hot -/// path — running it inline would starve HTTP handlers, the registry WS, the +/// path — running it inline would starve HTTP handlers, the events WS, the /// liveness watcher, and PTY readers (NAP-256). async fn tmux_blocking(f: F) -> T where diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index 10e3070..dd4469a 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -59,6 +59,7 @@ async fn spawn_daemon_ws() -> Harness { } } +#[allow(clippy::result_large_err)] async fn connect_attach( addr: SocketAddr, session: Uuid, diff --git a/crates/maestro-installer/src/install.rs b/crates/maestro-installer/src/install.rs index 7bc3b1f..f7ac941 100644 --- a/crates/maestro-installer/src/install.rs +++ b/crates/maestro-installer/src/install.rs @@ -44,9 +44,7 @@ impl SystemctlAction { /// Build the `cargo install` argument vector. /// /// `features`, when `Some`, restricts the build to an explicit feature set via -/// `--no-default-features --features `. The installer uses this to skip -/// the `registry` feature of the umbrella `maestro` crate: a host runs the CLI -/// + daemon (+ TUI); the registry is a separate, centrally-deployed service. +/// `--no-default-features --features `. pub fn cargo_install_args( source: &CargoSource, crate_name: &str, diff --git a/crates/maestro-installer/src/main.rs b/crates/maestro-installer/src/main.rs index d147e3b..df67e1f 100644 --- a/crates/maestro-installer/src/main.rs +++ b/crates/maestro-installer/src/main.rs @@ -1,6 +1,5 @@ //! `maestro-install` — interactive wizard that installs the unified `maestro` -//! binary via `cargo install`, optionally drops a systemd user unit, and -//! optionally kicks off `maestro daemon configure`. +//! binary via `cargo install` and optionally drops a systemd user unit. //! //! Intended bootstrap path: //! @@ -22,13 +21,9 @@ use maestro_installer::{ }; const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro"; -/// Unified `maestro` crate. The installer builds it with the host feature set -/// (CLI + daemon + TUI); the `registry` feature is left out (see -/// `MAESTRO_HOST_FEATURES`). +/// Unified `maestro` crate, built with the full host feature set. const MAESTRO_CRATE: &str = "maestro"; -/// Features installed for a host: CLI + per-host daemon + TUI. The `registry` -/// feature is deliberately omitted — the registry is a separate, centrally- -/// deployed service, not something a host install should compile or run. +/// Features installed for a host: CLI + per-host daemon + TUI. const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui"; const WEB_CRATE: &str = "maestro-web"; const SYSTEMD_PROBE: &str = "/run/systemd/system"; @@ -57,7 +52,7 @@ struct Args { #[arg(long)] no_locked: bool, - /// Don't prompt — accept all defaults (install + systemd + configure). + /// Don't prompt — accept all defaults (install + systemd). #[arg(long, short)] yes: bool, @@ -65,10 +60,6 @@ struct Args { #[arg(long)] no_systemd: bool, - /// Skip the `maestro daemon configure` step. - #[arg(long)] - no_configure: bool, - /// Override the ExecStart path written to the systemd unit. /// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`). #[arg(long, value_name = "PATH")] @@ -107,51 +98,25 @@ fn run(args: Args) -> Result<(), Box> { return Ok(()); } - // 1. Install the unified binary (NAP-245), minus the registry feature. + // 1. Install the unified binary (NAP-245). let locked = !args.no_locked; install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?; - // 2. Offer to run `maestro daemon configure` now (must happen before systemd - // start, since the daemon refuses to run without registry_url / token). - // Re-runs of the wizard auto-skip when the unified config already has a - // [daemon] section; that's what makes "re-run to update" pleasant. In - // non-interactive mode (--yes / no TTY) we also skip — `maestro daemon - // configure` would prompt for required fields and we don't want to - // surprise scripts. - let configured = if let Some(path) = maestro_config_path() { - if daemon_section_present(&path) { - eprintln!( - "\n[daemon] section already present in {} — skipping configure step.", - path.display() - ); - eprintln!("(run `maestro daemon configure --force` to rewrite it.)"); - true - } else { - run_configure_step(&args, interactive)? - } - } else { - run_configure_step(&args, interactive)? - }; - - // 3. systemd integration. + // 2. systemd integration. let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE)); if args.no_systemd { eprintln!("\nSkipping systemd step (--no-systemd)."); } else if !systemd_detected { eprintln!("\nNo systemd detected ({SYSTEMD_PROBE} missing). Skipping unit install."); } else { - handle_systemd(&args, interactive, configured)?; + handle_systemd(&args, interactive)?; } - // 4. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing + // 3. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing // install and refresh rather than re-prompting, matching the daemon flow. let web_installed = handle_web(&args, interactive, systemd_detected)?; - print_summary( - configured, - systemd_detected && !args.no_systemd, - web_installed, - ); + print_summary(systemd_detected && !args.no_systemd, web_installed); Ok(()) } @@ -170,12 +135,9 @@ fn print_banner(source: &CargoSource) { eprintln!("maestro installer"); eprintln!(); eprintln!("This wizard will:"); - eprintln!( - " 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI; registry runs separately)" - ); - eprintln!(" 2. (optional) run `maestro daemon configure` to bootstrap the config file"); - eprintln!(" 3. (optional) install a systemd user unit and enable it"); - eprintln!(" 4. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)"); + eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI)"); + eprintln!(" 2. (optional) install a systemd user unit and enable it"); + eprintln!(" 3. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)"); eprintln!(); match source { CargoSource::Git { url, branch } => { @@ -205,65 +167,7 @@ fn install_crate( Ok(()) } -fn maestro_config_path() -> Option { - Some(dirs::config_dir()?.join("maestro").join("config.toml")) -} - -/// True iff the unified file exists and already has a `[daemon]` section. -/// Used to skip the configure prompt on re-runs without forcing the user -/// to manually delete a co-located `[registry]` / `[cli]` section first. -fn daemon_section_present(path: &Path) -> bool { - if !path.exists() { - return false; - } - let Ok(body) = std::fs::read_to_string(path) else { - return false; - }; - body.lines().any(|l| l.trim_start().starts_with("[daemon]")) -} - -fn run_configure_step(args: &Args, interactive: bool) -> Result> { - let want = interactive - && !args.no_configure - && confirm( - "Run `maestro daemon configure` now (writes ~/.config/maestro/config.toml)?", - true, - )?; - if want { - run_configure_passthrough() - } else { - Ok(false) - } -} - -fn run_configure_passthrough() -> Result> { - // Run the freshly-installed `maestro daemon configure` interactively. We - // don't hard-code the path; rely on it being on PATH after `cargo install`. - eprintln!("\n==> maestro daemon configure"); - let status = Command::new("maestro") - .args(["daemon", "configure"]) - .status(); - match status { - Ok(s) if s.success() => Ok(true), - Ok(s) => { - eprintln!("`maestro daemon configure` exited {s}"); - Ok(false) - } - Err(e) => { - eprintln!( - "couldn't launch `maestro`: {e}. Make sure ~/.cargo/bin is on PATH, \ - then run `maestro daemon configure` manually." - ); - Ok(false) - } - } -} - -fn handle_systemd( - args: &Args, - interactive: bool, - configured: bool, -) -> Result<(), Box> { +fn handle_systemd(args: &Args, interactive: bool) -> Result<(), Box> { let unit_path = user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?; let exec_start = match &args.exec_start { Some(p) => p.clone(), @@ -326,12 +230,6 @@ fn handle_systemd( } else { let want = if !interactive { false - } else if !configured { - eprintln!( - "Note: starting now will fail until you run `maestro daemon configure` \ - (no registry_url / token yet)." - ); - confirm("Start maestro.service now anyway?", false)? } else { confirm("Start maestro.service now?", true)? }; @@ -421,10 +319,10 @@ fn handle_web( } else { eprintln!( "\nOptional: install the browser UI (`maestro-web`)?\n \ - Reverse-proxies a local maestro daemon and serves the SPA at \ + Reverse-proxies the local maestro daemon and serves the SPA at \ http://127.0.0.1:8080/ by default.\n \ Edit [web].bind_addr in ~/.config/maestro/config.toml \ - to expose it to your perimeter (e.g. WireGuard IP)." + to expose it on your LAN/VPN address." ); confirm("Install maestro-web?", true)? }; @@ -549,21 +447,18 @@ fn confirm(prompt: &str, default_yes: bool) -> io::Result { }) } -fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool) { +fn print_summary(systemd_enabled: bool, web_installed: bool) { eprintln!(); eprintln!("Done."); eprintln!(); eprintln!("Next steps:"); - if !configured { - eprintln!(" • Run `maestro daemon configure` to bootstrap your config file."); - } if systemd_enabled { eprintln!(" • Check status: `systemctl --user status maestro`"); eprintln!(" • Logs: `journalctl --user -u maestro -f`"); } else { eprintln!(" • Start the daemon: `maestro daemon`"); } - eprintln!(" • Set the CLI's registry: `maestro set-registry `"); + eprintln!(" • Open the TUI: `maestro tui`"); if web_installed { if systemd_enabled { eprintln!( diff --git a/crates/maestro-protocol/Cargo.toml b/crates/maestro-protocol/Cargo.toml index 60e482d..8a2a0cd 100644 --- a/crates/maestro-protocol/Cargo.toml +++ b/crates/maestro-protocol/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true repository.workspace = true publish.workspace = true -description = "Shared wire types for Maestro daemon/registry/client communication" +description = "Shared wire types for Maestro daemon/client communication" [dependencies] chrono.workspace = true diff --git a/crates/maestro/src/main.rs b/crates/maestro/src/main.rs index 96e956e..8a97966 100644 --- a/crates/maestro/src/main.rs +++ b/crates/maestro/src/main.rs @@ -142,6 +142,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { } #[cfg(feature = "cli")] +#[allow(clippy::result_large_err)] async fn cli_dispatch( command: maestro_cli::cli::Command, config: Option, diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 43c5032..2106eab 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -16,11 +16,9 @@ The bootstrap script builds `maestro-installer` into a temp directory, runs the wizard, and cleans up — only the unified `maestro` binary ends up persisted in `~/.cargo/bin`. -**Updating is the same command.** Re-running `./install.sh` detects an -existing `[daemon]` section in `~/.config/maestro/config.toml` and an -existing systemd unit, skips the configure prompt, refreshes the unit, and -runs `systemctl --user restart maestro` instead of `start`. No `--update` -flag needed. **The restart keeps your sessions:** the unit sets +**Updating is the same command.** Re-running `./install.sh` detects an existing +systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead +of `start`. No `--update` flag needed. **The restart keeps your sessions:** the unit sets `KillMode=process`, so systemd signals only the daemon — the tmux server (and every session) survives, and the new daemon re-adopts them on start. A unit written before this default lacks the line; re-run `./install.sh` to refresh it. @@ -43,11 +41,9 @@ The wizard: 1. Runs `cargo install` for the unified `maestro` crate (pinned to the committed `Cargo.lock` via `--locked`). The same binary hosts the CLI, - the per-host daemon (`maestro daemon`), and the always-on registry - (`maestro registry`), gated by Cargo features. -2. Optionally runs `maestro daemon configure` to populate `~/.config/maestro/config.toml` - (one shared TOML for the CLI, daemon, registry, and web). -3. On systemd hosts, optionally writes a user unit at + the per-host daemon (`maestro daemon`), and the TUI, gated by Cargo + features. +2. On systemd hosts, optionally writes a user unit at `~/.config/systemd/user/maestro.service`, reloads systemd, and offers to enable + start it. @@ -55,15 +51,11 @@ The binary lands in `~/.cargo/bin/maestro` — make sure that's on your `$PATH`. ## Thin builds for single-role hosts -The default install pulls in everything (`cli` + `daemon` + `registry` + `tui`). -Single-role hosts can opt out of unused dependencies: +The default install pulls in everything (`cli` + `daemon` + `tui`). Single-role +hosts can opt out of unused dependencies: ```sh -# Registry server — no tmux/PTY/jwt-verifier code: -cargo install --git https://git.naps.pt/naps62/maestro \ - --no-default-features --features registry --locked maestro - -# Daemon-only host — no embedded web assets, no registry HTTP surface: +# Daemon-only host — no CLI or TUI: cargo install --git https://git.naps.pt/naps62/maestro \ --no-default-features --features daemon --locked maestro @@ -81,8 +73,7 @@ in the default feature set, so a vanilla `cargo install maestro` builds it. Drop the `tui` feature to skip it on hosts that don't need the UI surface (`--no-default-features --features cli,daemon`). -It talks only to the **local** `maestro daemon` over `http://127.0.0.1:7070` -by default; peer daemons reach you transparently via the catalog mirror. +It talks to the local `maestro daemon` over `http://127.0.0.1:7070` by default. Override the daemon URL with `--daemon-url` or `$MAESTRO_DAEMON_URL`. The `[tui]` section in `~/.config/maestro/config.toml` (or whatever path you @@ -117,8 +108,8 @@ The full action vocabulary lives in `maestro-web` is a separate binary that serves the SPA bundle and reverse- proxies API calls to a co-located `maestro daemon`. -**Interactive install (default path).** The wizard now offers it as an -optional step right after the daemon flow: +**Interactive install (default path).** The wizard offers it as an optional +step right after the daemon flow: ```text Optional: install the browser UI (`maestro-web`)? @@ -150,9 +141,11 @@ http://127.0.0.1:7070`. The `[web]` section of `~/.config/maestro/config.toml` overrides both — see `crates/maestro-web/README.md`. -**Trust model.** Anyone who can reach the bind address can use it. Pick -`127.0.0.1` for local-only, a WireGuard/LAN address for one-segment access, -never a public IP without adding auth first. +**Trust model.** There is no authentication layer: anyone who can reach the +bind address (of either the daemon or `maestro-web`) can drive every session on +the host. Pick `127.0.0.1` for local-only, a WireGuard/LAN address for +one-segment access, and never a public IP — the network perimeter is the only +boundary Maestro has. Headless hosts (servers, dev VMs) skip the prompt with `--no-web`. Hosts where you want browser access answer yes — both binaries end up in @@ -168,10 +161,9 @@ where you want browser access answer yes — both binaries end up in | `--no-locked` | Drop `--locked` from `cargo install` (resolves a fresh dep tree). | | `--exec-start ` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestro daemon`). | | `--no-systemd` | Skip the systemd step even if detected. | -| `--no-configure` | Skip the `maestro daemon configure` step. | | `--no-web` | Skip the optional `maestro-web` install step. | | `--web-exec-start ` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). | -| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-run `configure` or auto-start the unit (those need real config). Also skips the web step unless an existing `maestro-web.service` is being refreshed. | +| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-start the unit. Also skips the web step unless an existing `maestro-web.service` is being refreshed. | ## Where tmux sockets land @@ -190,44 +182,6 @@ Pick `/tmp` if the daemon must outlive your login session and you can't run tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes precedence over the file. -## Cross-host attach (perimeter setup) - -Attaching to a session that lives on **another** host is brokered over the "T2" -transport, which only works when both daemons advertise a **matching -`perimeter_id`** and the session's owner advertises a **reachable -`private_addr`**. These default to unset, and without them every cross-host -attach fails — the registry resolves the session but returns no perimeter -candidate, so the attaching daemon rejects it (`no_perimeter_candidate`) and the -browser pane shows the reason and closes. Same-host attach is unaffected. - -Set both on **every** host in `~/.config/maestro/config.toml`: - -```toml -[daemon] -# Identical on every daemon that should reach each other — name it after your -# VPN/LAN segment. Two daemons broker to each other only when these match. -perimeter_id = "wg-home" -# THIS host's address as its peers reach it on that perimeter (host:port of the -# daemon's bind_addr). Per-host — never the same value on two daemons. -private_addr = "10.0.0.7:7070" -``` - -It's both-or-neither: setting only one is ignored with a startup warning. A -daemon started with no perimeter configured logs a `WARN` saying cross-host -attach is unavailable. (T1 direct-WSS was dropped — NAP-235 — and T3 relay isn't -implemented — NAP-238 — so T2 is currently the only cross-host path.) - -## Peer attach trust model - -There is no per-peer allow-list. The operator's network perimeter (VPN/firewall) -is the trust boundary for *which hosts can connect*; every daemon reachable on -the perimeter may broker-attach to any other. Attach is still authenticated: the -registry mints a short-lived, pair-scoped JWT bound to the target daemon and the -specific session (`iss`/`aud`/`sid`/`exp`), which the target verifies — so a -reachable host still can't attach to a session it wasn't handed, or replay a -token elsewhere. (NAP-339 dropped the earlier `[daemon.policy].allow_inbound` -gate; re-introducing a per-pair allow-list later is a verify-time change.) - ## What the systemd unit looks like The wizard writes a *user* unit (`systemctl --user`), since `maestro daemon` @@ -273,13 +227,16 @@ script avoids that by installing to a scratch dir. ```sh cargo install --git https://git.naps.pt/naps62/maestro --locked maestro -maestro daemon configure maestro daemon # foreground; or wire your own service manager ``` +Every setting has a default, so `~/.config/maestro/config.toml` is optional — +create it only to change the bind address, worktree root, spawn presets, or TUI +keymap. + ## Non-Linux hosts -The wizard works on macOS for the `cargo install` + `maestro daemon configure` -parts. The systemd step is automatically skipped (probe of +The wizard works on macOS for the `cargo install` part. The systemd step is +automatically skipped (probe of `/run/systemd/system`). launchd integration is not yet supported — start `maestro daemon` from your shell or your own service manager. diff --git a/docs/superpowers/specs/2026-05-24-maestro-design.md b/docs/superpowers/specs/2026-05-24-maestro-design.md index 0aded2a..947f828 100644 --- a/docs/superpowers/specs/2026-05-24-maestro-design.md +++ b/docs/superpowers/specs/2026-05-24-maestro-design.md @@ -4,6 +4,13 @@ **Status**: Approved (brainstorm) **Scope**: First-pass prototype. No production polish. +> **Superseded (2026-08-22).** Maestro is now single-host: one daemon per +> machine, clients (CLI, TUI, web) talking straight to it, no registry, no +> peer-to-peer attach, and no authentication layer — reaching the daemon's bind +> address is the authorization. Everything below about the registry, the +> daemon↔registry control link, JWTs, cookies, and cross-host transports is +> historical. `CLAUDE.md` describes the current architecture. + ## What this is A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about: workstation, desktop, dev VM, always-on VMs, and small LXC containers. diff --git a/docs/superpowers/specs/2026-05-28-maestro-tui-design.md b/docs/superpowers/specs/2026-05-28-maestro-tui-design.md index a38ceed..b34581d 100644 --- a/docs/superpowers/specs/2026-05-28-maestro-tui-design.md +++ b/docs/superpowers/specs/2026-05-28-maestro-tui-design.md @@ -7,6 +7,8 @@ - `docs/superpowers/specs/2026-05-26-connection-orchestration.md` — broker / catalog-mirror surface this TUI rides on - NAP-230 (CLI attach footer) — adjacent, separate; NAP-244 (CLI through broker) — orthogonal +> **Partly superseded (2026-08-22).** Maestro is now single-host — the registry and cross-host catalog it references no longer exist. See `CLAUDE.md`. + ## 1. Goals & non-goals ### Goals diff --git a/docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md b/docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md index c875af6..a4672e2 100644 --- a/docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md +++ b/docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md @@ -3,6 +3,8 @@ **Date:** 2026-05-28 **Status:** Approved (design) +> **Partly superseded (2026-08-22).** Maestro is now single-host — the bundle is embedded in `maestro-web`, not a registry binary. See `CLAUDE.md`. + ## Problem `maestro-web` embeds the SPA bundle at **compile time** via diff --git a/docs/superpowers/specs/2026-06-15-spawn-dir-autocomplete-design.md b/docs/superpowers/specs/2026-06-15-spawn-dir-autocomplete-design.md index 0f809b1..07eb701 100644 --- a/docs/superpowers/specs/2026-06-15-spawn-dir-autocomplete-design.md +++ b/docs/superpowers/specs/2026-06-15-spawn-dir-autocomplete-design.md @@ -1,5 +1,7 @@ # Spawn form: home-dir prefill + live directory autocomplete (NAP-329) +> **Partly superseded (2026-08-22).** Maestro is now single-host — the endpoint is now `GET /api/dirs` with no daemon id in the path. See `CLAUDE.md`. + ## Problem The web spawn form's **Working directory** field is `required` but offers no diff --git a/docs/superpowers/specs/2026-06-18-maestro-updates-design.md b/docs/superpowers/specs/2026-06-18-maestro-updates-design.md index fd93cc3..87ac48a 100644 --- a/docs/superpowers/specs/2026-06-18-maestro-updates-design.md +++ b/docs/superpowers/specs/2026-06-18-maestro-updates-design.md @@ -5,6 +5,8 @@ **Depends on**: NAP-330 (`KillMode=process` — restart without session loss). **Done.** **Touches**: NAP-225 (installer / systemd unit), the "prebuilt binaries for thin hosts" idea. +> **Partly superseded (2026-08-22).** Maestro is now single-host — the registry it references no longer exists. See `CLAUDE.md`. + ## What this is Turn today's yolo-local update hack into a first-class, cross-host maestro diff --git a/web/README.md b/web/README.md index 73ec70f..2dec9ca 100644 --- a/web/README.md +++ b/web/README.md @@ -1,7 +1,7 @@ # maestro-web -Browser UI for Maestro. Connects to the registry HTTP/WS API for the catalog -and to individual daemons (via direct WebSocket) for terminal I/O. +Browser UI for Maestro. Talks to a single local daemon over its `/api/*` +HTTP/WS surface — session catalog, spawn/kill, and terminal I/O. ## Framework choice: vanilla TypeScript @@ -11,17 +11,17 @@ rendering; everything else is a session tree and a spawn form, both trivial in plain DOM. This decision is non-architectural — swap in a framework later without -touching the registry or daemon if needed. +touching the daemon if needed. ## Layout ``` src/ main.ts # entry point, hash-route dispatch - api.ts # registry HTTP API client + api.ts # daemon HTTP API client events.ts # `/api/events` WebSocket subscriber - catalog.ts # session tree view (groups by metadata.label) - attach.ts # xterm.js + daemon WS attach view + catalog.ts # session tree view (groups by working directory) + attach.ts # xterm.js + attach WS view spawn.ts # spawn-session modal theme.ts # dark/light theme toggle + persistence types.ts # mirror of `maestro-protocol` JSON shapes @@ -39,40 +39,34 @@ follows the OS by default and can be pinned via the header toggle (persisted in `localStorage`). `mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev -server with `?mock` to seed a representative catalog without a running registry, +server with `?mock` to seed a representative catalog without a running daemon, `?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme. Routing is hash-based: - `#/` → catalog - `#/attach/` → terminal -This keeps the registry's static handler dumb (no SPA fallback needed). -Concurrent attaches: open the same `#/attach/` URL in another tab. +This keeps the static handler dumb (no SPA fallback needed). Concurrent +attaches: open the same `#/attach/` URL in another tab. ## Dev ```sh pnpm install -pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:8080 -pnpm build # → dist/, embedded into the registry binary at cargo build time +pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:7070 +pnpm build # → dist/, embedded into the maestro-web binary at cargo build time ``` -The Vite dev server proxies `/api` and the events WebSocket to a -`maestro-registry`. Default is `http://127.0.0.1:8080`; point it elsewhere by -exporting `MAESTRO_REGISTRY` before `pnpm dev`: +The Vite dev server proxies `/api` and the WebSockets to a local daemon. +Default is `http://127.0.0.1:7070`; point it elsewhere by exporting +`MAESTRO_DAEMON_URL` before `pnpm dev`: ```sh -MAESTRO_REGISTRY=https://maestro-registry.example.com pnpm dev -# Daemon-facing URL also works — `wss://...` is rewritten to `https://...` -# and a trailing `/daemon` is stripped. -MAESTRO_REGISTRY=wss://maestro-registry.example.com/daemon pnpm dev +MAESTRO_DAEMON_URL=http://laptop.lan:7070 pnpm dev ``` -Direct daemon WS connections (for attach) bypass the proxy and use the URL -returned by `GET /api/sessions/:id/connect-info`. - ## Production -`pnpm build` writes the bundle to `web/dist/`. The Rust registry crate -embeds that directory via `rust-embed` at compile time and serves it from -`/`. See `crates/maestro-registry/src/web_assets.rs`. +`pnpm build` writes the bundle to `web/dist/`. The `maestro-web` crate embeds +that directory at compile time and serves it from `/`, reverse-proxying `/api/*` +to the daemon. See `crates/maestro-web/src/static_bundle.rs`. diff --git a/web/src/main.ts b/web/src/main.ts index 601fb2e..d3f4362 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -50,7 +50,7 @@ const store = new CatalogStore(); store.start(); // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed -// without a running registry. Stripped from production builds by Vite. +// without a running daemon. Stripped from production builds by Vite. if (import.meta.env.DEV && location.search.includes("mock")) { void import("./mock.js").then((m) => m.seedMock(store)); } diff --git a/web/vite.config.ts b/web/vite.config.ts index b4fe962..7357e07 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,23 +1,21 @@ import { defineConfig, loadEnv } from "vite"; // Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a -// local `maestro-registry` on 127.0.0.1:8080. Override by exporting either: -// MAESTRO_REGISTRY=https://maestro-registry.example.com (preferred) -// MAESTRO_REGISTRY=wss://maestro-registry.example.com/daemon -// The second form (the daemon-facing URL) is normalised: ws/wss → http/https -// and any trailing `/daemon` is stripped, since the proxy needs the base URL. +// local daemon on 127.0.0.1:7070. Override with: +// MAESTRO_DAEMON_URL=http://laptop.lan:7070 +// `ws://`/`wss://` forms are normalised to http/https, since the proxy needs an +// HTTP base URL. function resolveProxyTarget(raw: string | undefined): string { - if (!raw) return "http://127.0.0.1:8080"; + if (!raw) return "http://127.0.0.1:7070"; return raw .replace(/^wss:\/\//, "https://") .replace(/^ws:\/\//, "http://") - .replace(/\/daemon\/?$/, "") .replace(/\/+$/, ""); } export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); - const target = resolveProxyTarget(env.MAESTRO_REGISTRY); + const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL); const secure = target.startsWith("https://"); return { build: {