chore(maestro): initial scaffold and brainstorm spec

Adds .claude/linear.json for Linear-driven skills (Maestro project on
team Naps62) and the brainstorm design doc covering architecture, wire
protocol, session model, auth, and prototype scope boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 11:51:03 +00:00
co-authored by Claude Opus 4.7
commit 7af764c1a3
2 changed files with 323 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"org": "naps62",
"team": "Naps62",
"project": "Maestro",
"defaultBranch": "main",
"commitScope": "maestro",
"remoteHost": "gitea",
"remoteBaseUrl": "https://git.naps.pt",
"contextFiles": ["CLAUDE.md"],
"prReviewers": [],
"labels": []
}
@@ -0,0 +1,311 @@
# 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 Tailscale)
│ │ │
└──────────────┬───────────┴──────────────┬───────────┘
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ Web client │ │ Web client │
│ (browser) │ │ (phone) │
└───────────────┘ └───────────────┘
```
Three components, all reachable via the user's Tailscale tailnet:
- **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, which Tailscale already secures end-to-end.
## 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. Default port `:8080`, bound to the Tailscale interface.
### 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. Default port `:8081`, bound to the Tailscale interface.
### Web client
Responsibilities:
- Authenticate to the registry (cookie set on first tailnet-IP visit).
- 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 exclusively to the Tailscale interface (resolved at startup from `tailscale ip --4` or equivalent), so non-tailnet packets never reach the process. On first request, it sets an httpOnly cookie signed with the shared secret; all subsequent API calls require it. The cookie is the second factor against a curious tailnet member.
### 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 `MAESTRO_TOKEN` at first run, printed once, stored in `~/.config/maestro/registry.toml`.
- Same token deployed to each daemon (config file or env var) and used in `Hello`.
- Web client gets a cookie from the registry, scoped by tailnet IP. 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. Tailscale is the perimeter; the token is the second factor against a curious tailnet member.
## 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 (non-Tailscale) access | Tailscale assumption stands |
| 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.