diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..fd9e136
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,55 @@
+# lvmh web UI
+
+React 19 + Vite + TypeScript single-page app for the lvmh daemon. Talks only
+to the REST API under `/api` and the `/ws` websocket described in
+`../PROTOCOL.md`.
+
+## Run (dev)
+
+```
+cd web
+npm install
+npm run dev
+```
+
+Dev server proxies `/api` and `/ws` to `http://localhost:8686`
+(see `vite.config.ts`).
+
+## Build
+
+```
+npm run build
+```
+
+Type-checks with `tsc --noEmit`, bundles to `web/dist/` (the daemon serves
+this directory).
+
+## First use
+
+On first load the UI shows a settings gate: server URL (defaults to
+same-origin) + bearer token (`LVMH_TOKEN`). Stored in `localStorage`; cleared
+via the "disconnect" button in the sidebar footer.
+
+## Structure
+
+- `src/protocol.ts` — typed mirror of PROTOCOL.md (envelope, event types,
+ REST routes, WS frames)
+- `src/ws.ts` — single websocket manager: auto-reconnect with exponential
+ backoff + jitter, resubscribe on open, seq tracking helpers
+- `src/api.ts` — `fetchJson` helper with bearer auth + error parsing
+- `src/settings.ts` — localStorage settings + WS URL builder
+- `src/store.ts` — sessions store (REST seed + WS `session_list` updates),
+ toasts, time formatting
+- `src/derive.ts` — client-side derivation: chat view from events, todo list
+ from `todo` tool calls, subagent runs from `subagent` tool calls
+- `src/ChatView.tsx` — chat: history load, live streaming via `message_update`,
+ abort, sticky scroll
+- `src/SessionsView.tsx` — session list with online dots, agent badges, stop
+- `src/SpawnView.tsx` — GitLab connect + repo/branch picker + spawn polling
+- `src/SettingsGate.tsx` — first-run token gate
+
+## Conventions
+
+- No UI/state/HTTP libraries beyond react, react-dom, react-router-dom.
+- All icon-only buttons carry `aria-label`.
+- Dark theme, mobile-friendly (≤400px) via hand-rolled CSS in `src/index.css`.
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..dabc5d6
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ lvmh
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..7329536
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1899 @@
+{
+ "name": "lvmh-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lvmh-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-router-dom": "^7.6.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.0",
+ "@types/react-dom": "^19.1.0",
+ "@vitejs/plugin-react": "^4.4.1",
+ "typescript": "^5.8.3",
+ "vite": "^6.3.5"
+ }
+ },
+ "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.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "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.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
+ "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
+ "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/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.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/baseline-browser-mapping": {
+ "version": "2.11.15",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
+ "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
+ "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.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001809",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
+ "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
+ "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/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "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.409",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.409.tgz",
+ "integrity": "sha512-ChI4N44d0B4A6C8prnNjMOaGgE59fUyEVYcRYm2XEXIjMbbvF5i9UL1cblDbpGqiU0uS8FE8UcKxqZqTXdmzbQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "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/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "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==",
+ "dev": true,
+ "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/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.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "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.53",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
+ "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/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "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.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "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/react-router": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
+ "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
+ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
+ "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.62.4",
+ "@rollup/rollup-android-arm64": "4.62.4",
+ "@rollup/rollup-darwin-arm64": "4.62.4",
+ "@rollup/rollup-darwin-x64": "4.62.4",
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
+ "@rollup/rollup-freebsd-x64": "4.62.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
+ "@rollup/rollup-openbsd-x64": "4.62.4",
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "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/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "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/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "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.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
+ "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": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "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..0b4c53d
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "lvmh-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-router-dom": "^7.6.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.0",
+ "@types/react-dom": "^19.1.0",
+ "@vitejs/plugin-react": "^4.4.1",
+ "typescript": "^5.8.3",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest
new file mode 100644
index 0000000..4c5ad02
--- /dev/null
+++ b/web/public/manifest.webmanifest
@@ -0,0 +1,9 @@
+{
+ "name": "lvmh",
+ "short_name": "lvmh",
+ "description": "lvmh session control UI",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#212121",
+ "theme_color": "#212121"
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..9f8dd87
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from "react";
+import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
+import ChatView from "./ChatView";
+import SessionsView from "./SessionsView";
+import SpawnView from "./SpawnView";
+import SettingsGate from "./SettingsGate";
+import { classNames, useSessions, useToasts } from "./store";
+import { clearSettings, getSettings } from "./settings";
+
+export default function App() {
+ const [configured, setConfigured] = useState(getSettings() !== null);
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const { toasts, push } = useToasts();
+ const store = useSessions(push);
+ const location = useLocation();
+
+ useEffect(() => {
+ setSidebarOpen(false);
+ }, [location.pathname]);
+
+ if (!configured) return setConfigured(true)} />;
+ if (store === null) return setConfigured(true)} />;
+
+ return (
+
+
+ {sidebarOpen &&
setSidebarOpen(false)} />}
+
+
+
+ void store.refresh()} pushToast={push} />} />
+ } />
+ } />
+ } />
+
+
+
+ {toasts.map((t) => (
+
{t.text}
+ ))}
+
+
+ );
+}
diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx
new file mode 100644
index 0000000..6afc9b3
--- /dev/null
+++ b/web/src/ChatStream.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useRef } from "react";
+import type { ChatMessage, ToolState } from "./derive";
+
+const PREVIEW_LEN: number = 120;
+
+function oneLine(text: string): string {
+ const flat = text.replace(/\s+/g, " ").trim();
+ return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
+}
+
+function ToolCard({ tool }: { tool: ToolState }) {
+ const status: string = tool.running
+ ? "running…"
+ : tool.isError
+ ? "error"
+ : "done";
+ const statusClass: string = tool.isError ? "tool-status-err" : "tool-status-ok";
+ return (
+
+
+ 🛠 {tool.name}
+ {tool.running ? "working…" : status}
+
+
+
+
+
result
+
{tool.preview}
+
+
+
+ );
+}
+
+function Thinking({ text }: { text: string }) {
+ return (
+
+ thinking
+ {text}
+
+ );
+}
+
+export function Bubble({ msg, tools }: { msg: ChatMessage; tools: Map
}) {
+ const rowClass = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "system";
+ const msgTools: ToolState[] = [];
+ if (msg.role === "assistant") {
+ for (const c of msg.toolCalls) {
+ const t = tools.get(c.id);
+ if (t !== undefined) msgTools.push(t);
+ }
+ }
+ return (
+
+
+ {msg.thinking !== null && msg.thinking.length > 0 &&
}
+ {msgTools.map((t) => (
+
+ ))}
+ {msg.role === "toolResult" ? (
+
+
+ result
+ {oneLine(msg.text)}
+
+ {msg.text}
+
+ ) : (
+ msg.text.length > 0 &&
{msg.text}
+ )}
+ {msg.streaming &&
▍}
+
+
+ );
+}
+
+export function TypingIndicator() {
+ return (
+
+ );
+}
+
+interface Props {
+ messages: ChatMessage[];
+ tools: Map;
+ busy: boolean;
+}
+
+export default function ChatStream({ messages, tools, busy }: Props) {
+ const scrollRef = useRef(null);
+ const pinnedRef = useRef(true);
+
+ const onScroll = (): void => {
+ const el = scrollRef.current;
+ if (el === null) return;
+ pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
+ };
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight;
+ }, [messages, busy]);
+
+ useEffect(() => {
+ pinnedRef.current = true;
+ const el = scrollRef.current;
+ if (el !== null) el.scrollTop = el.scrollHeight;
+ }, []);
+
+ const last: ChatMessage | undefined = messages[messages.length - 1];
+ const streamingOpen: boolean = last !== undefined && last.streaming;
+ const showTyping: boolean = busy && !streamingOpen;
+
+ return (
+
+
+ {messages.map((m) => (
+
+ ))}
+ {showTyping && }
+
+
+ );
+}
diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx
new file mode 100644
index 0000000..c0ff227
--- /dev/null
+++ b/web/src/ChatView.tsx
@@ -0,0 +1,205 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import type { EventFrame } from "./protocol";
+import { Route } from "./protocol";
+import { ApiError, errMessage, fetchJson } from "./api";
+import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive";
+import type { SessionsStore } from "./store";
+import { classNames } from "./store";
+import ChatStream from "./ChatStream";
+import TaskPanel from "./TaskPanel";
+
+const HISTORY_LIMIT: number = 1000;
+const TEXTAREA_MAX_H: number = 200;
+const SEND_KEY: string = "Enter";
+
+interface Props {
+ store: SessionsStore;
+ pushToast: (text: string) => void;
+}
+
+export default function ChatView({ store, pushToast }: Props) {
+ const { id } = useParams<{ id: string }>();
+ const sessionId: string = id ?? "";
+
+ const [events, setEvents] = useState([]);
+ const [loadError, setLoadError] = useState("");
+ const [draft, setDraft] = useState("");
+ const [sending, setSending] = useState(false);
+ const [tasksOpen, setTasksOpen] = useState(false);
+
+ const lastSeqRef = useRef(0);
+ const loadedRef = useRef(false);
+ const taRef = useRef(null);
+
+ const session = store.sessions.find((s) => s.id === sessionId);
+
+ const applyEvents = useCallback((incoming: EventFrame[]): void => {
+ setEvents((prev) => {
+ const merged = mergeEvents(prev, incoming);
+ lastSeqRef.current = Math.max(lastSeqRef.current, lastPersistedSeq(merged));
+ return merged;
+ });
+ }, []);
+
+ // history load on mount / session switch
+ useEffect(() => {
+ if (sessionId.length === 0) return;
+ loadedRef.current = false;
+ setLoadError("");
+ setEvents([]);
+ lastSeqRef.current = 0;
+ let alive = true;
+ void (async () => {
+ try {
+ const evts = await fetchJson(`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`);
+ if (!alive) return;
+ applyEvents(evts);
+ } catch (err) {
+ if (alive) setLoadError(errMessage(err));
+ } finally {
+ if (alive) loadedRef.current = true;
+ }
+ })();
+ return () => {
+ alive = false;
+ };
+ }, [sessionId, applyEvents]);
+
+ // ws subscription
+ useEffect(() => {
+ if (sessionId.length === 0 || store.state !== "open") return;
+ return store.subscribe(sessionId, applyEvents);
+ }, [sessionId, store.state, store, applyEvents]);
+
+ // missed events after reconnect (persisted only)
+ useEffect(() => {
+ if (store.state !== "open" || !loadedRef.current) return;
+ const after: number = lastSeqRef.current;
+ void fetchJson(`${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`)
+ .then(applyEvents)
+ .catch(() => undefined);
+ }, [store.state, sessionId, applyEvents]);
+
+ const chat = useMemo(() => deriveChat(events), [events]);
+ const tasks = useMemo(() => deriveTasks(events), [events]);
+
+ const autosize = useCallback((): void => {
+ const el = taRef.current;
+ if (el === null) return;
+ el.style.height = "auto";
+ el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_H)}px`;
+ }, []);
+
+ useEffect(autosize, [draft, autosize]);
+
+ const send = async (): Promise => {
+ const text = draft.trim();
+ if (text.length === 0 || sending) return;
+ setDraft("");
+ setSending(true);
+ try {
+ await fetchJson(Route.SessionPrompt(sessionId), {
+ method: "POST",
+ body: JSON.stringify({ message: text }),
+ });
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 409) pushToast("session offline");
+ else pushToast(errMessage(err));
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const abort = async (): Promise => {
+ try {
+ await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
+ } catch (err) {
+ pushToast(errMessage(err));
+ }
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent): void => {
+ if (e.key === SEND_KEY && !e.shiftKey) {
+ e.preventDefault();
+ void send();
+ }
+ };
+
+ if (sessionId.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
{session?.name ?? session?.repo ?? sessionId}
+
{session?.model}
+
+
+
+
+ {tasksOpen && (
+
+
+
+ )}
+ {loadError.length > 0 ? (
+
+
Failed to load history: {loadError}
+
Back to sessions
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/SessionsView.tsx b/web/src/SessionsView.tsx
new file mode 100644
index 0000000..1e1bae0
--- /dev/null
+++ b/web/src/SessionsView.tsx
@@ -0,0 +1,80 @@
+import { type MouseEvent, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import type { SessionListItem } from "./protocol";
+import { Route } from "./protocol";
+import { fetchJson } from "./api";
+import { classNames, relativeTime } from "./store";
+
+interface Props {
+ sessions: SessionListItem[];
+ onChanged: () => void;
+ pushToast: (text: string) => void;
+}
+
+export default function SessionsView({ sessions, onChanged, pushToast }: Props) {
+ const navigate = useNavigate();
+
+ const open = useCallback((id: string): void => {
+ navigate(`/s/${id}`);
+ }, [navigate]);
+
+ const stop = async (e: MouseEvent, s: SessionListItem): Promise => {
+ e.preventDefault();
+ e.stopPropagation();
+ try {
+ await fetchJson(Route.SessionContainer(s.id), { method: "DELETE" });
+ pushToast(`stopped ${s.name ?? s.id}`);
+ onChanged();
+ } catch (err) {
+ pushToast(`stop failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ };
+
+ const sorted = [...sessions].sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt));
+
+ return (
+
+
Sessions
+ {sorted.length === 0 &&
No sessions yet. Spawn one from the sidebar.
}
+ {sorted.map((s) => (
+
open(s.id)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") open(s.id);
+ }}
+ >
+
+
+
+ {s.name ?? s.repo ?? s.cwd}
+
+
+ {s.repo !== null && {s.repo}}
+ {s.model}
+ {relativeTime(s.lastEventAt)}
+
+
+ {s.agent && agent}
+ {s.agent && (
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/web/src/SettingsGate.tsx b/web/src/SettingsGate.tsx
new file mode 100644
index 0000000..107ff60
--- /dev/null
+++ b/web/src/SettingsGate.tsx
@@ -0,0 +1,83 @@
+import { type FormEvent, useState } from "react";
+import { saveSettings } from "./settings";
+
+interface Props {
+ onSaved: () => void;
+}
+
+export default function SettingsGate({ onSaved }: Props) {
+ const [serverUrl, setServerUrl] = useState(defaultServerUrl());
+ const [token, setToken] = useState("");
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (ev: FormEvent): Promise => {
+ ev.preventDefault();
+ if (token.trim().length === 0) {
+ setError("token required");
+ return;
+ }
+ setBusy(true);
+ setError("");
+ const url = normalizeUrl(serverUrl);
+ try {
+ await validate(url, token.trim());
+ saveSettings({ serverUrl: url, token: token.trim() });
+ onSaved();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function defaultServerUrl(): string {
+ return `${window.location.protocol}//${window.location.host}`;
+}
+
+function normalizeUrl(raw: string): string {
+ const trimmed = raw.trim().replace(/\/+$/, "");
+ return trimmed.length > 0 ? trimmed : defaultServerUrl();
+}
+
+async function validate(serverUrl: string, token: string): Promise {
+ const res = await fetch(`${serverUrl}/api/sessions`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`connection failed (${res.status})`);
+}
diff --git a/web/src/SpawnView.tsx b/web/src/SpawnView.tsx
new file mode 100644
index 0000000..7fba8dc
--- /dev/null
+++ b/web/src/SpawnView.tsx
@@ -0,0 +1,260 @@
+import { useEffect, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import type { GitlabStatus, Repo, SpawnJob, SpawnResponse } from "./protocol";
+import { Route } from "./protocol";
+import { errMessage, fetchJson } from "./api";
+import type { SessionsStore } from "./store";
+
+const POLL_MS: number = 1500;
+const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
+
+interface Props {
+ store: SessionsStore;
+ pushToast: (text: string) => void;
+}
+
+export default function SpawnView({ store, pushToast }: Props) {
+ const navigate = useNavigate();
+
+ const [status, setStatus] = useState(null);
+ const [pat, setPat] = useState("");
+ const [repos, setRepos] = useState(null);
+ const [query, setQuery] = useState("");
+ const [selected, setSelected] = useState(null);
+ const [branch, setBranch] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [spawning, setSpawning] = useState(null);
+
+ const timerRef = useRef(null);
+ const tickRef = useRef(0);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current !== null) window.clearInterval(timerRef.current);
+ };
+ }, []);
+
+ const loadStatus = async (): Promise => {
+ const s = await fetchJson(Route.GitlabStatus);
+ setStatus(s);
+ return s;
+ };
+
+ const loadRepos = async (): Promise => {
+ const list = await fetchJson(Route.GitlabRepos);
+ setRepos(list);
+ };
+
+ useEffect(() => {
+ void (async () => {
+ try {
+ const s = await loadStatus();
+ if (s.connected) await loadRepos();
+ } catch (err) {
+ setError(errMessage(err));
+ }
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const connect = async (): Promise => {
+ if (pat.trim().length === 0) {
+ setError("token required");
+ return;
+ }
+ setBusy(true);
+ setError("");
+ try {
+ await fetchJson(Route.GitlabConnect, { method: "POST", body: JSON.stringify({ token: pat.trim() }) });
+ setPat("");
+ await loadStatus();
+ await loadRepos();
+ } catch (err) {
+ setError(errMessage(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const pick = (repo: Repo): void => {
+ setSelected(repo);
+ setBranch(repo.defaultBranch);
+ };
+
+ const filtered: Repo[] = (repos ?? []).filter((r) => r.path.toLowerCase().includes(query.toLowerCase()));
+
+ const spawn = async (): Promise => {
+ if (selected === null) {
+ setError("pick a repo first");
+ return;
+ }
+ setBusy(true);
+ setError("");
+ try {
+ const body = { repo: selected.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}) };
+ const res = await fetchJson(Route.Spawn, { method: "POST", body: JSON.stringify(body) });
+ setSpawning(res);
+ startPolling(res.sessionId);
+ } catch (err) {
+ setError(errMessage(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const startPolling = (sessionId: string): void => {
+ tickRef.current = 0;
+ if (timerRef.current !== null) window.clearInterval(timerRef.current);
+ timerRef.current = window.setInterval(() => {
+ tickRef.current += 1;
+ if (tickRef.current > POLL_MAX_TICKS) {
+ if (timerRef.current !== null) window.clearInterval(timerRef.current);
+ timerRef.current = null;
+ return;
+ }
+ void (async () => {
+ try {
+ await fetchJson(Route.SpawnStatus).catch(() => undefined);
+ await store.refresh();
+ const s = store.sessions.find((x) => x.id === sessionId);
+ if (s !== undefined && s.online) {
+ if (timerRef.current !== null) window.clearInterval(timerRef.current);
+ timerRef.current = null;
+ navigate(`/s/${sessionId}`);
+ }
+ } catch (err) {
+ pushToast(errMessage(err));
+ }
+ })();
+ }, POLL_MS);
+ };
+
+ const jobLine = (): string => {
+ if (spawning === null) return "";
+ const job: SpawnJob | undefined = store.spawnJobs.find((j) => j.sessionId === spawning.sessionId);
+ if (job !== undefined) return `${job.repo}: ${job.state}`;
+ return "waiting for session to come online…";
+ };
+
+ if (status === null) {
+ return (
+
+
Spawn
+
{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}
+
+ );
+ }
+
+ if (!status.connected) {
+ return (
+
+ );
+ }
+
+ return (
+
+
Spawn
+
+ {spawning !== null && (
+
+
Spawning…
+
{jobLine()}
+
container {spawning.containerId.slice(0, 12)}
+ {error.length > 0 &&
{error}
}
+
+ )}
+
+ {spawning === null && (
+ <>
+
+
Repository
+
setQuery(e.target.value)}
+ />
+ {repos === null &&
loading repos…
}
+ {repos !== null && filtered.length === 0 &&
no matching repos
}
+
+ {filtered.map((r) => (
+
pick(r)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") pick(r);
+ }}
+ >
+
+
{r.path}
+
+ default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
+
+
+
+ ))}
+
+
+
+
+
Branch
+
+ setBranch(e.target.value)}
+ />
+
+
+ {selected === null &&
select a repo above
}
+ {error.length > 0 &&
{error}
}
+
+ >
+ )}
+
+ );
+}
diff --git a/web/src/TaskPanel.tsx b/web/src/TaskPanel.tsx
new file mode 100644
index 0000000..178649a
--- /dev/null
+++ b/web/src/TaskPanel.tsx
@@ -0,0 +1,79 @@
+import type { SubagentRun, TodoItem, TodoStatus } from "./derive";
+import type { TaskDerivation } from "./derive";
+
+const STATUS_ICON: Record = {
+ pending: "○",
+ "in-progress": "◺",
+ completed: "●",
+};
+
+function TodoRow({ item }: { item: TodoItem }) {
+ return (
+
+
+ {STATUS_ICON[item.status]}
+
+
+ {item.content}
+
+
+ );
+}
+
+function SubagentRow({ run }: { run: SubagentRun }) {
+ return (
+
+ {run.running ? (
+
+ ) : (
+
+ {run.isError ? "✕" : "✓"}
+
+ )}
+ {run.name}
+
+ {run.running ? "running" : run.isError ? "failed" : "done"}
+
+
+ );
+}
+
+export default function TaskPanel({ tasks }: { tasks: TaskDerivation }) {
+ const hasTodos: boolean = tasks.todos.length > 0;
+ const hasSubagents: boolean = tasks.subagents.length > 0;
+ const hasWorking: boolean = tasks.workingTools.length > 0;
+ const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
+
+ return (
+
+ {empty &&
No tasks yet.
}
+ {hasTodos && (
+
+ Tasks
+ {tasks.todos.map((t) => (
+
+ ))}
+
+ )}
+ {hasSubagents && (
+
+ Subagents
+ {tasks.subagents.map((s) => (
+
+ ))}
+
+ )}
+ {hasWorking && (
+
+ Working
+ {tasks.workingTools.map((w) => (
+
+
+ {w.name}…
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web/src/api.ts b/web/src/api.ts
new file mode 100644
index 0000000..e8fcc61
--- /dev/null
+++ b/web/src/api.ts
@@ -0,0 +1,38 @@
+import { getSettings } from "./settings";
+
+export class ApiError extends Error {
+ readonly status: number;
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ }
+}
+
+export async function fetchJson(path: string, init?: RequestInit): Promise {
+ const settings = getSettings();
+ if (settings === null) throw new ApiError("not configured", 401);
+
+ const headers: Record = { Authorization: `Bearer ${settings.token}` };
+ if (init?.body !== undefined) headers["Content-Type"] = "application/json";
+
+ const res = await fetch(`${settings.serverUrl}${path}`, { ...init, headers });
+ if (!res.ok) {
+ let message: string = `${res.status} ${res.statusText}`;
+ try {
+ const body: unknown = await res.json();
+ if (body !== null && typeof body === "object" && typeof (body as { error?: unknown }).error === "string") {
+ message = (body as { error: string }).error;
+ }
+ } catch {
+ // keep status-derived message
+ }
+ throw new ApiError(message, res.status);
+ }
+ return (await res.json()) as T;
+}
+
+export function errMessage(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ return String(err);
+}
diff --git a/web/src/derive.ts b/web/src/derive.ts
new file mode 100644
index 0000000..8bb9a43
--- /dev/null
+++ b/web/src/derive.ts
@@ -0,0 +1,293 @@
+import type { EventFrame } from "./protocol";
+
+// ---------- event list merge ----------
+
+export function mergeEvents(existing: EventFrame[], incoming: EventFrame[]): EventFrame[] {
+ const bySeq = new Map();
+ for (const e of existing) bySeq.set(e.seq, e);
+ for (const e of incoming) bySeq.set(e.seq, e);
+ return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq);
+}
+
+/** Highest persisted seq — deltas (`message_update`) are not persisted, so they
+ * must not advance the refetch cursor. */
+export function lastPersistedSeq(events: EventFrame[]): number {
+ let max = 0;
+ for (const e of events) if (e.type !== "message_update" && e.seq > max) max = e.seq;
+ return max;
+}
+
+// ---------- chat view derivation ----------
+
+export interface ToolState {
+ id: string;
+ name: string;
+ args: string;
+ running: boolean;
+ isError: boolean;
+ preview: string;
+}
+
+export interface ChatMessage {
+ key: string;
+ role: "user" | "assistant" | "system" | "toolResult";
+ text: string;
+ thinking: string | null;
+ toolCalls: { id: string; name: string; argsJson: string }[];
+ toolCallId: string | null;
+ streaming: boolean;
+}
+
+export interface ChatDerivation {
+ messages: ChatMessage[];
+ tools: Map;
+ busy: boolean;
+}
+
+export function deriveChat(events: EventFrame[]): ChatDerivation {
+ const messages: ChatMessage[] = [];
+ const tools = new Map();
+ let stream: { id: string; text: string } | null = null;
+ let busy = false;
+
+ for (const e of events) {
+ switch (e.type) {
+ case "tool_execution_start": {
+ if (e.toolCallId !== undefined) {
+ tools.set(e.toolCallId, {
+ id: e.toolCallId,
+ name: e.toolName ?? "tool",
+ args: e.args ?? "",
+ running: true,
+ isError: false,
+ preview: "",
+ });
+ }
+ break;
+ }
+ case "tool_execution_end": {
+ const t = e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined;
+ if (t !== undefined) {
+ t.running = false;
+ t.isError = e.isError ?? false;
+ t.preview = e.resultPreview ?? "";
+ }
+ break;
+ }
+ case "agent_start":
+ busy = true;
+ break;
+ case "agent_settled":
+ busy = false;
+ break;
+ case "message_start":
+ if (e.message?.role === "assistant") stream = { id: e.message.id, text: "" };
+ break;
+ case "message_update":
+ if (stream !== null) stream.text += e.delta ?? "";
+ break;
+ case "message_end": {
+ const m = e.message;
+ if (m !== undefined) {
+ if (stream !== null && stream.id === m.id) stream = null;
+ messages.push({
+ key: `msg-${e.seq}`,
+ role: m.role,
+ text: m.text,
+ thinking: m.thinking,
+ toolCalls: m.toolCalls ?? [],
+ toolCallId: m.toolCallId,
+ streaming: false,
+ });
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ if (stream !== null) {
+ messages.push({
+ key: `stream-${stream.id}`,
+ role: "assistant",
+ text: stream.text,
+ thinking: null,
+ toolCalls: [],
+ toolCallId: null,
+ streaming: true,
+ });
+ }
+
+ return { messages, tools, busy: busy || stream !== null };
+}
+
+// ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ----------
+
+export type TodoStatus = "pending" | "in-progress" | "completed";
+
+export interface TodoItem {
+ content: string;
+ status: TodoStatus;
+ deleted: boolean;
+}
+
+export interface SubagentRun {
+ key: string;
+ name: string;
+ running: boolean;
+ isError: boolean;
+}
+
+export interface TaskDerivation {
+ todos: TodoItem[];
+ subagents: SubagentRun[];
+ workingTools: ToolState[];
+}
+
+const TODO_TOOL = "todo";
+const SUBAGENT_TOOL = "subagent";
+
+function parseJson(text: string | undefined | null): unknown {
+ if (typeof text !== "string" || text.length === 0) return undefined;
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ return undefined;
+ }
+}
+
+function normalizeStatus(raw: unknown): TodoStatus {
+ if (typeof raw !== "string") return "pending";
+ switch (raw.toLowerCase()) {
+ case "in_progress":
+ case "in-progress":
+ case "inprogress":
+ case "in progress":
+ case "doing":
+ case "started":
+ return "in-progress";
+ case "completed":
+ case "complete":
+ case "done":
+ return "completed";
+ default:
+ return "pending";
+ }
+}
+
+function extractSnapshot(raw: unknown): { content: string; status: TodoStatus }[] | null {
+ let arr: unknown = raw;
+ if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") {
+ const o = raw as Record;
+ const nested = o.todos ?? o.items ?? o.tasks ?? o.list;
+ if (Array.isArray(nested)) arr = nested;
+ }
+ if (Array.isArray(arr) === false) return null;
+ const items: { content: string; status: TodoStatus }[] = [];
+ for (const entry of arr) {
+ if (typeof entry === "string") {
+ items.push({ content: entry, status: "pending" });
+ continue;
+ }
+ if (entry !== null && typeof entry === "object") {
+ const o = entry as Record;
+ const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary;
+ if (typeof content === "string" && content.length > 0) {
+ items.push({ content, status: normalizeStatus(o.status) });
+ }
+ }
+ }
+ return items.length > 0 ? items : null;
+}
+
+export function deriveTasks(events: EventFrame[]): TaskDerivation {
+ const toolNames = new Map(); // toolCallId -> toolName
+ for (const e of events) {
+ if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
+ toolNames.set(e.toolCallId, e.toolName ?? "");
+ }
+ }
+
+ // todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message
+ const snapshots: { seq: number; items: { content: string; status: TodoStatus }[] }[] = [];
+ const subagents: SubagentRun[] = [];
+ const runningTools: ToolState[] = [];
+
+ for (const e of events) {
+ if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
+ const name = e.toolName ?? "";
+ if (name === TODO_TOOL) {
+ const snap = extractSnapshot(parseJson(e.args));
+ if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
+ } else if (name === SUBAGENT_TOOL) {
+ const parsed = parseJson(e.args);
+ const o = parsed !== null && typeof parsed === "object" ? (parsed as Record) : {};
+ const nameField = o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role;
+ subagents.push({
+ key: e.toolCallId,
+ name: typeof nameField === "string" && nameField.length > 0 ? nameField : "subagent",
+ running: true,
+ isError: false,
+ });
+ } else {
+ runningTools.push({
+ id: e.toolCallId,
+ name,
+ args: e.args ?? "",
+ running: true,
+ isError: false,
+ preview: "",
+ });
+ }
+ } else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) {
+ const name = toolNames.get(e.toolCallId) ?? "";
+ if (name === SUBAGENT_TOOL) {
+ const run = subagents.find((s) => s.key === e.toolCallId);
+ if (run !== undefined) {
+ run.running = false;
+ run.isError = e.isError ?? false;
+ }
+ } else if (name !== TODO_TOOL) {
+ const t = runningTools.find((w) => w.id === e.toolCallId);
+ if (t !== undefined) {
+ t.running = false;
+ t.isError = e.isError ?? false;
+ t.preview = e.resultPreview ?? "";
+ }
+ }
+ if (name === TODO_TOOL) {
+ const snap = extractSnapshot(parseJson(e.resultPreview));
+ if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
+ }
+ } else if (e.type === "message_end" && e.message?.role === "toolResult" && e.message.toolCallId !== null) {
+ const name = toolNames.get(e.message.toolCallId) ?? "";
+ if (name === TODO_TOOL) {
+ const snap = extractSnapshot(parseJson(e.message.text));
+ if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
+ }
+ }
+ }
+
+ // todos: latest snapshot wins; earlier items missing from it are deleted
+ const todos: TodoItem[] = [];
+ if (snapshots.length > 0) {
+ snapshots.sort((a, b) => a.seq - b.seq);
+ const latest = snapshots[snapshots.length - 1]?.items ?? [];
+ const seen = new Map();
+ for (const snap of snapshots) {
+ for (const item of snap.items) if (!seen.has(item.content)) seen.set(item.content, item.status);
+ }
+ for (const item of latest) {
+ todos.push({ content: item.content, status: item.status, deleted: false });
+ seen.delete(item.content);
+ }
+ for (const [content] of seen) todos.push({ content, status: "pending", deleted: true });
+ }
+
+ return {
+ todos,
+ subagents,
+ workingTools: runningTools.filter((t) => t.running),
+ };
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 0000000..24c6169
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,468 @@
+:root {
+ color-scheme: dark;
+ --bg: #212121;
+ --bg-raised: #2f2f2f;
+ --bg-hover: #383838;
+ --border: #3d3d3d;
+ --text: #ececec;
+ --text-dim: #a6a6a6;
+ --text-faint: #7c7c7c;
+ --accent: #10a37f;
+ --accent-dim: #0d8a6c;
+ --danger: #ef4444;
+ --user-bubble: #2f2f2f;
+ --assistant-bubble: #212121;
+ --radius: 12px;
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html, body, #root { height: 100%; margin: 0; }
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-size: 15px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+button:disabled { cursor: not-allowed; opacity: 0.5; }
+
+input, textarea, select {
+ font: inherit;
+ color: var(--text);
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 10px;
+}
+input:focus, textarea:focus, select:focus { outline: 1px solid var(--accent); }
+
+a { color: var(--text-dim); text-decoration: none; }
+a:hover { color: var(--text); }
+
+/* ---------- app shell ---------- */
+
+.app-shell { display: flex; height: 100%; overflow: hidden; }
+
+.sidebar {
+ width: 264px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-raised);
+ border-right: 1px solid var(--border);
+}
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 14px;
+ font-weight: 600;
+}
+.sidebar-header .brand { display: flex; align-items: center; gap: 8px; }
+.sidebar-header .brand .logo {
+ width: 24px; height: 24px;
+ border-radius: 6px;
+ background: var(--accent);
+ color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 13px; font-weight: 700;
+}
+.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.conn-dot.open { background: #22c55e; }
+.conn-dot.connecting { background: #eab308; }
+.conn-dot.closed { background: var(--danger); }
+.new-chat-btn {
+ display: block;
+ width: calc(100% - 20px);
+ margin: 0 10px 8px;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ text-align: center;
+ color: var(--text);
+}
+.new-chat-btn:hover { background: var(--bg-hover); }
+
+.sidebar-sessions { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
+.session-link {
+ display: block;
+ padding: 8px 10px;
+ border-radius: 8px;
+ color: var(--text-dim);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.session-link:hover { background: var(--bg-hover); color: var(--text); }
+.session-link.active { background: var(--bg-hover); color: var(--text); }
+.sidebar-footer {
+ padding: 10px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.sidebar-footer .spacer { flex: 1; }
+.icon-btn {
+ padding: 6px;
+ border-radius: 8px;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
+.icon-btn.danger:hover { color: var(--danger); }
+
+.main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
+
+/* ---------- sessions view ---------- */
+
+.page { flex: 1; overflow-y: auto; padding: 24px; max-width: 860px; margin: 0 auto; width: 100%; }
+.page h1 { font-size: 20px; margin: 0 0 16px; }
+.page .empty { color: var(--text-faint); padding: 40px 0; text-align: center; }
+
+.session-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-bottom: 10px;
+ cursor: pointer;
+ background: var(--bg);
+}
+.session-card:hover { background: var(--bg-raised); }
+.session-card .online-dot {
+ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
+ background: #525252;
+}
+.session-card .online-dot.on { background: #22c55e; }
+.session-card .card-body { flex: 1; min-width: 0; }
+.session-card .card-title {
+ font-weight: 600;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.session-card .card-meta {
+ font-size: 12px; color: var(--text-faint);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ display: flex; gap: 8px; flex-wrap: wrap;
+}
+.badge {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+ flex-shrink: 0;
+}
+
+/* ---------- chat view ---------- */
+
+.chat-layout { flex: 1; display: flex; min-height: 0; }
+.chat-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+.chat-header {
+ display: flex; align-items: center; gap: 10px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+}
+.chat-header .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.chat-header .sub { font-size: 12px; color: var(--text-faint); }
+.chat-header .spacer, .task-col .spacer { flex: 1; }
+
+.chat-scroll { flex: 1; overflow-y: auto; padding: 20px 16px; scroll-behavior: smooth; }
+.chat-inner { max-width: 760px; margin: 0 auto; }
+
+.bubble-row { display: flex; margin-bottom: 14px; }
+.bubble-row.user { justify-content: flex-end; }
+.bubble {
+ max-width: 86%;
+ padding: 10px 14px;
+ border-radius: var(--radius);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.bubble-row.user .bubble { background: var(--user-bubble); }
+.bubble-row.assistant .bubble { background: transparent; padding: 0; max-width: 100%; }
+.bubble-row.system .bubble { color: var(--text-faint); font-size: 13px; font-style: italic; }
+
+.bubble .tool-card {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-raised);
+ margin: 8px 0;
+ overflow: hidden;
+}
+.bubble .tool-card summary {
+ cursor: pointer;
+ padding: 8px 12px;
+ font-size: 13px;
+ color: var(--text-dim);
+ display: flex; align-items: center; gap: 8px;
+ list-style: none;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.bubble .tool-card summary::-webkit-details-marker { display: none; }
+.bubble .tool-card .tool-body {
+ border-top: 1px solid var(--border);
+ padding: 10px 12px;
+ font-size: 13px;
+ color: var(--text-dim);
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 300px;
+ overflow-y: auto;
+}
+.tool-status-ok { color: var(--accent); }
+.tool-status-err { color: var(--danger); }
+
+details.thinking { margin-bottom: 8px; }
+details.thinking summary {
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-faint);
+ list-style: none;
+}
+details.thinking summary::-webkit-details-marker { display: none; }
+details.thinking summary::before { content: "◆ "; }
+details.thinking .thinking-body {
+ font-size: 13px;
+ color: var(--text-faint);
+ border-left: 2px solid var(--border);
+ padding-left: 10px;
+ margin-top: 6px;
+ white-space: pre-wrap;
+ max-height: 260px;
+ overflow-y: auto;
+}
+
+.typing {
+ display: inline-flex; gap: 4px; align-items: center;
+ padding: 8px 0;
+}
+.typing .dot {
+ width: 6px; height: 6px; border-radius: 50%;
+ background: var(--text-faint);
+ animation: typing-bounce 1.2s infinite;
+}
+.typing .dot:nth-child(2) { animation-delay: 0.15s; }
+.typing .dot:nth-child(3) { animation-delay: 0.3s; }
+@keyframes typing-bounce {
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
+ 30% { transform: translateY(-4px); opacity: 1; }
+}
+
+.composer {
+ border-top: 1px solid var(--border);
+ padding: 12px 16px;
+ background: var(--bg);
+}
+.composer-inner { max-width: 760px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end; }
+.composer textarea {
+ flex: 1;
+ resize: none;
+ max-height: 200px;
+ min-height: 42px;
+ border-radius: var(--radius);
+}
+.composer .send-btn {
+ background: var(--accent);
+ color: #fff;
+ border-radius: 10px;
+ padding: 10px 14px;
+}
+.composer .send-btn:hover { background: var(--accent-dim); }
+.composer .send-btn:disabled { background: var(--bg-hover); color: var(--text-faint); }
+.composer .abort-btn {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px 14px;
+ color: var(--danger);
+}
+
+/* ---------- task panel ---------- */
+
+.task-col {
+ width: 300px;
+ flex-shrink: 0;
+ border-left: 1px solid var(--border);
+ overflow-y: auto;
+ padding: 14px;
+ background: var(--bg);
+}
+.task-col h2 { font-size: 13px; margin: 0 0 8px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
+.task-col .empty { color: var(--text-faint); font-size: 13px; }
+.task-section { margin-bottom: 18px; }
+
+.todo-item { display: flex; gap: 8px; align-items: baseline; font-size: 13px; padding: 3px 0; }
+.todo-item .todo-icon { flex-shrink: 0; width: 14px; text-align: center; }
+.todo-item .todo-icon.pending { color: var(--text-faint); }
+.todo-item .todo-icon.in-progress { color: #eab308; }
+.todo-item .todo-icon.completed { color: var(--accent); }
+.todo-item.done .todo-text { text-decoration: line-through; color: var(--text-faint); }
+.todo-item .todo-text { color: var(--text); }
+
+.subagent-item { font-size: 13px; padding: 4px 0; display: flex; gap: 8px; align-items: center; }
+.subagent-item .spinner {
+ width: 12px; height: 12px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ flex-shrink: 0;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+.subagent-item .done-icon { color: var(--accent); flex-shrink: 0; }
+.working-line { font-size: 12px; color: var(--text-faint); padding: 3px 0; display: flex; gap: 6px; align-items: center; }
+
+.task-toggle { display: none; }
+
+/* ---------- settings gate ---------- */
+
+.gate {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+.gate-card {
+ width: 100%;
+ max-width: 380px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--bg-raised);
+ padding: 28px;
+}
+.gate-card h1 { font-size: 18px; margin: 0 0 4px; display: flex; align-items: center; gap: 8px; }
+.gate-card .sub { color: var(--text-faint); font-size: 13px; margin-bottom: 20px; }
+.gate-card label { display: block; font-size: 13px; color: var(--text-dim); margin: 12px 0 4px; }
+.gate-card input { width: 100%; }
+.gate-card .gate-error { color: var(--danger); font-size: 13px; margin-top: 12px; min-height: 18px; }
+.gate-card .gate-submit {
+ width: 100%;
+ margin-top: 20px;
+ padding: 10px;
+ background: var(--accent);
+ color: #fff;
+ border-radius: 10px;
+}
+
+/* ---------- spawn view ---------- */
+
+.spawn-card {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 18px;
+ margin-bottom: 16px;
+ background: var(--bg-raised);
+}
+.spawn-card h2 { font-size: 15px; margin: 0 0 10px; }
+.spawn-card input[type="text"], .spawn-card input[type="password"], .spawn-card select { width: 100%; }
+.spawn-card .row { display: flex; gap: 10px; }
+.spawn-card .row > * { flex: 1; }
+.spawn-card .actions { display: flex; gap: 8px; margin-top: 14px; }
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+ border-radius: 10px;
+ padding: 8px 16px;
+}
+.btn-primary:hover { background: var(--accent-dim); }
+.btn-primary:disabled { background: var(--bg-hover); color: var(--text-faint); }
+.btn-secondary { border: 1px solid var(--border); border-radius: 10px; padding: 8px 16px; }
+.btn-secondary:hover { background: var(--bg-hover); }
+.error-text { color: var(--danger); font-size: 13px; margin-top: 10px; }
+
+.repo-list { max-height: 420px; overflow-y: auto; }
+.repo-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 10px;
+ border-radius: 8px;
+ cursor: pointer;
+}
+.repo-item:hover { background: var(--bg-hover); }
+.repo-item.selected { background: var(--bg-hover); outline: 1px solid var(--accent); }
+.repo-item .repo-path { font-weight: 600; }
+.repo-item .repo-meta { font-size: 12px; color: var(--text-faint); }
+
+.spawn-job-line { font-size: 13px; color: var(--text-dim); padding: 4px 0; }
+
+/* ---------- toasts ---------- */
+
+.toasts {
+ position: fixed;
+ bottom: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 100;
+}
+.toast {
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 10px 16px;
+ border-radius: 10px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ font-size: 14px;
+}
+
+/* ---------- mobile ≤400px ---------- */
+
+@media (max-width: 860px) {
+ .task-col { display: none; }
+ .task-toggle {
+ display: inline-flex;
+ align-items: center;
+ }
+ .mobile-tasks {
+ border-bottom: 1px solid var(--border);
+ padding: 12px 16px;
+ background: var(--bg-raised);
+ }
+}
+
+@media (max-width: 700px) {
+ .sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 50;
+ transform: translateX(-100%);
+ transition: transform 0.2s ease;
+ width: 280px;
+ }
+ .sidebar.open { transform: translateX(0); }
+ .sidebar-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 40;
+ }
+ .menu-btn { display: inline-flex !important; }
+ .page { padding: 14px; }
+ .chat-scroll { padding: 14px 10px; }
+ .bubble { max-width: 94%; }
+}
+
+@media (max-width: 400px) {
+ body { font-size: 14px; }
+ .sidebar { width: 100%; }
+ .gate-card { padding: 20px; }
+ .session-card { padding: 10px; gap: 8px; }
+ .composer textarea { font-size: 16px; } /* prevent iOS zoom */
+ .composer .send-btn { padding: 10px 12px; }
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..69fc901
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { HashRouter } from "react-router-dom";
+import App from "./App";
+import "./index.css";
+
+const rootEl = document.getElementById("root");
+if (rootEl === null) throw new Error("#root missing in index.html");
+
+createRoot(rootEl).render(
+
+
+
+
+
+);
diff --git a/web/src/protocol.ts b/web/src/protocol.ts
new file mode 100644
index 0000000..ed8ca15
--- /dev/null
+++ b/web/src/protocol.ts
@@ -0,0 +1,158 @@
+// Typed mirror of PROTOCOL.md v1.
+
+export const PROTOCOL_VERSION: 1 = 1;
+
+/** Event types emitted by the plugin, carried in the envelope `type` field. */
+export const EventType = {
+ Hello: "hello",
+ MessageStart: "message_start",
+ MessageUpdate: "message_update",
+ MessageEnd: "message_end",
+ ToolExecutionStart: "tool_execution_start",
+ ToolExecutionUpdate: "tool_execution_update",
+ ToolExecutionEnd: "tool_execution_end",
+ AgentStart: "agent_start",
+ AgentEnd: "agent_end",
+ AgentSettled: "agent_settled",
+ SessionInfo: "session_info",
+ Bye: "bye",
+} as const;
+export type EventType = (typeof EventType)[keyof typeof EventType];
+
+/** REST routes (base `/api`, bearer auth). */
+export const Route = {
+ Sessions: "/api/sessions",
+ SessionEvents: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/events`,
+ SessionPrompt: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/prompt`,
+ SessionAbort: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/abort`,
+ SessionContainer: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/container`,
+ Spawn: "/api/spawn",
+ SpawnStatus: "/api/spawn/status",
+ GitlabStatus: "/api/gitlab/status",
+ GitlabConnect: "/api/gitlab/connect",
+ GitlabRepos: "/api/gitlab/repos",
+} as const;
+
+/** Session snapshot, carried by `hello`/`session_info` and REST session list. */
+export interface SessionInfo {
+ id: string;
+ name: string | null;
+ cwd: string;
+ model: string;
+ provider: string;
+ /** true if spawned-by-daemon container session */
+ agent: boolean;
+ repo: string | null;
+ startedAt: number;
+}
+
+/** Session list row (REST `GET /api/sessions` + WS `session_list`). */
+export interface SessionListItem extends SessionInfo {
+ online: boolean;
+ lastEventAt: number | null;
+}
+
+export type MessageRole = "user" | "assistant" | "toolResult" | "system";
+
+export interface ToolCall {
+ id: string;
+ name: string;
+ argsJson: string;
+}
+
+export interface Message {
+ role: MessageRole;
+ id: string;
+ text: string;
+ thinking: string | null;
+ toolCalls: ToolCall[];
+ /** toolResult messages: which call this answers */
+ toolCallId: string | null;
+}
+
+/** Persisted event envelope + flattened payload. */
+export interface EventFrame {
+ v: 1;
+ sessionId: string;
+ /** monotonic per-session, plugin-assigned, starts at 1 */
+ seq: number;
+ /** unix ms */
+ ts: number;
+ type: EventType | string;
+ // ---- payload fields (union, present depending on `type`) ----
+ session?: SessionInfo;
+ message?: Message;
+ delta?: string;
+ toolCallId?: string;
+ toolName?: string;
+ args?: string;
+ partial?: string;
+ isError?: boolean;
+ resultPreview?: string;
+ usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number };
+ reason?: string;
+}
+
+/** Query params + response shapes for REST routes. */
+export interface PromptBody {
+ message: string;
+}
+
+export interface PromptResponse {
+ ok: boolean;
+}
+
+export interface AbortResponse {
+ ok: boolean;
+}
+
+export interface ContainerResponse {
+ ok: boolean;
+}
+
+export interface SpawnBody {
+ repo: string;
+ branch?: string;
+}
+
+export interface SpawnResponse {
+ sessionId: string;
+ containerId: string;
+}
+
+export interface SpawnJob {
+ repo: string;
+ state: string;
+ containerId?: string;
+ sessionId?: string;
+}
+
+export interface GitlabStatus {
+ connected: boolean;
+ baseUrl: string;
+ username?: string;
+}
+
+export interface GitlabConnectResponse {
+ username: string;
+}
+
+export interface Repo {
+ path: string;
+ name: string;
+ namespace: string;
+ lastActivityAt: string;
+ webUrl: string;
+ defaultBranch: string;
+}
+
+/** ---- WS transport 3 (browser → daemon) ---- */
+
+export type ServerFrame =
+ | { type: "session_list"; sessions: SessionListItem[] }
+ | { type: "events"; sessionId: string; after: number; events: EventFrame[] }
+ | { type: "spawn_status"; jobs: SpawnJob[] };
+
+export type ClientFrame =
+ | { type: "subscribe"; sessionId: string }
+ | { type: "unsubscribe"; sessionId: string };
diff --git a/web/src/settings.ts b/web/src/settings.ts
new file mode 100644
index 0000000..be661d4
--- /dev/null
+++ b/web/src/settings.ts
@@ -0,0 +1,38 @@
+export interface Settings {
+ serverUrl: string;
+ token: string;
+}
+
+const STORAGE_KEY: string = "lvmh.settings";
+
+export function getSettings(): Settings | null {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw === null) return null;
+ const parsed: unknown = JSON.parse(raw);
+ if (
+ parsed !== null &&
+ typeof parsed === "object" &&
+ typeof (parsed as { serverUrl?: unknown }).serverUrl === "string" &&
+ typeof (parsed as { token?: unknown }).token === "string"
+ ) {
+ return parsed as Settings;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveSettings(settings: Settings): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
+}
+
+export function clearSettings(): void {
+ localStorage.removeItem(STORAGE_KEY);
+}
+
+export function buildWsUrl(settings: Settings): string {
+ const base = settings.serverUrl.replace(/\/+$/, "").replace(/^http/, "ws");
+ return `${base}/ws?token=${encodeURIComponent(settings.token)}`;
+}
diff --git a/web/src/store.ts b/web/src/store.ts
new file mode 100644
index 0000000..6ab8543
--- /dev/null
+++ b/web/src/store.ts
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useState } from "react";
+import type { EventFrame, ServerFrame, SessionListItem, SpawnJob } from "./protocol";
+import { Route } from "./protocol";
+import { buildWsUrl, getSettings, clearSettings } from "./settings";
+import { createWsManager, type WsManager, type WsState } from "./ws";
+import { errMessage, fetchJson } from "./api";
+
+// ---------- small shared helpers ----------
+
+const MINUTE_MS: number = 60_000;
+const HOUR_MS: number = 60 * MINUTE_MS;
+const DAY_MS: number = 24 * HOUR_MS;
+
+export function relativeTime(ts: number | null): string {
+ if (ts === null) return "never";
+ const abs: number = Math.abs(Date.now() - ts);
+ if (abs < MINUTE_MS) return "just now";
+ if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`;
+ if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`;
+ return `${Math.floor(abs / DAY_MS)}d ago`;
+}
+
+export function classNames(...parts: Array): string {
+ return parts.filter(Boolean).join(" ");
+}
+
+// ---------- toasts ----------
+
+export interface Toast {
+ id: number;
+ text: string;
+}
+
+let toastSeq: number = 0;
+const TOAST_TTL_MS: number = 4000;
+
+export function useToasts(): { toasts: Toast[]; push: (text: string) => void } {
+ const [toasts, setToasts] = useState([]);
+ const push = useCallback((text: string): void => {
+ const id: number = ++toastSeq;
+ setToasts((prev) => [...prev, { id, text }]);
+ window.setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), TOAST_TTL_MS);
+ }, []);
+ return { toasts, push };
+}
+
+// ---------- sessions store (REST seed + WS updates) ----------
+
+export interface SessionsStore {
+ sessions: SessionListItem[];
+ state: WsState;
+ spawnJobs: SpawnJob[];
+ refresh: () => Promise;
+ /** Subscribe to one session's event stream (protocol allows one at a time). */
+ subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void) => () => void;
+}
+
+export function useSessions(pushToast: (text: string) => void): SessionsStore | null {
+ const [sessions, setSessions] = useState([]);
+ const [spawnJobs, setSpawnJobs] = useState([]);
+ const [state, setState] = useState("connecting");
+ const [manager, setManager] = useState(null);
+
+ useEffect(() => {
+ const settings = getSettings();
+ if (settings === null) return;
+
+ const m = createWsManager(buildWsUrl(settings));
+ setManager(m);
+
+ const offState = m.onState(setState);
+ const offFrames = m.onFrame((frame: ServerFrame) => {
+ if (frame.type === "session_list") setSessions(frame.sessions);
+ else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
+ });
+ m.onAuthError(() => {
+ clearSettings();
+ window.location.reload();
+ });
+
+ const refresh = async (): Promise => {
+ try {
+ setSessions(await fetchJson(Route.Sessions));
+ } catch (err) {
+ pushToast(`sessions: ${errMessage(err)}`);
+ }
+ };
+ void refresh();
+
+ return () => {
+ offState();
+ offFrames();
+ m.close();
+ setManager(null);
+ };
+ }, [pushToast]);
+
+ if (getSettings() === null) return null;
+
+ const refresh = useCallback(async (): Promise => {
+ try {
+ setSessions(await fetchJson(Route.Sessions));
+ } catch (err) {
+ pushToast(`sessions: ${errMessage(err)}`);
+ }
+ }, [pushToast]);
+
+ const subscribe = useCallback(
+ (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
+ if (manager === null) return () => undefined;
+ const off = manager.onFrame((frame: ServerFrame) => {
+ if (frame.type === "events" && frame.sessionId === sessionId) onEvents(frame.events);
+ });
+ manager.subscribe(sessionId);
+ return () => {
+ manager.unsubscribe(sessionId);
+ off();
+ };
+ },
+ [manager]
+ );
+
+ return { sessions, state, spawnJobs, refresh, subscribe };
+}
diff --git a/web/src/ws.ts b/web/src/ws.ts
new file mode 100644
index 0000000..ec264cc
--- /dev/null
+++ b/web/src/ws.ts
@@ -0,0 +1,142 @@
+import type { ServerFrame, ClientFrame, EventFrame } from "./protocol";
+
+export type WsState = "connecting" | "open" | "closed";
+
+export interface WsManager {
+ onState(cb: (state: WsState) => void): () => void;
+ onFrame(cb: (frame: ServerFrame) => void): () => void;
+ onAuthError(cb: () => void): () => void;
+ subscribe(sessionId: string): void;
+ unsubscribe(sessionId: string): void;
+ /** Resubscribe all live subscriptions; call on (re)open. */
+ resubscribeAll(): void;
+ /** SessionIds subscribed right now (used by onOpen hooks to refetch). */
+ subscriptions(): ReadonlySet;
+ close(): void;
+}
+
+const BACKOFF_INITIAL_MS: number = 500;
+const BACKOFF_MAX_MS: number = 30_000;
+const CLOSED_BY_USER: number = 4900;
+
+export function createWsManager(url: string | (() => string)): WsManager {
+ const urlFn: () => string = typeof url === "string" ? () => url : url;
+
+ let ws: WebSocket | null = null;
+ let closed = false;
+ let attempt: number = 0;
+ let reconnectTimer: number | undefined;
+ let state: WsState = "connecting";
+
+ const stateCbs = new Set<(s: WsState) => void>();
+ const frameCbs = new Set<(f: ServerFrame) => void>();
+ const authErrorCbs = new Set<() => void>();
+ const subs = new Set();
+
+ const setState = (s: WsState): void => {
+ state = s;
+ stateCbs.forEach((cb) => cb(s));
+ };
+
+ const connect = (): void => {
+ if (closed) return;
+ setState("connecting");
+ const sock = new WebSocket(urlFn());
+ ws = sock;
+ sock.onopen = () => {
+ attempt = 0;
+ setState("open");
+ // resubscribe current chat + sessions fan-out resumes automatically
+ subs.forEach((id) => {
+ const frame: ClientFrame = { type: "subscribe", sessionId: id };
+ sock.send(JSON.stringify(frame));
+ });
+ };
+ sock.onmessage = (ev: MessageEvent) => {
+ let frame: ServerFrame;
+ try {
+ frame = JSON.parse(typeof ev.data === "string" ? ev.data : "") as ServerFrame;
+ } catch {
+ return;
+ }
+ if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") return;
+ frameCbs.forEach((cb) => cb(frame));
+ };
+ sock.onclose = (ev: CloseEvent) => {
+ ws = null;
+ setState("closed");
+ if (closed) return;
+ if (ev.code === 1008) {
+ // policy violation: token rejected
+ authErrorCbs.forEach((cb) => cb());
+ return;
+ }
+ const jitter: number = Math.random();
+ const exp: number = Math.min(BACKOFF_INITIAL_MS * 2 ** attempt, BACKOFF_MAX_MS);
+ attempt += 1;
+ reconnectTimer = window.setTimeout(connect, exp * (0.5 + 0.5 * jitter));
+ };
+ sock.onerror = () => {
+ /* onclose follows */
+ };
+ };
+
+ connect();
+
+ return {
+ onState(cb) {
+ stateCbs.add(cb);
+ cb(state);
+ return () => stateCbs.delete(cb);
+ },
+ onFrame(cb) {
+ frameCbs.add(cb);
+ return () => frameCbs.delete(cb);
+ },
+ onAuthError(cb) {
+ authErrorCbs.add(cb);
+ return () => authErrorCbs.delete(cb);
+ },
+ subscribe(sessionId) {
+ subs.add(sessionId);
+ if (ws !== null && ws.readyState === WebSocket.OPEN) {
+ const frame: ClientFrame = { type: "subscribe", sessionId };
+ ws.send(JSON.stringify(frame));
+ }
+ },
+ unsubscribe(sessionId) {
+ subs.delete(sessionId);
+ if (ws !== null && ws.readyState === WebSocket.OPEN) {
+ const frame: ClientFrame = { type: "unsubscribe", sessionId };
+ ws.send(JSON.stringify(frame));
+ }
+ },
+ resubscribeAll() {
+ const sock: WebSocket | null = ws;
+ if (sock !== null && sock.readyState === WebSocket.OPEN) {
+ subs.forEach((id) => {
+ const frame: ClientFrame = { type: "subscribe", sessionId: id };
+ sock.send(JSON.stringify(frame));
+ });
+ }
+ },
+ subscriptions() {
+ return subs;
+ },
+ close() {
+ closed = true;
+ if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
+ if (ws !== null) {
+ ws.onclose = null;
+ ws.onmessage = null;
+ ws.onerror = null;
+ ws.onopen = null;
+ ws.close(CLOSED_BY_USER);
+ ws = null;
+ }
+ setState("closed");
+ },
+ };
+}
+
+export type { EventFrame };
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..26d09e8
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "useDefineForClassFields": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..8ad3167
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+const DEV_PROXY_TARGET: string = "http://localhost:8686";
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: "dist",
+ },
+ server: {
+ proxy: {
+ "/api": DEV_PROXY_TARGET,
+ "/ws": { target: DEV_PROXY_TARGET, ws: true },
+ },
+ },
+});