diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..23d18e7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+bin/
+internal/web/dist/
+web/node_modules/
+web/dist/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1f19e1b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,26 @@
+VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
+GO ?= go
+PREFIX ?= /usr/local/bin
+
+.PHONY: all web build dev deploy clean
+
+all: build
+
+web:
+ cd web && npm ci --no-audit --no-fund && npm run build
+
+build: web
+ $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet
+
+# backend only (assumes web/dist already built)
+go:
+ $(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet
+
+dev:
+ cd web && npm run dev
+
+deploy: build
+ ./deploy/install.sh
+
+clean:
+ rm -rf bin internal/web/dist web/dist
diff --git a/cmd/hamlet/main.go b/cmd/hamlet/main.go
new file mode 100644
index 0000000..e6359d1
--- /dev/null
+++ b/cmd/hamlet/main.go
@@ -0,0 +1,160 @@
+// Hamlet — Proxmox-style web console for a bare-metal Debian host.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "os/user"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "golang.org/x/term"
+
+ "hamlet/internal/api"
+ "hamlet/internal/auth"
+ "hamlet/internal/config"
+ "hamlet/internal/store"
+ hterm "hamlet/internal/term"
+ "hamlet/internal/web"
+)
+
+var version = "dev"
+
+func main() {
+ defaultCfg := os.Getenv("HAMLET_CONFIG")
+ if defaultCfg == "" {
+ home, _ := os.UserHomeDir()
+ defaultCfg = filepath.Join(home, "apps", "hamlet", "config.json")
+ }
+ cfgPath := flag.String("config", defaultCfg, "path to config.json")
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "usage: hamlet [-config path] [serve|passwd|version]\n")
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+
+ cmd := "serve"
+ if flag.NArg() > 0 {
+ cmd = flag.Arg(0)
+ }
+ cfg, err := config.Load(*cfgPath)
+ if err != nil {
+ log.Fatal(err)
+ }
+ switch cmd {
+ case "version":
+ fmt.Println("hamlet", version)
+ case "passwd":
+ if err := passwd(cfg); err != nil {
+ log.Fatal(err)
+ }
+ case "serve":
+ if err := serve(cfg); err != nil {
+ log.Fatal(err)
+ }
+ default:
+ flag.Usage()
+ os.Exit(2)
+ }
+}
+
+func passwd(cfg *config.Config) error {
+ fmt.Fprint(os.Stderr, "New password: ")
+ p1, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Fprintln(os.Stderr)
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(os.Stderr, "Again: ")
+ p2, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Fprintln(os.Stderr)
+ if err != nil {
+ return err
+ }
+ if string(p1) != string(p2) {
+ return errors.New("passwords do not match")
+ }
+ if len(p1) < 12 {
+ return errors.New("password must be at least 12 characters")
+ }
+ h, err := auth.HashPassword(string(p1))
+ if err != nil {
+ return err
+ }
+ cfg.PasswordHash = h
+ if err := cfg.Save(); err != nil {
+ return err
+ }
+ fmt.Fprintln(os.Stderr, "password set in", cfg.Path())
+ return nil
+}
+
+func serve(cfg *config.Config) error {
+ if cfg.PasswordHash == "" {
+ return fmt.Errorf("no password set — run: hamlet -config %s passwd", cfg.Path())
+ }
+ if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil {
+ return err
+ }
+ st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
+ if err != nil {
+ return err
+ }
+ sessions := auth.NewSessions(st)
+
+ u, err := user.Current()
+ if err != nil {
+ return err
+ }
+ shell := os.Getenv("SHELL")
+ if shell == "" {
+ shell = "/bin/bash"
+ }
+ mgr := hterm.NewManager(shell, u.HomeDir)
+ mgr.OnChange = func(list []hterm.Info) {
+ _ = st.Update(func(s *store.State) {
+ s.Terminals = s.Terminals[:0]
+ for _, t := range list {
+ s.Terminals = append(s.Terminals, store.Terminal{ID: t.ID, Name: t.Name})
+ }
+ })
+ }
+ // Re-open the shells that existed before the last restart (fresh processes,
+ // same names/order) so the sidebar comes back as it was.
+ var saved []store.Terminal
+ st.View(func(s *store.State) { saved = append(saved, s.Terminals...) })
+ for _, t := range saved {
+ if _, err := mgr.Create(t.ID, t.Name); err != nil {
+ log.Printf("restore terminal %q: %v", t.Name, err)
+ }
+ }
+
+ srv := api.New(cfg, sessions, mgr, web.FS(), version)
+ hs := &http.Server{
+ Addr: cfg.Listen,
+ Handler: srv.Handler(),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ go func() {
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
+ <-sig
+ log.Print("shutting down")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = hs.Shutdown(ctx)
+ mgr.Shutdown()
+ }()
+ log.Printf("hamlet %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
+ if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+}
diff --git a/deploy/hamlet.service b/deploy/hamlet.service
new file mode 100644
index 0000000..069cfff
--- /dev/null
+++ b/deploy/hamlet.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Hamlet web console
+After=network.target
+
+[Service]
+Type=simple
+User=core
+Group=core
+WorkingDirectory=/home/core
+Environment=HAMLET_CONFIG=/home/core/apps/hamlet/config.json
+ExecStart=/usr/local/bin/hamlet serve
+Restart=always
+RestartSec=3
+KillMode=control-group
+TimeoutStopSec=10
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/install.sh b/deploy/install.sh
new file mode 100755
index 0000000..575edb8
--- /dev/null
+++ b/deploy/install.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+# Install the built binary and unit on this host, then (re)start the service.
+set -eu
+cd "$(dirname "$0")/.."
+sudo install -m 0755 bin/hamlet /usr/local/bin/hamlet
+sudo install -m 0644 deploy/hamlet.service /etc/systemd/system/hamlet.service
+sudo systemctl daemon-reload
+sudo systemctl enable --now hamlet.service >/dev/null
+sudo systemctl restart hamlet.service
+sleep 1
+systemctl is-active hamlet.service && /usr/local/bin/hamlet version
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..26fb4e9
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,12 @@
+module hamlet
+
+go 1.26.0
+
+require (
+ github.com/coder/websocket v1.8.15
+ github.com/creack/pty v1.1.24
+ golang.org/x/crypto v0.57.0
+ golang.org/x/term v0.46.0
+)
+
+require golang.org/x/sys v0.48.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f1c4538
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
+golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
diff --git a/internal/api/server.go b/internal/api/server.go
new file mode 100644
index 0000000..cd21fa6
--- /dev/null
+++ b/internal/api/server.go
@@ -0,0 +1,326 @@
+// Package api is the HTTP/WebSocket surface: auth, terminal CRUD, attach, static UI.
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/coder/websocket"
+
+ "hamlet/internal/auth"
+ "hamlet/internal/config"
+ "hamlet/internal/term"
+)
+
+const cookieName = "hamlet_session"
+
+type Server struct {
+ cfg *config.Config
+ sessions *auth.Sessions
+ limiter auth.Limiter
+ mgr *term.Manager
+ version string
+ started time.Time
+ user string
+ host string
+ ui fs.FS
+ mux *http.ServeMux
+}
+
+func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, ui fs.FS, version string) *Server {
+ host, _ := os.Hostname()
+ s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, ui: ui, version: version,
+ started: time.Now(), host: host, user: os.Getenv("USER")}
+ if s.user == "" {
+ s.user = "core"
+ }
+ m := http.NewServeMux()
+ m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
+ m.HandleFunc("POST /api/login", s.login)
+ m.HandleFunc("POST /api/logout", s.auth(s.logout))
+ m.HandleFunc("GET /api/me", s.auth(s.me))
+ m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
+ m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
+ m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal))
+ m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal))
+ m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
+ m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
+ m.HandleFunc("/", s.static)
+ s.mux = m
+ return s
+}
+
+func (s *Server) Handler() http.Handler { return s.mux }
+
+// ---- helpers ----
+
+func jsonOK(w http.ResponseWriter, code int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func jsonErr(w http.ResponseWriter, code int, msg string) {
+ jsonOK(w, code, map[string]string{"error": msg})
+}
+
+func secure(r *http.Request) bool {
+ return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
+
+func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, maxAge int) {
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName, Value: tok, Path: "/", MaxAge: maxAge,
+ HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteStrictMode,
+ })
+}
+
+func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(cookieName)
+ if err != nil || !s.sessions.Valid(c.Value) {
+ jsonErr(w, 401, "unauthenticated")
+ return
+ }
+ next(w, r)
+ }
+}
+
+// ---- auth ----
+
+func (s *Server) login(w http.ResponseWriter, r *http.Request) {
+ if err := s.limiter.Allow(); err != nil {
+ jsonErr(w, 429, "too many attempts, try again later")
+ return
+ }
+ var body struct {
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
+ jsonErr(w, 400, "bad request")
+ return
+ }
+ if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
+ s.limiter.Fail()
+ log.Printf("login failed from %s", clientIP(r))
+ jsonErr(w, 401, "wrong password")
+ return
+ }
+ s.limiter.Reset()
+ tok, err := s.sessions.Create()
+ if err != nil {
+ jsonErr(w, 500, "session error")
+ return
+ }
+ s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
+ log.Printf("login ok from %s", clientIP(r))
+ w.WriteHeader(204)
+}
+
+func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie(cookieName); err == nil {
+ s.sessions.Delete(c.Value)
+ }
+ s.setCookie(w, r, "", -1)
+ w.WriteHeader(204)
+}
+
+func clientIP(r *http.Request) string {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
+ }
+ return r.RemoteAddr
+}
+
+// ---- me ----
+
+func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
+ load := ""
+ if b, err := os.ReadFile("/proc/loadavg"); err == nil {
+ f := strings.Fields(string(b))
+ if len(f) >= 3 {
+ load = strings.Join(f[:3], " ")
+ }
+ }
+ uptime := 0.0
+ if b, err := os.ReadFile("/proc/uptime"); err == nil {
+ if f := strings.Fields(string(b)); len(f) > 0 {
+ uptime, _ = json.Number(f[0]).Float64()
+ }
+ }
+ jsonOK(w, 200, map[string]any{
+ "host": s.host, "user": s.user, "version": s.version,
+ "load": load, "uptime": uptime, "service_started": s.started,
+ })
+}
+
+// ---- terminals ----
+
+func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
+ jsonOK(w, 200, s.mgr.List())
+}
+
+func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name string `json:"name"`
+ }
+ _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
+ name := strings.TrimSpace(body.Name)
+ if name == "" {
+ name = "shell " + time.Now().Format("15:04")
+ }
+ sess, err := s.mgr.Create("", name)
+ if err != nil {
+ log.Printf("create terminal: %v", err)
+ jsonErr(w, 500, err.Error())
+ return
+ }
+ jsonOK(w, 201, map[string]string{"id": sess.ID, "name": sess.Name})
+}
+
+func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
+ jsonErr(w, 400, "name required")
+ return
+ }
+ if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
+ jsonErr(w, 404, err.Error())
+ return
+ }
+ w.WriteHeader(204)
+}
+
+func (s *Server) deleteTerminal(w http.ResponseWriter, r *http.Request) {
+ if err := s.mgr.Close(r.PathValue("id")); err != nil {
+ jsonErr(w, 404, err.Error())
+ return
+ }
+ w.WriteHeader(204)
+}
+
+type resizeMsg struct {
+ Type string `json:"type"`
+ Cols uint16 `json:"cols"`
+ Rows uint16 `json:"rows"`
+}
+
+func (s *Server) attach(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ client, snapshot, running, err := s.mgr.Attach(id)
+ if err != nil {
+ jsonErr(w, 404, err.Error())
+ return
+ }
+ // Origin must match Host (coder/websocket default) — blocks cross-site attaches.
+ conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
+ if err != nil {
+ s.mgr.Detach(client)
+ return
+ }
+ defer s.mgr.Detach(client)
+ defer conn.Close(websocket.StatusNormalClosure, "")
+ conn.SetReadLimit(1 << 20)
+
+ ctx, cancel := context.WithCancel(r.Context())
+ defer cancel()
+
+ if len(snapshot) > 0 {
+ if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil {
+ return
+ }
+ }
+ if !running {
+ _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"exit"}`))
+ }
+
+ // writer: PTY → socket, plus keepalive pings so idle proxies don't cut us.
+ go func() {
+ defer cancel()
+ ping := time.NewTicker(25 * time.Second)
+ defer ping.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ping.C:
+ pctx, pc := context.WithTimeout(ctx, 10*time.Second)
+ err := conn.Ping(pctx)
+ pc()
+ if err != nil {
+ return
+ }
+ case msg, ok := <-client.Ch:
+ if !ok {
+ _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"closed"}`))
+ return
+ }
+ if msg.Data != nil {
+ if err := conn.Write(ctx, websocket.MessageBinary, msg.Data); err != nil {
+ return
+ }
+ continue
+ }
+ if msg.Event == "exit" {
+ b, _ := json.Marshal(map[string]any{"type": "exit", "code": msg.Code})
+ _ = conn.Write(ctx, websocket.MessageText, b)
+ }
+ }
+ }
+ }()
+
+ // reader: socket → PTY (binary) or control (text JSON).
+ for {
+ typ, data, err := conn.Read(ctx)
+ if err != nil {
+ return
+ }
+ switch typ {
+ case websocket.MessageBinary:
+ if err := s.mgr.Write(id, data); err != nil && !errors.Is(err, term.ErrNotFound) {
+ log.Printf("pty write %s: %v", id, err)
+ }
+ case websocket.MessageText:
+ var m resizeMsg
+ if json.Unmarshal(data, &m) == nil && m.Type == "resize" {
+ _ = s.mgr.Resize(id, m.Cols, m.Rows)
+ }
+ }
+ }
+}
+
+// ---- static UI with SPA fallback ----
+
+func (s *Server) static(w http.ResponseWriter, r *http.Request) {
+ p := strings.TrimPrefix(r.URL.Path, "/")
+ if p == "" {
+ p = "index.html"
+ }
+ f, err := s.ui.Open(p)
+ if err == nil {
+ st, _ := f.Stat()
+ f.Close()
+ if st != nil && !st.IsDir() {
+ if strings.HasPrefix(p, "assets/") {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ http.ServeFileFS(w, r, s.ui, p)
+ return
+ }
+ }
+ // SPA fallback
+ w.Header().Set("Cache-Control", "no-cache")
+ http.ServeFileFS(w, r, s.ui, "index.html")
+}
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..316b907
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,160 @@
+// Package auth: argon2id password hashing, cookie sessions, login rate limiting.
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/argon2"
+
+ "hamlet/internal/store"
+)
+
+const (
+ argonTime = 3
+ argonMemory = 64 * 1024
+ argonThreads = 2
+ argonKeyLen = 32
+
+ SessionTTL = 30 * 24 * time.Hour
+)
+
+func HashPassword(pw string) (string, error) {
+ salt := make([]byte, 16)
+ if _, err := rand.Read(salt); err != nil {
+ return "", err
+ }
+ key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
+ enc := base64.RawStdEncoding.EncodeToString
+ return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
+ argonMemory, argonTime, argonThreads, enc(salt), enc(key)), nil
+}
+
+func VerifyPassword(hash, pw string) bool {
+ parts := strings.Split(hash, "$")
+ if len(parts) != 6 || parts[1] != "argon2id" {
+ return false
+ }
+ var m, t uint32
+ var p uint8
+ if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
+ return false
+ }
+ salt, err := base64.RawStdEncoding.DecodeString(parts[4])
+ if err != nil {
+ return false
+ }
+ want, err := base64.RawStdEncoding.DecodeString(parts[5])
+ if err != nil {
+ return false
+ }
+ got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
+ return subtle.ConstantTimeCompare(got, want) == 1
+}
+
+// Sessions is a token store backed by the state file. Expiry is sliding.
+type Sessions struct {
+ st *store.Store
+}
+
+func NewSessions(st *store.Store) *Sessions {
+ s := &Sessions{st: st}
+ _ = st.Update(func(state *store.State) { pruneExpired(state) })
+ return s
+}
+
+func pruneExpired(state *store.State) {
+ now := time.Now()
+ for tok, exp := range state.Sessions {
+ if exp.Before(now) {
+ delete(state.Sessions, tok)
+ }
+ }
+}
+
+func (s *Sessions) Create() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ tok := hex.EncodeToString(b)
+ err := s.st.Update(func(state *store.State) {
+ pruneExpired(state)
+ state.Sessions[tok] = time.Now().Add(SessionTTL)
+ })
+ return tok, err
+}
+
+// Valid reports whether tok is live, sliding its expiry (persisted at most hourly).
+func (s *Sessions) Valid(tok string) bool {
+ if tok == "" {
+ return false
+ }
+ ok := false
+ extend := false
+ s.st.View(func(state *store.State) {
+ exp, found := state.Sessions[tok]
+ if found && exp.After(time.Now()) {
+ ok = true
+ extend = time.Until(exp) < SessionTTL-time.Hour
+ }
+ })
+ if ok && extend {
+ _ = s.st.Update(func(state *store.State) {
+ if _, found := state.Sessions[tok]; found {
+ state.Sessions[tok] = time.Now().Add(SessionTTL)
+ }
+ })
+ }
+ return ok
+}
+
+func (s *Sessions) Delete(tok string) {
+ _ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
+}
+
+// Limiter is a single global backoff: after 5 failures, each further failure
+// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
+type Limiter struct {
+ mu sync.Mutex
+ failures int
+ lockUntil time.Time
+}
+
+var ErrLocked = errors.New("too many failed logins")
+
+func (l *Limiter) Allow() error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if time.Now().Before(l.lockUntil) {
+ return ErrLocked
+ }
+ return nil
+}
+
+func (l *Limiter) Fail() {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.failures++
+ if l.failures >= 5 {
+ d := 30 * time.Second << uint(l.failures-5)
+ if d > 10*time.Minute {
+ d = 10 * time.Minute
+ }
+ l.lockUntil = time.Now().Add(d)
+ }
+}
+
+func (l *Limiter) Reset() {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.failures = 0
+ l.lockUntil = time.Time{}
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..3a72f21
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,55 @@
+// Package config loads and saves hamlet's JSON config file.
+package config
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+type Config struct {
+ Listen string `json:"listen"`
+ PasswordHash string `json:"password_hash"`
+
+ path string
+}
+
+func Default(path string) *Config {
+ return &Config{Listen: "127.0.0.1:8100", path: path}
+}
+
+// Load reads the config at path. A missing file yields the defaults.
+func Load(path string) (*Config, error) {
+ c := Default(path)
+ b, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return c, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(b, c); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ return c, nil
+}
+
+func (c *Config) Path() string { return c.path }
+func (c *Config) DataDir() string { return filepath.Dir(c.path) }
+
+func (c *Config) Save() error {
+ if err := os.MkdirAll(c.DataDir(), 0o700); err != nil {
+ return err
+ }
+ b, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := c.path + ".tmp"
+ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, c.path)
+}
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..c905722
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,71 @@
+// Package store persists small runtime state (terminal names, login sessions)
+// to a JSON file with atomic writes.
+package store
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "sync"
+ "time"
+)
+
+type Terminal struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type State struct {
+ Terminals []Terminal `json:"terminals"`
+ Sessions map[string]time.Time `json:"sessions"` // token -> expiry
+}
+
+type Store struct {
+ mu sync.Mutex
+ path string
+ state State
+}
+
+func Open(path string) (*Store, error) {
+ s := &Store{path: path}
+ b, err := os.ReadFile(path)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ if err == nil {
+ if err := json.Unmarshal(b, &s.state); err != nil {
+ return nil, err
+ }
+ }
+ if s.state.Sessions == nil {
+ s.state.Sessions = map[string]time.Time{}
+ }
+ return s, nil
+}
+
+// Update applies fn under the lock and writes the result to disk.
+func (s *Store) Update(fn func(*State)) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ fn(&s.state)
+ return s.save()
+}
+
+// View reads state under the lock.
+func (s *Store) View(fn func(*State)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ fn(&s.state)
+}
+
+func (s *Store) save() error {
+ b, err := json.MarshalIndent(&s.state, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
diff --git a/internal/term/manager.go b/internal/term/manager.go
new file mode 100644
index 0000000..5d284a5
--- /dev/null
+++ b/internal/term/manager.go
@@ -0,0 +1,287 @@
+// Package term owns PTY-backed shell sessions that outlive any one browser tab.
+package term
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "os"
+ "os/exec"
+ "sort"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/creack/pty"
+)
+
+const (
+ ringSize = 256 * 1024
+ clientBuf = 512
+ killGrace = 3 * time.Second
+ readChunk = 32 * 1024
+ defaultCol = 120
+ defaultRow = 30
+)
+
+var ErrNotFound = errors.New("terminal not found")
+
+// Msg is one unit of output to a client: raw PTY bytes, or a control event.
+type Msg struct {
+ Data []byte // non-nil for PTY output
+ Event string // "exit" when the shell has ended
+ Code int
+}
+
+type Client struct {
+ Ch chan Msg
+ session *Session
+ dropped bool
+}
+
+type Info struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Running bool `json:"running"`
+ ExitCode int `json:"exit_code"`
+ Created time.Time `json:"created"`
+ Clients int `json:"clients"`
+}
+
+type Session struct {
+ ID string
+ Name string
+ Created time.Time
+
+ mu sync.Mutex
+ ptmx *os.File
+ cmd *exec.Cmd
+ ring *Ring
+ clients map[*Client]struct{}
+ running bool
+ exitCode int
+}
+
+type Manager struct {
+ mu sync.RWMutex
+ sessions map[string]*Session
+ shell string
+ home string
+ OnChange func([]Info) // called (without locks held) when the list changes
+}
+
+func NewManager(shell, home string) *Manager {
+ return &Manager{sessions: map[string]*Session{}, shell: shell, home: home}
+}
+
+func newID() string {
+ b := make([]byte, 4)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// Create starts a new login shell. id may be empty.
+func (m *Manager) Create(id, name string) (*Session, error) {
+ if id == "" {
+ id = newID()
+ }
+ cmd := exec.Command(m.shell, "-l")
+ cmd.Dir = m.home
+ cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLET=1")
+ ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
+ if err != nil {
+ return nil, err
+ }
+ s := &Session{
+ ID: id, Name: name, Created: time.Now(),
+ ptmx: ptmx, cmd: cmd, ring: NewRing(ringSize),
+ clients: map[*Client]struct{}{}, running: true,
+ }
+ m.mu.Lock()
+ m.sessions[id] = s
+ m.mu.Unlock()
+ go m.pump(s)
+ m.changed()
+ return s, nil
+}
+
+func (m *Manager) pump(s *Session) {
+ buf := make([]byte, readChunk)
+ for {
+ n, err := s.ptmx.Read(buf)
+ if n > 0 {
+ chunk := make([]byte, n)
+ copy(chunk, buf[:n])
+ s.mu.Lock()
+ s.ring.Write(chunk)
+ s.broadcast(Msg{Data: chunk})
+ s.mu.Unlock()
+ }
+ if err != nil {
+ break // EIO when the slave side closes
+ }
+ }
+ _ = s.cmd.Wait()
+ code := s.cmd.ProcessState.ExitCode()
+ s.mu.Lock()
+ s.running = false
+ s.exitCode = code
+ s.broadcast(Msg{Event: "exit", Code: code})
+ s.mu.Unlock()
+ _ = s.ptmx.Close()
+ m.changed()
+}
+
+// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
+func (s *Session) broadcast(msg Msg) {
+ for c := range s.clients {
+ select {
+ case c.Ch <- msg:
+ default:
+ if !c.dropped {
+ c.dropped = true
+ close(c.Ch)
+ }
+ delete(s.clients, c)
+ }
+ }
+}
+
+func (m *Manager) get(id string) (*Session, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ s, ok := m.sessions[id]
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return s, nil
+}
+
+func (m *Manager) List() []Info {
+ m.mu.RLock()
+ out := make([]Info, 0, len(m.sessions))
+ for _, s := range m.sessions {
+ s.mu.Lock()
+ out = append(out, Info{ID: s.ID, Name: s.Name, Running: s.running, ExitCode: s.exitCode,
+ Created: s.Created, Clients: len(s.clients)})
+ s.mu.Unlock()
+ }
+ m.mu.RUnlock()
+ sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) })
+ return out
+}
+
+func (m *Manager) Rename(id, name string) error {
+ s, err := m.get(id)
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ s.Name = name
+ s.mu.Unlock()
+ m.changed()
+ return nil
+}
+
+// Attach subscribes a client and returns the scrollback snapshot to replay first.
+func (m *Manager) Attach(id string) (*Client, []byte, bool, error) {
+ s, err := m.get(id)
+ if err != nil {
+ return nil, nil, false, err
+ }
+ c := &Client{Ch: make(chan Msg, clientBuf), session: s}
+ s.mu.Lock()
+ snap := s.ring.Bytes()
+ running := s.running
+ s.clients[c] = struct{}{}
+ s.mu.Unlock()
+ m.changed()
+ return c, snap, running, nil
+}
+
+func (m *Manager) Detach(c *Client) {
+ s := c.session
+ s.mu.Lock()
+ if _, ok := s.clients[c]; ok {
+ delete(s.clients, c)
+ if !c.dropped {
+ c.dropped = true
+ close(c.Ch)
+ }
+ }
+ s.mu.Unlock()
+ m.changed()
+}
+
+func (m *Manager) Write(id string, p []byte) error {
+ s, err := m.get(id)
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ running := s.running
+ s.mu.Unlock()
+ if !running {
+ return nil
+ }
+ _, err = s.ptmx.Write(p)
+ return err
+}
+
+func (m *Manager) Resize(id string, cols, rows uint16) error {
+ s, err := m.get(id)
+ if err != nil {
+ return err
+ }
+ if cols == 0 || rows == 0 {
+ return nil
+ }
+ return pty.Setsize(s.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
+}
+
+// Close ends the shell (SIGHUP to its process group, SIGKILL after a grace
+// period) and forgets the session.
+func (m *Manager) Close(id string) error {
+ m.mu.Lock()
+ s, ok := m.sessions[id]
+ if ok {
+ delete(m.sessions, id)
+ }
+ m.mu.Unlock()
+ if !ok {
+ return ErrNotFound
+ }
+ s.mu.Lock()
+ running := s.running
+ for c := range s.clients {
+ if !c.dropped {
+ c.dropped = true
+ close(c.Ch)
+ }
+ delete(s.clients, c)
+ }
+ s.mu.Unlock()
+ if running && s.cmd.Process != nil {
+ pid := s.cmd.Process.Pid
+ _ = syscall.Kill(-pid, syscall.SIGHUP)
+ go func() {
+ time.Sleep(killGrace)
+ _ = syscall.Kill(-pid, syscall.SIGKILL)
+ }()
+ }
+ m.changed()
+ return nil
+}
+
+func (m *Manager) changed() {
+ if m.OnChange != nil {
+ m.OnChange(m.List())
+ }
+}
+
+// Shutdown hangs up every shell. Called on daemon exit.
+func (m *Manager) Shutdown() {
+ for _, s := range m.List() {
+ _ = m.Close(s.ID)
+ }
+}
diff --git a/internal/term/ring.go b/internal/term/ring.go
new file mode 100644
index 0000000..dbf37e3
--- /dev/null
+++ b/internal/term/ring.go
@@ -0,0 +1,37 @@
+package term
+
+// Ring is a fixed-size byte ring buffer holding the most recent output.
+type Ring struct {
+ buf []byte
+ start int
+ n int
+}
+
+func NewRing(size int) *Ring { return &Ring{buf: make([]byte, size)} }
+
+func (r *Ring) Write(p []byte) {
+ size := len(r.buf)
+ if len(p) >= size {
+ copy(r.buf, p[len(p)-size:])
+ r.start, r.n = 0, size
+ return
+ }
+ end := (r.start + r.n) % size
+ first := copy(r.buf[end:], p)
+ copy(r.buf, p[first:])
+ if r.n+len(p) > size {
+ r.start = (end + len(p)) % size
+ r.n = size
+ } else {
+ r.n += len(p)
+ }
+}
+
+func (r *Ring) Bytes() []byte {
+ out := make([]byte, r.n)
+ first := copy(out, r.buf[r.start:])
+ if first < r.n {
+ copy(out[first:], r.buf[:r.n-first])
+ }
+ return out
+}
diff --git a/internal/web/embed.go b/internal/web/embed.go
new file mode 100644
index 0000000..583c901
--- /dev/null
+++ b/internal/web/embed.go
@@ -0,0 +1,18 @@
+// Package web embeds the built frontend (web/dist, produced by `make web`).
+package web
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed all:dist
+var dist embed.FS
+
+func FS() fs.FS {
+ sub, err := fs.Sub(dist, "dist")
+ if err != nil {
+ panic(err)
+ }
+ return sub
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..8660e4f
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hamlet
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..bbab6fd
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1777 @@
+{
+ "name": "hamlet-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hamlet-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/xterm": "^5.5.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.11"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
+ "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz",
+ "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz",
+ "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz",
+ "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz",
+ "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz",
+ "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz",
+ "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz",
+ "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz",
+ "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz",
+ "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz",
+ "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz",
+ "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz",
+ "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz",
+ "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz",
+ "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz",
+ "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz",
+ "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz",
+ "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz",
+ "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz",
+ "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz",
+ "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz",
+ "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz",
+ "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz",
+ "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz",
+ "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
+ "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/addon-web-links": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
+ "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ }
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
+ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.24",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
+ "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.0",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
+ "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.23",
+ "caniuse-lite": "^1.0.30001810",
+ "electron-to-chromium": "^1.5.427",
+ "node-releases": "^2.0.55",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.430",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
+ "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "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"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.56",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
+ "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
+ "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.3",
+ "@rollup/rollup-android-arm64": "4.63.3",
+ "@rollup/rollup-darwin-arm64": "4.63.3",
+ "@rollup/rollup-darwin-x64": "4.63.3",
+ "@rollup/rollup-freebsd-arm64": "4.63.3",
+ "@rollup/rollup-freebsd-x64": "4.63.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.3",
+ "@rollup/rollup-linux-arm64-musl": "4.63.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.3",
+ "@rollup/rollup-linux-loong64-musl": "4.63.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.3",
+ "@rollup/rollup-linux-x64-gnu": "4.63.3",
+ "@rollup/rollup-linux-x64-musl": "4.63.3",
+ "@rollup/rollup-openbsd-x64": "4.63.3",
+ "@rollup/rollup-openharmony-arm64": "4.63.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.3",
+ "@rollup/rollup-win32-x64-gnu": "4.63.3",
+ "@rollup/rollup-win32-x64-msvc": "4.63.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "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
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..e7e4428
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "hamlet-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "icons": "node scripts/icons.mjs"
+ },
+ "dependencies": {
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/xterm": "^5.5.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.11"
+ }
+}
diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..156229a
Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ
diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png
new file mode 100644
index 0000000..43a0df8
Binary files /dev/null and b/web/public/icons/icon-192.png differ
diff --git a/web/public/icons/icon-512-maskable.png b/web/public/icons/icon-512-maskable.png
new file mode 100644
index 0000000..fb5cbb9
Binary files /dev/null and b/web/public/icons/icon-512-maskable.png differ
diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png
new file mode 100644
index 0000000..0896b99
Binary files /dev/null and b/web/public/icons/icon-512.png differ
diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest
new file mode 100644
index 0000000..c24d929
--- /dev/null
+++ b/web/public/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "name": "Hamlet",
+ "short_name": "Hamlet",
+ "description": "Linux management console",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#111418",
+ "theme_color": "#111418",
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ { "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/web/public/sw.js b/web/public/sw.js
new file mode 100644
index 0000000..af261a6
--- /dev/null
+++ b/web/public/sw.js
@@ -0,0 +1,17 @@
+// Hamlet service worker: cache hashed assets, never the API, network-first for the shell.
+const CACHE = 'hamlet-v1';
+self.addEventListener('install', () => self.skipWaiting());
+self.addEventListener('activate', (e) => {
+ e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
+});
+self.addEventListener('fetch', (e) => {
+ const url = new URL(e.request.url);
+ if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return;
+ if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/')) {
+ e.respondWith(caches.open(CACHE).then(async (c) => (await c.match(e.request)) || fetch(e.request).then((r) => { if (r.ok) c.put(e.request, r.clone()); return r; })));
+ return;
+ }
+ // navigations + everything else: network first, fall back to cached index
+ e.respondWith(fetch(e.request).then((r) => { if (r.ok && e.request.mode === 'navigate') caches.open(CACHE).then((c) => c.put('/', r.clone())); return r; })
+ .catch(() => caches.match(e.request.mode === 'navigate' ? '/' : e.request)));
+});
diff --git a/web/scripts/icons.mjs b/web/scripts/icons.mjs
new file mode 100644
index 0000000..9f102f7
--- /dev/null
+++ b/web/scripts/icons.mjs
@@ -0,0 +1,26 @@
+// Renders the PWA icons with node-canvas (borrowed from ../../calorie-counter/node_modules).
+import { createRequire } from 'node:module'
+import { writeFileSync } from 'node:fs'
+const require = createRequire(import.meta.url)
+const { createCanvas } = require('/home/core/projects/calorie-counter/node_modules/canvas')
+
+function draw(size, { pad = 0, radius = 0 }) {
+ const c = createCanvas(size, size), g = c.getContext('2d')
+ g.fillStyle = '#111418'
+ if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size)
+ const inner = size - pad * 2
+ g.fillStyle = '#e3a33b'
+ g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif`
+ g.textAlign = 'center'; g.textBaseline = 'middle'
+ g.fillText('H', size / 2, size / 2 + inner * 0.03)
+ // prompt caret
+ g.fillStyle = '#58a6ff'
+ g.font = `bold ${inner * 0.22}px "DejaVu Sans Mono", monospace`
+ g.fillText('>_', size / 2 + inner * 0.27, size / 2 + inner * 0.27)
+ return c.toBuffer('image/png')
+}
+writeFileSync('public/icons/icon-192.png', draw(192, {}))
+writeFileSync('public/icons/icon-512.png', draw(512, {}))
+writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 }))
+writeFileSync('public/icons/apple-touch-icon.png', draw(180, {}))
+console.log('icons written')
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..6dfe9eb
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,23 @@
+import { useCallback, useEffect, useState } from 'react'
+import { api, Me, Unauthenticated } from './api'
+import Login from './Login'
+import Shell from './Shell'
+
+export default function App() {
+ const [me, setMe] = useState(undefined)
+
+ const refresh = useCallback(async () => {
+ try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
+ }, [])
+
+ useEffect(() => { refresh() }, [refresh])
+ useEffect(() => {
+ if (!me) return
+ const t = setInterval(refresh, 30000)
+ return () => clearInterval(t)
+ }, [me, refresh])
+
+ if (me === undefined) return …
+ if (me === null) return
+ return setMe(null)} />
+}
diff --git a/web/src/KeyBar.tsx b/web/src/KeyBar.tsx
new file mode 100644
index 0000000..ba45f83
--- /dev/null
+++ b/web/src/KeyBar.tsx
@@ -0,0 +1,18 @@
+import { useState } from 'react'
+
+const KEYS: [string, string][] = [
+ ['Esc', '\x1b'], ['Tab', '\t'], ['↑', '\x1b[A'], ['↓', '\x1b[B'], ['←', '\x1b[D'], ['→', '\x1b[C'],
+ ['Home', '\x1b[H'], ['End', '\x1b[F'], ['|', '|'], ['~', '~'], ['-', '-'], ['/', '/'], ['^C', '\x03'], ['^D', '\x04'],
+]
+
+export default function KeyBar({ send, setCtrl }: { send: (s: string) => void; setCtrl: (v: boolean) => void }) {
+ const [ctrl, setCtrlState] = useState(false)
+ const toggle = () => { const v = !ctrl; setCtrlState(v); setCtrl(v) }
+ const press = (s: string) => { send(s); if (ctrl) { setCtrlState(false); setCtrl(false) } }
+ return (
+ e.preventDefault()}>
+
+ {KEYS.map(([label, seq]) => )}
+
+ )
+}
diff --git a/web/src/Login.tsx b/web/src/Login.tsx
new file mode 100644
index 0000000..a05e8b9
--- /dev/null
+++ b/web/src/Login.tsx
@@ -0,0 +1,28 @@
+import { FormEvent, useState } from 'react'
+import { api } from './api'
+
+export default function Login({ onLogin }: { onLogin: () => void }) {
+ const [pw, setPw] = useState('')
+ const [err, setErr] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ async function submit(e: FormEvent) {
+ e.preventDefault()
+ setBusy(true); setErr('')
+ try { await api.login(pw); onLogin() }
+ catch (e) { setErr(e instanceof Error ? e.message : 'login failed') }
+ finally { setBusy(false) }
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/Rail.tsx b/web/src/Rail.tsx
new file mode 100644
index 0000000..1d555fa
--- /dev/null
+++ b/web/src/Rail.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react'
+import { Term } from './api'
+
+export interface Group { id: string; label: string; icon: string; soon?: boolean }
+
+interface Props {
+ groups: Group[]; terms: Term[]; active: string | null
+ onSelect: (id: string) => void; onNew: () => void
+ onRename: (t: Term) => void; onClose: (t: Term) => void
+}
+
+export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) {
+ const [open, setOpen] = useState('terminal')
+ return (
+
+ )
+}
diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx
new file mode 100644
index 0000000..fb2b358
--- /dev/null
+++ b/web/src/Shell.tsx
@@ -0,0 +1,100 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { api, Me, Term } from './api'
+import Rail, { Group } from './Rail'
+import TerminalPane, { TermHandle } from './TerminalPane'
+import KeyBar from './KeyBar'
+
+const GROUPS: Group[] = [
+ { id: 'terminal', label: 'Terminal', icon: '>_' },
+ { id: 'services', label: 'Services', icon: '⚙', soon: true },
+ { id: 'cron', label: 'Cron', icon: '⏱', soon: true },
+ { id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
+ { id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true },
+]
+
+function fmtUptime(s: number): string {
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
+ return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
+}
+
+export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
+ const [terms, setTerms] = useState([])
+ const [active, setActive] = useState(null)
+ const [drawer, setDrawer] = useState(false)
+ const [online, setOnline] = useState(true)
+ const handles = useRef>({})
+
+ const reload = useCallback(async () => {
+ const list = await api.terminals()
+ setTerms(list)
+ setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
+ }, [])
+
+ useEffect(() => { reload() }, [reload])
+
+ async function create() {
+ const t = await api.createTerminal()
+ await reload()
+ setActive(t.id)
+ setDrawer(false)
+ }
+ async function rename(t: Term) {
+ const name = window.prompt('Rename terminal', t.name)
+ if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() }
+ }
+ async function close(t: Term) {
+ if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
+ await api.deleteTerminal(t.id)
+ delete handles.current[t.id]
+ await reload()
+ }
+ async function logout() { await api.logout(); onLoggedOut() }
+
+ const activeTerm = terms.find((t) => t.id === active)
+
+ return (
+
+
+
+ Hamlet
+ {me.user}@{me.host}
+
+ {me.load}
+
+
+
+
+
+
{ setActive(id); setDrawer(false) }}
+ onNew={create} onRename={rename} onClose={close} />
+ setDrawer(false)} />
+
+
+ {terms.length === 0 && (
+
+
No terminals.
+
+
+ )}
+ {terms.map((t) => (
+ { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
+ onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
+ onExit={reload} />
+ ))}
+
+
+
+ {activeTerm && handles.current[activeTerm.id]?.input(s)}
+ setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
+
+
+
+ )
+}
diff --git a/web/src/TerminalPane.tsx b/web/src/TerminalPane.tsx
new file mode 100644
index 0000000..eeb5155
--- /dev/null
+++ b/web/src/TerminalPane.tsx
@@ -0,0 +1,128 @@
+import { useEffect, useRef } from 'react'
+import { Terminal } from '@xterm/xterm'
+import { FitAddon } from '@xterm/addon-fit'
+import { WebLinksAddon } from '@xterm/addon-web-links'
+import { wsURL } from './api'
+
+export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
+
+interface Props {
+ id: string; active: boolean
+ register: (h: TermHandle | null) => void
+ onStatus: (ok: boolean) => void
+ onExit: () => void
+}
+
+const THEME = {
+ background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
+ black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff',
+ magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198',
+ brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
+ brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
+}
+
+export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) {
+ const box = useRef
(null)
+ const activeRef = useRef(active)
+ activeRef.current = active
+ const cbs = useRef({ onStatus, onExit })
+ cbs.current = { onStatus, onExit }
+ const fitRef = useRef<() => void>(() => {})
+
+ useEffect(() => {
+ const el = box.current!
+ const term = new Terminal({
+ theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
+ fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14,
+ cursorBlink: true, scrollback: 5000, allowProposedApi: true,
+ })
+ const fit = new FitAddon()
+ term.loadAddon(fit)
+ term.loadAddon(new WebLinksAddon())
+ term.open(el)
+
+ let ws: WebSocket | null = null
+ let closed = false
+ let ctrlArmed = false
+ let retry = 0
+ let retryTimer: number | undefined
+ let lastSize = ''
+ const enc = new TextEncoder()
+
+ const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) }
+ const sendResize = () => {
+ const key = `${term.cols}x${term.rows}`
+ if (key === lastSize) return
+ lastSize = key
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
+ }
+ const doFit = () => {
+ if (!activeRef.current || el.clientWidth === 0 || el.clientHeight === 0) return
+ fit.fit()
+ sendResize()
+ }
+
+ const connect = () => {
+ if (closed) return
+ const sock = new WebSocket(wsURL(id))
+ sock.binaryType = 'arraybuffer'
+ ws = sock
+ sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
+ sock.onmessage = (ev) => {
+ if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
+ try {
+ const m = JSON.parse(ev.data)
+ if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() }
+ if (m.type === 'closed') closed = true
+ } catch { /* ignore */ }
+ }
+ sock.onclose = () => {
+ if (ws !== sock) return
+ cbs.current.onStatus(false)
+ if (closed) return
+ retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
+ }
+ sock.onerror = () => sock.close()
+ }
+ connect()
+
+ term.onData((d) => {
+ if (ctrlArmed && d.length === 1) {
+ ctrlArmed = false
+ const c = d.toUpperCase().charCodeAt(0)
+ if (c >= 64 && c <= 95) { send(String.fromCharCode(c & 0x1f)); return }
+ }
+ send(d)
+ })
+ term.onBinary((d) => send(d))
+ term.onResize(sendResize)
+
+ fitRef.current = () => { doFit(); term.focus() }
+ const ro = new ResizeObserver(() => doFit())
+ ro.observe(el)
+ const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } }
+ document.addEventListener('visibilitychange', onVis)
+
+ register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
+
+ return () => {
+ register(null)
+ closed = true
+ clearTimeout(retryTimer)
+ document.removeEventListener('visibilitychange', onVis)
+ ro.disconnect()
+ ws?.close()
+ term.dispose()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id])
+
+ // Refit + focus whenever this pane becomes the visible one (after the display change applies).
+ useEffect(() => {
+ if (!active) return
+ const t = window.setTimeout(() => fitRef.current(), 0)
+ return () => clearTimeout(t)
+ }, [active])
+
+ return
+}
diff --git a/web/src/api.ts b/web/src/api.ts
new file mode 100644
index 0000000..2fd9430
--- /dev/null
+++ b/web/src/api.ts
@@ -0,0 +1,36 @@
+export class Unauthenticated extends Error {}
+
+async function req(method: string, path: string, body?: unknown): Promise {
+ const r = await fetch(path, {
+ method,
+ credentials: 'same-origin',
+ headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ })
+ if (r.status === 401) throw new Unauthenticated()
+ if (!r.ok) {
+ let msg = r.statusText
+ try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
+ throw new Error(msg)
+ }
+ if (r.status === 204) return undefined as T
+ return r.json()
+}
+
+export interface Me { host: string; user: string; version: string; load: string; uptime: number }
+export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
+
+export const api = {
+ login: (password: string) => req('POST', '/api/login', { password }),
+ logout: () => req('POST', '/api/logout'),
+ me: () => req('GET', '/api/me'),
+ terminals: () => req('GET', '/api/terminals'),
+ createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
+ renameTerminal: (id: string, name: string) => req('PATCH', `/api/terminals/${id}`, { name }),
+ deleteTerminal: (id: string) => req('DELETE', `/api/terminals/${id}`),
+}
+
+export function wsURL(id: string): string {
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
+ return `${proto}//${location.host}/api/terminals/${id}/attach`
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..5d18c6e
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,15 @@
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import App from './App'
+import '@xterm/xterm/css/xterm.css'
+import './styles.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
+
+if ('serviceWorker' in navigator && location.protocol === 'https:') {
+ window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) })
+}
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..658796e
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,78 @@
+:root {
+ --bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b;
+ --fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff;
+ --ok: #3fb950; --bad: #ff7b72;
+ --header-h: 44px; --footer-h: 28px; --rail-w: 220px;
+}
+* { box-sizing: border-box; }
+html, body, #root { height: 100%; margin: 0; }
+body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overscroll-behavior: none; }
+button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
+.spacer { flex: 1; }
+.muted { color: var(--muted); }
+.center { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
+
+/* login */
+.login { display: flex; flex-direction: column; gap: 10px; width: min(320px, 90vw); }
+.login h1 { margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; }
+.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
+.login button, .primary { padding: 10px 12px; background: var(--accent); color: #111; border-radius: 6px; font-weight: 600; }
+.login button:disabled { opacity: .5; }
+.error { color: var(--bad); font-size: 13px; }
+
+/* layout */
+.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
+.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); }
+.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
+.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; }
+.stat { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); }
+.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
+.dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
+.link { color: var(--muted); } .link:hover { color: var(--fg); }
+.icon-btn { padding: 2px 6px; color: var(--muted); border-radius: 4px; } .icon-btn:hover { color: var(--fg); background: var(--bg3); }
+.menu { display: none; font-size: 18px; }
+.body { flex: 1; display: flex; min-height: 0; position: relative; }
+.footer { height: var(--footer-h); display: flex; align-items: center; gap: 16px; padding: 0 12px; background: var(--bg2); border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); padding-bottom: env(safe-area-inset-bottom); box-sizing: content-box; }
+
+/* rail */
+.rail { width: var(--rail-w); background: var(--bg2); border-right: 1px solid var(--border); overflow-y: auto; padding: 6px 0; flex-shrink: 0; }
+.group-head { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-align: left; }
+.group-head:hover { background: var(--bg3); }
+.group.soon .group-head { color: var(--muted); cursor: default; }
+.group.soon .group-head:hover { background: none; }
+.chev { width: 10px; color: var(--muted); font-size: 11px; }
+.gicon { width: 18px; text-align: center; color: var(--accent2); font-family: ui-monospace, monospace; font-size: 12px; }
+.badge { margin-left: auto; font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; }
+.leaves { padding: 2px 0 6px; }
+.leaf { display: flex; align-items: center; gap: 8px; padding: 7px 12px 7px 30px; cursor: pointer; width: 100%; text-align: left; }
+.leaf:hover { background: var(--bg3); }
+.leaf.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
+.leaf.exited .lname { color: var(--muted); text-decoration: line-through; }
+.leaf.new { color: var(--accent2); }
+.lname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.leaf-actions { display: flex; gap: 2px; }
+.sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
+.sdot.run { background: var(--ok); }
+
+/* main + terminal */
+.main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
+.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
+.term .xterm { height: 100%; }
+.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); }
+
+/* key bar (touch devices / narrow screens) */
+.keybar { display: none; gap: 4px; padding: 4px 6px; background: var(--bg2); border-top: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; }
+.keybar button { flex: 0 0 auto; padding: 8px 10px; min-width: 40px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, monospace; font-size: 13px; }
+.keybar button.on { background: var(--accent); color: #111; border-color: var(--accent); }
+@media (pointer: coarse), (max-width: 800px) { .keybar { display: flex; } }
+
+/* mobile: rail becomes a drawer */
+.backdrop { display: none; }
+@media (max-width: 800px) {
+ .menu { display: inline-block; }
+ .host, .stat { display: none; }
+ .rail { position: absolute; top: 0; bottom: 0; left: 0; z-index: 10; transform: translateX(-100%); transition: transform .15s ease; box-shadow: 4px 0 16px rgba(0,0,0,.5); }
+ .drawer-open .rail { transform: none; }
+ .drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); }
+ .footer { font-size: 11px; }
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..61b917c
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..a34d20b
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ build: { outDir: '../internal/web/dist', emptyOutDir: true },
+ server: {
+ proxy: { '/api': { target: 'http://127.0.0.1:8100', ws: true } },
+ },
+})