This commit is contained in:
@@ -49,7 +49,6 @@ crates/
|
||||
├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245)
|
||||
├── maestro-protocol/ # shared wire types (NAP-211)
|
||||
├── maestro-config/ # shared on-disk config schema (NAP-233)
|
||||
├── maestro-names/ # orchestra-themed session-handle generator (#23)
|
||||
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
|
||||
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214)
|
||||
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
|
||||
@@ -63,9 +62,6 @@ nix/ # Home Manager module (`services.maestro`)
|
||||
|
||||
See `docs/INSTALL.md` for end-user install instructions.
|
||||
|
||||
The dated design docs under `docs/superpowers/specs/` predate the single-host
|
||||
refactor; they carry banners marking what no longer applies.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
|
||||
|
||||
Generated
-8
@@ -1077,7 +1077,6 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1147,13 +1146,6 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-names"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-protocol"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -18,13 +18,11 @@ dirs = "5"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
libc = "0.2"
|
||||
maestro-config = { path = "crates/maestro-config" }
|
||||
maestro-names = { path = "crates/maestro-names" }
|
||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||
maestro-tui = { path = "crates/maestro-tui" }
|
||||
nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
rust-embed = "8"
|
||||
include_dir = "0.7"
|
||||
mime_guess = "2"
|
||||
bytes = "1"
|
||||
|
||||
@@ -28,7 +28,6 @@ tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
urlencoding = "2"
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -45,7 +45,7 @@ impl CliConfig {
|
||||
/// Load the subset of the unified file the CLI consumes. A missing file is
|
||||
/// fine — every field has a default.
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||
let file = MaestroConfigFile::load_or_default(path)?;
|
||||
let file = MaestroConfigFile::load(path)?;
|
||||
Ok(Self {
|
||||
daemon_url: file.cli.daemon_url,
|
||||
daemon_bind_addr: file.daemon.bind_addr,
|
||||
|
||||
@@ -41,12 +41,6 @@ pub enum ConfigError {
|
||||
TomlEncode(#[from] toml::ser::Error),
|
||||
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
|
||||
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}")]
|
||||
InvalidBindAddr {
|
||||
env: &'static str,
|
||||
@@ -314,27 +308,17 @@ fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> {
|
||||
}
|
||||
|
||||
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> {
|
||||
if !path.exists() {
|
||||
return Err(ConfigError::MissingFile {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let bytes = std::fs::read(path)?;
|
||||
let s = std::str::from_utf8(&bytes).unwrap_or("");
|
||||
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.
|
||||
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -447,20 +431,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_strict_load_errors_with_pointer_to_configure() {
|
||||
fn missing_file_loads_as_empty_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let err = MaestroConfigFile::load(&path).unwrap_err();
|
||||
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();
|
||||
let cfg = MaestroConfigFile::load(&path).unwrap();
|
||||
assert_eq!(cfg, MaestroConfigFile::default());
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ impl EnvOverrides {
|
||||
/// Resolve the runtime configuration: env overrides on top of the unified
|
||||
/// file, then defaults. A missing file is not an error.
|
||||
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;
|
||||
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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,7 +1,4 @@
|
||||
//! `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)]
|
||||
|
||||
@@ -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()))
|
||||
});
|
||||
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()))?,
|
||||
Err(_) => maestro_config::MaestroConfigFile::default(),
|
||||
};
|
||||
|
||||
@@ -79,5 +79,5 @@ pnpm dev
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -59,11 +59,7 @@ impl EnvOverrides {
|
||||
/// 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.
|
||||
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<WebConfig, WebConfigError> {
|
||||
let file = if path.exists() {
|
||||
MaestroConfigFile::load(path)?
|
||||
} else {
|
||||
MaestroConfigFile::default()
|
||||
};
|
||||
let file = MaestroConfigFile::load(path)?;
|
||||
resolve_with(file.web, env)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI
|
||||
//! 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)]
|
||||
|
||||
|
||||
+2
-2
@@ -149,8 +149,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
||||
# want extra Insert-mode bindings)
|
||||
```
|
||||
|
||||
The full action vocabulary lives in
|
||||
`docs/superpowers/specs/2026-05-28-maestro-tui-design.md` §3.8.
|
||||
The full action vocabulary is the `Action` enum in
|
||||
`crates/maestro-tui/src/action.rs`.
|
||||
|
||||
## 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
@@ -5,7 +5,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
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
|
||||
* 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`
|
||||
* endpoint.
|
||||
*/
|
||||
function brokerStreamUrl(sessionId: Uuid): string {
|
||||
function attachStreamUrl(sessionId: Uuid): string {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
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";
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
// 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
|
||||
// — the Rust generator is the source of truth, but the web wants a zero-round-
|
||||
// trip reroll, so the lists are duplicated here. Keep them in sync.
|
||||
// (#23/#27). The spawn form rerolls without a round trip, so the wordlists
|
||||
// live here; nothing server-side generates handles.
|
||||
|
||||
/** Musical adjectives — tempo, dynamics, and articulation markings. */
|
||||
const ADJECTIVES = [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 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
|
||||
// (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).
|
||||
|
||||
import { freshId, type Layout, type Node, type Tab } from "./layout.js";
|
||||
|
||||
Reference in New Issue
Block a user