docs(maestro): describe the single-host architecture
ci / rust (push) Successful in 2m40s

Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.

The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
This commit is contained in:
Miguel Palhas
2026-08-22 10:26:49 +01:00
parent de097cf17f
commit c856333e4a
25 changed files with 156 additions and 326 deletions
-10
View File
@@ -1,10 +0,0 @@
target/
worktrees/
.git/
.github/
.claude/
docs/
web/node_modules/
web/dist/
*.md
.env*
+39 -20
View File
@@ -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.**
+9 -10
View File
@@ -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 <id>`, 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`.
-37
View File
@@ -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=<secret> 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"]
+1 -1
View File
@@ -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
+12 -13
View File
@@ -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.
+3 -3
View File
@@ -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<SocketAddr>,
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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};
+1
View File
@@ -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<Uuid>,
State(s): State<LocalApiState>,
+4 -4
View File
@@ -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<T, F>(f: F) -> T
where
+1
View File
@@ -59,6 +59,7 @@ async fn spawn_daemon_ws() -> Harness {
}
}
#[allow(clippy::result_large_err)]
async fn connect_attach(
addr: SocketAddr,
session: Uuid,
+1 -3
View File
@@ -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 <list>`. 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 <list>`.
pub fn cargo_install_args(
source: &CargoSource,
crate_name: &str,
+17 -122
View File
@@ -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<dyn std::error::Error>> {
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<PathBuf> {
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<bool, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
fn handle_systemd(args: &Args, interactive: bool) -> Result<(), Box<dyn std::error::Error>> {
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<bool> {
})
}
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 <URL>`");
eprintln!(" • Open the TUI: `maestro tui`");
if web_installed {
if systemd_enabled {
eprintln!(
+1 -1
View File
@@ -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
+1
View File
@@ -142,6 +142,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
#[cfg(feature = "cli")]
#[allow(clippy::result_large_err)]
async fn cli_dispatch(
command: maestro_cli::cli::Command,
config: Option<PathBuf>,
+24 -67
View File
@@ -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 <path>` | 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 <path>` | 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.
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
+18 -24
View File
@@ -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/<session-id>` → terminal
This keeps the registry's static handler dumb (no SPA fallback needed).
Concurrent attaches: open the same `#/attach/<id>` URL in another tab.
This keeps the static handler dumb (no SPA fallback needed). Concurrent
attaches: open the same `#/attach/<id>` 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`.
+1 -1
View File
@@ -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));
}
+6 -8
View File
@@ -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: {