- Installer: check for a `[daemon]` section in the unified file (rather than the existence of the now-defunct `~/.config/maestrod/config.toml`) to decide whether to skip the configure prompt on re-runs. The configure prompt + maestrod_config_path() helper now reference `~/.config/maestro/config.toml`. - docs/INSTALL.md: mention the unified file and the single-TOML shape. - spec: update §Registry / §Daemon / §Auth to call out `[registry].bind_addr`, `[daemon].bind_addr`, `[shared].registry_url`, and `[shared].token` instead of the old per-app files.
18 KiB
Maestro — Prototype Design
Date: 2026-05-24 Status: Approved (brainstorm) Scope: First-pass prototype. No production polish.
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:
- Laptop and phone both load the maestro web UI.
- The session list shows tmux sessions from at least two hosts (laptop + dev VM).
- A new session can be spawned from the UI on a chosen host, optionally backed by a fresh git worktree.
- Both clients can attach to the same session concurrently and see live output.
- 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 (
maestrod) — 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/eventsWS.
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 (maestrod)
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.
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):
{
"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 freshSessionsSnapshot. - 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].tokenin~/.config/maestro/config.toml(env var name:MAESTRO_SHARED_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:
maestro/
├── Cargo.toml # workspace
├── crates/
│ ├── maestro-protocol/ # message types, shared between binaries
│ ├── maestro-registry/ # registry binary
│ ├── maestro-daemon/ # daemon binary
│ └── maestro-cli/ # `maestro` CLI for scripting (spawn/list/kill from shell)
├── web/
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
├── docs/superpowers/specs/ # this file, future specs
├── .claude/
│ └── linear.json
└── CLAUDE.md
maestro-cli is a low-priority bonus — handy for scripted spawning and for testing the registry HTTP API. Drops out of scope if it slows the plan.
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 realtmuxbinary in a temp$HOME(withTMUX_TMPDIRset). Worktree creation tested against a tmpdir git repo (git init, then exerciseworktree 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-panefor output capture andtmux send-keysfor input. If pipe-pane latency or framing turns out to be unworkable, fallback isportable-ptywrappingtmux attach. This is implementation-only — no protocol impact. - Registry HTTP framework:
axumis the default Rust choice; reconfirm at plan time. - CLI scope: keep it minimal (
maestro list,maestro spawn,maestro attach) or fuller. Probably minimal.