This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/docs/superpowers/specs/2026-05-24-maestro-design.md
T
naps62-yolo f061005c55
ci / rust (push) Successful in 4m10s
ci / rust (pull_request) Successful in 4m22s
docs(maestro): unified-config rollover in installer + INSTALL + spec (NAP-233)
- 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.
2026-05-26 09:36:08 +00:00

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:

  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 (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/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 (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 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_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 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.