commit 7af764c1a38565e87a43d0091565944c53343efb Author: naps62-yolo Date: Sun May 24 11:51:03 2026 +0000 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) diff --git a/.claude/linear.json b/.claude/linear.json new file mode 100644 index 0000000..4fda4ad --- /dev/null +++ b/.claude/linear.json @@ -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": [] +} diff --git a/docs/superpowers/specs/2026-05-24-maestro-design.md b/docs/superpowers/specs/2026-05-24-maestro-design.md new file mode 100644 index 0000000..9edef8c --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-maestro-design.md @@ -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-`). +- 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=` + +- **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-` + daemon_id: String, + cwd: PathBuf, // resolved at create time (may be a worktree path) + command: String, + env: HashMap, + metadata: HashMap, // opaque; common keys: name, label + status: SessionStatus, // Running | Dead + created_at: DateTime, +} +``` + +`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 -b ` 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- -c + │ + ▼ + 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/.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.