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:
@@ -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.
|
||||||
Reference in New Issue
Block a user