feat(maestro): web client (Vite + TS + xterm.js)

NAP-218. Browser UI for the prototype: catalog tree derived from
`metadata.label`, live updates over `WS /api/events`, search across
session name+label, spawn modal (with optional git worktree), and an
attach view that opens the daemon's per-session WS for xterm.js.

Framework choice: vanilla TypeScript. The interactivity surface is
small enough that pulling in a framework wouldn't pay back; xterm.js
does the heavy lifting. Easy to swap later without protocol impact.

Hash-based routing (`#/`, `#/attach/<id>`) keeps the registry's
static surface dumb — no SPA fallback needed; concurrent attaches
work by opening the same hash URL in another tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 09:57:11 +00:00
co-authored by Claude Opus 4.7
parent b68e39a9e5
commit e68c9b6d73
16 changed files with 2188 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/*
!dist/.gitkeep
*.log
*.tsbuildinfo
+54
View File
@@ -0,0 +1,54 @@
# maestro-web
Browser UI for Maestro. Connects to the registry HTTP/WS API for the catalog
and to individual daemons (via direct WebSocket) for terminal I/O.
## Framework choice: vanilla TypeScript
No frontend framework. The UI is small enough that the cost of pulling in
Preact / Solid / Leptos doesn't pay back. xterm.js does all the heavy
rendering; everything else is a session tree and a spawn form, both trivial
in plain DOM.
This decision is non-architectural — swap in a framework later without
touching the registry or daemon if needed.
## Layout
```
src/
main.ts # entry point, hash-route dispatch
api.ts # registry HTTP API client
events.ts # `/api/events` WebSocket subscriber
catalog.ts # session tree view (groups by metadata.label)
attach.ts # xterm.js + daemon WS attach view
spawn.ts # spawn-session modal
types.ts # mirror of `maestro-protocol` JSON shapes
styles.css # mobile-first base styles
```
Routing is hash-based:
- `#/` → catalog
- `#/attach/<session-id>` → terminal
This keeps the registry's static handler dumb (no SPA fallback needed).
Concurrent attaches: open the same `#/attach/<id>` URL in another tab.
## Dev
```sh
pnpm install
pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:8080
pnpm build # → dist/, embedded into the registry binary at cargo build time
```
The Vite dev server proxies `/api` and the events WebSocket to a locally
running `maestro-registry` (default `127.0.0.1:8080`). Direct daemon WS
connections (for attach) bypass the proxy and use the URL returned by
`GET /api/sessions/:id/connect-info`.
## Production
`pnpm build` writes the bundle to `web/dist/`. The Rust registry crate
embeds that directory via `rust-embed` at compile time and serves it from
`/`. See `crates/maestro-registry/src/web_assets.rs`.
View File
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="color-scheme" content="dark" />
<title>maestro</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{
"name": "maestro-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+615
View File
@@ -0,0 +1,615 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
devDependencies:
typescript:
specifier: ^5.6.3
version: 5.9.3
vite:
specifier: ^5.4.10
version: 5.4.21
packages:
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@rollup/rollup-android-arm-eabi@4.60.4':
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.4':
resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.4':
resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.4':
resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.4':
resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.4':
resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.4':
resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.4':
resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.4':
resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.4':
resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.4':
resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.4':
resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.4':
resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.4':
resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.4':
resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.4':
resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.4':
resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.4':
resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.4':
resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.4':
resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.4':
resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.4':
resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.4':
resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.4':
resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.4':
resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.60.4:
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.21.5':
optional: true
'@esbuild/android-arm64@0.21.5':
optional: true
'@esbuild/android-arm@0.21.5':
optional: true
'@esbuild/android-x64@0.21.5':
optional: true
'@esbuild/darwin-arm64@0.21.5':
optional: true
'@esbuild/darwin-x64@0.21.5':
optional: true
'@esbuild/freebsd-arm64@0.21.5':
optional: true
'@esbuild/freebsd-x64@0.21.5':
optional: true
'@esbuild/linux-arm64@0.21.5':
optional: true
'@esbuild/linux-arm@0.21.5':
optional: true
'@esbuild/linux-ia32@0.21.5':
optional: true
'@esbuild/linux-loong64@0.21.5':
optional: true
'@esbuild/linux-mips64el@0.21.5':
optional: true
'@esbuild/linux-ppc64@0.21.5':
optional: true
'@esbuild/linux-riscv64@0.21.5':
optional: true
'@esbuild/linux-s390x@0.21.5':
optional: true
'@esbuild/linux-x64@0.21.5':
optional: true
'@esbuild/netbsd-x64@0.21.5':
optional: true
'@esbuild/openbsd-x64@0.21.5':
optional: true
'@esbuild/sunos-x64@0.21.5':
optional: true
'@esbuild/win32-arm64@0.21.5':
optional: true
'@esbuild/win32-ia32@0.21.5':
optional: true
'@esbuild/win32-x64@0.21.5':
optional: true
'@rollup/rollup-android-arm-eabi@4.60.4':
optional: true
'@rollup/rollup-android-arm64@4.60.4':
optional: true
'@rollup/rollup-darwin-arm64@4.60.4':
optional: true
'@rollup/rollup-darwin-x64@4.60.4':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.4':
optional: true
'@rollup/rollup-freebsd-x64@4.60.4':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.4':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.4':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.4':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.4':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.4':
optional: true
'@rollup/rollup-openbsd-x64@4.60.4':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.4':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.4':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.4':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.4':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.4':
optional: true
'@types/estree@1.0.8': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/xterm@5.5.0': {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
'@esbuild/android-arm': 0.21.5
'@esbuild/android-arm64': 0.21.5
'@esbuild/android-x64': 0.21.5
'@esbuild/darwin-arm64': 0.21.5
'@esbuild/darwin-x64': 0.21.5
'@esbuild/freebsd-arm64': 0.21.5
'@esbuild/freebsd-x64': 0.21.5
'@esbuild/linux-arm': 0.21.5
'@esbuild/linux-arm64': 0.21.5
'@esbuild/linux-ia32': 0.21.5
'@esbuild/linux-loong64': 0.21.5
'@esbuild/linux-mips64el': 0.21.5
'@esbuild/linux-ppc64': 0.21.5
'@esbuild/linux-riscv64': 0.21.5
'@esbuild/linux-s390x': 0.21.5
'@esbuild/linux-x64': 0.21.5
'@esbuild/netbsd-x64': 0.21.5
'@esbuild/openbsd-x64': 0.21.5
'@esbuild/sunos-x64': 0.21.5
'@esbuild/win32-arm64': 0.21.5
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
fsevents@2.3.3:
optional: true
nanoid@3.3.12: {}
picocolors@1.1.1: {}
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.60.4:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.4
'@rollup/rollup-android-arm64': 4.60.4
'@rollup/rollup-darwin-arm64': 4.60.4
'@rollup/rollup-darwin-x64': 4.60.4
'@rollup/rollup-freebsd-arm64': 4.60.4
'@rollup/rollup-freebsd-x64': 4.60.4
'@rollup/rollup-linux-arm-gnueabihf': 4.60.4
'@rollup/rollup-linux-arm-musleabihf': 4.60.4
'@rollup/rollup-linux-arm64-gnu': 4.60.4
'@rollup/rollup-linux-arm64-musl': 4.60.4
'@rollup/rollup-linux-loong64-gnu': 4.60.4
'@rollup/rollup-linux-loong64-musl': 4.60.4
'@rollup/rollup-linux-ppc64-gnu': 4.60.4
'@rollup/rollup-linux-ppc64-musl': 4.60.4
'@rollup/rollup-linux-riscv64-gnu': 4.60.4
'@rollup/rollup-linux-riscv64-musl': 4.60.4
'@rollup/rollup-linux-s390x-gnu': 4.60.4
'@rollup/rollup-linux-x64-gnu': 4.60.4
'@rollup/rollup-linux-x64-musl': 4.60.4
'@rollup/rollup-openbsd-x64': 4.60.4
'@rollup/rollup-openharmony-arm64': 4.60.4
'@rollup/rollup-win32-arm64-msvc': 4.60.4
'@rollup/rollup-win32-ia32-msvc': 4.60.4
'@rollup/rollup-win32-x64-gnu': 4.60.4
'@rollup/rollup-win32-x64-msvc': 4.60.4
fsevents: 2.3.3
source-map-js@1.2.1: {}
typescript@5.9.3: {}
vite@5.4.21:
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.60.4
optionalDependencies:
fsevents: 2.3.3
+64
View File
@@ -0,0 +1,64 @@
import type {
ApiError,
ConnectInfo,
CreateSessionRequest,
DaemonInfo,
Session,
Uuid,
} from "./types.js";
async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) {
if (res.status === 204) return undefined as unknown as T;
return (await res.json()) as T;
}
let detail = "";
try {
const body = (await res.json()) as ApiError;
detail = `${body.error.code}: ${body.error.message}`;
} catch {
detail = await res.text();
}
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
export async function listDaemons(): Promise<DaemonInfo[]> {
return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" }));
}
export async function listSessions(): Promise<Session[]> {
return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" }));
}
export async function createSession(req: CreateSessionRequest): Promise<Session> {
return jsonOrError(
await fetch("/api/sessions", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(req),
}),
);
}
export async function killSession(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "same-origin",
}),
);
}
export async function getConnectInfo(id: Uuid): Promise<ConnectInfo> {
return jsonOrError(
await fetch(`/api/sessions/${encodeURIComponent(id)}/connect-info`, {
credentials: "same-origin",
}),
);
}
export function eventsWsUrl(): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/events`;
}
+171
View File
@@ -0,0 +1,171 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { getConnectInfo } from "./api.js";
import type { ClientControl, Uuid } from "./types.js";
interface AttachHandle {
dispose: () => void;
}
function statusMessage(host: HTMLElement, kind: "info" | "error", text: string): void {
host.innerHTML = `<div class="attach-status status-${kind}">${text}</div>`;
}
function streamUrl(daemonUrl: string, sessionId: Uuid, jwt: string): string {
let base = daemonUrl;
// Allow operators to give an http(s) URL; convert to ws(s).
if (base.startsWith("http://")) base = "ws://" + base.slice("http://".length);
else if (base.startsWith("https://")) base = "wss://" + base.slice("https://".length);
// Trim trailing slash.
base = base.replace(/\/+$/, "");
const encId = encodeURIComponent(sessionId);
const encJwt = encodeURIComponent(jwt);
return `${base}/sessions/${encId}/stream?token=${encJwt}`;
}
export async function renderAttach(
root: HTMLElement,
sessionId: Uuid,
): Promise<AttachHandle> {
root.innerHTML = "";
const shell = document.createElement("div");
shell.className = "attach";
shell.innerHTML = `
<header class="attach-header">
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog">←</a>
<span class="attach-title" title="${sessionId}">${sessionId.slice(0, 8)}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
</header>
<div class="attach-body"></div>
`;
root.appendChild(shell);
const body = shell.querySelector(".attach-body") as HTMLDivElement;
const stateEl = shell.querySelector(".attach-state") as HTMLSpanElement;
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
stateEl.textContent = s;
stateEl.dataset.state = kind;
};
statusMessage(body, "info", "Fetching connect info…");
let connect;
try {
connect = await getConnectInfo(sessionId);
} catch (e) {
setState("error", "error");
statusMessage(body, "error", `Failed to get connect info: ${(e as Error).message}`);
return { dispose: () => {} };
}
body.innerHTML = "";
const term = new Terminal({
cursorBlink: true,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
background: "#101115",
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
const tryFit = () => {
try {
fit.fit();
} catch {
// ignore — fit fails if container is detached
}
};
tryFit();
const wsUrl = streamUrl(connect.daemon_url, sessionId, connect.jwt);
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
ws.onopen = () => {
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
// Text frames: ignore unknown control messages; we just need a parse.
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore
}
return;
}
if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data));
} else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer".
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
}
};
ws.onerror = () => {
setState("error", "error");
};
ws.onclose = (ev) => {
setState(`closed (${ev.code})`, "closed");
};
// PTY input: every keystroke goes out as a binary frame.
const dataDisp = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
window.addEventListener("resize", onResize);
// Keep focus + sizing correct when the visual viewport shifts (mobile keyboards).
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
};
return { dispose };
}
+228
View File
@@ -0,0 +1,228 @@
import { killSession } from "./api.js";
import { CatalogStore } from "./events.js";
import type { DaemonInfo, Session } from "./types.js";
import { sessionLabel, sessionName } from "./types.js";
import { openSpawnModal } from "./spawn.js";
interface TreeFolder {
kind: "folder";
name: string;
path: string;
children: TreeNode[];
}
interface TreeLeaf {
kind: "leaf";
session: Session;
}
type TreeNode = TreeFolder | TreeLeaf;
const collapsed = new Set<string>();
function compareNodes(a: TreeNode, b: TreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const an = a.kind === "folder" ? a.name : sessionName(a.session);
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
return an.localeCompare(bn);
}
function buildTree(sessions: Session[], filter: string): TreeNode {
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
if (lc) {
const haystack = `${sessionName(s)} ${sessionLabel(s) ?? ""}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
const label = sessionLabel(s);
const parts = label ? label.split("/").filter(Boolean) : [];
let cursor = root;
for (const part of parts) {
const path = cursor.path ? `${cursor.path}/${part}` : part;
let next = cursor.children.find(
(n): n is TreeFolder => n.kind === "folder" && n.name === part,
);
if (!next) {
next = { kind: "folder", name: part, path, children: [] };
cursor.children.push(next);
}
cursor = next;
}
cursor.children.push({ kind: "leaf", session: s });
}
const sortRec = (n: TreeFolder) => {
n.children.sort(compareNodes);
for (const c of n.children) if (c.kind === "folder") sortRec(c);
};
sortRec(root);
return root;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
wrap.className = "tree-folder";
if (node.name) {
const header = document.createElement("button");
header.type = "button";
header.className = "tree-folder-header";
header.style.paddingLeft = `${depth * 16}px`;
const isCollapsed = collapsed.has(node.path);
header.setAttribute("aria-expanded", String(!isCollapsed));
header.innerHTML = `<span class="chevron">${isCollapsed ? "▸" : "▾"}</span><span class="folder-name">${escapeHtml(node.name)}</span><span class="folder-count">${countLeaves(node)}</span>`;
header.addEventListener("click", () => {
if (collapsed.has(node.path)) collapsed.delete(node.path);
else collapsed.add(node.path);
// re-render handled by caller observing the store; trigger a refresh:
document.dispatchEvent(new CustomEvent("catalog-rerender"));
});
wrap.appendChild(header);
if (isCollapsed) return wrap;
}
for (const child of node.children) {
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
}
return wrap;
}
const s = node.session;
const row = document.createElement("a");
row.className = "tree-leaf";
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
const status = s.status === "Running" ? "running" : "dead";
row.innerHTML = `
<span class="status status-${status}" aria-hidden="true"></span>
<span class="leaf-main">
<span class="leaf-name">${escapeHtml(sessionName(s))}</span>
<span class="leaf-meta">${escapeHtml(s.daemon_id)} · ${escapeHtml(s.command)}</span>
</span>
<button class="leaf-kill" type="button" title="Kill session" aria-label="Kill session">✕</button>
`;
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", async (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
try {
await killSession(s.id);
} catch (e) {
alert(`Failed to kill: ${(e as Error).message}`);
}
});
return row;
}
function countLeaves(node: TreeNode): number {
if (node.kind === "leaf") return 1;
return node.children.reduce((n, c) => n + countLeaves(c), 0);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderDaemons(daemons: DaemonInfo[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "daemon-bar";
if (daemons.length === 0) {
wrap.innerHTML = `<span class="daemon-empty">No daemons connected</span>`;
return wrap;
}
for (const d of daemons) {
const chip = document.createElement("span");
chip.className = "daemon-chip";
chip.title = `${d.hostname} · ${d.daemon_url}`;
chip.textContent = d.daemon_id;
wrap.appendChild(chip);
}
return wrap;
}
export function renderCatalog(root: HTMLElement, store: CatalogStore): () => void {
root.innerHTML = "";
const shell = document.createElement("div");
shell.className = "catalog";
const header = document.createElement("header");
header.className = "catalog-header";
header.innerHTML = `
<h1>maestro</h1>
<div class="catalog-actions">
<input
type="search"
class="search"
placeholder="Search sessions…"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
<button type="button" class="btn btn-primary spawn-btn">+ Spawn</button>
</div>
`;
shell.appendChild(header);
const daemonsHost = document.createElement("div");
shell.appendChild(daemonsHost);
const treeHost = document.createElement("div");
treeHost.className = "tree";
shell.appendChild(treeHost);
root.appendChild(shell);
const search = header.querySelector(".search") as HTMLInputElement;
let filter = "";
search.addEventListener("input", () => {
filter = search.value;
refresh();
});
const spawnBtn = header.querySelector(".spawn-btn") as HTMLButtonElement;
spawnBtn.addEventListener("click", () => {
openSpawnModal(store.snapshot().daemons);
});
const refresh = () => {
const state = store.snapshot();
daemonsHost.replaceChildren(renderDaemons([...state.daemons.values()]));
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, filter);
treeHost.replaceChildren();
if (sessions.length === 0) {
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent =
store.snapshot().daemons.size === 0
? "Waiting for a daemon to connect…"
: "No sessions yet. Press + Spawn to create one.";
treeHost.appendChild(empty);
return;
}
if (tree.kind === "folder") {
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
};
const rerenderHandler = () => refresh();
document.addEventListener("catalog-rerender", rerenderHandler);
const unsubscribe = store.subscribe(refresh);
return () => {
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
};
}
+136
View File
@@ -0,0 +1,136 @@
import { eventsWsUrl, listDaemons, listSessions } from "./api.js";
import type { DaemonInfo, RegistryEvent, Session, Uuid } from "./types.js";
export interface CatalogState {
daemons: Map<string, DaemonInfo>;
sessions: Map<Uuid, Session>;
}
export type CatalogListener = (state: CatalogState) => void;
export class CatalogStore {
private state: CatalogState = { daemons: new Map(), sessions: new Map() };
private listeners = new Set<CatalogListener>();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
private backoffMs = 500;
start(): void {
this.connect();
}
stop(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
}
subscribe(listener: CatalogListener): () => void {
this.listeners.add(listener);
listener(this.snapshot());
return () => this.listeners.delete(listener);
}
snapshot(): CatalogState {
return this.state;
}
private connect(): void {
const ws = new WebSocket(eventsWsUrl());
this.ws = ws;
ws.onmessage = (ev) => {
let event: RegistryEvent;
try {
event = JSON.parse(ev.data) as RegistryEvent;
} catch {
return;
}
this.apply(event);
};
ws.onopen = () => {
this.backoffMs = 500;
};
ws.onclose = () => {
this.ws = null;
this.scheduleReconnect();
};
ws.onerror = () => {
try {
ws.close();
} catch {
// ignore
}
};
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return;
const delay = Math.min(this.backoffMs, 10_000);
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
this.backoffMs = Math.min(this.backoffMs * 2, 10_000);
this.connect();
}, delay);
}
private apply(event: RegistryEvent): void {
switch (event.type) {
case "Snapshot":
this.state = {
daemons: new Map(event.daemons.map((d) => [d.daemon_id, d])),
sessions: new Map(event.sessions.map((s) => [s.id, s])),
};
break;
case "DaemonAdded":
this.state.daemons.set(event.daemon.daemon_id, event.daemon);
break;
case "DaemonRemoved":
this.state.daemons.delete(event.daemon_id);
for (const [id, sess] of this.state.sessions) {
if (sess.daemon_id === event.daemon_id) this.state.sessions.delete(id);
}
break;
case "SessionAdded":
this.state.sessions.set(event.session.id, event.session);
break;
case "SessionRemoved":
this.state.sessions.delete(event.id);
break;
case "SessionUpdated":
this.state.sessions.set(event.session.id, event.session);
break;
case "Lagged":
// Re-derive from REST to avoid showing stale state.
void this.resync();
return;
}
this.emit();
}
private async resync(): Promise<void> {
try {
const [daemons, sessions] = await Promise.all([listDaemons(), listSessions()]);
this.state = {
daemons: new Map(daemons.map((d) => [d.daemon_id, d])),
sessions: new Map(sessions.map((s) => [s.id, s])),
};
this.emit();
} catch {
// surface in UI on next reconnect cycle; nothing else useful to do
}
}
private emit(): void {
for (const fn of this.listeners) fn(this.state);
}
}
+67
View File
@@ -0,0 +1,67 @@
import { renderAttach } from "./attach.js";
import { renderCatalog } from "./catalog.js";
import { CatalogStore } from "./events.js";
import "./styles.css";
const root = document.getElementById("app");
if (!root) {
throw new Error("missing #app");
}
const store = new CatalogStore();
store.start();
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
| { kind: "not-found"; raw: string };
function parseRoute(): Route {
const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" };
const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "not-found", raw: hash };
}
let currentTeardown: (() => void) | (() => Promise<void>) | null = null;
async function dispatch() {
if (currentTeardown) {
try {
await Promise.resolve(currentTeardown());
} catch {
// ignore
}
currentTeardown = null;
}
const route = parseRoute();
switch (route.kind) {
case "catalog": {
const teardown = renderCatalog(root!, store);
currentTeardown = teardown;
return;
}
case "attach": {
const handle = await renderAttach(root!, route.sessionId);
currentTeardown = () => handle.dispose();
return;
}
case "not-found": {
root!.innerHTML = `
<div class="not-found">
<h1>Unknown route</h1>
<p><code>#${route.raw}</code> doesn't match anything.</p>
<a class="btn btn-primary" href="#/">Back to catalog</a>
</div>
`;
return;
}
}
}
window.addEventListener("hashchange", () => {
void dispatch();
});
void dispatch();
+155
View File
@@ -0,0 +1,155 @@
import { createSession } from "./api.js";
import type { CreateSessionRequest, DaemonInfo } from "./types.js";
export function openSpawnModal(daemons: Map<string, DaemonInfo>): void {
const existing = document.getElementById("modal-host");
if (existing) existing.remove();
const host = document.createElement("div");
host.id = "modal-host";
host.className = "modal-backdrop";
host.innerHTML = `
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
<header class="modal-header">
<h2 id="spawn-title">Spawn session</h2>
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
</header>
<form class="modal-body" id="spawn-form">
<label>
Daemon
<select name="daemon_id" required></select>
</label>
<label>
Working directory
<input name="cwd" type="text" required placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</label>
<label>
Command
<input name="command" type="text" required placeholder="bash" autocomplete="off" />
</label>
<label>
Name (optional)
<input name="name" type="text" placeholder="e.g. impl" autocomplete="off" />
</label>
<label>
Label (optional, e.g. <code>personal/maestro</code>)
<input name="label" type="text" placeholder="group/name" autocomplete="off" />
</label>
<fieldset class="worktree">
<legend>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
</legend>
<label>
Base repo
<input name="worktree_base_repo" type="text" placeholder="/path/to/repo" autocomplete="off" />
</label>
<label>
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="checkbox" name="worktree_cleanup_on_kill" />
Remove worktree when session is killed
</label>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
<div class="modal-actions">
<button type="button" class="btn btn-ghost cancel">Cancel</button>
<button type="submit" class="btn btn-primary">Spawn</button>
</div>
</form>
</div>
`;
document.body.appendChild(host);
const select = host.querySelector("select[name=daemon_id]") as HTMLSelectElement;
if (daemons.size === 0) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(no daemons connected)";
opt.disabled = true;
opt.selected = true;
select.appendChild(opt);
} else {
for (const d of daemons.values()) {
const opt = document.createElement("option");
opt.value = d.daemon_id;
opt.textContent = `${d.daemon_id} (${d.hostname})`;
select.appendChild(opt);
}
}
const close = () => host.remove();
host.querySelector(".close")?.addEventListener("click", close);
host.querySelector(".cancel")?.addEventListener("click", close);
host.addEventListener("click", (e) => {
if (e.target === host) close();
});
window.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") close();
},
{ once: true },
);
const form = host.querySelector("#spawn-form") as HTMLFormElement;
const errEl = form.querySelector(".form-error") as HTMLElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
errEl.textContent = "";
const data = new FormData(form);
const daemonId = String(data.get("daemon_id") ?? "");
const cwd = String(data.get("cwd") ?? "").trim();
const command = String(data.get("command") ?? "").trim();
if (!daemonId || !cwd || !command) {
errEl.textContent = "Daemon, cwd, and command are required.";
return;
}
const name = String(data.get("name") ?? "").trim();
const label = String(data.get("label") ?? "").trim();
const metadata: Record<string, unknown> = {};
if (name) metadata.name = name;
if (label) metadata.label = label;
const req: CreateSessionRequest = {
daemon_id: daemonId,
cwd,
command,
metadata,
};
if (data.get("worktree_enabled")) {
const baseRepo = String(data.get("worktree_base_repo") ?? "").trim();
const branch = String(data.get("worktree_branch") ?? "").trim();
if (!baseRepo || !branch) {
errEl.textContent = "Worktree requires base repo and branch.";
return;
}
req.worktree = {
base_repo: baseRepo,
branch,
cleanup_on_kill: Boolean(data.get("worktree_cleanup_on_kill")),
};
}
const submitBtn = form.querySelector(
"button[type=submit]",
) as HTMLButtonElement;
submitBtn.disabled = true;
try {
const session = await createSession(req);
close();
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
} catch (err) {
errEl.textContent = (err as Error).message;
submitBtn.disabled = false;
}
});
const firstInput = form.querySelector("input, select") as HTMLElement | null;
firstInput?.focus();
}
+535
View File
@@ -0,0 +1,535 @@
:root {
--bg: #0c0d10;
--surface: #14161b;
--surface-2: #1c1f26;
--border: #2a2e38;
--fg: #e6e6e6;
--muted: #98a2b3;
--accent: #6c8eef;
--accent-fg: #0c0d10;
--danger: #e5484d;
--ok: #30a46c;
--row-h: 44px;
--font-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
monospace;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.4;
-webkit-text-size-adjust: 100%;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
a {
color: inherit;
text-decoration: none;
}
code {
font-family: var(--font-mono);
font-size: 0.92em;
padding: 1px 4px;
background: var(--surface-2);
border-radius: 3px;
}
button,
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
}
.btn {
border: 1px solid var(--border);
background: var(--surface);
color: var(--fg);
padding: 0 14px;
height: 36px;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:hover {
background: var(--surface-2);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-ghost {
background: transparent;
}
.btn-icon {
width: 36px;
padding: 0;
}
/* ---------- catalog ---------- */
.catalog {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.catalog-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 1;
}
.catalog-header h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0.02em;
}
.catalog-actions {
display: flex;
flex: 1 1 280px;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.search {
flex: 1 1 220px;
max-width: 360px;
height: 36px;
padding: 0 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
outline: none;
}
.search:focus {
border-color: var(--accent);
}
.daemon-bar {
padding: 8px 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.daemon-chip {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
color: var(--muted);
}
.daemon-empty {
font-size: 12px;
color: var(--muted);
}
.tree {
flex: 1 1 auto;
overflow: auto;
padding: 8px 0 24px;
}
.empty {
padding: 24px 16px;
color: var(--muted);
text-align: center;
}
.tree-folder {
display: flex;
flex-direction: column;
}
.tree-folder-header {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: 0;
width: 100%;
text-align: left;
height: var(--row-h);
padding-right: 16px;
color: var(--muted);
cursor: pointer;
font-size: 13px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.tree-folder-header:hover {
background: var(--surface);
}
.tree-folder-header .chevron {
width: 14px;
text-align: center;
color: var(--muted);
}
.folder-name {
flex: 1 1 auto;
color: var(--fg);
text-transform: none;
font-size: 14px;
letter-spacing: 0;
}
.folder-count {
font-size: 12px;
color: var(--muted);
padding: 1px 8px;
border-radius: 999px;
background: var(--surface-2);
}
.tree-leaf {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--row-h);
padding: 8px 16px 8px 8px;
border-bottom: 1px solid transparent;
color: var(--fg);
}
.tree-leaf:hover {
background: var(--surface);
}
.tree-leaf:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.status {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 8px;
background: var(--muted);
}
.status-running {
background: var(--ok);
}
.status-dead {
background: var(--danger);
opacity: 0.7;
}
.leaf-main {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.leaf-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-meta {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-kill {
background: transparent;
color: var(--muted);
border: 1px solid transparent;
border-radius: 8px;
width: 36px;
height: 36px;
cursor: pointer;
flex: 0 0 36px;
}
.leaf-kill:hover {
color: var(--danger);
background: var(--surface-2);
border-color: var(--border);
}
/* ---------- attach view ---------- */
.attach {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
}
.attach-header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.attach-header .back {
font-size: 18px;
width: 40px;
height: 36px;
}
.attach-title {
font-family: var(--font-mono);
font-size: 13px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
.attach-state {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
border: 1px solid var(--border);
}
.attach-state[data-state="open"] {
color: var(--ok);
}
.attach-state[data-state="closed"],
.attach-state[data-state="error"] {
color: var(--danger);
}
.attach-body {
flex: 1 1 auto;
min-height: 0;
padding: 6px;
display: flex;
background: #101115;
}
.attach-body .xterm {
width: 100%;
height: 100%;
}
.attach-status {
margin: 16px;
padding: 12px 14px;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--border);
font-size: 14px;
}
.attach-status.status-error {
border-color: var(--danger);
color: var(--danger);
}
/* ---------- modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
z-index: 50;
}
@media (min-width: 600px) {
.modal-backdrop {
align-items: center;
padding: 24px;
}
}
.modal {
background: var(--surface);
border-radius: 16px 16px 0 0;
border: 1px solid var(--border);
width: 100%;
max-width: 560px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
@media (min-width: 600px) {
.modal {
border-radius: 12px;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
margin: 0;
font-size: 16px;
}
.modal-body {
padding: 16px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-body label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--muted);
}
.modal-body label.row-inline {
flex-direction: row;
align-items: center;
gap: 8px;
color: var(--fg);
font-size: 14px;
}
.modal-body input[type="text"],
.modal-body select {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
color: var(--fg);
font-size: 15px;
width: 100%;
}
.modal-body input[type="text"]:focus,
.modal-body select:focus {
outline: none;
border-color: var(--accent);
}
.worktree {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.worktree legend {
padding: 0 6px;
color: var(--fg);
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.form-error {
color: var(--danger);
margin: 0;
font-size: 13px;
min-height: 1.2em;
}
/* ---------- not-found ---------- */
.not-found {
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
/* ---------- responsive tweaks ---------- */
@media (max-width: 480px) {
.catalog-header h1 {
font-size: 16px;
}
.catalog-actions {
flex: 1 1 100%;
justify-content: stretch;
}
.search {
max-width: none;
}
}
+81
View File
@@ -0,0 +1,81 @@
// Mirror of `maestro-protocol` JSON shapes. Keep in sync with
// crates/maestro-protocol/src/lib.rs.
export type Uuid = string;
export type IsoTimestamp = string;
export type SessionStatus = "Running" | "Dead";
export interface Session {
id: Uuid;
daemon_id: string;
cwd: string;
command: string;
env: Record<string, string>;
metadata: Record<string, unknown>;
status: SessionStatus;
created_at: IsoTimestamp;
}
export interface DaemonInfo {
daemon_id: string;
hostname: string;
version: string;
daemon_url: string;
}
export interface ConnectInfo {
daemon_url: string;
jwt: string;
}
export interface WorktreeSpec {
base_repo: string;
branch: string;
cleanup_on_kill?: boolean;
}
export interface CreateSessionRequest {
daemon_id: string;
cwd: string;
command: string;
env?: Record<string, string>;
metadata?: Record<string, unknown>;
worktree?: WorktreeSpec;
}
export type RegistryEvent =
| {
type: "Snapshot";
daemons: DaemonInfo[];
sessions: Session[];
}
| { type: "DaemonAdded"; daemon: DaemonInfo }
| { type: "DaemonRemoved"; daemon_id: string }
| { type: "SessionAdded"; session: Session }
| { type: "SessionRemoved"; daemon_id: string; id: Uuid }
| { type: "SessionUpdated"; session: Session }
| { type: "Lagged"; skipped: number };
export type ClientControl =
| { type: "Resize"; cols: number; rows: number }
| { type: "Ping" }
| { type: "Pong" }
| { type: "Error"; code: string; message: string };
export interface ApiError {
error: { code: string; message: string };
}
export function sessionName(s: Session): string {
const meta = s.metadata as Record<string, unknown>;
const name = meta?.name;
if (typeof name === "string" && name.length > 0) return name;
return s.command;
}
export function sessionLabel(s: Session): string | undefined {
const meta = s.metadata as Record<string, unknown>;
const label = meta?.label;
return typeof label === "string" ? label : undefined;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"useDefineForClassFields": true,
"noEmit": true
},
"include": ["src"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
export default defineConfig({
build: {
target: "es2022",
outDir: "dist",
emptyOutDir: true,
sourcemap: false,
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:8080",
changeOrigin: false,
ws: true,
},
},
},
});