Merge remote-tracking branch 'origin/main' into nix
ci / rust (push) Successful in 3m7s

This commit is contained in:
Miguel Palhas
2026-08-22 10:56:21 +01:00
24 changed files with 18 additions and 5297 deletions
-4
View File
@@ -49,7 +49,6 @@ crates/
├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245) ├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245)
├── maestro-protocol/ # shared wire types (NAP-211) ├── maestro-protocol/ # shared wire types (NAP-211)
├── maestro-config/ # shared on-disk config schema (NAP-233) ├── maestro-config/ # shared on-disk config schema (NAP-233)
├── maestro-names/ # orchestra-themed session-handle generator (#23)
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216) ├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214) ├── 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-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
@@ -63,9 +62,6 @@ nix/ # Home Manager module (`services.maestro`)
See `docs/INSTALL.md` for end-user install instructions. 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 ## Conventions
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together. - **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
Generated
-8
View File
@@ -1077,7 +1077,6 @@ dependencies = [
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"urlencoding",
"uuid", "uuid",
] ]
@@ -1147,13 +1146,6 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "maestro-names"
version = "0.1.0"
dependencies = [
"rand 0.8.6",
]
[[package]] [[package]]
name = "maestro-protocol" name = "maestro-protocol"
version = "0.1.0" version = "0.1.0"
-2
View File
@@ -18,13 +18,11 @@ dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2" libc = "0.2"
maestro-config = { path = "crates/maestro-config" } maestro-config = { path = "crates/maestro-config" }
maestro-names = { path = "crates/maestro-names" }
maestro-protocol = { path = "crates/maestro-protocol" } maestro-protocol = { path = "crates/maestro-protocol" }
maestro-tui = { path = "crates/maestro-tui" } maestro-tui = { path = "crates/maestro-tui" }
nix = { version = "0.29", default-features = false, features = ["term", "signal"] } nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
rand = "0.8" rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
rust-embed = "8"
include_dir = "0.7" include_dir = "0.7"
mime_guess = "2" mime_guess = "2"
bytes = "1" bytes = "1"
-1
View File
@@ -28,7 +28,6 @@ tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
toml.workspace = true toml.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
urlencoding = "2"
uuid.workspace = true uuid.workspace = true
[dev-dependencies] [dev-dependencies]
+1 -1
View File
@@ -45,7 +45,7 @@ impl CliConfig {
/// Load the subset of the unified file the CLI consumes. A missing file is /// Load the subset of the unified file the CLI consumes. A missing file is
/// fine — every field has a default. /// fine — every field has a default.
pub fn load(path: &Path) -> Result<Self, ConfigError> { pub fn load(path: &Path) -> Result<Self, ConfigError> {
let file = MaestroConfigFile::load_or_default(path)?; let file = MaestroConfigFile::load(path)?;
Ok(Self { Ok(Self {
daemon_url: file.cli.daemon_url, daemon_url: file.cli.daemon_url,
daemon_bind_addr: file.daemon.bind_addr, daemon_bind_addr: file.daemon.bind_addr,
+5 -31
View File
@@ -41,12 +41,6 @@ pub enum ConfigError {
TomlEncode(#[from] toml::ser::Error), TomlEncode(#[from] toml::ser::Error),
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")] #[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
NoConfigDir, NoConfigDir,
#[error(
"maestro config not found at {}\n\
Run `maestro daemon configure` to create it.",
path.display()
)]
MissingFile { path: PathBuf },
#[error("invalid {env} ({input:?}): {source}")] #[error("invalid {env} ({input:?}): {source}")]
InvalidBindAddr { InvalidBindAddr {
env: &'static str, env: &'static str,
@@ -314,27 +308,17 @@ fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> {
} }
impl MaestroConfigFile { impl MaestroConfigFile {
/// Strict load — error with [`ConfigError::MissingFile`] if absent. /// Load the file, or return an empty config if it doesn't exist. Every
/// field has a default, so a missing file is never an error.
pub fn load(path: &Path) -> Result<Self, ConfigError> { pub fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() { if !path.exists() {
return Err(ConfigError::MissingFile { return Ok(Self::default());
path: path.to_path_buf(),
});
} }
let bytes = std::fs::read(path)?; let bytes = std::fs::read(path)?;
let s = std::str::from_utf8(&bytes).unwrap_or(""); let s = std::str::from_utf8(&bytes).unwrap_or("");
Ok(toml::from_str(s)?) Ok(toml::from_str(s)?)
} }
/// Load if present, else return an empty config. Use for callers that
/// want a default-on-missing read.
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
Self::load(path)
}
/// Atomic write (tmp + rename). Creates parent dirs on demand. /// Atomic write (tmp + rename). Creates parent dirs on demand.
pub fn save(&self, path: &Path) -> Result<(), ConfigError> { pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -447,20 +431,10 @@ mod tests {
} }
#[test] #[test]
fn missing_file_strict_load_errors_with_pointer_to_configure() { fn missing_file_loads_as_empty_config() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml"); let path = dir.path().join("config.toml");
let err = MaestroConfigFile::load(&path).unwrap_err(); let cfg = MaestroConfigFile::load(&path).unwrap();
let msg = err.to_string();
assert!(matches!(err, ConfigError::MissingFile { .. }));
assert!(msg.contains("maestro daemon configure"), "msg was: {msg}");
}
#[test]
fn missing_file_load_or_default_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile::load_or_default(&path).unwrap();
assert_eq!(cfg, MaestroConfigFile::default()); assert_eq!(cfg, MaestroConfigFile::default());
} }
+1 -1
View File
@@ -111,7 +111,7 @@ impl EnvOverrides {
/// Resolve the runtime configuration: env overrides on top of the unified /// Resolve the runtime configuration: env overrides on top of the unified
/// file, then defaults. A missing file is not an error. /// file, then defaults. A missing file is not an error.
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> { pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
let file = MaestroConfigFile::load_or_default(path)?; let file = MaestroConfigFile::load(path)?;
let daemon = file.daemon; let daemon = file.daemon;
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id, // Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "maestro-names"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Orchestra-flavoured session-handle generator (adjective × instrument) shared across Maestro"
[dependencies]
rand.workspace = true
[dev-dependencies]
rand = { workspace = true, features = ["small_rng"] }
-214
View File
@@ -1,214 +0,0 @@
//! `maestro-names` — the shared session-handle generator.
//!
//! Maestro is a conductor's podium for every shell you care about, so the
//! handles it hands out are drawn from the orchestra pit: a musical adjective
//! (tempo, dynamics, articulation) paired with an instrument or musical form.
//! The result reads like a marking on a score — `allegro-cello`,
//! `crescendo-oboe`, `staccato-viola`.
//!
//! ## Determinism
//!
//! Every pure path takes an **injected** [`rand::Rng`]; nothing here reaches
//! for `thread_rng()` or the clock, so seeded tests are reproducible (a repo
//! invariant). [`generate_thread`] is the one impure convenience wrapper for
//! production callers that just want a name and don't care about the seed.
//!
//! The Rust generator is the source of truth. If the web UI wants an instant
//! client-side reroll it can mirror [`ADJECTIVES`] / [`NOUNS`] verbatim, but
//! the wordlists live here.
use rand::seq::SliceRandom;
use rand::Rng;
/// Musical adjectives — tempo, dynamics, and articulation markings.
pub const ADJECTIVES: &[&str] = &[
"allegro",
"andante",
"adagio",
"presto",
"vivace",
"largo",
"grave",
"lento",
"moderato",
"crescendo",
"staccato",
"legato",
"forte",
"dolce",
"agitato",
"cantabile",
"sostenuto",
"tremolo",
"rubato",
"marcato",
"brillante",
"maestoso",
"grazioso",
"espressivo",
"tranquillo",
"animato",
"spirito",
"sotto",
"vivo",
"feroce",
];
/// Musical nouns — instruments and forms from the concert hall.
pub const NOUNS: &[&str] = &[
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
"motif", "overture",
];
/// Generate a single `adjective-noun` handle from an injected RNG.
///
/// ```
/// use rand::SeedableRng;
/// let mut rng = rand::rngs::SmallRng::seed_from_u64(7);
/// let name = maestro_names::generate(&mut rng);
/// assert!(name.contains('-'));
/// ```
pub fn generate<R: Rng + ?Sized>(rng: &mut R) -> String {
// Both lists are compile-time non-empty (asserted in tests), so the
// `choose` calls cannot return `None`.
let adj = ADJECTIVES.choose(rng).expect("ADJECTIVES is non-empty");
let noun = NOUNS.choose(rng).expect("NOUNS is non-empty");
format!("{adj}-{noun}")
}
/// Generate a handle that isn't already taken.
///
/// Draws `adjective-noun` candidates from `rng`; if `is_taken` rejects one, a
/// numeric uniqueness suffix is appended and incremented (`-2`, `-3`, …) until
/// a free handle is found. The bare form is preferred — the suffix only
/// appears on collision, matching the score-marking aesthetic.
///
/// `is_taken` is the single source of "already used"; given the same RNG and
/// the same predicate the output is fully deterministic.
pub fn generate_unique<R, F>(rng: &mut R, is_taken: F) -> String
where
R: Rng + ?Sized,
F: Fn(&str) -> bool,
{
let base = generate(rng);
if !is_taken(&base) {
return base;
}
// Start at 2 so the first duplicate reads `name`, `name-2`, `name-3` …
for suffix in 2u64.. {
let candidate = format!("{base}-{suffix}");
if !is_taken(&candidate) {
return candidate;
}
}
unreachable!("u64 suffix space is effectively inexhaustible")
}
/// Impure convenience wrapper: generate a handle from the thread RNG.
///
/// For production callers that just want a name. Tests and any deterministic
/// path should use [`generate`] / [`generate_unique`] with a seeded RNG.
pub fn generate_thread() -> String {
generate(&mut rand::thread_rng())
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use std::collections::HashSet;
fn seeded(seed: u64) -> SmallRng {
SmallRng::seed_from_u64(seed)
}
#[test]
fn wordlists_are_non_empty() {
assert!(!ADJECTIVES.is_empty());
assert!(!NOUNS.is_empty());
}
#[test]
fn wordlists_have_no_duplicates() {
assert_eq!(
ADJECTIVES.iter().collect::<HashSet<_>>().len(),
ADJECTIVES.len(),
"duplicate adjective"
);
assert_eq!(
NOUNS.iter().collect::<HashSet<_>>().len(),
NOUNS.len(),
"duplicate noun"
);
}
#[test]
fn generate_is_adjective_dash_noun() {
let mut rng = seeded(1);
let name = generate(&mut rng);
let (adj, noun) = name.split_once('-').expect("must contain a single dash");
assert!(ADJECTIVES.contains(&adj), "{adj} not an adjective");
assert!(NOUNS.contains(&noun), "{noun} not a noun");
}
#[test]
fn generation_is_deterministic_under_seed() {
// Same seed → identical sequence; this is the core repo invariant.
let mut a = seeded(42);
let mut b = seeded(42);
for _ in 0..50 {
assert_eq!(generate(&mut a), generate(&mut b));
}
}
#[test]
fn different_seeds_diverge() {
// Not a guarantee for a single draw, but across a run the sequences
// must differ — otherwise the RNG isn't actually being threaded.
let mut a = seeded(1);
let mut b = seeded(2);
let seq_a: Vec<_> = (0..30).map(|_| generate(&mut a)).collect();
let seq_b: Vec<_> = (0..30).map(|_| generate(&mut b)).collect();
assert_ne!(seq_a, seq_b);
}
#[test]
fn unique_returns_bare_name_when_free() {
let mut rng = seeded(3);
let name = generate_unique(&mut rng, |_| false);
assert!(
!name.ends_with("-2"),
"no suffix expected when nothing taken"
);
assert!(name.contains('-'));
}
#[test]
fn unique_appends_incrementing_suffix_on_collision() {
// Reserve the bare name and the first two suffixed variants; the
// generator must walk past them to `-4`.
let mut probe = seeded(9);
let base = generate(&mut probe);
let taken: HashSet<String> = [base.clone(), format!("{base}-2"), format!("{base}-3")]
.into_iter()
.collect();
let mut rng = seeded(9);
let name = generate_unique(&mut rng, |n| taken.contains(n));
assert_eq!(name, format!("{base}-4"));
}
#[test]
fn unique_suffix_starts_at_two() {
let mut probe = seeded(11);
let base = generate(&mut probe);
let taken: HashSet<String> = [base.clone()].into_iter().collect();
let mut rng = seeded(11);
let name = generate_unique(&mut rng, |n| taken.contains(n));
assert_eq!(name, format!("{base}-2"));
}
}
+1 -4
View File
@@ -1,7 +1,4 @@
//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface. //! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
//!
//! Design: `docs/superpowers/specs/2026-05-28-maestro-tui-design.md`
//! Implementation: `docs/superpowers/plans/2026-05-28-maestro-tui.md`
#![deny(missing_debug_implementations)] #![deny(missing_debug_implementations)]
@@ -74,7 +71,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
maestro_config::resolve_config_path().map_err(|e| TuiError::Config(e.to_string())) maestro_config::resolve_config_path().map_err(|e| TuiError::Config(e.to_string()))
}); });
let cfg_file = match cfg_path { let cfg_file = match cfg_path {
Ok(path) => maestro_config::MaestroConfigFile::load_or_default(&path) Ok(path) => maestro_config::MaestroConfigFile::load(&path)
.map_err(|e| TuiError::Config(e.to_string()))?, .map_err(|e| TuiError::Config(e.to_string()))?,
Err(_) => maestro_config::MaestroConfigFile::default(), Err(_) => maestro_config::MaestroConfigFile::default(),
}; };
+1 -1
View File
@@ -79,5 +79,5 @@ pnpm dev
``` ```
Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at
`127.0.0.1:7070` (override with `MAESTRO_REGISTRY=...`). You don't need to `127.0.0.1:7070` (override with `MAESTRO_DAEMON_URL=...`). You don't need to
rebuild the Rust binary while iterating on the frontend. rebuild the Rust binary while iterating on the frontend.
+1 -5
View File
@@ -59,11 +59,7 @@ impl EnvOverrides {
/// Resolve the runtime configuration. Missing-file is fine: every field has /// Resolve the runtime configuration. Missing-file is fine: every field has
/// a default and the binary is happy to run with no config.toml at all. /// a default and the binary is happy to run with no config.toml at all.
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<WebConfig, WebConfigError> { pub fn load_config(path: &Path, env: EnvOverrides) -> Result<WebConfig, WebConfigError> {
let file = if path.exists() { let file = MaestroConfigFile::load(path)?;
MaestroConfigFile::load(path)?
} else {
MaestroConfigFile::default()
};
resolve_with(file.web, env) resolve_with(file.web, env)
} }
-3
View File
@@ -1,8 +1,5 @@
//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI //! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI
//! and reverse-proxies API calls to a co-located `maestrod`. //! and reverse-proxies API calls to a co-located `maestrod`.
//!
//! See `docs/superpowers/specs/2026-05-26-connection-orchestration.md` §UI
//! hosting for the architectural decisions.
#![deny(missing_debug_implementations)] #![deny(missing_debug_implementations)]
+2 -2
View File
@@ -149,8 +149,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
# want extra Insert-mode bindings) # want extra Insert-mode bindings)
``` ```
The full action vocabulary lives in The full action vocabulary is the `Action` enum in
`docs/superpowers/specs/2026-05-28-maestro-tui-design.md` §3.8. `crates/maestro-tui/src/action.rs`.
## Adding the browser UI (optional) ## Adding the browser UI (optional)
File diff suppressed because it is too large Load Diff
@@ -1,403 +0,0 @@
# maestro-web Bundle Build Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make every compiled `maestro-web` binary embed a real SPA bundle by building the frontend in `build.rs`, so no install method ever serves the "bundle not built" placeholder.
**Architecture:** A new `crates/maestro-web/build.rs` runs `pnpm install --frozen-lockfile && pnpm build` in `web/` before `include_dir!` embeds `web/dist`; missing pnpm/node hard-fails the build. The installer wizard preflights the toolchain at its opt-in web step and prompts abort/skip when it's absent. CI gains the Node + pnpm toolchain.
**Tech Stack:** Rust (cargo build scripts, `std::process::Command`), pnpm 9.15.4 + Node, Gitea Actions CI.
**Spec:** `docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md`
---
## File Structure
- **Create** `crates/maestro-web/build.rs` — builds the SPA bundle and declares frontend rerun triggers. Sole responsibility: guarantee `web/dist` is fresh before compile.
- **Modify** `crates/maestro-installer/src/main.rs` — add a pure `classify_web_prereq` decision + `frontend_toolchain_present` probe, and wire the abort/skip prompt into `handle_web`.
- **Modify** `.gitea/workflows/ci.yml` — install Node + pnpm so the workspace compiles `maestro-web`.
No changes to `static_bundle.rs` (the placeholder stays as unreachable defense-in-depth).
---
## Task 1: build.rs builds the SPA bundle at compile time
**Files:**
- Create: `crates/maestro-web/build.rs`
- Test: `crates/maestro-web/tests/embedded_bundle.rs`
- [ ] **Step 1: Write the failing test**
Create `crates/maestro-web/tests/embedded_bundle.rs`. It asserts the embedded
index is the real SPA (which mounts `<div id="app">`), not the placeholder
(whose `<h1>` reads "maestro-web is running"). `serve_index` is public in
`maestro_web::static_bundle` via `lib.rs`.
```rust
//! The compiled binary must embed the real SPA bundle, never the placeholder.
//! `build.rs` guarantees `web/dist` is populated before `include_dir!` runs.
use axum::body::to_bytes;
use maestro_web::static_bundle::serve_index;
#[tokio::test]
async fn embedded_index_is_real_spa_not_placeholder() {
let resp = serve_index();
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"id="app""#),
"served index is missing the SPA mount point; got:\n{html}"
);
assert!(
!html.contains("maestro-web is running"),
"served index is the placeholder, not the built bundle"
);
}
```
`static_bundle` is already `pub mod` in `crates/maestro-web/src/lib.rs` and `serve_index` is public, so no `lib.rs` change is needed. The `axum` and `tokio` dev-deps are already in `Cargo.toml`.
- [ ] **Step 2: Run the test and verify it fails**
First force an empty bundle so the test has a true red (this machine already has a built `web/dist`):
```bash
rm -rf web/dist && mkdir -p web/dist && touch web/dist/.gitkeep
cargo test -p maestro-web --test embedded_bundle
```
Expected: FAIL — `serve_index()` returns the placeholder, so the `id="app"` assertion fails. (Without `build.rs` yet, nothing rebuilds the bundle.)
- [ ] **Step 3: Write the build script**
Create `crates/maestro-web/build.rs`:
```rust
//! Build the embedded SPA bundle before the crate compiles.
//!
//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`.
//! This script guarantees that directory holds a freshly built bundle by
//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and
//! node are HARD requirements — a missing toolchain fails the build with an
//! actionable message rather than silently shipping the placeholder.
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
let web_dir = manifest_dir.join("../../web");
// Rebuild the bundle only when a frontend input changes — not on every
// unrelated Rust recompile.
for rel in ["src", "index.html", "package.json", "pnpm-lock.yaml", "vite.config.ts"] {
println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
}
require_tool("pnpm");
require_tool("node");
run(
Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&web_dir),
"pnpm install --frozen-lockfile",
);
run(
Command::new("pnpm")
.arg("build")
.current_dir(&web_dir),
"pnpm build",
);
}
fn require_tool(tool: &str) {
let ok = Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
panic!(
"maestro-web requires `{tool}` to build its embedded SPA bundle. \
Install pnpm + node (https://pnpm.io) or use a prebuilt binary."
);
}
}
fn run(cmd: &mut Command, label: &str) {
let status = cmd
.status()
.unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
if !status.success() {
panic!("`{label}` failed with {status}");
}
}
```
- [ ] **Step 4: Run the test and verify it passes**
```bash
cargo test -p maestro-web --test embedded_bundle
```
Expected: PASS — `build.rs` ran `pnpm build`, repopulating `web/dist`, so the
embedded index now contains `id="app"`. Confirm `web/dist/assets/` has the
built JS/CSS again.
- [ ] **Step 5: Lint**
```bash
cargo clippy -p maestro-web --all-targets -- -D warnings
cargo fmt --all
```
Expected: clean.
- [ ] **Step 6: Commit**
```bash
git add crates/maestro-web/build.rs crates/maestro-web/tests/embedded_bundle.rs
git commit -m "feat(maestro): build maestro-web SPA bundle in build.rs
include_dir! now embeds a freshly built web/dist on every compile; pnpm +
node are hard build requirements. Fixes the placeholder served by
cargo install --git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Installer preflights the frontend toolchain
**Files:**
- Modify: `crates/maestro-installer/src/main.rs` (add `WebPrereq` enum + `classify_web_prereq` + `frontend_toolchain_present`; wire into `handle_web` at the opt-in point, ~line 389–396)
- Test: `crates/maestro-installer/src/main.rs` (new `#[cfg(test)] mod tests`)
- [ ] **Step 1: Write the failing test**
Append to the end of `crates/maestro-installer/src/main.rs` (no test module exists yet):
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prereq_ok_when_toolchain_present() {
assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok);
assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok);
}
#[test]
fn prereq_prompts_when_missing_and_interactive() {
assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing);
}
#[test]
fn prereq_skips_when_missing_and_noninteractive() {
assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing);
}
}
```
- [ ] **Step 2: Run the test and verify it fails**
```bash
cargo test -p maestro-installer classify_web_prereq
```
Expected: FAIL to compile — `WebPrereq` and `classify_web_prereq` don't exist yet.
- [ ] **Step 3: Add the enum, decision function, and toolchain probe**
In `crates/maestro-installer/src/main.rs`, just above `fn handle_web(` (line 350), add:
```rust
/// What to do at the (opt-in) web step given whether pnpm + node are present.
#[derive(Debug, PartialEq, Eq)]
enum WebPrereq {
/// Toolchain present — install maestro-web normally.
Ok,
/// Missing and we can ask: prompt the operator to abort or skip.
PromptMissing,
/// Missing and we can't prompt (non-interactive refresh): warn + skip.
SkipMissing,
}
fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq {
if toolchain_present {
WebPrereq::Ok
} else if interactive {
WebPrereq::PromptMissing
} else {
WebPrereq::SkipMissing
}
}
/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's
/// build.rs needs them to build the embedded SPA bundle.
fn frontend_toolchain_present() -> bool {
["pnpm", "node"].iter().all(|tool| {
Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
}
```
- [ ] **Step 4: Run the unit test and verify it passes**
```bash
cargo test -p maestro-installer classify_web_prereq
```
Expected: PASS (3 tests).
- [ ] **Step 5: Wire the preflight into `handle_web`**
In `handle_web`, the existing flow is:
```rust
if !want_web {
return Ok(false);
}
// Install the binary. Even if cargo-install fails we keep going so the
// operator can fix the cause and re-run; the existing daemon work
// doesn't need to be undone.
let source = build_source(args);
```
Insert the preflight between the `if !want_web` block and `let source =`:
```rust
if !want_web {
return Ok(false);
}
// maestro-web's build.rs requires pnpm + node. Check before cargo install so
// a missing toolchain surfaces a clear choice instead of a raw build panic.
match classify_web_prereq(frontend_toolchain_present(), interactive) {
WebPrereq::Ok => {}
WebPrereq::PromptMissing => {
eprintln!("\nmaestro-web needs pnpm + node, which aren't installed.");
if confirm("Abort the whole install? (No = skip web UI)", false)? {
return Err(
"aborted: maestro-web prerequisites (pnpm + node) are missing".into(),
);
}
eprintln!("Skipping maestro-web install.");
return Ok(false);
}
WebPrereq::SkipMissing => {
eprintln!(
"\nSkipping maestro-web refresh: pnpm + node not installed. \
Existing unit left in place."
);
return Ok(false);
}
}
// Install the binary. Even if cargo-install fails we keep going so the
// operator can fix the cause and re-run; the existing daemon work
// doesn't need to be undone.
let source = build_source(args);
```
- [ ] **Step 6: Verify the crate builds, lints, and tests pass**
```bash
cargo build -p maestro-installer
cargo clippy -p maestro-installer --all-targets -- -D warnings
cargo test -p maestro-installer
cargo fmt --all
```
Expected: all clean/pass. (`Command` and `confirm` are already imported/defined in the file.)
- [ ] **Step 7: Commit**
```bash
git add crates/maestro-installer/src/main.rs
git commit -m "feat(maestro): preflight pnpm + node at installer web step
The opt-in maestro-web step now checks for the frontend toolchain its
build.rs requires; missing tools prompt abort/skip (interactive) or skip
the refresh (non-interactive) instead of failing cargo with a raw panic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: CI installs Node + pnpm
**Files:**
- Modify: `.gitea/workflows/ci.yml`
- [ ] **Step 1: Add a Node + pnpm step**
`clippy` and `test` compile `maestro-web`, which now needs the toolchain. After
the "Install rustup + stable toolchain" step (line 27) and before "Cache
cargo", add:
```yaml
- name: Install Node.js + pnpm
run: |
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y --no-install-recommends nodejs
corepack enable
corepack prepare pnpm@9.15.4 --activate
```
`corepack prepare pnpm@9.15.4` matches the `packageManager` pin in
`web/package.json`, keeping CI deterministic.
- [ ] **Step 2: Verify locally that the affected jobs compile**
CI can't be run locally, but confirm the commands the runner executes still
work on a machine with the toolchain:
```bash
cargo clippy --all-targets -- -D warnings
cargo test --all
```
Expected: clean / all pass (these now exercise `maestro-web`'s `build.rs`).
- [ ] **Step 3: Commit and push**
```bash
git add .gitea/workflows/ci.yml
git commit -m "ci(maestro): install node + pnpm for maestro-web build.rs
Compiling maestro-web now requires the frontend toolchain to build the
embedded SPA bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git push
```
- [ ] **Step 4: Confirm CI is green**
Poll the Gitea CI run for this push (per project preference: rely on CI rather
than long local builds). Expected: `ci / rust` job passes including the new
Node/pnpm step.
---
## Self-Review Notes
- **Spec coverage:** §1 build.rs → Task 1; §2 static_bundle unchanged → no task (intentional); §3 installer preflight (prompt abort/skip; non-interactive skip) → Task 2; §4 CI → Task 3; §5 Justfile unchanged → no task (intentional).
- **Type consistency:** `WebPrereq::{Ok,PromptMissing,SkipMissing}`, `classify_web_prereq(bool, bool)`, and `frontend_toolchain_present()` are used identically in the definition (Task 2 Step 3), the wiring (Step 5), and the tests (Step 1).
- **Placeholders:** none — every code/command step is concrete.
@@ -1,323 +0,0 @@
# Maestro — Prototype Design
**Date**: 2026-05-24
**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.
The interface stays a terminal (xterm.js in the browser for the prototype). Sessions persist via tmux on whichever host owns them. Maestro is a thin coordination layer on top, not a terminal-emulator rewrite.
## Why
Today: Claude Code runs locally for editing the host, and inside a dev VM for project work in dangerous mode. The VM uses agent-deck to keep sessions alive. Reaching those sessions means SSH-ing in first, no unified view, no side-by-side, and no phone access. Maestro removes the SSH-in step and adds a phone client.
## Success criterion for the prototype
A working demo where:
1. Laptop and phone both load the maestro web UI.
2. The session list shows tmux sessions from at least two hosts (laptop + dev VM).
3. A new session can be spawned from the UI on a chosen host, optionally backed by a fresh git worktree.
4. Both clients can attach to the same session concurrently and see live output.
5. Closing a client tab doesn't kill the session; reattaching resumes it.
UI polish, layout/tiling, edge-case terminals — all out of scope.
## Architecture
```
┌──────────────────────┐
│ Registry │
│ (always-on VM) │
│ │
│ • presence index │
│ • session catalog │
│ • serves web client │
└──────┬───────────────┘
│ WS (control only,
│ no PTY bytes)
┌──────────────────────────┼──────────────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Daemon │ │ Daemon │ │ Daemon │
│ (laptop) │ │ (dev VM) │ │ (LXC) │
│ │ │ │ │ │
│ tmux ↔ WS │ │ tmux ↔ WS │ │ tmux ↔ WS │
└──────▲──────┘ └──────▲──────┘ └──────▲──────┘
│ │ │
│ WS streams (PTY bytes, peer-to-peer over the operator's network)
│ │ │
└──────────────┬───────────┴──────────────┬───────────┘
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ Web client │ │ Web client │
│ (browser) │ │ (phone) │
└───────────────┘ └───────────────┘
```
Three components, all reachable over an operator-managed network (LAN, VPN, mesh — maestro is network-agnostic; the operator configures bind addresses and ensures clients can route to them):
- **Registry** — one always-on instance. Knows who's online and what sessions exist. Serves the web client bundle. Never touches PTY bytes.
- **Daemon (`maestro daemon`)** — one per host. Wraps local tmux. Streams sessions directly to clients over WebSocket.
- **Web client** — one TS bundle. Connects to the registry for the catalog, then directly to whichever daemon owns the session being attached.
The registry is a phone book, not a relay. This keeps it tiny and pushes byte traffic to a direct daemon ↔ client link. Confidentiality of that link is the operator's responsibility (Wireguard, a private LAN, mTLS in a future iteration — maestro itself does not encrypt at the transport layer beyond what the operator's network provides).
## Components
### Registry
Responsibilities:
- Accept persistent WS connections from daemons (presence + session-list updates).
- Issue short-lived JWTs to authenticated clients for daemon access.
- Serve the web client bundle and a small HTTP API.
- Broadcast catalog changes to connected web clients via a `/api/events` WS.
Non-responsibilities:
- No PTY bytes through this process.
- No persistent storage in the prototype. State is in-memory; daemons re-register on reconnect.
- No multi-user model. One shared secret, one effective user.
Runs as a single Rust binary on a designated VM. Listens on a configured `bind_addr` (default `127.0.0.1:8080`; operator sets the actual reachable address — e.g. a Wireguard interface IP — in `[registry].bind_addr` of the unified config at `~/.config/maestro/config.toml`).
### Daemon (`maestro daemon`)
Responsibilities:
- Connect to the registry on startup, identify with the shared token, advertise its current sessions.
- Maintain a tmux session list. Spawn / kill tmux sessions on demand.
- Expose a per-session WS endpoint streaming PTY bytes both directions.
- Handle git worktree creation on session create, when requested.
- Recover existing sessions on restart by scanning tmux for names matching its naming pattern.
Non-responsibilities:
- Doesn't adopt foreign tmux sessions (only the ones it created — names prefixed `maestro-<id>`).
- Doesn't relay to other daemons. It only knows about local tmux.
- Doesn't manage its own auth tokens; the registry issues them.
Runs as a single Rust binary on every host that holds sessions. Small footprint matters because some hosts are LXC containers. Listens on a configured `bind_addr` (default `127.0.0.1:8081`; operator sets the actual reachable address in `[daemon].bind_addr` of the unified config at `~/.config/maestro/config.toml`). Also requires `[shared].registry_url` so it knows where to register on startup.
### Web client
Responsibilities:
- Authenticate to the registry (cookie set on first visit; assumes the operator restricts who can reach the registry's bind address).
- Render the session catalog as a tree, using each session's `metadata.label` (e.g. `personal/maestro`) to derive grouping.
- Provide search across session names and labels.
- Open a WS to a daemon to attach to a chosen session; render via xterm.js.
- Support concurrent attaches (multiple tabs to the same session work).
- Spawn a new session through a small form: pick host, working dir, command, optional worktree.
Non-responsibilities (prototype):
- No split-pane / side-by-side. One session at a time per tab — but you can open two browser tabs to two sessions, which gets you side-by-side via the OS.
- No theming / settings UI.
- No mobile-specific UX beyond "works in mobile Chrome at a reasonable touch size."
TypeScript + xterm.js. Build tool TBD in implementation (Vite is the default); frontend framework choice is non-architectural and deferred to the plan.
## Wire protocol
Three pairs of conversations. All JSON unless otherwise noted.
### Daemon ↔ Registry — single persistent WS, daemon-initiated
| Direction | Message | Purpose |
|---|---|---|
| D→R | `Hello { daemon_id, hostname, version, token }` | identify, auth |
| D→R | `SessionsSnapshot { sessions: [Session] }` | full list at startup |
| D→R | `SessionAdded { session }` | diff |
| D→R | `SessionRemoved { id }` | diff |
| D→R | `SessionUpdated { session }` | diff |
| D→R | `Heartbeat` | every ~10s |
| D→R | `CreateSessionResult { reply_to, result }` | reply to a `CreateSession` request, carries the new `Session` or an error |
| D→R | `KillSessionResult { reply_to, result }` | reply to a `KillSession` request |
| R→D | `Ack` / `Error { code, message }` | acks, faults |
| R→D | `CreateSession { request, reply_to }` | registry asks daemon to spawn a session; `reply_to` correlates with the result |
| R→D | `KillSession { id, reply_to }` | registry asks daemon to kill; `reply_to` correlates with the result |
The HTTP `POST /api/sessions` handler in the registry holds the client request open while it sends `CreateSession` to the chosen daemon over the WS, then completes the response when it receives the matching `CreateSessionResult`. Same pattern for `DELETE`. Each request gets a fresh `reply_to` UUID. Timeout is 10s; after that the registry returns 504 to the client.
### Client ↔ Registry — HTTP for queries, one WS for live updates
| Endpoint | Purpose |
|---|---|
| `GET /` | static web client bundle |
| `GET /api/daemons` | list connected daemons |
| `GET /api/sessions` | flat session list |
| `POST /api/sessions` | spawn (request forwarded to chosen daemon) |
| `DELETE /api/sessions/:id` | kill (forwarded to owning daemon) |
| `GET /api/sessions/:id/connect-info` | returns `{ daemon_url, jwt }` for direct attach |
| `WS /api/events` | live catalog + presence deltas |
Auth: the registry binds its listener to the operator-configured `bind_addr` only, so packets from elsewhere never reach the process. The operator is expected to point this at a private interface (Wireguard, LAN, etc.) — maestro does not police the network itself. On first request, the registry sets an httpOnly cookie signed with the shared secret; all subsequent API calls require it. The cookie is the second factor against anyone who happens to have routing to the bind address.
### Client ↔ Daemon — one WS per attached session
`WS /sessions/:id/stream?token=<jwt>`
- **Text frames**: control JSON — `Resize { cols, rows }`, `Ping`, `Pong`, `Error { ... }`.
- **Binary frames**: raw PTY bytes, bidirectional.
JWT is short-lived (~5 min) and validated by the daemon using the shared secret. No callback to registry.
Daemon broadcasts incoming PTY output to all currently-attached client WS connections for that session.
## Session model
A `Session` is the only first-class entity. The daemon is the source of truth for its own sessions.
```rust
struct Session {
id: Uuid, // maestro's id; tmux session name is `maestro-<id>`
daemon_id: String,
cwd: PathBuf, // resolved at create time (may be a worktree path)
command: String,
env: HashMap<String, String>,
metadata: HashMap<String, Value>, // opaque; common keys: name, label
status: SessionStatus, // Running | Dead
created_at: DateTime<Utc>,
}
```
`metadata` is intentionally opaque to the daemon and registry. Clients use convention — e.g. `label: "personal/maestro"` becomes a tree path. The protocol doesn't enforce shape.
### Create options
`POST /api/sessions` body (forwarded to daemon as `CreateSession`):
```json
{
"daemon_id": "...",
"cwd": "/home/naps62/projects/maestro",
"command": "claude",
"env": { "FOO": "bar" },
"metadata": { "name": "maestro-impl", "label": "personal/maestro" },
"worktree": {
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/foo",
"cleanup_on_kill": false
}
}
```
`worktree` is optional. When present, the daemon runs `git worktree add <generated-path> -b <branch>` rooted at `base_repo`, then uses the generated path as `cwd`. If `cleanup_on_kill` is true, the daemon prunes the worktree on `KillSession`. Default is false — the worktree stays on disk.
### Lifecycle
```
Create Attach Detach Kill
│ │ │ │
▼ ▼ ▼ ▼
client→registry client→daemon close WS client→daemon
POST /sessions WS /sessions/:id (session DELETE /sessions/:id
│ │ keeps │
▼ ▼ running) ▼
registry→daemon daemon attaches daemon kills
CreateSession via tmux pipe-pane tmux session,
│ │ optionally
▼ ▼ prunes worktree
daemon: (optional) bidirectional stream
git worktree add
│
▼
daemon: tmux new-session
-d -s maestro-<id> -c <cwd> <cmd>
│
▼
daemon→registry
SessionAdded
```
### Crash and restart recovery
- **Daemon restart**: on boot, the daemon lists tmux sessions matching `maestro-*`. For each, it reads a sidecar JSON in `~/.local/state/maestrod/sessions/<id>.json` (written at create time) to recover metadata. Sessions whose sidecars are missing are still advertised but with empty metadata. The daemon re-establishes the WS to the registry and sends a fresh `SessionsSnapshot`.
- **Registry restart**: in-memory state is lost. Daemons hold an outbound WS with exponential-backoff retry; within seconds of registry coming back, every daemon has re-registered and the catalog is rebuilt. Clients holding direct daemon WS streams are unaffected — only catalog-listing breaks temporarily.
- **Client disconnect**: just closes the WS. Session keeps running. Reconnecting re-attaches.
- **Daemon dies / unreachable**: registry marks it offline after a missed heartbeat (~30s). Its sessions disappear from the catalog. When it comes back, they reappear.
## Auth
- Registry generates the shared token at first run, printed once, stored as `[shared].token` in `~/.config/maestro/config.toml` (env var name: `MAESTRO_TOKEN`).
- Same token deployed to each daemon (config file or env var) and used in `Hello`.
- Web client gets a cookie from the registry on first visit. All registry API calls use it.
- Per-attach JWT issued by registry, signed with the shared secret, validated locally by the daemon. ~5 min expiry; client refreshes by re-requesting `GET /api/sessions/:id/connect-info`.
Single-user trust model. The operator's network (Wireguard / private LAN / etc.) is the perimeter; the token is the second factor against anyone who happens to have routing to the bind addresses.
## Repo layout
Cargo workspace. NAP-245 collapsed the three binaries (`maestro`, `maestrod`, `maestro-registry`) into a single `maestro` binary with feature-gated subcommands. Library crates kept their existing shape; per-binary `main.rs` files are gone.
```
maestro/
├── Cargo.toml # workspace
├── crates/
│ ├── maestro/ # umbrella binary: `maestro <list|spawn|attach|kill|set-registry|policy|daemon|registry>` (features: cli/daemon/registry)
│ ├── maestro-protocol/ # message types, shared between subsystems
│ ├── maestro-config/ # unified on-disk config schema
│ ├── maestro-cli/ # CLI subcommand types + dispatch (library)
│ ├── maestro-daemon/ # daemon subsystem (library + `maestro daemon` dispatch)
│ ├── maestro-registry/ # registry subsystem (library + `maestro registry` dispatch)
│ ├── maestro-web/ # standalone `maestro-web` browser-UI binary
│ ├── maestro-installer/ # `maestro-install` wizard
│ └── maestro-e2e/ # cross-process integration tests
├── web/
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
├── docs/superpowers/specs/ # this file, future specs
├── .claude/
│ └── linear.json
└── CLAUDE.md
```
Thin builds for single-role hosts: `cargo install --no-default-features --features {cli|daemon|registry} maestro` produces a binary with only that subsystem's dependencies compiled in.
## Out of scope (prototype)
| Deferred | Why |
|---|---|
| UI polish, themes, animations | prove it works first |
| Side-by-side / split-pane layout in one tab | protocol supports concurrent attaches; two browser tabs is the workaround |
| Native TUI client, Leptos/WASM client, native Android | protocol is stable; addable later |
| Public internet exposure / TLS on the wire | maestro doesn't terminate TLS or police the network; operator runs it on a private network |
| Multi-user / roles / RBAC | single user, single token |
| Persistent registry storage | in-memory, re-derived from daemon reconnects |
| Auto-adopting foreign tmux sessions | daemon only owns sessions it created |
| Auto-restart of dead inner processes | tmux keeps the session; if the inner command exits, it exits |
| Recording / replay | future |
| File transfer / drag-drop | future |
## Testing strategy
- **`maestro-protocol`**: unit tests on every message variant, JSON round-trip, schema stability.
- **`maestro-daemon`**: integration tests against a real `tmux` binary in a temp `$HOME` (with `TMUX_TMPDIR` set). Worktree creation tested against a tmpdir git repo (`git init`, then exercise `worktree add` + cleanup).
- **`maestro-registry`**: tests with a mock daemon WS client. Cover register, heartbeat-timeout, session diff propagation, JWT issuance.
- **End-to-end**: harness that spawns one registry + two daemons as subprocesses in temp dirs, then a programmatic client (using `tokio-tungstenite`) that lists, spawns, attaches, sends bytes, kills. Asserts on both daemons' tmux state and on registry catalog deltas.
- **Web client**: smoke-only for the prototype. Manual verification in desktop Chrome and Android Chrome. A single Playwright test if it doesn't slow CI.
CI runs the Rust workspace; `npm run build` is gated on it succeeding.
## Open questions to resolve during implementation
These don't block the design but should be settled early in the plan:
- **Frontend framework**: vanilla TS, Preact, SolidJS, or Leptos-WASM from day one? Pick during the implementation plan; doesn't affect the protocol.
- **Sidecar persistence file format**: JSON is fine; only flagged because if it grows, we'll want a single sqlite per-daemon instead.
- **PTY backend details**: the prototype uses `tmux pipe-pane` for output capture and `tmux send-keys` for input. If pipe-pane latency or framing turns out to be unworkable, fallback is `portable-pty` wrapping `tmux attach`. This is implementation-only — no protocol impact.
- **Registry HTTP framework**: `axum` is the default Rust choice; reconfirm at plan time.
- **CLI scope**: keep it minimal (`maestro list`, `maestro spawn`, `maestro attach`) or fuller. Probably minimal.
@@ -1,410 +0,0 @@
# Maestro TUI — design
**Status:** draft (brainstormed 2026-05-28)
**Scope:** new `maestro tui` subcommand on the umbrella `maestro` binary, providing a multi-session terminal UI that talks to the local daemon.
**Related:**
- `docs/superpowers/specs/2026-05-24-maestro-design.md` — overall maestro architecture
- `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
- A keyboard-and-mouse driven terminal UI that exposes the same surface as `maestro-web`: list daemons + sessions, spawn, attach, kill.
- Multi-session viewing: attach to several sessions at once, switch between them as tabs, split tabs into side-by-side panes — mental model is vim's buffers/windows/tabs.
- Sessions remain live in the background even when not visible (their PTY bytes are consumed continuously into a maintained screen state).
- Configurable keymap: every action is bindable, the shipped defaults are just defaults.
- First-class mouse support (click sidebar / tabs / panes, drag split dividers).
### Non-goals (v1)
- A vim-style `:` command palette. Parked as v1.5 candidate once direct bindings prove their reach.
- Persisting tabs/splits across TUI restarts.
- Saving named split layouts ("workspaces") to disk.
- Pixel-perfect rendering across exotic terminal emulators — we trust ratatui + crossterm.
### Phasing
The full goal set above is what the design supports — but for delivery the work is staged so we ship something useful before the split-tree work is done:
- **v1** — sidebar + tab bar + one pane per tab, full multi-session liveness, configurable keymap, mouse. No splits. The layout tree (§3.3) is implemented but every tab has exactly one leaf.
- **v1.5** — splits enabled. The pane-tree code path is already there; v1.5 unlocks `SplitHorizontal` / `SplitVertical` actions and the focus-motion / resize bindings.
Same crate, same implementation plan, just two milestones inside it.
### Out of scope (forever)
- Adding persistence to maestro. Sessions persist because tmux owns them on the daemon side. The TUI is a multi-attach *client*; closing it closes its WS connections, the sessions keep running.
## 2. Architecture
```
┌──────────── maestro tui (process) ─────────────┐
│ │
│ ┌──────────┐ ┌──────────────────────────┐ │
catalog WS ───► │ │ Catalog │ │ Render loop │ │
HTTP REST ────► │ │ store │◄──►│ (ratatui, 30 Hz max) │ │
│ └──────────┘ │ sidebar │ tabs │ panes │ │
│ └──────────────────────────┘ │
│ ▲ ▲ │
│ ┌────────────────────┐ │ │ │
│ │ Session emulators │──┘ │ │
│ │ (1 task / session) │ │ │
│ │ vt100 ↔ WS ↔ buf │ │ │
│ └────────────────────┘ │ │
│ │ │
│ ┌────────────────────┐ │ │
│ │ Input router │──────────┘ │
│ │ (crossterm events) │ │
│ └────────────────────┘ │
└────────────────────────────────────────────────┘
│ ▲ ▲
▼ │ │
┌──────── local maestro daemon ────────┐
│ GET /api/sessions /api/daemons │
│ POST /api/sessions │
│ DEL /api/sessions/:id │
│ WS /api/events │
│ WS /api/attach/:id ×N │
└──────────────────────────────────────┘
```
Four concurrent concerns inside the TUI process, each its own Tokio task / unit:
1. **Catalog store** — single source of truth for the daemons-and-sessions tree. Bootstrapped via `GET /api/daemons` + `GET /api/sessions`, then kept live by `WS /api/events`.
2. **Session emulators** — one task per *attached* session, owning a `tokio_tungstenite` WS to `/api/attach/:id` and a `vt100::Parser`. Stays alive even when the session is not visible — that is how background liveness works.
3. **Input router** — owns the crossterm event stream. Distinguishes between TUI-mode keys (sidebar nav, tab/split keys, action keys, mouse events) and pass-through keys (anything else when a session pane is focused, forwarded as binary frames to that session's WS sink).
4. **Render loop** — at most ~30 Hz, redraws on (a) any emulator screen-dirty bit, (b) catalog change, (c) input that changes focus/layout. Composes: sidebar (left), tab bar (top), pane tree (rest).
The TUI talks **only** to the local daemon's `/api/*` surface — same contract `maestro-web` uses. Peer sessions reach us via the catalog mirror (NAP-246); attaching to a peer session goes through the broker (NAP-247) without any TUI-side awareness of "remote".
The new crate is `crates/maestro-tui/`. It lives behind a `tui` cargo feature on the `maestro` umbrella binary, included in the default feature set. Registry-only / daemon-only hosts opt out per the established NAP-245 pattern (`--no-default-features --features registry`).
## 3. Components
### 3.1 Sidebar (`sidebar::Sidebar`)
Left column, default 28 cols wide, togglable. Two-level tree: daemons → sessions, ordered (self first, peers alphabetical). Each session row shows label, command, age, and a status glyph:
- ● attached in current TUI
- ○ live but not attached
- ✗ ended (kept visible briefly, then aged out by catalog events)
Sidebar is navigation-only. Possible actions: highlight motion, attach highlighted, toggle-attach highlighted, collapse/expand daemon. It never enters Insert mode and never forwards keystrokes to a session.
### 3.2 Tab bar (`tabs::TabBar`)
Top row of the main area. A tab is a named workspace owning a layout tree. Tabs are TUI-local — closing the TUI does not persist them.
### 3.3 Layout tree (`layout::Layout`)
A binary tree per tab. Leaves are `PaneId`s; internal nodes are horizontal or vertical splits with a ratio in `[0.1, 0.9]`. Operations:
- `split(direction)` — replace the focused leaf with a split. The new pane inherits the same session by default (vim semantics: `:split` shows the current buffer in both).
- `close()` — remove the focused leaf, collapse the parent.
- `move_focus(direction)` — geometric: find the nearest leaf in `h/j/k/l` direction.
- `resize(direction, delta)` — adjust the nearest ancestor split's ratio.
Each leaf binds to a `SessionId`. Multiple leaves *can* bind to the same session — viewing the same session in two splits, identical to vim opening a buffer in two windows. The session's emulator owns one canonical screen; multiple panes render that same screen.
### 3.4 Spawn modal (`spawn_modal::SpawnModal`)
Standalone overlay form for creating a session. Fields: daemon (dropdown sourced from the catalog), cwd (text), command (text), label (text, optional), env entries (repeatable `KEY=VALUE`). Submit constructs a `CreateSessionRequest` and `POST /api/sessions`; the new session appears in the sidebar via the events WS, not by waiting on the POST response. Field set matches the existing `SpawnArgs` in `crates/maestro-cli/src/cli.rs`.
### 3.5 Attach pane (`pane::Pane`)
Renders the bound session's emulator screen via `vt100::Screen` into a `ratatui` `Buffer`. When focused in Insert mode, all keystrokes that aren't the leader are forwarded to that session's WS as binary frames. On every render, if the pane's allocated `(cols, rows)` differs from the last value sent for that session, the TUI sends a `ClientControl::Resize`. When multiple panes show the same session at different sizes, the per-dimension minimum wins: `cols = min(p.cols)` and `rows = min(p.rows)` across all panes bound to the session, computed independently. Matches tmux's convention when a session is attached at two sizes.
### 3.6 Help overlay (`help::Help`)
Transient overlay listing all current bindings (resolved through the active keymap). Closes on any key. This is the discoverability mechanism in lieu of a command palette.
### 3.7 Modal model
Three mutually-exclusive modes:
- **Normal** — sidebar nav, tab/split keys, action keys (spawn, kill, help). Default.
- **Insert** — pane has full passthrough; the only intercepted key is the **leader**. Enter from Normal when a pane is focused; leave with the configured `LeaveInsert` binding.
- **Modal overlay** — Spawn modal, Kill-confirm, or Help. Captures all input until dismissed.
The leader is the one byte the TUI consumes in Insert mode — same idea as tmux's prefix. Default `Ctrl-a`, configurable.
### 3.8 Action set (the verbs the TUI knows about)
This is the closed list of things the user can ask the TUI to do. Bindings map input → action; the action surface itself is the stable contract.
| Action | Effect |
|---|---|
| `EnterInsert` | Active pane begins forwarding all input (except leader) to the session WS |
| `LeaveInsert` | Return to Normal mode |
| `FocusSidebar` | Move focus to the sidebar tree |
| `FocusPane(Left/Right/Up/Down)` | Geometric focus motion across the current tab's layout |
| `AttachHighlighted` | Attach to the sidebar's highlighted session: if a pane is focused (in the main area), bind the session to that pane (replacing its previous binding); if focus is on the sidebar, open the session in a new tab |
| `ToggleAttachHighlighted` | Attach/detach the highlighted session without changing focus |
| `OpenSpawnModal` | Open the spawn form |
| `KillHighlighted` | Kill highlighted session (always followed by a confirm overlay; not bindable to bypass) |
| `NewTab` / `CloseTab` / `NextTab` / `PrevTab` / `GotoTab(n)` | Tab management |
| `SplitHorizontal` / `SplitVertical` / `ClosePane` | Split-tree management |
| `ResizePane(dir, delta)` | Adjust nearest split ratio |
| `ToggleSidebar` | Hide / show sidebar |
| `ShowHelp` | Open the help overlay |
| `Quit` | Quit (confirm if any pane has an active attach) |
### 3.9 Keymap (`keymap::Keymap`)
A two-level table loaded from config: `mode → (input → action)`, with separate Normal and Insert tables (Insert is sparse — usually only the leader is bound). Input lives in a canonical form: `"ctrl-w h"`, `"g t"`, `"mouse:click(pane)"`, `"mouse:dblclick(sidebar-row)"`, `"mouse:drag(split-divider)"`.
Maestro ships a default keymap that covers every action — recorded as the **shipped default**, not a hardcoded contract. Defaults to be finalized during implementation; the configuration surface is the part being committed to here.
Config lives at `[tui.keymap]` in `~/.config/maestro/config.toml`:
```toml
[tui.keymap]
leader = "ctrl-a"
chord_timeout_ms = 1000
[tui.keymap.normal]
# (illustrative — not contractual)
"i" = "EnterInsert"
"s" = "OpenSpawnModal"
"x" = "KillHighlighted"
"ctrl-t" = "NewTab"
"ctrl-w h" = "FocusPane(Left)"
"mouse:click(pane)" = "FocusPaneAtCursor"
"mouse:drag(split-divider)" = "ResizePaneByDrag"
"mouse:click(sidebar-row)" = "HighlightSidebarRow"
"mouse:dblclick(sidebar-row)" = "AttachHighlighted"
"mouse:click(tab)" = "GotoTabAtCursor"
[tui.keymap.insert]
"<leader>" = "LeaveInsert"
```
User can rebind, unbind (`= "Noop"`), or supply a partial table that merges over the shipped defaults.
**Chord handling.** Prefixes (`g t`, `ctrl-w h`) are handled by a small state machine inside the resolver: if the current key extends a known prefix, hold it for one cycle waiting for the next key; if the next key doesn't complete a binding, drop the chord. Timeout (default 1s, configurable via `chord_timeout_ms`) flushes pending chords.
**Validation.** Parsed at TUI startup in two passes:
1. **Lex pass** — canonicalise each key string. Bad input (`"ctrl-glorb"`) collected with line numbers.
2. **Resolve pass** — every action name resolved against the closed `Action` enum. Unknown actions collected.
If either pass produces errors, the TUI prints the diagnostic list and exits 2. A `--keymap-check` flag (validate without launching) makes this scriptable. Prefix conflicts (`g` shadows `g t`) are warnings, not errors — last-load-wins.
### 3.10 Mouse support
Crossterm exposes mouse events; ratatui forwards them. The TUI consumes them in two layers:
1. **Region hit-test** — the render layer tags regions (sidebar rows, tab cells, split dividers, panes) with stable identifiers; the input router resolves a mouse coordinate to one tagged region.
2. **Action binding** — mouse events become canonical input strings; the same keymap table handles them as keyboard input.
Mouse passthrough to the session (for tmux-aware programs that want their own mouse) is enabled when a pane is in Insert mode and the underlying session indicates mouse support; otherwise the TUI consumes the click. The exact heuristic for "underlying session indicates mouse support" is to be validated during implementation — `TERM` inspection is the obvious starting point.
## 4. Data flow
### 4.1 Catalog bootstrap & live updates
On startup the TUI hits `GET /api/daemons` and `GET /api/sessions` against the local daemon (`http://127.0.0.1:7070` by default, configurable via `[shared].daemon_url`/`MAESTRO_DAEMON_URL`). The two responses populate `CatalogStore`:
```rust
struct CatalogStore {
daemons: HashMap<DaemonId, DaemonInfo>,
sessions: HashMap<SessionId, Session>,
version: u64, // bumped on every change
}
```
Then it opens `WS /api/events`. The receive task deserialises `RegistryEvent` frames (already the protocol surface emitted by `LocalEventBus`, NAP-248) and applies them:
- `SessionCreated` → insert
- `SessionUpdated` → replace
- `SessionEnded` → remove and mark any attached emulator as `Ended`
- `DaemonJoined` / `DaemonLeft` → likewise
Each apply bumps `version` and wakes the render loop via a `Notify`.
### 4.2 Attach lifecycle
Triggered by the `AttachHighlighted` action:
1. Look up the highlighted session in the catalog.
2. Open `WS /api/attach/:id` against the **local** daemon. No client auth — the local-bind trust model is "whoever can reach `bind_addr`" (matches `local_api.rs` v1). The broker handles JWT minting for cross-daemon hops internally; that's transparent to the TUI.
3. Spawn a `SessionTask`:
- Owns `(ws_sink, ws_stream, parser: vt100::Parser, screen_arc: Arc<Mutex<vt100::Screen>>)`.
- Receive side: `Message::Binary(bytes)` → `parser.process(&bytes)`; on dirty screen, notify the render loop.
- Receive side, text frames: route `ClientControl::Ping` back as `Pong`; other control frames are no-ops in v1.
- Send side: receives `SessionInput` enum from the input router — `Bytes(Vec<u8>)` becomes a binary frame; `Resize { cols, rows }` becomes a `ClientControl::Resize` text frame.
- On WS close: state transitions to `Ended`; the screen remains visible (frozen last frame) until the pane is closed.
```rust
struct SessionHandle {
tx: mpsc::Sender<SessionInput>,
screen: Arc<Mutex<vt100::Screen>>,
state: Arc<AtomicU8>, // Connecting | Live | Reconnecting | Ended
}
// Lives in:
struct SessionEmulators(HashMap<SessionId, SessionHandle>);
```
Panes hold a `SessionId` and look up the handle on every render — cheap and avoids stale references when a session ends and a new pane binds to a new one.
### 4.3 Resize propagation
Each pane, on render, computes its `(cols, rows)` from the ratatui `Rect` it is given. It compares with `last_sent_size: HashMap<SessionId, (u16, u16)>`. After the render pass, the per-session minimum across all panes bound to that session is recomputed; if it changed, one `Resize` is sent. Window-level SIGWINCH triggers a full re-layout; the same per-session min-size recompute handles propagation.
### 4.4 Input routing
Per frame the input router pulls events from the crossterm stream and runs:
```
Event → canonicalise(Event) # "ctrl-a", "g t", "mouse:click(pane)"
→ Keymap.resolve(mode, key) # → Option<Action>
→ if Some(action): dispatch(action)
→ if None: if mode == Insert and focus is a pane → forward bytes to that pane's SessionTask
else: ignore
```
Bytes forwarded to a session in Insert mode go straight onto the session task's input channel without TUI interpretation — that is how `Ctrl-C`/`Ctrl-Z`/`Esc` reach the remote shell. The leader is the **only** key the TUI consumes in Insert mode.
### 4.5 Spawn & kill
Spawn: `OpenSpawnModal` → form → submit constructs a `CreateSessionRequest` and `POST /api/sessions`. On 201, the catalog WS will emit a `SessionCreated` that adds it to the sidebar; the modal closes on submit, doesn't wait for the event.
Kill: `KillHighlighted` → confirm overlay → on confirm `DELETE /api/sessions/:id`. Same pattern — the event stream is the source of truth for the disappearance; the request just kicks it off.
If either request fails (4xx / 5xx / network), a status-bar toast surfaces the error for ~5s; catalog state is unchanged.
## 5. Error handling & resilience
### 5.1 Daemon unreachable at startup
If the initial `GET /api/web/info` (health probe) fails after a short retry (3× 500ms backoff), the TUI does not enter the main loop. It prints a single line on the inherited terminal explaining what it tried to reach and how to fix it (`maestro daemon configure`, `systemctl --user status maestro-daemon`), then exits 1.
### 5.2 Events WS reconnect
On disconnect: exponential backoff (1s base, 30s cap, ±25% jitter, infinite retries). Sidebar shows `△ catalog offline` in its footer until reconnect. On reconnect, re-fetch `/api/daemons` + `/api/sessions` and diff against `CatalogStore` — sessions that disappeared while disconnected become `Ended`, new ones appear. This avoids drift across reconnects without needing the events WS to replay missed events.
### 5.3 Attach WS drops (per-session)
A session's WS dropping does *not* end the session — only signals our client lost contact. The `SessionTask` transitions to `Reconnecting`, posts a status overlay on every pane bound to it (`reconnecting…`), and retries with the same backoff. On reconnect:
- Re-open `WS /api/attach/:id` on the local daemon.
- Send a fresh `ClientControl::Resize`.
- Do **not** clear the vt100 screen — last-known frame stays visible (frozen) until new bytes arrive.
If the underlying session has actually ended (daemon emits `SessionEnded` on the events WS while we're reconnecting), the task aborts reconnect and transitions to `Ended`.
### 5.4 vt100 parser glitches
`vt100::Parser::process` is infallible on malformed input — it discards unrecognized sequences. The user-visible failure mode is rendering glitches (wrong colour cells, wrap math off). Recovery: a per-pane `RedrawSession` action (default unbound, available to keymap) re-initialises the parser at the current size and asks the remote `tmux refresh-client -t maestro-<id>` via a daemon endpoint (`POST /api/sessions/:id/refresh`, **new in v1**, shared with `maestro-web`).
If `vt100` actually panics on input (shouldn't, but: parser bugs happen), the panic hook catches it, logs, and ends that one `SessionTask` cleanly — the rest of the TUI stays up.
### 5.5 Terminal restore on panic / abrupt exit
The TUI installs `panic::set_hook` and a `SIGTERM`/`SIGINT` handler that:
- Disable mouse capture
- Leave the alternate screen buffer
- Restore the original termios (saved at startup)
- Print the panic message / signal name
- Re-raise the signal so the parent shell sees the correct exit reason
A `Drop` impl on the same RAII guard handles the normal-exit path. Pattern matches the existing `RawTty` guard in `crates/maestro-cli/src/attach.rs`.
## 6. Testing strategy
### 6.1 Unit tests — pure logic, no I/O
- `keymap::parse` — table tests over the canonical form: valid key/chord strings parse, bad strings surface the right diagnostic, conflicting bindings produce the expected warning.
- `layout::Layout` — algebraic: split-then-close is identity, focus motion under arbitrary trees finds the geometrically-nearest leaf, ratio bounds are enforced. Property tests via `proptest`: "focus is always on a real leaf", "ratios sum to 1.0 within each split", "close-on-singleton collapses to empty".
- `action::dispatch` — given a `(CatalogStore, Layout, Mode)` snapshot and an `Action`, the post-state is deterministic. Covers edge cases (kill-while-focused-pane-shows-it, attach-when-already-attached, close-pane-when-only-one-left).
- `vt100` framing — bytes-in, screen-out. We don't test `vt100` itself, but we do test that our `SessionTask`'s parser correctly accumulates a partial CSI across `process` calls (WS frames have no guarantee of sequence alignment). Fixtures from real bash/vim/claude byte streams.
### 6.2 Integration tests — against a real daemon (in-process)
`crates/maestro-tui/tests/integration.rs` spins up a `SessionManager` + `LocalApiState` + axum router on a random port — same seam `maestro-e2e` uses. The TUI's networking layer is pointed at that port.
Coverage:
- Catalog bootstrap loads both REST snapshots and reaches steady state.
- Events WS receives `SessionCreated` after `POST /api/sessions` and updates the sidebar state.
- Attach against a daemon-owned bash session round-trips bytes (test feeds keystrokes through the input router, asserts the bash session sees them via `echo`'d output through the same WS into the parser).
- Reconnect: forcibly drop the events WS, assert backoff fires, assert state reconciles on reconnect.
Real `tmux`, isolated `$TMUX_TMPDIR`. Behind `cargo test --features integration`; CI runs them, default `cargo test` does not (slow).
### 6.3 Render snapshot tests
`ratatui::backend::TestBackend` for known-buffer assertions:
- Sidebar tree rendering (collapsed, expanded, with status glyphs).
- Tab bar at varying counts, with truncation.
- Split layouts (h-split, v-split, nested).
- Help overlay.
- Spawn modal.
Goldens stored under `tests/snapshots/`. Choice between `insta` or a small home-grown matcher made during implementation.
### 6.4 End-to-end via `maestro-e2e`
`maestro-e2e` (NAP-217) already spins registry + two daemons. We add a `tui` e2e target that drives the TUI by piping crossterm events into a substitutable Tokio source.
Coverage:
- "Spawn → catalog updates → attach → type → see output → kill" round-trip across two daemons.
- Sidebar shows peer sessions (catalog mirror); attach against a peer session goes through the broker (NAP-247) without TUI-side awareness of "remote".
Behind the same `--features e2e` gate.
### 6.5 What we explicitly don't test
- Pixel-perfect rendering across exotic terminals — trust `ratatui` + `crossterm`. Manual smoke per release.
- Mouse behaviour in CI — non-interactive runs don't expose mouse. Mouse hit-test logic (region tagging → resolved region) is covered by unit tests; the actual event plumbing is exercised manually.
- `vt100` correctness on weird ANSI — if a program breaks rendering, file a follow-up with the byte stream as a fixture.
### 6.6 Acceptance bar for v1 sign-off
Rough target: ~30 unit, ~6 integration, ~10 snapshot, ~2 e2e. Not a contract — a sanity check that we've covered each layer.
## 7. Open questions / follow-ups parked for after v1
- **Command palette** — vim-`:` style ex-line. To revisit once direct bindings prove their reach in real use.
- **Tab persistence** — saving the tab/split layout across TUI restarts. Probably wanted, but only after the layout primitives are stable.
- **Named workspaces** — saving and restoring multiple layouts.
- **Mouse passthrough heuristic** — finalize how we detect that the inner program wants mouse events so the TUI yields its mouse handling.
- **`wezterm-term` swap** — if `vt100` proves insufficient for some real program (vim/claude with exotic sequences), the emulator behind the `SessionTask` is swappable behind a thin trait.
- **`POST /api/sessions/:id/refresh`** — small new daemon endpoint behind the per-pane redraw recovery; share with `maestro-web`.
## 8. Repo layout
```
crates/
├── maestro/ # umbrella binary; `tui` feature (default-on) wires TUI subcommand
└── maestro-tui/ # new crate — all TUI logic
├── src/
│ ├── lib.rs # `run(Config) -> Result<()>`
│ ├── cli.rs # clap subcommand definition
│ ├── catalog.rs # CatalogStore, REST bootstrap, events WS
│ ├── session_task.rs # one task per attached session
│ ├── emulators.rs # SessionEmulators registry
│ ├── input.rs # crossterm event canonicalisation + router
│ ├── keymap.rs # config parsing, resolver, chord state machine
│ ├── action.rs # Action enum + dispatch
│ ├── layout.rs # binary split tree
│ ├── sidebar.rs # sidebar widget
│ ├── tabs.rs # tab bar widget
│ ├── pane.rs # attach pane widget
│ ├── spawn_modal.rs # spawn form
│ ├── help.rs # help overlay
│ ├── render.rs # top-level draw orchestration
│ └── terminal.rs # RAII setup/restore, panic hook
└── tests/
├── integration.rs # in-process daemon
├── snapshots/ # ratatui TestBackend goldens
└── ...
```
@@ -1,133 +0,0 @@
# maestro-web: build the SPA bundle at compile time
**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
`include_dir!("$CARGO_MANIFEST_DIR/../../web/dist")` (`crates/maestro-web/src/static_bundle.rs`).
Only `web/dist/.gitkeep` is checked into git — the real bundle is gitignored and
must be produced by `pnpm -C web build`.
The installer's default source is `cargo install --git` (`maestro-installer`
`build_source`). A fresh git clone has `web/dist` containing *only* `.gitkeep`,
so `include_dir!` embeds an empty bundle and the running binary serves the
"bundle not built" placeholder. **Every git-based install hits this** — it is
guaranteed, not bad luck.
Secondary problems found while diagnosing:
- The placeholder (and `Justfile`'s `web:` target) tell the operator to run
`cargo build -p maestro-web --release`, which writes `target/release/...`, but
the systemd unit runs `~/.cargo/bin/maestro-web` (from `cargo install`). So
following the placeholder's own advice never updates the running service.
- `include_dir!` does not emit `cargo:rerun-if-changed` for the embedded
directory. Once `maestro-web` is compiled with an empty `web/dist`, rebuilding
after `pnpm build` does **not** re-embed the bundle — cargo sees no source
change and reuses the stale object.
## Goal
A compiled `maestro-web` binary always embeds a real SPA bundle, regardless of
install method (`cargo install --git`, `--path`, or the wizard). No placeholder
for installed users.
## Design
### 1. `crates/maestro-web/build.rs` (new)
Runs during every compile of the crate that cargo deems necessary (gated by the
`rerun-if-changed` set below).
- Locate `web/` relative to `CARGO_MANIFEST_DIR` (`../../web`).
- Resolve `pnpm` on `PATH`, and confirm `node` is present.
**If either is missing → fail the build** (`panic!` from `build.rs`, which
cargo surfaces as a build error) with an actionable message, e.g.:
> `maestro-web requires pnpm + node to build its embedded SPA bundle. Install
> them (https://pnpm.io) or use a prebuilt binary.`
- Run, in `web/`:
1. `pnpm install --frozen-lockfile` (a fresh `--git` clone has no
`node_modules`, so install is required).
2. `pnpm build`.
If either exits non-zero, fail the build and surface stderr.
- Emit `cargo:rerun-if-changed=` for the frontend inputs so the bundle rebuilds
exactly when those change (and not on every Rust compile):
- `../../web/src`
- `../../web/index.html`
- `../../web/package.json`
- `../../web/pnpm-lock.yaml`
- `../../web/vite.config.ts`
This also fixes the staleness bug: changing the frontend now reliably
re-embeds.
### 2. `crates/maestro-web/src/static_bundle.rs` — unchanged
`include_dir!` still embeds `web/dist`, now guaranteed populated by `build.rs`.
The placeholder path stays as harmless defense-in-depth; in a successful build
it is unreachable. Existing tests remain valid.
### 3. Installer wizard — `handle_web` (`crates/maestro-installer/src/main.rs`)
The web step is already opt-in (interactive prompt, or silent refresh when a
unit already exists). After the user opts in, **preflight pnpm + node** before
`install_crate(WEB_CRATE)`:
- **Interactive, prerequisite missing:** prompt
> `maestro-web needs pnpm + node, which aren't installed. [A]bort install or [S]kip web UI?`
- *Abort* → exit the installer non-zero.
- *Skip* → continue without the web binary; daemon/registry work already done
is left intact. Return `Ok(false)` from `handle_web`.
- **Non-interactive refresh path** (existing unit, no TTY): cannot prompt —
print a clear warning that the web refresh is skipped because pnpm/node are
missing, and leave the existing unit in place. Return `Ok(false)`.
- **Both present:** proceed exactly as today. The existing `cargo install --git`
now Just Works because `build.rs` builds the bundle inside the temp clone.
This keeps the daemon/registry install unaffected by a missing frontend
toolchain, and never lets a raw `build.rs` panic leak to the user — the wizard
catches the missing-prereq case first.
### 4. CI — `.gitea/workflows/ci.yml`
`clippy` and `test` compile `maestro-web`, which now requires the frontend
toolchain. Add a step (before `clippy`) that installs Node.js + pnpm and runs
`pnpm -C web install --frozen-lockfile`, so the workspace builds on the runner.
Node/pnpm install goes in the existing "Install … build deps" step or a new
adjacent step; `corepack enable` (or a pinned pnpm install) provides `pnpm`.
### 5. `Justfile` — unchanged
The `web:` target stays as a `--release` convenience and as the documented
local build command. With `build.rs` in place its `pnpm build` step is partly
redundant but harmless.
## Consequences accepted
- The `maestro-web` crate cannot be compiled without node + pnpm. This is
intentional (hard fail). It affects: workspace CI (addressed in §4), any
Rust-only contributor building the full workspace, and `cargo install --git`
on a machine without node.
- `cargo install --git` becomes slower (runs `pnpm install` + `pnpm build`), but
produces a correct binary — the whole point.
## Out of scope
- Shipping a prebuilt release binary (considered, deferred — the build.rs +
wizard path covers the reported problem).
- Changing the data-plane / proxy behavior of `maestro-web`.
## Testing
- `build.rs` is exercised implicitly by every compile; a green
`cargo build -p maestro-web` on a clean checkout (no prior `pnpm build`) that
yields a binary whose `/` serves the real `index.html` is the acceptance
signal. An integration check can assert the served root contains the SPA
mount (`<div id="app">`) rather than the placeholder string.
- Installer: unit-test the preflight decision (missing-prereq → abort/skip
branch selection) without invoking cargo, following existing
`maestro-installer` test patterns.
- CI itself is the regression test for the toolchain requirement.
@@ -1,109 +0,0 @@
# 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
help filling it: no reliable prefill, no completion. The user wants
1. the field prefilled with the **selected host's home dir**, updating when the
host changes; and
2. **shell-style directory completion** (`cd dir/sub<tab>`) driven by **real-time
queries to each host's daemon**, so it works across every federated host.
Applies to both the **cwd** field and the worktree **Base repo** field.
## Architecture context
The web SPA talks to a single **home daemon** (served by `maestro-web`, which
reverse-proxies `/api/*`) — or, in dev, directly to the **registry** (vite
proxy). The home daemon owns its sessions and mirrors peers' catalogs; it
**brokers** cross-daemon operations (`create`/`kill`) to the owning daemon over
the registry control link (NAP-327). The registry can also drive a daemon RPC
directly (`create_session`/`kill_session`) for its own `client_api` front door.
Directory listing must reach **the daemon whose host you're spawning on**, which
may be the home daemon itself or any peer. So we mirror the create/kill plumbing
end to end rather than only the registry leg.
## Design
### Listing strategy
The daemon lists **one directory's immediate subdirectories**; the web caches
the result per `(daemonId, dir)` and filters the trailing path segment
client-side, re-querying only when the user crosses a `/`. Fewer round-trips,
instant filtering, still live. Directories only (cwd / base-repo are always
dirs); hidden dirs excluded; sorted case-insensitively; capped.
### Wire protocol (`maestro-protocol`)
- `DirListing { path: PathBuf, entries: Vec<String>, truncated: bool }` — `path`
is the **resolved absolute** dir (after `~`/relative expansion); `entries` are
immediate subdirectory names.
- `DaemonInbound::ListDir { reply_to, path: String }` — registry → owner daemon.
- `DaemonOutbound::ListDirResult { reply_to, result: RpcResult<DirListing> }` —
owner → registry.
- `DaemonOutbound::ForwardListDir { reply_to, daemon_id, path }` — home daemon →
registry (relay to owner).
- `DaemonInbound::ForwardListDirResult { reply_to, result: RpcResult<DirListing> }`
— registry → home daemon.
Carried by the existing `RpcResult` + `#[serde(other)] Unknown` forward-compat
machinery, so older peers degrade gracefully.
### Daemon (`maestro-daemon`)
- New pure module `dirlist`: `list_dir(home_dir, requested) -> Result<DirListing, ErrorInfo>`.
Expansion: empty → home; leading `~` → home; relative → joined onto home.
Reads the dir, keeps directories (resolving symlinks), sorts, caps (`MAX_ENTRIES`).
Error codes: `not_found`, `permission_denied`, `not_a_directory`, `io_error`.
- `registry_client` handles `ListDir` (calls `dirlist::list_dir`, replies
`ListDirResult`) and `ForwardListDirResult` (routes to broker).
- `broker` gains `forward_list_dir(daemon_id, path)` + `complete_forward_list_dir`,
mirroring `forward_kill` (fail-fast while disconnected, RAII pending cleanup).
- `local_api`: `GET /api/daemons/:id/dirs?path=<p>` — self → `dirlist::list_dir`;
peer → `broker.forward_list_dir`. Maps error codes to HTTP status.
### Registry (`maestro-registry`)
- `Registry::list_dir(daemon_id, path)` mirroring `create_session` (new
`pending_list_dir` map + `PendingGuard`, `complete_list_dir`); no catalog
side-effects.
- `forward_list_dir(home, reply_to, target, path)` mirroring `forward_kill`.
- `handler` dispatches `ListDirResult` → `complete_list_dir` and `ForwardListDir`
→ `forward_list_dir`.
- `client_api`: `GET /api/daemons/:id/dirs?path=<p>` → `registry.list_dir`, same
timeout/error mapping as `create_session`.
### Web (`web/src`)
- `types.ts`: `DirListing`.
- `api.ts`: `listDir(daemonId, path) -> Promise<DirListing>` →
`GET /api/daemons/:id/dirs?path=…`.
- New `dirAutocomplete.ts`: attaches to an input + a daemon-id getter. Debounced
fetch of the current dir, `(daemon,dir)` cache, fixed-position dropdown with
arrow-nav; **Tab** completes the longest common prefix / accepts the highlighted
entry; **Enter** accepts (suppressing submit) or submits when closed; **`/`**
drills into the next level; **Esc** closes. Failures degrade to no suggestions.
- `spawn.ts`: keep host-change home-dir prefill; wire the widget onto cwd +
worktree base repo.
## Testing
- Protocol: serde round-trip for the four new variants + `DirListing`, and an
unknown-variant decode (already covered generically).
- Daemon `dirlist`: temp-dir listing (dirs-only, hidden excluded, sorted), `~`
and relative + empty expansion, and `not_found` / `not_a_directory` codes.
- Daemon `broker`: `forward_list_dir` round-trip + fail-fast-while-disconnected,
mirroring the existing forward_create/kill tests.
- Registry: `list_dir` RPC pairing + `complete_list_dir`; `forward_list_dir`
relay, reusing the create/kill in-process channel harness.
- Web: no test runner present; rely on `tsc` typecheck + manual verification.
## Scope
In: the full web→(home daemon|registry)→owner daemon path so completion works
for any host. Out: listing files (dirs only); fuzzy matching (prefix only);
persisting recently-used paths.
@@ -1,316 +0,0 @@
# Maestro — In-app Update Notifications + Optional Auto-update
**Date**: 2026-06-18
**Status**: Design spike (NAP-331). Produces decisions, not implementation.
**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
capability: every client surface ("a newer version is available, X→Y") and an
opt-in mechanism to apply the update without dropping sessions.
This is a design spike. It answers the six questions on NAP-331, then proposes
an implementation ticket breakdown. No code ships from this ticket beyond this
document.
## Prior art (the hack we're replacing)
A yolo-local prototype (out of repo, intentionally uncommitted): a gitea push
webhook → a tiny `--user` systemd listener on a private ip:port → worker that
hard-resets a dedicated checkout to `origin/main`, `cargo install`s, restarts
the daemon (stamping the last-built commit). Works, but it is per-host
shell+systemd with no in-app surface and no policy. We keep its shape (separate
supervised updater, dedicated checkout, systemd restart) and add: build-commit
stamping in the binary, a registry-mediated "latest seen" signal, client
surfaces, a config policy block, and a prebuilt-binary path for thin hosts.
## Key existing infrastructure we build on
Grounding the design in what already exists in the repo today:
- **`DaemonInfo.version: String`** (`maestro-protocol`) is *already* advertised
by each daemon in `Hello` and fanned out by the registry in
`RegistryEvent::Snapshot` / `RegistryEvent::DaemonAdded`. The registry already
knows every connected daemon's version. Cross-host version awareness is
therefore an *extension of an existing field*, not new plumbing.
- **`RegistryEvent` fan-out WS** (`/api/events`, `maestro-registry/src/events.rs`)
already server-pushes catalog/presence deltas to every client. A
"newest-version-seen" signal rides this same channel.
- **`MaestroConfigFile`** (`maestro-config`) is sectioned TOML
(`[shared]`, `[daemon]`, `[registry]`, `[web]`, `[tui]`, `[cli]`). An
`[autoupdate]` section drops in with the same `skip_serializing_if = is_empty`
pattern.
- **systemd unit** (`maestro-installer/src/systemd.rs`): `Type=simple`,
`ExecStart={exec} daemon`, `KillMode=process` (NAP-330), `Restart=on-failure`,
`RestartSec=5`. `systemctl restart maestro` already preserves tmux sessions.
- **Version is `0.1.0` workspace-wide, no git hash.** `version.workspace = true`;
the only consumer today is a `user_agent` string in `maestro-cli/src/http.rs`.
There is **no `build.rs`** in the umbrella crate yet. Stamping the commit is
net-new and is the hard prerequisite for everything else.
---
## Q1 — Version detection
**Decision: stamp the build commit at compile time and carry it as a structured
`BuildVersion`, with `origin/main` HEAD as the source of truth for "latest" in
the self-hosted (cargo-install) path, and a CI-published `manifest.json` for the
prebuilt path.**
### Stamping the binary
Add a `build.rs` to `crates/maestro/` that emits the git commit (and dirty flag)
as compile-time env vars. Two viable approaches:
- **`vergen`** (or `vergen-gix`): mature, emits `VERGEN_GIT_SHA`,
`VERGEN_GIT_COMMIT_TIMESTAMP`, dirty, etc. Heavier dep tree.
- **Hand-rolled `build.rs`** running `git rev-parse HEAD` + `git status
--porcelain`, with `cargo:rerun-if-changed=.git/HEAD`. Zero deps, ~20 lines.
Recommend the **hand-rolled build.rs** — the repo already favors minimal deps and
real-tool integration tests; we only need three values. Critically, the build
script must degrade gracefully: when built from a tarball / crates.io / a
checkout with no `.git` (thin host installing a prebuilt), fall back to
`option_env!` and emit `unknown`. Never fail the build for a missing git.
Expose a structured version everywhere `--version` is printed:
```
maestro 0.1.0 (a1b2c3d 2026-06-18, dirty)
```
Add a `BuildVersion { semver, commit, commit_time, dirty }` type
(`maestro-protocol`, since it crosses the wire) and a `maestro version --json`
subcommand for machine consumers (the updater, tests).
`DaemonInfo.version` is currently a bare `String` (`"0.1.0"`). **Migrate it to
carry the commit** — either widen it to `BuildVersion` (clean, breaks wire compat
with older daemons) or keep the `String` and adopt the `"0.1.0 (a1b2c3d)"`
display form (compat-friendly, parse on read). Recommend **widening to a struct
with `#[serde(default)]`-friendly fields** so old daemons that send a bare semver
still deserialize (commit ⇒ `None`/`unknown`). This is the only wire change.
### Source of truth for "latest"
Two sources, matching the two apply mechanisms (Q3):
- **Self-hosted / cargo-install hosts** → `origin/main` HEAD commit. The updater
component (Q6) is the only thing that talks to git; it resolves
`git ls-remote origin main` (or reads from its dedicated checkout) to learn the
latest commit. "Newer" = `latest_commit != my_build_commit` **and** latest is a
descendant (avoid offering downgrades on a force-push; `git merge-base
--is-ancestor`).
- **Thin / prebuilt hosts** → a CI-published `manifest.json`
(`{ semver, commit, commit_time, artifacts: { "<target-triple>": { url, sha256 } } }`)
at a stable URL. The updater polls/fetches it; "newer" = manifest commit_time >
my commit_time.
Semver (`0.1.0`) alone is **not** sufficient — it is bumped rarely and the same
semver spans many commits. Commit identity is the real version key; semver stays
the human-facing coarse label.
---
## Q2 — Surfaces
**Decision: the registry computes a `latest_version` across all daemons it has
ever seen and broadcasts it on the existing `RegistryEvent` channel; every client
compares its local daemon's `BuildVersion` against that and renders a passive
"update available X→Y" affordance.**
### Registry as the version oracle
The registry already receives every daemon's version. Extend its state with
`latest_seen: BuildVersion` = max-by-commit-time across connected daemons,
optionally bumped by the external "latest" source (Q1) if the registry is
configured to poll it. Add to `RegistryEvent`:
- `RegistryEvent::Snapshot` gains a `latest_version: Option<BuildVersion>` field.
- A new `RegistryEvent::LatestVersion { version }` delta pushed when the max
advances (a daemon with a newer build connects, or the poll finds a new commit).
This means **a phone that only ever talks to the registry still learns that a
newer version exists** the moment any single host upgrades — exactly the
"broadcast the newest version it has seen" idea from the ticket. No client needs
to talk to git.
### Per-surface rendering
| Surface | Affordance | Notes |
|---|---|---|
| CLI `maestro list` | One-line footer: `update available: 0.1.0 (a1b2c3d) → 0.1.0 (e4f5a6b) on 2/3 hosts` | Only when at least one host is behind `latest`. `--quiet` / `MAESTRO_NO_UPDATE_NOTICE` suppresses. |
| TUI | Dismissable banner row / status-bar segment | Color-coded; click/hotkey opens a per-host update list. |
| Web | Header badge (dot + tooltip) | Subscribes to `RegistryEvent`; badge lists which hosts are behind. |
All three are **read-only notices** in the notify path. Applying is a separate,
explicit action (a CLI subcommand / a confirmed button), never a side effect of
viewing the list. Per-host granularity matters: in a federated fleet some hosts
upgrade before others, so the surface shows *which* hosts are behind, not a single
global flag.
---
## Q3 — Apply mechanism
**Decision: support both, selected per-host by a config/auto-detected
`method`. Default `cargo-install` where a toolchain exists, `prebuilt` on thin
hosts.**
- **`cargo-install` (build-on-host).** Mirrors the prototype: dedicated checkout
(NOT the user's working tree), `git fetch` + hard-reset to the target commit,
`cargo install --path crates/maestro --locked`, then restart. Needs a Rust
toolchain; ~tens of seconds; produces a binary matching the host exactly.
- **`prebuilt` (CI binaries).** Fetch the artifact for the host's target triple
from the Q1 manifest, verify `sha256`, atomically replace `~/.cargo/bin/maestro`
(write to temp + `rename`), then restart. Works on a Pi / LXC with no compiler.
Requires CI to cross-compile and publish per-triple artifacts + manifest (new CI
work, called out as its own ticket).
`method` resolution order: explicit `[autoupdate].method` → auto-detect (is
`cargo` on `PATH` and a source checkout present? → `cargo-install`, else
`prebuilt`). Both paths converge on the same **stamp-then-restart** epilogue:
record the newly-installed commit where the updater can read it back (so a failed
restart doesn't claim success), then `systemctl --user restart maestro`.
---
## Q4 — Auto-run policy
**Decision: opt-in `[autoupdate]` config section, default `off`. Three modes,
three trigger styles.**
```toml
[autoupdate]
mode = "notify-only" # off | notify-only | auto (default: off)
method = "cargo-install" # cargo-install | prebuilt | auto (default: auto)
trigger = "poll" # webhook | poll | schedule (default: poll)
source = "origin/main" # git ref, or a manifest URL for prebuilt
poll_interval = "15m" # for trigger = poll
window = "*-*-* 04:00:00" # for trigger = schedule (systemd OnCalendar)
```
- **`off`** — no detection, no notice, no apply. (Detection cost is near-zero, but
`off` honors users who want maestro to never phone-home about versions.)
- **`notify-only`** — detect + surface (Q2), never apply. The safe default to
*recommend* once stamping lands.
- **`auto`** — detect + apply via the updater (Q6), respecting `trigger`/`window`.
Fits `MaestroConfigFile` as a new section with the existing
`skip_serializing_if = AutoupdateSection::is_empty` pattern; lives logically near
`[daemon]` (the daemon is what gets restarted) but is consumed by the **updater**,
not the daemon process (Q6).
**Triggers:**
- `webhook` — the prototype's model: gitea push → updater's tiny HTTP listener.
Lowest latency; needs an exposed port on the perimeter.
- `poll` — updater wakes on `poll_interval`, checks the Q1 source. No inbound
port. **Default** — simplest, works everywhere.
- `schedule` — systemd timer (`OnCalendar=window`); apply only inside a quiet
window. Best for `auto` mode so restarts don't interrupt active work.
---
## Q5 — Restart safety
**Decision: rely on NAP-330's `KillMode=process` + `systemctl restart`; the
updater never kills the daemon directly. Add a pre-restart drain check.**
NAP-330 already shipped `KillMode=process` on the unit, so `systemctl --user
restart maestro` SIGTERMs only the daemon's main process — the tmux server and
its sessions (separate process tree) survive, and the fresh daemon re-adopts them
on startup. This is the load-bearing guarantee; the spike confirms it is already
in place.
Additions for safety:
- The updater calls `systemctl restart` (a supervised swap), **never** `kill`. The
binary is replaced on disk first; systemd execs the new one.
- **Atomic binary replace** (`prebuilt` path) so a half-written binary can't be
exec'd: write to `maestro.new`, `fsync`, `rename` over the target.
- **Re-adoption is already the daemon's normal startup behavior** (it lists
existing tmux sessions on boot). Verify post-restart the daemon re-appears in the
registry with the new `BuildVersion` within a timeout; if not, the updater logs a
failure (and, future, could roll back to the prior binary — out of scope here,
flagged as a follow-up).
- `auto` mode should prefer the `schedule` trigger / a quiet window so a restart
never lands mid-keystroke for an attached client. A restart is invisible to
persistence but *does* blip live WS streams (clients reconnect); doing it at
04:00 is kinder than at noon.
---
## Q6 — Trust / security
**Decision: the self-modifying build+exec logic lives in a separate, minimal
`maestro-updater` component supervised by systemd — never inside the daemon
process.**
Rationale: a daemon that can `git reset`, `cargo install`, and exec arbitrary
freshly-built code is a remote-code-execution surface. The daemon is the
network-facing, multi-client process; it must stay incapable of rewriting its own
binary. A supervised restart via systemd is needed anyway (the running process
can't replace the file it's executing cleanly), so the separation is free.
Shape:
- **`maestro-updater`** — a small binary (new crate `crates/maestro-updater/`, or a
feature-gated subcommand `maestro update`). Owns the dedicated checkout, the
fetch/build/fetch-artifact, the atomic replace, and `systemctl restart`. Run as
its own `--user` systemd unit/timer, NOT a child of the daemon.
- **Trust boundary on the source.** `cargo-install` path trusts whatever
`origin/main` resolves to — acceptable only because the operator controls the
repo and the perimeter (consistent with maestro's network-agnostic threat model:
the perimeter is the VPN/LAN). `prebuilt` path **must** verify `sha256` from the
manifest against the downloaded artifact, and the manifest should be fetched over
TLS from a trusted origin. Document that turning on `auto` = trusting that
source to run code on the host.
- **No webhook auth shortcuts.** If `trigger = webhook`, the listener must
authenticate the caller (shared secret / HMAC of the gitea payload), bind to the
perimeter interface, and only ever *trigger* an update of the configured source —
it must not accept a commit/url from the request body (that would let any caller
pick the code to run).
- **Least privilege.** The updater needs write to `~/.cargo/bin` and the dedicated
checkout, and `systemctl --user restart` rights. Nothing more. It does not need
the daemon's token or registry credentials for the apply itself (it may read the
config for policy only).
---
## Proposed implementation ticket breakdown
In dependency order (each is a separate impl ticket spun off from this spike):
1. **Build-commit stamping.** `build.rs` in `crates/maestro/`, `BuildVersion`
type, `--version` long form + `maestro version --json`. Graceful no-git
fallback. *(Prerequisite for everything.)*
2. **Wire the commit through `DaemonInfo`.** Widen `DaemonInfo.version` to carry
the commit with backward-compatible deserialization; daemon advertises real
`BuildVersion` in `Hello`.
3. **Registry "latest seen" + `RegistryEvent`.** Compute `latest_version`,
add `Snapshot.latest_version` + `RegistryEvent::LatestVersion`.
4. **Client surfaces.** CLI `list` footer, TUI banner, web header badge. (Can be
three sub-tickets; share the registry signal from #3.)
5. **`[autoupdate]` config section.** Schema + parsing + defaults in
`maestro-config`. Notify-only behavior end-to-end (no apply yet).
6. **`maestro-updater` component — cargo-install path.** Dedicated checkout,
fetch/reset/install, atomic stamp, `systemctl restart`. systemd unit/timer via
the installer (NAP-225).
7. **Prebuilt path + CI manifest.** CI cross-compiles per-triple artifacts,
publishes `manifest.json` + `sha256`; updater `prebuilt` method with
verification.
8. **`auto` mode + triggers.** Wire `webhook` / `poll` / `schedule` to the
updater; quiet-window scheduling; post-restart version-confirm check.
#5 and #6 can proceed in parallel after #1–#4. #7 is the heaviest (new CI) and
gates only thin-host auto-update; #1–#6 deliver the full cross-host *notify* story
plus auto-update on build-capable hosts.
## Scope boundary
- **In scope (this spike):** the decisions above + the ticket breakdown.
- **Out of scope:** rollback-on-failed-restart (flagged in Q5), multi-arch CI
setup details (lands in #7), and any auth design for a hosted/public manifest
CDN — maestro stays network-agnostic; the operator's perimeter is the trust
boundary (spec §Auth).
+3 -3
View File
@@ -5,7 +5,7 @@ import "@xterm/xterm/css/xterm.css";
import type { ClientControl, Uuid } from "./types.js"; import type { ClientControl, Uuid } from "./types.js";
/** /**
* A single live attach: an xterm terminal bound to a daemon-broker WebSocket. * A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
* *
* The connection is **decoupled from the UI**: it owns a long-lived WS that * The connection is **decoupled from the UI**: it owns a long-lived WS that
* keeps streaming PTY output into the terminal buffer whether or not the view * keeps streaming PTY output into the terminal buffer whether or not the view
@@ -56,7 +56,7 @@ function esc(s: string): string {
* reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id` * reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id`
* endpoint. * endpoint.
*/ */
function brokerStreamUrl(sessionId: Uuid): string { function attachStreamUrl(sessionId: Uuid): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/attach/${encodeURIComponent(sessionId)}`; return `${proto}//${location.host}/api/attach/${encodeURIComponent(sessionId)}`;
} }
@@ -264,7 +264,7 @@ export function createAttachConnection(
} }
}; };
const ws = new WebSocket(brokerStreamUrl(sessionId)); const ws = new WebSocket(attachStreamUrl(sessionId));
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
const sendControl = (msg: ClientControl) => { const sendControl = (msg: ClientControl) => {
+2 -3
View File
@@ -1,7 +1,6 @@
// Client-side session-handle generator for the spawn form's instant reroll // Client-side session-handle generator for the spawn form's instant reroll
// (#23/#27). Mirrors the wordlists in `crates/maestro-names/src/lib.rs` verbatim // (#23/#27). The spawn form rerolls without a round trip, so the wordlists
// — the Rust generator is the source of truth, but the web wants a zero-round- // live here; nothing server-side generates handles.
// trip reroll, so the lists are duplicated here. Keep them in sync.
/** Musical adjectives — tempo, dynamics, and articulation markings. */ /** Musical adjectives — tempo, dynamics, and articulation markings. */
const ADJECTIVES = [ const ADJECTIVES = [
+1 -1
View File
@@ -4,7 +4,7 @@
// anything malformed — a corrupt or stale blob falls back to a single empty tab. // anything malformed — a corrupt or stale blob falls back to a single empty tab.
// //
// Sessions that no longer exist when a layout is restored are not pruned here // Sessions that no longer exist when a layout is restored are not pruned here
// (the session list isn't known yet at boot); their panes attach, the broker // (the session list isn't known yet at boot); their panes attach, the daemon
// closes the dead socket, and the pane auto-closes (see ConnectionPool.onClosed). // closes the dead socket, and the pane auto-closes (see ConnectionPool.onClosed).
import { freshId, type Layout, type Node, type Tab } from "./layout.js"; import { freshId, type Layout, type Node, type Tab } from "./layout.js";