mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 支持自托管的 remote-control-server (#214)
* feat: 支持自托管的 remote-control-server (#214) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.githooks
|
||||||
|
.github
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
packages/remote-control-server/data/*.db
|
||||||
|
packages/remote-control-server/data/*.db-wal
|
||||||
|
packages/remote-control-server/data/*.db-shm
|
||||||
|
.claude
|
||||||
75
.github/workflows/release-rcs.yml
vendored
Normal file
75
.github/workflows/release-rcs.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Release RCS Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'rcs-v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository_owner }}/remote-control-server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract version
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF_NAME#rcs-v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
|
TAGS="${IMAGE}:${VERSION}"
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||||
|
if [ -n "$MAJOR" ] && [ -n "$MINOR" ]; then
|
||||||
|
TAGS="${TAGS},${IMAGE}:${MAJOR}.${MINOR}"
|
||||||
|
fi
|
||||||
|
TAGS="${TAGS},${IMAGE}:latest"
|
||||||
|
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: packages/remote-control-server/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: ${{ steps.tags.outputs.tags }}
|
||||||
|
build-args: VERSION=${{ steps.version.outputs.VERSION }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Verify image
|
||||||
|
run: |
|
||||||
|
IMAGE_TAG=$(echo "${{ steps.tags.outputs.tags }}" | cut -d',' -f1)
|
||||||
|
docker run -d --name rcs-test -p 3000:3000 "$IMAGE_TAG"
|
||||||
|
sleep 5
|
||||||
|
curl -sf http://localhost:3000/health || { docker logs rcs-test; exit 1; }
|
||||||
|
docker stop rcs-test
|
||||||
|
docker rm rcs-test
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
IFS=',' read -ra TAGS <<< "${{ steps.tags.outputs.tags }}"
|
||||||
|
for TAG in "${TAGS[@]}"; do
|
||||||
|
docker push "$TAG"
|
||||||
|
done
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,3 +27,5 @@ src/utils/vendor/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
logs
|
logs
|
||||||
|
|
||||||
|
data
|
||||||
|
|||||||
322
bun.lock
322
bun.lock
@@ -184,6 +184,26 @@
|
|||||||
"name": "modifiers-napi",
|
"name": "modifiers-napi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
},
|
},
|
||||||
|
"packages/remote-control-server": {
|
||||||
|
"name": "@anthropic/remote-control-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"uuid": "^11.0.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/url-handler-napi": {
|
"packages/url-handler-napi": {
|
||||||
"name": "url-handler-napi",
|
"name": "url-handler-napi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -216,6 +236,8 @@
|
|||||||
|
|
||||||
"@anthropic/ink": ["@anthropic/ink@workspace:packages/@ant/ink"],
|
"@anthropic/ink": ["@anthropic/ink@workspace:packages/@ant/ink"],
|
||||||
|
|
||||||
|
"@anthropic/remote-control-server": ["@anthropic/remote-control-server@workspace:packages/remote-control-server"],
|
||||||
|
|
||||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "https://registry.npmmirror.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "https://registry.npmmirror.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||||
|
|
||||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||||
@@ -316,8 +338,46 @@
|
|||||||
|
|
||||||
"@azure/msal-node": ["@azure/msal-node@5.1.1", "https://registry.npmmirror.com/@azure/msal-node/-/msal-node-5.1.1.tgz", { "dependencies": { "@azure/msal-common": "16.4.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-71grXU6+5hl+3CL3joOxlj/AW6rmhthuTlG0fRqsTrhPArQBpZuUFzCIlKOGdcafLUa/i1hBdV78ZxJdlvRA+g=="],
|
"@azure/msal-node": ["@azure/msal-node@5.1.1", "https://registry.npmmirror.com/@azure/msal-node/-/msal-node-5.1.1.tgz", { "dependencies": { "@azure/msal-common": "16.4.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-71grXU6+5hl+3CL3joOxlj/AW6rmhthuTlG0fRqsTrhPArQBpZuUFzCIlKOGdcafLUa/i1hBdV78ZxJdlvRA+g=="],
|
||||||
|
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.0", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.29.0", "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@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" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.1", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.29.2", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.2", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||||
|
|
||||||
"@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
"@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
"@biomejs/biome": ["@biomejs/biome@2.4.10", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.4.10.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
|
"@biomejs/biome": ["@biomejs/biome@2.4.10", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.4.10.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
|
||||||
|
|
||||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
|
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
|
||||||
@@ -344,6 +404,58 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
|
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
|
||||||
|
|
||||||
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "https://registry.npmmirror.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
|
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "https://registry.npmmirror.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
|
||||||
@@ -432,6 +544,16 @@
|
|||||||
|
|
||||||
"@inquirer/type": ["@inquirer/type@2.0.0", "https://registry.npmmirror.com/@inquirer/type/-/type-2.0.0.tgz", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag=="],
|
"@inquirer/type": ["@inquirer/type@2.0.0", "https://registry.npmmirror.com/@inquirer/type/-/type-2.0.0.tgz", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "https://registry.npmmirror.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "https://registry.npmmirror.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||||
|
|
||||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "https://registry.npmmirror.com/@mixmark-io/domino/-/domino-2.2.0.tgz", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "https://registry.npmmirror.com/@mixmark-io/domino/-/domino-2.2.0.tgz", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||||
@@ -648,6 +770,58 @@
|
|||||||
|
|
||||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||||
|
|
||||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||||
|
|
||||||
"@sentry/core": ["@sentry/core@10.47.0", "https://registry.npmmirror.com/@sentry/core/-/core-10.47.0.tgz", {}, "sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA=="],
|
"@sentry/core": ["@sentry/core@10.47.0", "https://registry.npmmirror.com/@sentry/core/-/core-10.47.0.tgz", {}, "sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA=="],
|
||||||
@@ -750,14 +924,54 @@
|
|||||||
|
|
||||||
"@smithy/uuid": ["@smithy/uuid@1.1.2", "https://registry.npmmirror.com/@smithy/uuid/-/uuid-1.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
"@smithy/uuid": ["@smithy/uuid@1.1.2", "https://registry.npmmirror.com/@smithy/uuid/-/uuid-1.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.2.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.2.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.2.tgz", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@types/babel__core": ["@types/babel__core@7.20.5", "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
|
|
||||||
|
"@types/babel__template": ["@types/babel__template@7.4.4", "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||||
|
|
||||||
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.11", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
"@types/bun": ["@types/bun@1.3.11", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||||
|
|
||||||
"@types/cacache": ["@types/cacache@20.0.1", "https://registry.npmmirror.com/@types/cacache/-/cacache-20.0.1.tgz", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
|
"@types/cacache": ["@types/cacache@20.0.1", "https://registry.npmmirror.com/@types/cacache/-/cacache-20.0.1.tgz", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
|
||||||
|
|
||||||
"@types/connect": ["@types/connect@3.4.38", "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
"@types/connect": ["@types/connect@3.4.38", "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/lodash": ["@types/lodash@4.17.24", "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
"@types/lodash": ["@types/lodash@4.17.24", "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||||
|
|
||||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
"@types/lodash-es": ["@types/lodash-es@4.17.12", "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||||
@@ -776,6 +990,8 @@
|
|||||||
|
|
||||||
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
"@types/react-reconciler": ["@types/react-reconciler@0.33.0", "https://registry.npmmirror.com/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", { "peerDependencies": { "@types/react": "*" } }, "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g=="],
|
"@types/react-reconciler": ["@types/react-reconciler@0.33.0", "https://registry.npmmirror.com/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", { "peerDependencies": { "@types/react": "*" } }, "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g=="],
|
||||||
|
|
||||||
"@types/sharp": ["@types/sharp@0.32.0", "https://registry.npmmirror.com/@types/sharp/-/sharp-0.32.0.tgz", { "dependencies": { "sharp": "*" } }, "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw=="],
|
"@types/sharp": ["@types/sharp@0.32.0", "https://registry.npmmirror.com/@types/sharp/-/sharp-0.32.0.tgz", { "dependencies": { "sharp": "*" } }, "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw=="],
|
||||||
@@ -784,10 +1000,14 @@
|
|||||||
|
|
||||||
"@types/turndown": ["@types/turndown@5.0.6", "https://registry.npmmirror.com/@types/turndown/-/turndown-5.0.6.tgz", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
"@types/turndown": ["@types/turndown@5.0.6", "https://registry.npmmirror.com/@types/turndown/-/turndown-5.0.6.tgz", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||||
|
|
||||||
|
"@types/uuid": ["@types/uuid@10.0.0", "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
|
||||||
|
|
||||||
"@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "https://registry.npmmirror.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="],
|
"@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "https://registry.npmmirror.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="],
|
||||||
|
|
||||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.4", "https://registry.npmmirror.com/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ=="],
|
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.4", "https://registry.npmmirror.com/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", { "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" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
@@ -824,6 +1044,8 @@
|
|||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.15", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ=="],
|
||||||
|
|
||||||
"bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
"bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||||
|
|
||||||
"bignumber.js": ["bignumber.js@9.3.1", "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
"bignumber.js": ["bignumber.js@9.3.1", "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||||
@@ -836,6 +1058,8 @@
|
|||||||
|
|
||||||
"braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||||
|
|
||||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
@@ -852,6 +1076,8 @@
|
|||||||
|
|
||||||
"camelcase": ["camelcase@5.3.1", "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"camelcase": ["camelcase@5.3.1", "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001786", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", {}, "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="],
|
||||||
|
|
||||||
"chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"chardet": ["chardet@0.7.0", "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="],
|
"chardet": ["chardet@0.7.0", "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="],
|
||||||
@@ -888,6 +1114,8 @@
|
|||||||
|
|
||||||
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
"convert-to-spaces": ["convert-to-spaces@2.0.1", "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||||
|
|
||||||
"cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
"cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
@@ -932,10 +1160,14 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.331", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
"emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.20.1", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="],
|
"env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="],
|
||||||
|
|
||||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
@@ -946,6 +1178,8 @@
|
|||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
@@ -982,6 +1216,8 @@
|
|||||||
|
|
||||||
"fd-package-json": ["fd-package-json@2.0.0", "https://registry.npmmirror.com/fd-package-json/-/fd-package-json-2.0.0.tgz", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
|
"fd-package-json": ["fd-package-json@2.0.0", "https://registry.npmmirror.com/fd-package-json/-/fd-package-json-2.0.0.tgz", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
"fflate": ["fflate@0.8.2", "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
"fflate": ["fflate@0.8.2", "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||||
@@ -1014,6 +1250,8 @@
|
|||||||
|
|
||||||
"fs-minipass": ["fs-minipass@3.0.3", "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-3.0.3.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
|
"fs-minipass": ["fs-minipass@3.0.3", "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-3.0.3.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"fuse.js": ["fuse.js@7.1.0", "https://registry.npmmirror.com/fuse.js/-/fuse.js-7.1.0.tgz", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
|
"fuse.js": ["fuse.js@7.1.0", "https://registry.npmmirror.com/fuse.js/-/fuse.js-7.1.0.tgz", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
|
||||||
@@ -1024,6 +1262,8 @@
|
|||||||
|
|
||||||
"gcp-metadata": ["gcp-metadata@8.1.2", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
"gcp-metadata": ["gcp-metadata@8.1.2", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
"get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
"get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
"get-east-asian-width": ["get-east-asian-width@1.5.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||||
@@ -1120,6 +1360,10 @@
|
|||||||
|
|
||||||
"jose": ["jose@6.2.2", "https://registry.npmmirror.com/jose/-/jose-6.2.2.tgz", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
"jose": ["jose@6.2.2", "https://registry.npmmirror.com/jose/-/jose-6.2.2.tgz", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
"json-bigint": ["json-bigint@1.0.0", "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
"json-bigint": ["json-bigint@1.0.0", "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||||
|
|
||||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||||
@@ -1128,6 +1372,8 @@
|
|||||||
|
|
||||||
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
"jsonc-parser": ["jsonc-parser@3.3.1", "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
"jsonc-parser": ["jsonc-parser@3.3.1", "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||||
|
|
||||||
"jsonfile": ["jsonfile@6.2.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
"jsonfile": ["jsonfile@6.2.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||||
@@ -1140,6 +1386,30 @@
|
|||||||
|
|
||||||
"knip": ["knip@6.1.1", "https://registry.npmmirror.com/knip/-/knip-6.1.1.tgz", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-BC/kbdxwCgv+p/3YkGbtlLxbOXhQDuR+CeKKFEpJyKb3BFwG1gZa+CMWSqAnPi+kUexz74m327d3zWxyn2fMew=="],
|
"knip": ["knip@6.1.1", "https://registry.npmmirror.com/knip/-/knip-6.1.1.tgz", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-BC/kbdxwCgv+p/3YkGbtlLxbOXhQDuR+CeKKFEpJyKb3BFwG1gZa+CMWSqAnPi+kUexz74m327d3zWxyn2fMew=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
"locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
"lodash-es": ["lodash-es@4.17.23", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
"lodash-es": ["lodash-es@4.17.23", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||||
@@ -1166,6 +1436,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@11.2.7", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.7.tgz", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
"lru-cache": ["lru-cache@11.2.7", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.7.tgz", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"marked": ["marked@17.0.5", "https://registry.npmmirror.com/marked/-/marked-17.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg=="],
|
"marked": ["marked@17.0.5", "https://registry.npmmirror.com/marked/-/marked-17.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
@@ -1204,6 +1476,8 @@
|
|||||||
|
|
||||||
"mz": ["mz@2.7.0", "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
"mz": ["mz@2.7.0", "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
"negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
"negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"node-domexception": ["node-domexception@1.0.0", "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
"node-domexception": ["node-domexception@1.0.0", "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
@@ -1212,6 +1486,8 @@
|
|||||||
|
|
||||||
"node-forge": ["node-forge@1.4.0", "https://registry.npmmirror.com/node-forge/-/node-forge-1.4.0.tgz", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
|
"node-forge": ["node-forge@1.4.0", "https://registry.npmmirror.com/node-forge/-/node-forge-1.4.0.tgz", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.37", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||||
|
|
||||||
"npm-run-path": ["npm-run-path@6.0.0", "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
"npm-run-path": ["npm-run-path@6.0.0", "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
@@ -1274,6 +1550,8 @@
|
|||||||
|
|
||||||
"pngjs": ["pngjs@5.0.0", "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
"pngjs": ["pngjs@5.0.0", "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||||
|
|
||||||
"postgres-array": ["postgres-array@2.0.0", "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
"postgres-array": ["postgres-array@2.0.0", "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
"postgres-bytea": ["postgres-bytea@1.0.1", "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
"postgres-bytea": ["postgres-bytea@1.0.1", "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||||
@@ -1308,8 +1586,12 @@
|
|||||||
|
|
||||||
"react-compiler-runtime": ["react-compiler-runtime@1.0.0", "https://registry.npmmirror.com/react-compiler-runtime/-/react-compiler-runtime-1.0.0.tgz", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="],
|
"react-compiler-runtime": ["react-compiler-runtime@1.0.0", "https://registry.npmmirror.com/react-compiler-runtime/-/react-compiler-runtime-1.0.0.tgz", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
"react-reconciler": ["react-reconciler@0.33.0", "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.33.0.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
"react-reconciler": ["react-reconciler@0.33.0", "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.33.0.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||||
|
|
||||||
|
"react-refresh": ["react-refresh@0.17.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"readdirp": ["readdirp@5.0.0", "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
"readdirp": ["readdirp@5.0.0", "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
|
|
||||||
"require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
"require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
@@ -1326,6 +1608,8 @@
|
|||||||
|
|
||||||
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||||
|
|
||||||
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||||
|
|
||||||
"run-applescript": ["run-applescript@7.1.0", "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
"run-applescript": ["run-applescript@7.1.0", "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||||
@@ -1370,6 +1654,8 @@
|
|||||||
|
|
||||||
"smol-toml": ["smol-toml@1.6.1", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
|
"smol-toml": ["smol-toml@1.6.1", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"ssri": ["ssri@13.0.1", "https://registry.npmmirror.com/ssri/-/ssri-13.0.1.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
|
"ssri": ["ssri@13.0.1", "https://registry.npmmirror.com/ssri/-/ssri-13.0.1.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
|
||||||
|
|
||||||
"stack-utils": ["stack-utils@2.0.6", "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
"stack-utils": ["stack-utils@2.0.6", "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||||
@@ -1392,10 +1678,16 @@
|
|||||||
|
|
||||||
"tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
"tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.2.2", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.2.tgz", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.2", "https://registry.npmmirror.com/tapable/-/tapable-2.3.2.tgz", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||||
|
|
||||||
"thenify": ["thenify@3.3.1", "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
"thenify": ["thenify@3.3.1", "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||||
|
|
||||||
"thenify-all": ["thenify-all@1.6.0", "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
"thenify-all": ["thenify-all@1.6.0", "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"tmp": ["tmp@0.0.33", "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="],
|
"tmp": ["tmp@0.0.33", "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
@@ -1430,14 +1722,18 @@
|
|||||||
|
|
||||||
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
"url-handler-napi": ["url-handler-napi@workspace:packages/url-handler-napi"],
|
"url-handler-napi": ["url-handler-napi@workspace:packages/url-handler-napi"],
|
||||||
|
|
||||||
"usehooks-ts": ["usehooks-ts@3.1.1", "https://registry.npmmirror.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
|
"usehooks-ts": ["usehooks-ts@3.1.1", "https://registry.npmmirror.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
|
||||||
|
|
||||||
"uuid": ["uuid@8.3.2", "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
"uuid": ["uuid@11.1.0", "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||||
|
|
||||||
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.4.2", "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", { "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" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||||
|
|
||||||
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
|
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
|
||||||
|
|
||||||
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
|
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
|
||||||
@@ -1498,6 +1794,8 @@
|
|||||||
|
|
||||||
"@anthropic-ai/vertex-sdk/google-auth-library": ["google-auth-library@9.15.1", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-9.15.1.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
|
"@anthropic-ai/vertex-sdk/google-auth-library": ["google-auth-library@9.15.1", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-9.15.1.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
|
||||||
|
|
||||||
|
"@anthropic/remote-control-server/typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||||
|
|
||||||
"@aws-crypto/sha256-browser/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
"@aws-crypto/sha256-browser/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||||
@@ -1644,6 +1942,14 @@
|
|||||||
|
|
||||||
"@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.13.1", "https://registry.npmmirror.com/@smithy/types/-/types-4.13.1.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
"@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.13.1", "https://registry.npmmirror.com/@smithy/types/-/types-4.13.1.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||||
|
|
||||||
|
"@azure/msal-node/uuid": ["uuid@8.3.2", "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
|
|
||||||
|
"@babel/core/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||||
|
|
||||||
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
@@ -1768,6 +2074,18 @@
|
|||||||
|
|
||||||
"@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
"@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
"ansi-escapes/type-fest": ["type-fest@0.21.3", "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
"ansi-escapes/type-fest": ["type-fest@0.21.3", "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||||
@@ -1856,6 +2174,8 @@
|
|||||||
|
|
||||||
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="],
|
"@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="],
|
||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="],
|
"@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="],
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
"check:unused": "knip-bun",
|
"check:unused": "knip-bun",
|
||||||
"health": "bun run scripts/health-check.ts",
|
"health": "bun run scripts/health-check.ts",
|
||||||
"postinstall": "node scripts/postinstall.cjs",
|
"postinstall": "node scripts/postinstall.cjs",
|
||||||
"docs:dev": "npx mintlify dev"
|
"docs:dev": "npx mintlify dev",
|
||||||
|
"rcs": "bun run scripts/rcs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
1
packages/remote-control-server/.gitignore
vendored
Normal file
1
packages/remote-control-server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data
|
||||||
32
packages/remote-control-server/Dockerfile
Normal file
32
packages/remote-control-server/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# ---- Stage 1: Install deps + build ----
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG VERSION=0.1.0
|
||||||
|
|
||||||
|
COPY packages/remote-control-server/package.json ./package.json
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
COPY packages/remote-control-server/src ./src
|
||||||
|
RUN bun build src/index.ts --outfile=dist/server.js --target=bun \
|
||||||
|
--define "process.env.RCS_VERSION=\"${VERSION}\""
|
||||||
|
|
||||||
|
# ---- Stage 2: Runtime ----
|
||||||
|
FROM oven/bun:1-slim AS runtime
|
||||||
|
|
||||||
|
ARG VERSION=0.1.0
|
||||||
|
ENV RCS_VERSION=${VERSION}
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist/server.js ./dist/server.js
|
||||||
|
COPY packages/remote-control-server/web ./web
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD bun run -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["bun", "run", "dist/server.js"]
|
||||||
167
packages/remote-control-server/README.md
Normal file
167
packages/remote-control-server/README.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Remote Control Server (RCS)
|
||||||
|
|
||||||
|
Remote Control Server 是 Claude Code 的远程控制后端,允许你通过浏览器 Web UI 远程监控和操作 Claude Code 会话。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **会话管理** — 创建、监控、归档 Claude Code 会话
|
||||||
|
- **实时消息流** — WebSocket / SSE 双向传输,实时查看对话和工具调用
|
||||||
|
- **权限审批** — 在 Web UI 中审批 Claude Code 的工具权限请求
|
||||||
|
- **多环境管理** — 注册多个运行环境,支持心跳和断线重连
|
||||||
|
- **认证安全** — API Key + JWT 双层认证
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### Docker 部署(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name rcs \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e RCS_API_KEYS=your-api-key-here \
|
||||||
|
-v rcs-data:/app/data \
|
||||||
|
ghcr.io/claude-code-best/remote-control-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
### 服务器配置
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `RCS_PORT` | `3000` | 监听端口 |
|
||||||
|
| `RCS_HOST` | `0.0.0.0` | 监听地址 |
|
||||||
|
| `RCS_API_KEYS` | _(空)_ | API 密钥列表,逗号分隔。客户端和 Worker 连接时需要提供 |
|
||||||
|
| `RCS_BASE_URL` | _(自动)_ | 外部访问地址,例如 `https://rcs.example.com`。用于生成 WebSocket 连接 URL |
|
||||||
|
| `RCS_VERSION` | `0.1.0` | 服务版本号,显示在 `/health` 响应中 |
|
||||||
|
|
||||||
|
### 超时与心跳
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `RCS_POLL_TIMEOUT` | `8` | V1 轮询超时(秒) |
|
||||||
|
| `RCS_HEARTBEAT_INTERVAL` | `20` | 心跳间隔(秒) |
|
||||||
|
| `RCS_JWT_EXPIRES_IN` | `3600` | JWT 令牌有效期(秒) |
|
||||||
|
| `RCS_DISCONNECT_TIMEOUT` | `300` | 断线判定超时(秒) |
|
||||||
|
|
||||||
|
## Claude Code 客户端配置
|
||||||
|
|
||||||
|
### 连接到自托管服务器
|
||||||
|
|
||||||
|
在 Claude Code 所在环境设置以下变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 指向你的 RCS 服务器地址
|
||||||
|
export CLAUDE_BRIDGE_BASE_URL="https://rcs.example.com"
|
||||||
|
|
||||||
|
# 认证令牌(与 RCS_API_KEYS 中的值对应)
|
||||||
|
export CLAUDE_BRIDGE_OAUTH_TOKEN="your-api-key-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
然后启动远程控制模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccb --remote-control
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:远程控制功能需要启用 `BRIDGE_MODE` feature flag。开发模式下默认启用。
|
||||||
|
|
||||||
|
### 环境变量参考
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `CLAUDE_BRIDGE_BASE_URL` | RCS 服务器地址,覆盖默认的 Anthropic 云端地址 |
|
||||||
|
| `CLAUDE_BRIDGE_OAUTH_TOKEN` | 认证令牌,用于连接 RCS 服务器 |
|
||||||
|
| `CLAUDE_BRIDGE_SESSION_INGRESS_URL` | WebSocket 入口地址(默认与 BASE_URL 相同) |
|
||||||
|
| `CLAUDE_CODE_REMOTE` | 设为 `1` 时标记为远程执行模式 |
|
||||||
|
|
||||||
|
## Docker Compose 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
rcs:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/remote-control-server/Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: "0.1.0"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- RCS_API_KEYS=sk-rcs-change-me
|
||||||
|
- RCS_BASE_URL=https://rcs.example.com
|
||||||
|
volumes:
|
||||||
|
- rcs-data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rcs-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## 反向代理配置
|
||||||
|
|
||||||
|
使用 Nginx 或 Caddy 反向代理时,需要支持 WebSocket 升级:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name rcs.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy 配置更简单,自动处理 WebSocket:
|
||||||
|
|
||||||
|
```
|
||||||
|
rcs.example.com {
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ WebSocket/SSE ┌──────────────────┐
|
||||||
|
│ Claude Code │ ◄──────────────────► │ Remote Control │
|
||||||
|
│ (Bridge CLI)│ HTTP API │ Server │
|
||||||
|
└─────────────┘ │ │
|
||||||
|
│ ┌────────────┐ │
|
||||||
|
┌─────────────┐ HTTP/SSE │ │ Event Bus │ │
|
||||||
|
│ Web UI │ ◄────────────────── │ └────────────┘ │
|
||||||
|
│ (/code/*) │ │ ┌────────────┐ │
|
||||||
|
└─────────────┘ │ │ In-Memory │ │
|
||||||
|
│ │ Store │ │
|
||||||
|
│ └────────────┘ │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **传输层**:WebSocket(V1)和 SSE + HTTP POST(V2)
|
||||||
|
- **存储**:纯内存存储(Map),重启后数据清除
|
||||||
|
- **认证**:API Key(客户端)+ JWT(Worker)
|
||||||
|
- **前端**:原生 JS SPA,通过 `/code/*` 路径访问
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# 开发模式(热重载)
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# 类型检查
|
||||||
|
bun run typecheck
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
bun test packages/remote-control-server/
|
||||||
|
```
|
||||||
27
packages/remote-control-server/package.json
Normal file
27
packages/remote-control-server/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@anthropic/remote-control-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"build:web": "cd web && bun run build",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"uuid": "^11.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
162
packages/remote-control-server/src/__tests__/auth.test.ts
Normal file
162
packages/remote-control-server/src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config before importing modules that depend on it
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-key-1", "test-key-2"],
|
||||||
|
baseUrl: "",
|
||||||
|
pollTimeout: 8,
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { validateApiKey, hashApiKey } from "../auth/api-key";
|
||||||
|
import { generateWorkerJwt, verifyWorkerJwt } from "../auth/jwt";
|
||||||
|
import { issueToken, resolveToken } from "../auth/token";
|
||||||
|
import { storeReset, storeCreateUser } from "../store";
|
||||||
|
|
||||||
|
// ---------- api-key ----------
|
||||||
|
|
||||||
|
describe("validateApiKey", () => {
|
||||||
|
test("validates a configured API key", () => {
|
||||||
|
expect(validateApiKey("test-key-1")).toBe(true);
|
||||||
|
expect(validateApiKey("test-key-2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unknown key", () => {
|
||||||
|
expect(validateApiKey("unknown-key")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects undefined", () => {
|
||||||
|
expect(validateApiKey(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects empty string", () => {
|
||||||
|
expect(validateApiKey("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hashApiKey", () => {
|
||||||
|
test("produces consistent SHA-256 hex", () => {
|
||||||
|
const hash = hashApiKey("my-key");
|
||||||
|
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(hashApiKey("my-key")).toBe(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different keys produce different hashes", () => {
|
||||||
|
expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- jwt ----------
|
||||||
|
|
||||||
|
describe("JWT", () => {
|
||||||
|
// JWT reads process.env.RCS_API_KEYS directly (not via config)
|
||||||
|
const originalKeys = process.env.RCS_API_KEYS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.RCS_API_KEYS = originalKeys;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateWorkerJwt", () => {
|
||||||
|
test("produces a three-part base64url token", () => {
|
||||||
|
const token = generateWorkerJwt("ses_123", 3600);
|
||||||
|
const parts = token.split(".");
|
||||||
|
expect(parts).toHaveLength(3);
|
||||||
|
for (const part of parts) {
|
||||||
|
expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains correct header", () => {
|
||||||
|
const token = generateWorkerJwt("ses_123", 3600);
|
||||||
|
const header = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
|
||||||
|
expect(header.alg).toBe("HS256");
|
||||||
|
expect(header.typ).toBe("JWT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when no API key configured", () => {
|
||||||
|
delete process.env.RCS_API_KEYS;
|
||||||
|
expect(() => generateWorkerJwt("ses_123", 3600)).toThrow("No API key configured");
|
||||||
|
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyWorkerJwt", () => {
|
||||||
|
test("verifies a valid token", () => {
|
||||||
|
const token = generateWorkerJwt("ses_abc", 3600);
|
||||||
|
const payload = verifyWorkerJwt(token);
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.session_id).toBe("ses_abc");
|
||||||
|
expect(payload!.role).toBe("worker");
|
||||||
|
expect(payload!.iat).toBeGreaterThan(0);
|
||||||
|
expect(payload!.exp).toBeGreaterThan(payload!.iat);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for expired token", () => {
|
||||||
|
const token = generateWorkerJwt("ses_old", -10);
|
||||||
|
expect(verifyWorkerJwt(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for malformed token (not 3 parts)", () => {
|
||||||
|
expect(verifyWorkerJwt("a.b")).toBeNull();
|
||||||
|
expect(verifyWorkerJwt("just-a-string")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for tampered signature", () => {
|
||||||
|
const token = generateWorkerJwt("ses_123", 3600);
|
||||||
|
const parts = token.split(".");
|
||||||
|
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`;
|
||||||
|
expect(verifyWorkerJwt(tampered)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for wrong signing key", () => {
|
||||||
|
const token = generateWorkerJwt("ses_123", 3600);
|
||||||
|
process.env.RCS_API_KEYS = "wrong-key";
|
||||||
|
expect(verifyWorkerJwt(token)).toBeNull();
|
||||||
|
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- token ----------
|
||||||
|
|
||||||
|
describe("issueToken / resolveToken", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("issues and resolves a token", () => {
|
||||||
|
storeCreateUser("alice");
|
||||||
|
const { token, expires_in } = issueToken("alice");
|
||||||
|
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/);
|
||||||
|
expect(expires_in).toBe(86400);
|
||||||
|
expect(resolveToken(token)).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for unknown token", () => {
|
||||||
|
expect(resolveToken("nonexistent")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for undefined token", () => {
|
||||||
|
expect(resolveToken(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokens are unique", () => {
|
||||||
|
storeCreateUser("alice");
|
||||||
|
const t1 = issueToken("alice").token;
|
||||||
|
const t2 = issueToken("alice").token;
|
||||||
|
expect(t1).not.toBe(t2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config with very short timeout for testing
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-api-key"],
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
pollTimeout: 8,
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
storeReset,
|
||||||
|
storeCreateEnvironment,
|
||||||
|
storeUpdateEnvironment,
|
||||||
|
storeCreateSession,
|
||||||
|
storeUpdateSession,
|
||||||
|
storeGetEnvironment,
|
||||||
|
storeGetSession,
|
||||||
|
storeListActiveEnvironments,
|
||||||
|
} from "../store";
|
||||||
|
|
||||||
|
describe("Disconnect Monitor Logic", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test the logic directly rather than the interval-based monitor
|
||||||
|
// to avoid long-running tests with timers
|
||||||
|
|
||||||
|
test("environment times out when lastPollAt is too old", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
const timeoutMs = 300 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Simulate lastPollAt being 6 minutes ago
|
||||||
|
const oldDate = new Date(Date.now() - timeoutMs - 60000);
|
||||||
|
storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
|
||||||
|
|
||||||
|
// Check the timeout logic (same as in disconnect-monitor.ts)
|
||||||
|
const now = Date.now();
|
||||||
|
const envs = storeListActiveEnvironments();
|
||||||
|
for (const e of envs) {
|
||||||
|
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
|
||||||
|
storeUpdateEnvironment(e.id, { status: "disconnected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = storeGetEnvironment(env.id);
|
||||||
|
expect(updated?.status).toBe("disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("environment stays active when lastPollAt is recent", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
const timeoutMs = 300 * 1000;
|
||||||
|
|
||||||
|
// lastPollAt is recent (just created)
|
||||||
|
const now = Date.now();
|
||||||
|
const envs = storeListActiveEnvironments();
|
||||||
|
for (const e of envs) {
|
||||||
|
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
|
||||||
|
storeUpdateEnvironment(e.id, { status: "disconnected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = storeGetEnvironment(env.id);
|
||||||
|
expect(updated?.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session becomes inactive when updatedAt is too old", () => {
|
||||||
|
const session = storeCreateSession({ status: "idle" });
|
||||||
|
storeUpdateSession(session.id, { status: "running" });
|
||||||
|
const timeoutMs = 300 * 1000 * 2; // 2x disconnect timeout
|
||||||
|
|
||||||
|
// Simulate updatedAt being older than 2x timeout
|
||||||
|
// We can't directly set updatedAt, but we can verify the logic
|
||||||
|
// by checking that recently updated sessions are not marked inactive
|
||||||
|
const now = Date.now();
|
||||||
|
const rec = storeGetSession(session.id);
|
||||||
|
// Session was just updated, should not be inactive
|
||||||
|
expect(rec?.status).toBe("running");
|
||||||
|
expect(now - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session stays running when recently updated", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
storeUpdateSession(session.id, { status: "running" });
|
||||||
|
|
||||||
|
const timeoutMs = 300 * 1000 * 2;
|
||||||
|
const rec = storeGetSession(session.id);
|
||||||
|
expect(rec?.status).toBe("running");
|
||||||
|
expect(Date.now() - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
|
||||||
|
});
|
||||||
|
});
|
||||||
176
packages/remote-control-server/src/__tests__/event-bus.test.ts
Normal file
176
packages/remote-control-server/src/__tests__/event-bus.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from "bun:test";
|
||||||
|
import { EventBus, getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||||
|
|
||||||
|
describe("EventBus", () => {
|
||||||
|
let bus: EventBus;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = new EventBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("publish", () => {
|
||||||
|
test("publishes event with seqNum starting at 1", () => {
|
||||||
|
const event = bus.publish({
|
||||||
|
id: "e1",
|
||||||
|
sessionId: "s1",
|
||||||
|
type: "user",
|
||||||
|
payload: { content: "hello" },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
expect(event.seqNum).toBe(1);
|
||||||
|
expect(event.createdAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("increments seqNum on each publish", () => {
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||||
|
const event = bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
|
||||||
|
expect(event.seqNum).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when publishing to a closed bus", () => {
|
||||||
|
bus.close();
|
||||||
|
expect(() =>
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" }),
|
||||||
|
).toThrow("EventBus is closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribe", () => {
|
||||||
|
test("receives published events", () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
bus.subscribe((event) => received.push(event));
|
||||||
|
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hi" }, direction: "outbound" });
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect((received[0] as any).payload).toEqual({ content: "hi" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unsubscribe stops receiving events", () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const unsub = bus.subscribe((event) => received.push(event));
|
||||||
|
unsub();
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple subscribers all receive events", () => {
|
||||||
|
const r1: unknown[] = [];
|
||||||
|
const r2: unknown[] = [];
|
||||||
|
bus.subscribe((e) => r1.push(e));
|
||||||
|
bus.subscribe((e) => r2.push(e));
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
expect(r1).toHaveLength(1);
|
||||||
|
expect(r2).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscriber error does not affect other subscribers", () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
bus.subscribe(() => {
|
||||||
|
throw new Error("boom");
|
||||||
|
});
|
||||||
|
bus.subscribe((e) => received.push(e));
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscriberCount", () => {
|
||||||
|
expect(bus.subscriberCount()).toBe(0);
|
||||||
|
const unsub1 = bus.subscribe(() => {});
|
||||||
|
expect(bus.subscriberCount()).toBe(1);
|
||||||
|
const unsub2 = bus.subscribe(() => {});
|
||||||
|
expect(bus.subscriberCount()).toBe(2);
|
||||||
|
unsub1();
|
||||||
|
expect(bus.subscriberCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEventsSince", () => {
|
||||||
|
test("returns events after given seqNum", () => {
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||||
|
bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
|
||||||
|
|
||||||
|
const events = bus.getEventsSince(1);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[0].seqNum).toBe(2);
|
||||||
|
expect(events[1].seqNum).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty for seqNum beyond last", () => {
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
expect(bus.getEventsSince(1)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns all events when seqNum is 0", () => {
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||||
|
expect(bus.getEventsSince(0)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLastSeqNum", () => {
|
||||||
|
test("returns 0 for empty bus", () => {
|
||||||
|
expect(bus.getLastSeqNum()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns last seqNum after publishes", () => {
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
bus.publish({ id: "e2", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
expect(bus.getLastSeqNum()).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close", () => {
|
||||||
|
test("clears subscribers and prevents publishing", () => {
|
||||||
|
bus.subscribe(() => {});
|
||||||
|
bus.close();
|
||||||
|
expect(bus.subscriberCount()).toBe(0);
|
||||||
|
expect(() => bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EventBus registry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clean up global registry
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEventBus", () => {
|
||||||
|
test("creates new bus for unknown session", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
expect(bus).toBeInstanceOf(EventBus);
|
||||||
|
expect(getAllEventBuses().has("s1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns same bus for same session", () => {
|
||||||
|
const bus1 = getEventBus("s1");
|
||||||
|
const bus2 = getEventBus("s1");
|
||||||
|
expect(bus1).toBe(bus2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeEventBus", () => {
|
||||||
|
test("removes and closes bus", () => {
|
||||||
|
const bus = getEventBus("s2");
|
||||||
|
removeEventBus("s2");
|
||||||
|
expect(getAllEventBuses().has("s2")).toBe(false);
|
||||||
|
expect(() => bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: {}, direction: "outbound" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no-op for non-existent bus", () => {
|
||||||
|
expect(() => removeEventBus("nonexistent")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllEventBuses", () => {
|
||||||
|
test("returns all registered buses", () => {
|
||||||
|
getEventBus("a");
|
||||||
|
getEventBus("b");
|
||||||
|
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
208
packages/remote-control-server/src/__tests__/middleware.test.ts
Normal file
208
packages/remote-control-server/src/__tests__/middleware.test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config before imports
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-api-key"],
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
pollTimeout: 8,
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { storeReset, storeCreateUser } from "../store";
|
||||||
|
import { apiKeyAuth, sessionIngressAuth, uuidAuth, getUuidFromRequest } from "../auth/middleware";
|
||||||
|
import { issueToken } from "../auth/token";
|
||||||
|
import { generateWorkerJwt } from "../auth/jwt";
|
||||||
|
|
||||||
|
// Helper: create a test app with middleware and a simple handler
|
||||||
|
function createTestApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Test route for apiKeyAuth
|
||||||
|
app.get("/api-key-test", apiKeyAuth, (c) => {
|
||||||
|
return c.json({ username: c.get("username") || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test route for sessionIngressAuth
|
||||||
|
app.get("/ingress/:id", sessionIngressAuth, (c) => {
|
||||||
|
return c.json({ ok: true, jwtPayload: c.get("jwtPayload") || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test route for uuidAuth
|
||||||
|
app.get("/uuid-test", uuidAuth, (c) => {
|
||||||
|
return c.json({ uuid: c.get("uuid") });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test route for getUuidFromRequest
|
||||||
|
app.get("/uuid-extract", (c) => {
|
||||||
|
return c.json({ uuid: getUuidFromRequest(c) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Auth Middleware", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
app = createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("apiKeyAuth", () => {
|
||||||
|
test("accepts valid API key with username header", async () => {
|
||||||
|
const res = await app.request("/api-key-test", {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"X-Username": "alice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.username).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid API key with username query param", async () => {
|
||||||
|
const res = await app.request("/api-key-test?username=bob", {
|
||||||
|
headers: { Authorization: "Bearer test-api-key" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.username).toBe("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid session token", async () => {
|
||||||
|
storeCreateUser("charlie");
|
||||||
|
const { token } = issueToken("charlie");
|
||||||
|
const res = await app.request("/api-key-test", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.username).toBe("charlie");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid token", async () => {
|
||||||
|
const res = await app.request("/api-key-test", {
|
||||||
|
headers: { Authorization: "Bearer wrong-key" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing token", async () => {
|
||||||
|
const res = await app.request("/api-key-test");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts token from query param", async () => {
|
||||||
|
storeCreateUser("dave");
|
||||||
|
const { token } = issueToken("dave");
|
||||||
|
const res = await app.request(`/api-key-test?token=${token}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.username).toBe("dave");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionIngressAuth", () => {
|
||||||
|
const originalKeys = process.env.RCS_API_KEYS;
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.RCS_API_KEYS = "test-api-key";
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.RCS_API_KEYS = originalKeys;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid API key", async () => {
|
||||||
|
const res = await app.request("/ingress/ses_123", {
|
||||||
|
headers: { Authorization: "Bearer test-api-key" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid JWT with matching session_id", async () => {
|
||||||
|
const jwt = generateWorkerJwt("ses_123", 3600);
|
||||||
|
const res = await app.request("/ingress/ses_123", {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.jwtPayload).not.toBeNull();
|
||||||
|
expect(body.jwtPayload.session_id).toBe("ses_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects JWT with mismatched session_id", async () => {
|
||||||
|
const jwt = generateWorkerJwt("ses_456", 3600);
|
||||||
|
const res = await app.request("/ingress/ses_123", {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing token", async () => {
|
||||||
|
const res = await app.request("/ingress/ses_123");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid token", async () => {
|
||||||
|
const res = await app.request("/ingress/ses_123", {
|
||||||
|
headers: { Authorization: "Bearer invalid" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uuidAuth", () => {
|
||||||
|
test("accepts UUID from query param", async () => {
|
||||||
|
const res = await app.request("/uuid-test?uuid=test-uuid-1");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.uuid).toBe("test-uuid-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts UUID from header", async () => {
|
||||||
|
const res = await app.request("/uuid-test", {
|
||||||
|
headers: { "X-UUID": "test-uuid-2" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.uuid).toBe("test-uuid-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing UUID", async () => {
|
||||||
|
const res = await app.request("/uuid-test");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUuidFromRequest", () => {
|
||||||
|
test("extracts from query param", async () => {
|
||||||
|
const res = await app.request("/uuid-extract?uuid=from-query");
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.uuid).toBe("from-query");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts from header", async () => {
|
||||||
|
const res = await app.request("/uuid-extract", {
|
||||||
|
headers: { "X-UUID": "from-header" },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.uuid).toBe("from-header");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined when no UUID", async () => {
|
||||||
|
const res = await app.request("/uuid-extract");
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.uuid).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
906
packages/remote-control-server/src/__tests__/routes.test.ts
Normal file
906
packages/remote-control-server/src/__tests__/routes.test.ts
Normal file
@@ -0,0 +1,906 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-api-key"],
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
pollTimeout: 1,
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession } from "../store";
|
||||||
|
import { removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||||
|
import { issueToken } from "../auth/token";
|
||||||
|
|
||||||
|
// Import route modules
|
||||||
|
import v1Sessions from "../routes/v1/sessions";
|
||||||
|
import v1Environments from "../routes/v1/environments";
|
||||||
|
import v1EnvironmentsWork from "../routes/v1/environments.work";
|
||||||
|
import v1SessionIngress from "../routes/v1/session-ingress";
|
||||||
|
import v2CodeSessions from "../routes/v2/code-sessions";
|
||||||
|
import v2Worker from "../routes/v2/worker";
|
||||||
|
import v2WorkerEvents from "../routes/v2/worker-events";
|
||||||
|
import webAuth from "../routes/web/auth";
|
||||||
|
import webSessions from "../routes/web/sessions";
|
||||||
|
import webControl from "../routes/web/control";
|
||||||
|
import webEnvironments from "../routes/web/environments";
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/v1/sessions", v1Sessions);
|
||||||
|
app.route("/v1/environments", v1Environments);
|
||||||
|
app.route("/v1/environments", v1EnvironmentsWork);
|
||||||
|
app.route("/v2/session_ingress", v1SessionIngress);
|
||||||
|
app.route("/v1/code/sessions", v2CodeSessions);
|
||||||
|
app.route("/v1/code/sessions", v2Worker);
|
||||||
|
app.route("/v1/code/sessions", v2WorkerEvents);
|
||||||
|
app.route("/web", webAuth);
|
||||||
|
app.route("/web", webSessions);
|
||||||
|
app.route("/web", webControl);
|
||||||
|
app.route("/web", webEnvironments);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_HEADERS = { Authorization: "Bearer test-api-key", "X-Username": "testuser" };
|
||||||
|
|
||||||
|
describe("V1 Session Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions — creates a session", async () => {
|
||||||
|
const res = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Test Session" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.id).toMatch(/^session_/);
|
||||||
|
expect(body.title).toBe("Test Session");
|
||||||
|
expect(body.status).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions — requires auth", async () => {
|
||||||
|
const res = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /v1/sessions/:id — returns created session", async () => {
|
||||||
|
const createRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const getRes = await app.request(`/v1/sessions/${id}`, {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const body = await getRes.json();
|
||||||
|
expect(body.id).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /v1/sessions/:id — 404 for unknown session", async () => {
|
||||||
|
const res = await app.request("/v1/sessions/nope", {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PATCH /v1/sessions/:id — updates title", async () => {
|
||||||
|
const createRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const patchRes = await app.request(`/v1/sessions/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Updated Title" }),
|
||||||
|
});
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
const body = await patchRes.json();
|
||||||
|
expect(body.title).toBe("Updated Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions/:id/archive — archives session", async () => {
|
||||||
|
const createRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const archiveRes = await app.request(`/v1/sessions/${id}/archive`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(archiveRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions/:id/events — publishes events", async () => {
|
||||||
|
const createRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const eventsRes = await app.request(`/v1/sessions/${id}/events`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ events: [{ type: "user", content: "hello" }] }),
|
||||||
|
});
|
||||||
|
expect(eventsRes.status).toBe(200);
|
||||||
|
const body = await eventsRes.json();
|
||||||
|
expect(body.events).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions with environment_id creates work item", async () => {
|
||||||
|
// First register an environment
|
||||||
|
const envRes = await app.request("/v1/environments/bridge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ machine_name: "test" }),
|
||||||
|
});
|
||||||
|
const { environment_id } = await envRes.json();
|
||||||
|
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ environment_id }),
|
||||||
|
});
|
||||||
|
expect(sessRes.status).toBe(200);
|
||||||
|
const body = await sessRes.json();
|
||||||
|
expect(body.environment_id).toBe(environment_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions with invalid environment_id — session created, work item fails silently", async () => {
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ environment_id: "env_nonexistent" }),
|
||||||
|
});
|
||||||
|
expect(sessRes.status).toBe(200);
|
||||||
|
const body = await sessRes.json();
|
||||||
|
expect(body.id).toMatch(/^session_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions with events — publishes initial events", async () => {
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ events: [{ type: "init", data: "starting" }] }),
|
||||||
|
});
|
||||||
|
expect(sessRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("V1 Environment Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/environments/bridge — registers environment", async () => {
|
||||||
|
const res = await app.request("/v1/environments/bridge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ machine_name: "mac1", directory: "/home" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.environment_id).toMatch(/^env_/);
|
||||||
|
expect(body.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DELETE /v1/environments/bridge/:id — deregisters environment", async () => {
|
||||||
|
const envRes = await app.request("/v1/environments/bridge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { environment_id } = await envRes.json();
|
||||||
|
|
||||||
|
const delRes = await app.request(`/v1/environments/bridge/${environment_id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/environments/:id/bridge/reconnect — reconnects environment", async () => {
|
||||||
|
const envRes = await app.request("/v1/environments/bridge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { environment_id } = await envRes.json();
|
||||||
|
|
||||||
|
const reconnectRes = await app.request(`/v1/environments/${environment_id}/bridge/reconnect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(reconnectRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("V1 Work Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
let envId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storeReset();
|
||||||
|
app = createApp();
|
||||||
|
|
||||||
|
const envRes = await app.request("/v1/environments/bridge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
envId = (await envRes.json()).environment_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /v1/environments/:id/work/poll — returns 204 when no work", async () => {
|
||||||
|
const res = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("work lifecycle: create → poll → ack → stop", async () => {
|
||||||
|
// Create session with environment (creates work item)
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ environment_id: envId }),
|
||||||
|
});
|
||||||
|
const sessionId = (await sessRes.json()).id;
|
||||||
|
|
||||||
|
// Poll for work
|
||||||
|
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(pollRes.status).toBe(200);
|
||||||
|
const work = await pollRes.json();
|
||||||
|
expect(work.id).toMatch(/^work_/);
|
||||||
|
expect(work.data.id).toBe(sessionId);
|
||||||
|
|
||||||
|
// Ack work
|
||||||
|
const ackRes = await app.request(`/v1/environments/${envId}/work/${work.id}/ack`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(ackRes.status).toBe(200);
|
||||||
|
|
||||||
|
// Stop work
|
||||||
|
const stopRes = await app.request(`/v1/environments/${envId}/work/${work.id}/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(stopRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST work heartbeat", async () => {
|
||||||
|
// Create session + work
|
||||||
|
await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ environment_id: envId }),
|
||||||
|
});
|
||||||
|
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
const work = await pollRes.json();
|
||||||
|
|
||||||
|
const hbRes = await app.request(`/v1/environments/${envId}/work/${work.id}/heartbeat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(hbRes.status).toBe(200);
|
||||||
|
const body = await hbRes.json();
|
||||||
|
expect(body.lease_extended).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("V2 Code Session Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
process.env.RCS_API_KEYS = "test-api-key";
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/code/sessions — creates code session", async () => {
|
||||||
|
const res = await app.request("/v1/code/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Code Session" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.session.id).toMatch(/^cse_/);
|
||||||
|
expect(body.session.title).toBe("Code Session");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/code/sessions/:id/bridge — returns bridge info with JWT", async () => {
|
||||||
|
// Create code session
|
||||||
|
const createRes = await app.request("/v1/code/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = (await createRes.json()).session;
|
||||||
|
|
||||||
|
const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(bridgeRes.status).toBe(200);
|
||||||
|
const body = await bridgeRes.json();
|
||||||
|
expect(body.api_base_url).toBe("http://localhost:3000");
|
||||||
|
expect(body.worker_epoch).toBe(1);
|
||||||
|
expect(body.worker_jwt).toBeTruthy();
|
||||||
|
expect(body.expires_in).toBe(3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/code/sessions/:id/bridge — 404 for unknown session", async () => {
|
||||||
|
const res = await app.request("/v1/code/sessions/nope/bridge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("V2 Worker Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
process.env.RCS_API_KEYS = "test-api-key";
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/code/sessions/:id/worker/register — increments epoch", async () => {
|
||||||
|
// Create session
|
||||||
|
const createRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const regRes = await app.request(`/v1/code/sessions/${id}/worker/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(regRes.status).toBe(200);
|
||||||
|
const body = await regRes.json();
|
||||||
|
expect(body.worker_epoch).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/code/sessions/:id/worker/register — 404 for unknown", async () => {
|
||||||
|
const res = await app.request("/v1/code/sessions/nope/worker/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Web Auth Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/bind — binds session to UUID", async () => {
|
||||||
|
// Create session first
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await sessRes.json();
|
||||||
|
|
||||||
|
const bindRes = await app.request("/web/bind?uuid=test-uuid", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sessionId: id }),
|
||||||
|
});
|
||||||
|
expect(bindRes.status).toBe(200);
|
||||||
|
const body = await bindRes.json();
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/bind — 404 for unknown session", async () => {
|
||||||
|
const res = await app.request("/web/bind?uuid=test-uuid", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sessionId: "nope" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/bind — 400 when missing params", async () => {
|
||||||
|
const res = await app.request("/web/bind", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Web Session Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions — creates and auto-binds session", async () => {
|
||||||
|
const res = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Web Session" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.id).toMatch(/^session_/);
|
||||||
|
expect(body.source).toBe("web");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions — returns sessions owned by UUID", async () => {
|
||||||
|
// Create and bind
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const listRes = await app.request("/web/sessions?uuid=user-1");
|
||||||
|
expect(listRes.status).toBe(200);
|
||||||
|
const sessions = await listRes.json();
|
||||||
|
expect(sessions).toHaveLength(1);
|
||||||
|
expect(sessions[0].id).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions — requires UUID", async () => {
|
||||||
|
const res = await app.request("/web/sessions");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/all — lists only sessions owned by requesting UUID", async () => {
|
||||||
|
// Create 2 sessions via different users
|
||||||
|
await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
await app.request("/web/sessions?uuid=user-2", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRes = await app.request("/web/sessions/all?uuid=user-1");
|
||||||
|
expect(allRes.status).toBe(200);
|
||||||
|
const sessions = await allRes.json();
|
||||||
|
expect(sessions).toHaveLength(1); // only user-1's session, not user-2's
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id — returns owned session", async () => {
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`);
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id — 403 for non-owner", async () => {
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`);
|
||||||
|
expect(getRes.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id/history — returns events", async () => {
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||||
|
expect(histRes.status).toBe(200);
|
||||||
|
const body = await histRes.json();
|
||||||
|
expect(body.events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id/history — 403 for non-owner", async () => {
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`);
|
||||||
|
expect(histRes.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id — 404 after session deleted", async () => {
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
// Archive/delete the session via v1
|
||||||
|
await app.request(`/v1/sessions/${id}/archive`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session still exists (archived), so we can still get it
|
||||||
|
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`);
|
||||||
|
// After archive, session status is "archived" but still exists
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id/history — 404 for non-existent session", async () => {
|
||||||
|
// Bind to a non-existent session won't work, but if ownership was set
|
||||||
|
// and session deleted, we need to test the 404 path
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
// Delete the session from store directly
|
||||||
|
const { storeDeleteSession } = await import("../store");
|
||||||
|
storeDeleteSession(id);
|
||||||
|
|
||||||
|
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||||
|
expect(histRes.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions with invalid environment_id — handles work item error", async () => {
|
||||||
|
const res = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ environment_id: "env_nonexistent" }),
|
||||||
|
});
|
||||||
|
// Session is still created even if work item fails
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.id).toMatch(/^session_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id/events — returns SSE stream", async () => {
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-1`);
|
||||||
|
expect(eventsRes.status).toBe(200);
|
||||||
|
expect(eventsRes.headers.get("Content-Type")).toBe("text/event-stream");
|
||||||
|
|
||||||
|
// Read initial keepalive and cancel
|
||||||
|
const reader = eventsRes.body?.getReader();
|
||||||
|
if (reader) {
|
||||||
|
const { value } = await reader.read();
|
||||||
|
const text = new TextDecoder().decode(value!);
|
||||||
|
expect(text).toContain(": keepalive");
|
||||||
|
reader.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/sessions/:id/events — 403 for non-owner", async () => {
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await createRes.json();
|
||||||
|
|
||||||
|
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`);
|
||||||
|
expect(eventsRes.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Web Control Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
app = createApp();
|
||||||
|
|
||||||
|
// Create and bind session
|
||||||
|
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
sessionId = (await createRes.json()).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions/:id/events — sends user message", async () => {
|
||||||
|
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-1`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "user", content: "hello" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
expect(body.event).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions/:id/events — 403 for non-owner", async () => {
|
||||||
|
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "user", content: "hello" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions/:id/control — sends control request", async () => {
|
||||||
|
const res = await app.request(`/web/sessions/${sessionId}/control?uuid=user-1`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "permission_response", approved: true, request_id: "r1" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions/:id/interrupt — interrupts session", async () => {
|
||||||
|
const res = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-1`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions/:id/interrupt — 403 for non-owner", async () => {
|
||||||
|
const res = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-2`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions/:id/control — 403 for non-owner", async () => {
|
||||||
|
const res = await app.request(`/web/sessions/${sessionId}/control?uuid=user-2`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "permission_response", approved: true }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /web/sessions/:id/events — 403 for non-existent session with no ownership", async () => {
|
||||||
|
const res = await app.request("/web/sessions/nonexistent/events?uuid=user-1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "user", content: "hello" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Web Environment Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/environments — lists active environments", async () => {
|
||||||
|
// Register an env via v1
|
||||||
|
await app.request("/v1/environments/bridge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ machine_name: "mac1" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/web/environments?uuid=user-1");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const envs = await res.json();
|
||||||
|
expect(envs).toHaveLength(1);
|
||||||
|
expect(envs[0].machine_name).toBe("mac1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /web/environments — requires UUID", async () => {
|
||||||
|
const res = await app.request("/web/environments");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("V1 Session Ingress Routes (HTTP)", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
process.env.RCS_API_KEYS = "test-api-key";
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v2/session_ingress/session/:sessionId/events — ingests events with API key", async () => {
|
||||||
|
// Create session first
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await sessRes.json();
|
||||||
|
|
||||||
|
const res = await app.request(`/v2/session_ingress/session/${id}/events`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ events: [{ type: "assistant", content: "response" }] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v2/session_ingress/session/:sessionId/events — rejects without auth", async () => {
|
||||||
|
const res = await app.request("/v2/session_ingress/session/nope/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ events: [] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v2/session_ingress/session/:sessionId/events — 404 for unknown session", async () => {
|
||||||
|
const res = await app.request("/v2/session_ingress/session/nope/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ events: [{ type: "user", content: "hi" }] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("V2 Worker Events Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
process.env.RCS_API_KEYS = "test-api-key";
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/code/sessions/:id/worker/events — publishes worker events", async () => {
|
||||||
|
// Create session
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await sessRes.json();
|
||||||
|
|
||||||
|
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify([{ type: "assistant", content: "response" }]),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
expect(body.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await sessRes.json();
|
||||||
|
|
||||||
|
const res = await app.request(`/v1/code/sessions/${id}/worker/state`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: "running" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PUT /v1/code/sessions/:id/worker/external_metadata — no-op", async () => {
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await sessRes.json();
|
||||||
|
|
||||||
|
const res = await app.request(`/v1/code/sessions/${id}/worker/external_metadata`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ meta: "data" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/code/sessions/:id/worker/events/:eventId/delivery — no-op", async () => {
|
||||||
|
const sessRes = await app.request("/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { id } = await sessRes.json();
|
||||||
|
|
||||||
|
const res = await app.request(`/v1/code/sessions/${id}/worker/events/evt123/delivery`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: "received" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
386
packages/remote-control-server/src/__tests__/services.test.ts
Normal file
386
packages/remote-control-server/src/__tests__/services.test.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config before importing modules
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-api-key"],
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
pollTimeout: 8,
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { storeReset, storeCreateEnvironment } from "../store";
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
createCodeSession,
|
||||||
|
getSession,
|
||||||
|
updateSessionTitle,
|
||||||
|
updateSessionStatus,
|
||||||
|
archiveSession,
|
||||||
|
incrementEpoch,
|
||||||
|
listSessions,
|
||||||
|
listSessionSummaries,
|
||||||
|
listSessionSummariesByUsername,
|
||||||
|
listSessionsByEnvironment,
|
||||||
|
} from "../services/session";
|
||||||
|
import {
|
||||||
|
registerEnvironment,
|
||||||
|
deregisterEnvironment,
|
||||||
|
getEnvironment,
|
||||||
|
updatePollTime,
|
||||||
|
listActiveEnvironments,
|
||||||
|
listActiveEnvironmentsResponse,
|
||||||
|
listActiveEnvironmentsByUsername,
|
||||||
|
reconnectEnvironment,
|
||||||
|
} from "../services/environment";
|
||||||
|
import { normalizePayload, publishSessionEvent } from "../services/transport";
|
||||||
|
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||||
|
|
||||||
|
// ---------- Session Service ----------
|
||||||
|
|
||||||
|
describe("Session Service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createSession", () => {
|
||||||
|
test("creates a session with defaults", () => {
|
||||||
|
const resp = createSession({});
|
||||||
|
expect(resp.id).toMatch(/^session_/);
|
||||||
|
expect(resp.status).toBe("idle");
|
||||||
|
expect(resp.source).toBe("remote-control");
|
||||||
|
expect(resp.environment_id).toBeNull();
|
||||||
|
expect(resp.worker_epoch).toBe(0);
|
||||||
|
expect(resp.created_at).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a session with all options", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
const resp = createSession({
|
||||||
|
environment_id: env.id,
|
||||||
|
title: "My Session",
|
||||||
|
source: "cli",
|
||||||
|
permission_mode: "auto",
|
||||||
|
});
|
||||||
|
expect(resp.environment_id).toBe(env.id);
|
||||||
|
expect(resp.title).toBe("My Session");
|
||||||
|
expect(resp.source).toBe("cli");
|
||||||
|
expect(resp.permission_mode).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates session with username", () => {
|
||||||
|
const resp = createSession({ username: "alice" });
|
||||||
|
expect(resp.username).toBe("alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCodeSession", () => {
|
||||||
|
test("creates a code session with cse_ prefix", () => {
|
||||||
|
const resp = createCodeSession({});
|
||||||
|
expect(resp.id).toMatch(/^cse_/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSession", () => {
|
||||||
|
test("returns null for non-existent session", () => {
|
||||||
|
expect(getSession("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns created session", () => {
|
||||||
|
const created = createSession({});
|
||||||
|
const fetched = getSession(created.id);
|
||||||
|
expect(fetched).not.toBeNull();
|
||||||
|
expect(fetched!.id).toBe(created.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateSessionTitle", () => {
|
||||||
|
test("updates title", () => {
|
||||||
|
const s = createSession({});
|
||||||
|
updateSessionTitle(s.id, "New Title");
|
||||||
|
expect(getSession(s.id)?.title).toBe("New Title");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateSessionStatus", () => {
|
||||||
|
test("updates status", () => {
|
||||||
|
const s = createSession({});
|
||||||
|
updateSessionStatus(s.id, "active");
|
||||||
|
expect(getSession(s.id)?.status).toBe("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archiveSession", () => {
|
||||||
|
test("sets status to archived and removes event bus", () => {
|
||||||
|
const s = createSession({});
|
||||||
|
// Create event bus for this session
|
||||||
|
getEventBus(s.id);
|
||||||
|
archiveSession(s.id);
|
||||||
|
expect(getSession(s.id)?.status).toBe("archived");
|
||||||
|
expect(getAllEventBuses().has(s.id)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("incrementEpoch", () => {
|
||||||
|
test("increments epoch by 1", () => {
|
||||||
|
const s = createSession({});
|
||||||
|
expect(incrementEpoch(s.id)).toBe(1);
|
||||||
|
expect(incrementEpoch(s.id)).toBe(2);
|
||||||
|
expect(getSession(s.id)?.worker_epoch).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws for non-existent session", () => {
|
||||||
|
expect(() => incrementEpoch("nope")).toThrow("Session not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listSessions", () => {
|
||||||
|
test("returns all sessions", () => {
|
||||||
|
createSession({});
|
||||||
|
createSession({});
|
||||||
|
expect(listSessions()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listSessionSummaries", () => {
|
||||||
|
test("returns summaries with correct fields", () => {
|
||||||
|
createSession({ title: "Test" });
|
||||||
|
const summaries = listSessionSummaries();
|
||||||
|
expect(summaries).toHaveLength(1);
|
||||||
|
expect(summaries[0].title).toBe("Test");
|
||||||
|
expect(summaries[0].updated_at).toBeGreaterThan(0);
|
||||||
|
// Summary should not have environment_id
|
||||||
|
expect("environment_id" in summaries[0]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listSessionSummariesByUsername", () => {
|
||||||
|
test("filters by username", () => {
|
||||||
|
createSession({ username: "alice" });
|
||||||
|
createSession({ username: "bob" });
|
||||||
|
expect(listSessionSummariesByUsername("alice")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listSessionsByEnvironment", () => {
|
||||||
|
test("filters by environment", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
createSession({ environment_id: env.id });
|
||||||
|
createSession({});
|
||||||
|
expect(listSessionsByEnvironment(env.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Environment Service ----------
|
||||||
|
|
||||||
|
describe("Environment Service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registerEnvironment", () => {
|
||||||
|
test("registers environment with defaults", () => {
|
||||||
|
const result = registerEnvironment({});
|
||||||
|
expect(result.environment_id).toMatch(/^env_/);
|
||||||
|
expect(result.environment_secret).toBe("test-api-key");
|
||||||
|
expect(result.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers with options", () => {
|
||||||
|
const result = registerEnvironment({
|
||||||
|
machine_name: "mac1",
|
||||||
|
directory: "/home/user",
|
||||||
|
branch: "main",
|
||||||
|
git_repo_url: "https://github.com/test/repo",
|
||||||
|
max_sessions: 5,
|
||||||
|
worker_type: "custom",
|
||||||
|
});
|
||||||
|
const env = getEnvironment(result.environment_id);
|
||||||
|
expect(env?.machineName).toBe("mac1");
|
||||||
|
expect(env?.directory).toBe("/home/user");
|
||||||
|
expect(env?.maxSessions).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers with username", () => {
|
||||||
|
const result = registerEnvironment({ username: "alice" });
|
||||||
|
const env = getEnvironment(result.environment_id);
|
||||||
|
expect(env?.username).toBe("alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deregisterEnvironment", () => {
|
||||||
|
test("sets status to deregistered", () => {
|
||||||
|
const result = registerEnvironment({});
|
||||||
|
deregisterEnvironment(result.environment_id);
|
||||||
|
const env = getEnvironment(result.environment_id);
|
||||||
|
expect(env?.status).toBe("deregistered");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updatePollTime", () => {
|
||||||
|
test("updates lastPollAt", () => {
|
||||||
|
const result = registerEnvironment({});
|
||||||
|
const before = getEnvironment(result.environment_id)?.lastPollAt;
|
||||||
|
// Small delay to ensure time difference
|
||||||
|
updatePollTime(result.environment_id);
|
||||||
|
const after = getEnvironment(result.environment_id)?.lastPollAt;
|
||||||
|
expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listActiveEnvironments", () => {
|
||||||
|
test("returns active environments", () => {
|
||||||
|
registerEnvironment({});
|
||||||
|
registerEnvironment({});
|
||||||
|
expect(listActiveEnvironments()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listActiveEnvironmentsResponse", () => {
|
||||||
|
test("returns response format", () => {
|
||||||
|
registerEnvironment({ machine_name: "mac1" });
|
||||||
|
const envs = listActiveEnvironmentsResponse();
|
||||||
|
expect(envs).toHaveLength(1);
|
||||||
|
expect(envs[0].machine_name).toBe("mac1");
|
||||||
|
expect(envs[0].last_poll_at).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listActiveEnvironmentsByUsername", () => {
|
||||||
|
test("filters by username", () => {
|
||||||
|
registerEnvironment({ username: "alice" });
|
||||||
|
registerEnvironment({ username: "bob" });
|
||||||
|
expect(listActiveEnvironmentsByUsername("alice")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconnectEnvironment", () => {
|
||||||
|
test("sets status back to active", () => {
|
||||||
|
const result = registerEnvironment({});
|
||||||
|
deregisterEnvironment(result.environment_id);
|
||||||
|
expect(getEnvironment(result.environment_id)?.status).toBe("deregistered");
|
||||||
|
reconnectEnvironment(result.environment_id);
|
||||||
|
expect(getEnvironment(result.environment_id)?.status).toBe("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Transport Service ----------
|
||||||
|
|
||||||
|
describe("Transport Service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizePayload", () => {
|
||||||
|
test("handles string payload", () => {
|
||||||
|
const result = normalizePayload("user", "hello world");
|
||||||
|
expect(result.content).toBe("hello world");
|
||||||
|
expect(result.raw).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null payload", () => {
|
||||||
|
const result = normalizePayload("user", null);
|
||||||
|
expect(result.content).toBe("");
|
||||||
|
expect(result.raw).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles object with direct content", () => {
|
||||||
|
const result = normalizePayload("user", { content: "direct text" });
|
||||||
|
expect(result.content).toBe("direct text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles object with message.content string", () => {
|
||||||
|
const result = normalizePayload("assistant", { message: { role: "assistant", content: "reply" } });
|
||||||
|
expect(result.content).toBe("reply");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles object with message.content array", () => {
|
||||||
|
const result = normalizePayload("assistant", {
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello " },
|
||||||
|
{ type: "text", text: "world" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.content).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves tool fields", () => {
|
||||||
|
const result = normalizePayload("tool_use", { tool_name: "Bash", tool_input: { cmd: "ls" } });
|
||||||
|
expect(result.tool_name).toBe("Bash");
|
||||||
|
expect(result.tool_input).toEqual({ cmd: "ls" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves permission fields", () => {
|
||||||
|
const result = normalizePayload("permission", {
|
||||||
|
request_id: "req_1",
|
||||||
|
approved: true,
|
||||||
|
updated_input: { cmd: "ls -la" },
|
||||||
|
});
|
||||||
|
expect(result.request_id).toBe("req_1");
|
||||||
|
expect(result.approved).toBe(true);
|
||||||
|
expect(result.updated_input).toEqual({ cmd: "ls -la" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves message field", () => {
|
||||||
|
const msg = { role: "user", content: "hi" };
|
||||||
|
const result = normalizePayload("user", { message: msg });
|
||||||
|
expect(result.message).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses name as tool_name fallback", () => {
|
||||||
|
const result = normalizePayload("tool", { name: "Read" });
|
||||||
|
expect(result.tool_name).toBe("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses input as tool_input fallback", () => {
|
||||||
|
const result = normalizePayload("tool", { input: { path: "/tmp" } });
|
||||||
|
expect(result.tool_input).toEqual({ path: "/tmp" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty content array", () => {
|
||||||
|
const result = normalizePayload("assistant", {
|
||||||
|
message: { content: [] },
|
||||||
|
});
|
||||||
|
expect(result.content).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles undefined payload", () => {
|
||||||
|
const result = normalizePayload("user", undefined);
|
||||||
|
expect(result.content).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("publishSessionEvent", () => {
|
||||||
|
test("publishes event to session bus", () => {
|
||||||
|
const event = publishSessionEvent("s1", "user", { content: "hello" }, "outbound");
|
||||||
|
expect(event.type).toBe("user");
|
||||||
|
expect(event.direction).toBe("outbound");
|
||||||
|
expect(event.sessionId).toBe("s1");
|
||||||
|
expect(event.seqNum).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes payload before publishing", () => {
|
||||||
|
const event = publishSessionEvent("s1", "assistant", { message: { content: "reply" } }, "inbound");
|
||||||
|
const payload = event.payload as Record<string, unknown>;
|
||||||
|
expect(payload.content).toBe("reply");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
179
packages/remote-control-server/src/__tests__/sse-writer.test.ts
Normal file
179
packages/remote-control-server/src/__tests__/sse-writer.test.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-api-key"],
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
pollTimeout: 8,
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { storeReset } from "../store";
|
||||||
|
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
|
||||||
|
import { createSSEWriter, createSSEStream } from "../transport/sse-writer";
|
||||||
|
|
||||||
|
/** Read up to N bytes from a Response stream, then cancel */
|
||||||
|
async function readPartialStream(res: Response, maxBytes = 4096): Promise<string> {
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
if (!reader) return "";
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
try {
|
||||||
|
while (totalBytes < maxBytes) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
totalBytes += value.length;
|
||||||
|
// Cancel after we have some data (first keepalive + any initial events)
|
||||||
|
if (totalBytes > 0) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.cancel();
|
||||||
|
}
|
||||||
|
const combined = new Uint8Array(totalBytes);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
combined.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SSE Writer", () => {
|
||||||
|
describe("createSSEWriter", () => {
|
||||||
|
test("creates SSEWriter with send and close methods", () => {
|
||||||
|
const app = new Hono();
|
||||||
|
let capturedWriter: ReturnType<typeof createSSEWriter> | null = null;
|
||||||
|
|
||||||
|
app.get("/test", (c) => {
|
||||||
|
capturedWriter = createSSEWriter(c);
|
||||||
|
return c.text("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.request("/test");
|
||||||
|
expect(capturedWriter).not.toBeNull();
|
||||||
|
expect(typeof capturedWriter!.send).toBe("function");
|
||||||
|
expect(typeof capturedWriter!.close).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createSSEStream", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns Response with correct SSE headers", async () => {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/stream/:sessionId", (c) => {
|
||||||
|
const sessionId = c.req.param("sessionId");
|
||||||
|
return createSSEStream(c, sessionId, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/stream/s1");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||||
|
expect(res.headers.get("Cache-Control")).toBe("no-cache");
|
||||||
|
expect(res.headers.get("Connection")).toBe("keep-alive");
|
||||||
|
expect(res.headers.get("X-Accel-Buffering")).toBe("no");
|
||||||
|
|
||||||
|
// Cancel the stream
|
||||||
|
res.body?.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends initial keepalive", async () => {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/stream/:sessionId", (c) => {
|
||||||
|
const sessionId = c.req.param("sessionId");
|
||||||
|
return createSSEStream(c, sessionId, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/stream/s2");
|
||||||
|
const text = await readPartialStream(res);
|
||||||
|
expect(text).toContain(": keepalive");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends historical events when fromSeqNum > 0", async () => {
|
||||||
|
// Pre-populate event bus with events
|
||||||
|
const bus = getEventBus("s3");
|
||||||
|
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: { content: "hello" }, direction: "outbound" });
|
||||||
|
bus.publish({ id: "e2", sessionId: "s3", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/stream/:sessionId", (c) => {
|
||||||
|
const sessionId = c.req.param("sessionId");
|
||||||
|
const fromSeq = parseInt(c.req.query("fromSeq") || "0");
|
||||||
|
return createSSEStream(c, sessionId, fromSeq);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/stream/s3?fromSeq=1");
|
||||||
|
const text = await readPartialStream(res);
|
||||||
|
// Should replay events since seq 1 (i.e., event 2)
|
||||||
|
expect(text).toContain('"seqNum":2');
|
||||||
|
expect(text).toContain("assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no historical events when fromSeqNum is 0", async () => {
|
||||||
|
const bus = getEventBus("s5");
|
||||||
|
bus.publish({ id: "e1", sessionId: "s5", type: "user", payload: {}, direction: "outbound" });
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/stream/:sessionId", (c) => {
|
||||||
|
const sessionId = c.req.param("sessionId");
|
||||||
|
return createSSEStream(c, sessionId, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/stream/s5");
|
||||||
|
const text = await readPartialStream(res);
|
||||||
|
// With fromSeqNum=0, no historical replay, just keepalive
|
||||||
|
expect(text).toContain(": keepalive");
|
||||||
|
// Should NOT contain event data (only keepalive)
|
||||||
|
expect(text).not.toContain("event: message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscribes to new events and delivers them", async () => {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/stream/:sessionId", (c) => {
|
||||||
|
const sessionId = c.req.param("sessionId");
|
||||||
|
return createSSEStream(c, sessionId, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/stream/s6");
|
||||||
|
|
||||||
|
// Read initial keepalive first
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const { value: firstChunk } = await reader.read();
|
||||||
|
const initialText = new TextDecoder().decode(firstChunk!);
|
||||||
|
expect(initialText).toContain(": keepalive");
|
||||||
|
|
||||||
|
// Now publish an event
|
||||||
|
const bus = getEventBus("s6");
|
||||||
|
bus.publish({ id: "e1", sessionId: "s6", type: "user", payload: { content: "real-time" }, direction: "outbound" });
|
||||||
|
|
||||||
|
// Read the event
|
||||||
|
const { value: secondChunk } = await reader.read();
|
||||||
|
const eventText = new TextDecoder().decode(secondChunk!);
|
||||||
|
expect(eventText).toContain("event: message");
|
||||||
|
expect(eventText).toContain("real-time");
|
||||||
|
|
||||||
|
reader.cancel();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
396
packages/remote-control-server/src/__tests__/store.test.ts
Normal file
396
packages/remote-control-server/src/__tests__/store.test.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from "bun:test";
|
||||||
|
import {
|
||||||
|
storeReset,
|
||||||
|
storeCreateUser,
|
||||||
|
storeGetUser,
|
||||||
|
storeCreateToken,
|
||||||
|
storeGetUserByToken,
|
||||||
|
storeDeleteToken,
|
||||||
|
storeCreateEnvironment,
|
||||||
|
storeGetEnvironment,
|
||||||
|
storeUpdateEnvironment,
|
||||||
|
storeListActiveEnvironments,
|
||||||
|
storeListActiveEnvironmentsByUsername,
|
||||||
|
storeCreateSession,
|
||||||
|
storeGetSession,
|
||||||
|
storeUpdateSession,
|
||||||
|
storeListSessions,
|
||||||
|
storeListSessionsByUsername,
|
||||||
|
storeListSessionsByEnvironment,
|
||||||
|
storeDeleteSession,
|
||||||
|
storeBindSession,
|
||||||
|
storeIsSessionOwner,
|
||||||
|
storeListSessionsByOwnerUuid,
|
||||||
|
storeCreateWorkItem,
|
||||||
|
storeGetWorkItem,
|
||||||
|
storeGetPendingWorkItem,
|
||||||
|
storeUpdateWorkItem,
|
||||||
|
} from "../store";
|
||||||
|
|
||||||
|
describe("store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- User ----------
|
||||||
|
|
||||||
|
describe("storeCreateUser", () => {
|
||||||
|
test("creates a new user", () => {
|
||||||
|
const user = storeCreateUser("alice");
|
||||||
|
expect(user.username).toBe("alice");
|
||||||
|
expect(user.createdAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns existing user on duplicate create", () => {
|
||||||
|
const first = storeCreateUser("bob");
|
||||||
|
const second = storeCreateUser("bob");
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeGetUser", () => {
|
||||||
|
test("returns undefined for non-existent user", () => {
|
||||||
|
expect(storeGetUser("nobody")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns created user", () => {
|
||||||
|
storeCreateUser("charlie");
|
||||||
|
const user = storeGetUser("charlie");
|
||||||
|
expect(user?.username).toBe("charlie");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Token ----------
|
||||||
|
|
||||||
|
describe("storeCreateToken / storeGetUserByToken", () => {
|
||||||
|
test("creates and resolves token", () => {
|
||||||
|
storeCreateUser("dave");
|
||||||
|
storeCreateToken("dave", "tk_123");
|
||||||
|
const entry = storeGetUserByToken("tk_123");
|
||||||
|
expect(entry?.username).toBe("dave");
|
||||||
|
expect(entry?.createdAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for unknown token", () => {
|
||||||
|
expect(storeGetUserByToken("nonexistent")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeDeleteToken", () => {
|
||||||
|
test("deletes an existing token", () => {
|
||||||
|
storeCreateUser("eve");
|
||||||
|
storeCreateToken("eve", "tk_del");
|
||||||
|
expect(storeDeleteToken("tk_del")).toBe(true);
|
||||||
|
expect(storeGetUserByToken("tk_del")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for non-existent token", () => {
|
||||||
|
expect(storeDeleteToken("nope")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Environment ----------
|
||||||
|
|
||||||
|
describe("storeCreateEnvironment", () => {
|
||||||
|
test("creates environment with defaults", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s1" });
|
||||||
|
expect(env.id).toMatch(/^env_/);
|
||||||
|
expect(env.secret).toBe("s1");
|
||||||
|
expect(env.status).toBe("active");
|
||||||
|
expect(env.machineName).toBeNull();
|
||||||
|
expect(env.maxSessions).toBe(1);
|
||||||
|
expect(env.workerType).toBe("claude_code");
|
||||||
|
expect(env.lastPollAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates environment with all options", () => {
|
||||||
|
const env = storeCreateEnvironment({
|
||||||
|
secret: "s2",
|
||||||
|
machineName: "mac1",
|
||||||
|
directory: "/home/user",
|
||||||
|
branch: "main",
|
||||||
|
gitRepoUrl: "https://github.com/test/repo",
|
||||||
|
maxSessions: 5,
|
||||||
|
workerType: "custom",
|
||||||
|
bridgeId: "bridge1",
|
||||||
|
username: "alice",
|
||||||
|
});
|
||||||
|
expect(env.machineName).toBe("mac1");
|
||||||
|
expect(env.directory).toBe("/home/user");
|
||||||
|
expect(env.branch).toBe("main");
|
||||||
|
expect(env.gitRepoUrl).toBe("https://github.com/test/repo");
|
||||||
|
expect(env.maxSessions).toBe(5);
|
||||||
|
expect(env.workerType).toBe("custom");
|
||||||
|
expect(env.bridgeId).toBe("bridge1");
|
||||||
|
expect(env.username).toBe("alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeGetEnvironment", () => {
|
||||||
|
test("returns undefined for non-existent env", () => {
|
||||||
|
expect(storeGetEnvironment("env_no")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns created environment", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
expect(storeGetEnvironment(env.id)).toBe(env);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeUpdateEnvironment", () => {
|
||||||
|
test("updates existing environment", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
const result = storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
const updated = storeGetEnvironment(env.id);
|
||||||
|
expect(updated?.status).toBe("disconnected");
|
||||||
|
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(env.updatedAt.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for non-existent environment", () => {
|
||||||
|
expect(storeUpdateEnvironment("env_no", { status: "active" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeListActiveEnvironments", () => {
|
||||||
|
test("returns only active environments", () => {
|
||||||
|
const env1 = storeCreateEnvironment({ secret: "s1" });
|
||||||
|
const env2 = storeCreateEnvironment({ secret: "s2" });
|
||||||
|
storeUpdateEnvironment(env1.id, { status: "deregistered" });
|
||||||
|
const active = storeListActiveEnvironments();
|
||||||
|
expect(active).toHaveLength(1);
|
||||||
|
expect(active[0].id).toBe(env2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeListActiveEnvironmentsByUsername", () => {
|
||||||
|
test("filters by username", () => {
|
||||||
|
storeCreateEnvironment({ secret: "s1", username: "alice" });
|
||||||
|
storeCreateEnvironment({ secret: "s2", username: "bob" });
|
||||||
|
const aliceEnvs = storeListActiveEnvironmentsByUsername("alice");
|
||||||
|
expect(aliceEnvs).toHaveLength(1);
|
||||||
|
expect(aliceEnvs[0].username).toBe("alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Session ----------
|
||||||
|
|
||||||
|
describe("storeCreateSession", () => {
|
||||||
|
test("creates session with defaults", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
expect(session.id).toMatch(/^session_/);
|
||||||
|
expect(session.status).toBe("idle");
|
||||||
|
expect(session.source).toBe("remote-control");
|
||||||
|
expect(session.environmentId).toBeNull();
|
||||||
|
expect(session.workerEpoch).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates session with options", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
const session = storeCreateSession({
|
||||||
|
environmentId: env.id,
|
||||||
|
title: "Test Session",
|
||||||
|
source: "cli",
|
||||||
|
permissionMode: "auto",
|
||||||
|
username: "alice",
|
||||||
|
});
|
||||||
|
expect(session.environmentId).toBe(env.id);
|
||||||
|
expect(session.title).toBe("Test Session");
|
||||||
|
expect(session.source).toBe("cli");
|
||||||
|
expect(session.permissionMode).toBe("auto");
|
||||||
|
expect(session.username).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates session with custom idPrefix", () => {
|
||||||
|
const session = storeCreateSession({ idPrefix: "cse_" });
|
||||||
|
expect(session.id).toMatch(/^cse_/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeGetSession", () => {
|
||||||
|
test("returns undefined for non-existent session", () => {
|
||||||
|
expect(storeGetSession("nope")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeUpdateSession", () => {
|
||||||
|
test("updates existing session", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
const result = storeUpdateSession(session.id, { title: "Updated", status: "active" });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
const updated = storeGetSession(session.id);
|
||||||
|
expect(updated?.title).toBe("Updated");
|
||||||
|
expect(updated?.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for non-existent session", () => {
|
||||||
|
expect(storeUpdateSession("nope", { title: "x" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("increments workerEpoch", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
storeUpdateSession(session.id, { workerEpoch: 1 });
|
||||||
|
expect(storeGetSession(session.id)?.workerEpoch).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeListSessions", () => {
|
||||||
|
test("returns all sessions", () => {
|
||||||
|
storeCreateSession({});
|
||||||
|
storeCreateSession({});
|
||||||
|
expect(storeListSessions()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeListSessionsByUsername", () => {
|
||||||
|
test("filters by username", () => {
|
||||||
|
storeCreateSession({ username: "alice" });
|
||||||
|
storeCreateSession({ username: "bob" });
|
||||||
|
expect(storeListSessionsByUsername("alice")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeListSessionsByEnvironment", () => {
|
||||||
|
test("filters by environment", () => {
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
storeCreateSession({ environmentId: env.id });
|
||||||
|
storeCreateSession({});
|
||||||
|
expect(storeListSessionsByEnvironment(env.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeDeleteSession", () => {
|
||||||
|
test("deletes existing session", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
expect(storeDeleteSession(session.id)).toBe(true);
|
||||||
|
expect(storeGetSession(session.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for non-existent session", () => {
|
||||||
|
expect(storeDeleteSession("nope")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Session Ownership ----------
|
||||||
|
|
||||||
|
describe("storeBindSession / storeIsSessionOwner", () => {
|
||||||
|
test("binds and checks ownership", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
storeBindSession(session.id, "uuid-1");
|
||||||
|
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
|
||||||
|
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unbound session has no owner", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple owners per session", () => {
|
||||||
|
const session = storeCreateSession({});
|
||||||
|
storeBindSession(session.id, "uuid-1");
|
||||||
|
storeBindSession(session.id, "uuid-2");
|
||||||
|
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
|
||||||
|
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeListSessionsByOwnerUuid", () => {
|
||||||
|
test("returns sessions owned by uuid", () => {
|
||||||
|
const s1 = storeCreateSession({});
|
||||||
|
const s2 = storeCreateSession({});
|
||||||
|
storeBindSession(s1.id, "uuid-1");
|
||||||
|
storeBindSession(s2.id, "uuid-1");
|
||||||
|
const owned = storeListSessionsByOwnerUuid("uuid-1");
|
||||||
|
expect(owned).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty for unknown uuid", () => {
|
||||||
|
expect(storeListSessionsByOwnerUuid("nope")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes deleted sessions", () => {
|
||||||
|
const s1 = storeCreateSession({});
|
||||||
|
storeBindSession(s1.id, "uuid-1");
|
||||||
|
storeDeleteSession(s1.id);
|
||||||
|
expect(storeListSessionsByOwnerUuid("uuid-1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Work Items ----------
|
||||||
|
|
||||||
|
describe("storeCreateWorkItem", () => {
|
||||||
|
test("creates work item with defaults", () => {
|
||||||
|
const item = storeCreateWorkItem({
|
||||||
|
environmentId: "env1",
|
||||||
|
sessionId: "ses1",
|
||||||
|
secret: "sec1",
|
||||||
|
});
|
||||||
|
expect(item.id).toMatch(/^work_/);
|
||||||
|
expect(item.environmentId).toBe("env1");
|
||||||
|
expect(item.sessionId).toBe("ses1");
|
||||||
|
expect(item.state).toBe("pending");
|
||||||
|
expect(item.secret).toBe("sec1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeGetWorkItem", () => {
|
||||||
|
test("returns undefined for non-existent", () => {
|
||||||
|
expect(storeGetWorkItem("nope")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns created work item", () => {
|
||||||
|
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||||
|
expect(storeGetWorkItem(item.id)).toBe(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeGetPendingWorkItem", () => {
|
||||||
|
test("returns pending work for environment", () => {
|
||||||
|
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||||
|
const found = storeGetPendingWorkItem("env1");
|
||||||
|
expect(found?.id).toBe(item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined when no pending work", () => {
|
||||||
|
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||||
|
expect(storeGetPendingWorkItem("env2")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips non-pending items", () => {
|
||||||
|
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||||
|
storeUpdateWorkItem(item.id, { state: "dispatched" });
|
||||||
|
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeUpdateWorkItem", () => {
|
||||||
|
test("updates existing work item", () => {
|
||||||
|
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||||
|
expect(storeUpdateWorkItem(item.id, { state: "acked" })).toBe(true);
|
||||||
|
expect(storeGetWorkItem(item.id)?.state).toBe("acked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for non-existent", () => {
|
||||||
|
expect(storeUpdateWorkItem("nope", { state: "acked" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- storeReset ----------
|
||||||
|
|
||||||
|
describe("storeReset", () => {
|
||||||
|
test("clears all data", () => {
|
||||||
|
storeCreateUser("alice");
|
||||||
|
storeCreateEnvironment({ secret: "s" });
|
||||||
|
storeCreateSession({});
|
||||||
|
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||||
|
|
||||||
|
storeReset();
|
||||||
|
|
||||||
|
expect(storeGetUser("alice")).toBeUndefined();
|
||||||
|
expect(storeListActiveEnvironments()).toHaveLength(0);
|
||||||
|
expect(storeListSessions()).toHaveLength(0);
|
||||||
|
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config before imports
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-api-key"],
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
pollTimeout: 1, // Short timeout for tests
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { storeReset, storeCreateEnvironment, storeCreateSession, storeGetWorkItem, storeGetPendingWorkItem } from "../store";
|
||||||
|
import {
|
||||||
|
createWorkItem,
|
||||||
|
pollWork,
|
||||||
|
ackWork,
|
||||||
|
stopWork,
|
||||||
|
heartbeatWork,
|
||||||
|
reconnectWorkForEnvironment,
|
||||||
|
} from "../services/work-dispatch";
|
||||||
|
|
||||||
|
describe("Work Dispatch", () => {
|
||||||
|
let envId: string;
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
const env = storeCreateEnvironment({ secret: "s" });
|
||||||
|
envId = env.id;
|
||||||
|
const session = storeCreateSession({ environmentId: envId });
|
||||||
|
sessionId = session.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createWorkItem", () => {
|
||||||
|
test("creates work item for active environment", async () => {
|
||||||
|
const workId = await createWorkItem(envId, sessionId);
|
||||||
|
expect(workId).toMatch(/^work_/);
|
||||||
|
const item = storeGetWorkItem(workId);
|
||||||
|
expect(item?.state).toBe("pending");
|
||||||
|
expect(item?.sessionId).toBe(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws for non-existent environment", async () => {
|
||||||
|
await expect(createWorkItem("env_no", sessionId)).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws for inactive environment", async () => {
|
||||||
|
const inactiveEnv = storeCreateEnvironment({ secret: "s2" });
|
||||||
|
// Manually set status to deregistered
|
||||||
|
const { storeUpdateEnvironment } = await import("../store");
|
||||||
|
storeUpdateEnvironment(inactiveEnv.id, { status: "deregistered" });
|
||||||
|
await expect(createWorkItem(inactiveEnv.id, sessionId)).rejects.toThrow("not active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encodes work secret as base64 JSON", async () => {
|
||||||
|
const workId = await createWorkItem(envId, sessionId);
|
||||||
|
const item = storeGetWorkItem(workId);
|
||||||
|
const decoded = JSON.parse(Buffer.from(item!.secret, "base64url").toString());
|
||||||
|
expect(decoded.version).toBe(1);
|
||||||
|
expect(decoded.session_ingress_token).toBe("test-api-key");
|
||||||
|
expect(decoded.api_base_url).toBe("http://localhost:3000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pollWork", () => {
|
||||||
|
test("returns null when no work available (timeout)", async () => {
|
||||||
|
const result = await pollWork(envId, 0.1);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns pending work and marks as dispatched", async () => {
|
||||||
|
const workId = await createWorkItem(envId, sessionId);
|
||||||
|
const result = await pollWork(envId, 1);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.id).toBe(workId);
|
||||||
|
expect(result!.state).toBe("dispatched");
|
||||||
|
expect(result!.data.type).toBe("session");
|
||||||
|
expect(result!.data.id).toBe(sessionId);
|
||||||
|
// Work should no longer be pending
|
||||||
|
expect(storeGetPendingWorkItem(envId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not return work for different environment", async () => {
|
||||||
|
const env2 = storeCreateEnvironment({ secret: "s2" });
|
||||||
|
await createWorkItem(envId, sessionId);
|
||||||
|
const result = await pollWork(env2.id, 0.1);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ackWork", () => {
|
||||||
|
test("marks work as acked", async () => {
|
||||||
|
const workId = await createWorkItem(envId, sessionId);
|
||||||
|
ackWork(workId);
|
||||||
|
expect(storeGetWorkItem(workId)?.state).toBe("acked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stopWork", () => {
|
||||||
|
test("marks work as completed", async () => {
|
||||||
|
const workId = await createWorkItem(envId, sessionId);
|
||||||
|
stopWork(workId);
|
||||||
|
expect(storeGetWorkItem(workId)?.state).toBe("completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("heartbeatWork", () => {
|
||||||
|
test("extends lease and returns heartbeat info", async () => {
|
||||||
|
const workId = await createWorkItem(envId, sessionId);
|
||||||
|
const result = heartbeatWork(workId);
|
||||||
|
expect(result.lease_extended).toBe(true);
|
||||||
|
expect(result.ttl_seconds).toBe(40); // heartbeatInterval * 2
|
||||||
|
expect(result.last_heartbeat).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default state for non-existent work", async () => {
|
||||||
|
const result = heartbeatWork("work_no");
|
||||||
|
expect(result.state).toBe("acked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconnectWorkForEnvironment", () => {
|
||||||
|
test("creates work items for idle sessions in environment", async () => {
|
||||||
|
// Create another idle session
|
||||||
|
storeCreateSession({ environmentId: envId });
|
||||||
|
const workIds = await reconnectWorkForEnvironment(envId);
|
||||||
|
expect(workIds).toHaveLength(2);
|
||||||
|
for (const id of workIds) {
|
||||||
|
expect(storeGetWorkItem(id)?.state).toBe("pending");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips non-idle sessions", async () => {
|
||||||
|
const activeSession = storeCreateSession({ environmentId: envId });
|
||||||
|
const { storeUpdateSession } = await import("../store");
|
||||||
|
storeUpdateSession(activeSession.id, { status: "active" });
|
||||||
|
const workIds = await reconnectWorkForEnvironment(envId);
|
||||||
|
// Only the original idle session should get work
|
||||||
|
expect(workIds).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty for environment with no sessions", async () => {
|
||||||
|
const emptyEnv = storeCreateEnvironment({ secret: "s_empty" });
|
||||||
|
const workIds = await reconnectWorkForEnvironment(emptyEnv.id);
|
||||||
|
expect(workIds).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
484
packages/remote-control-server/src/__tests__/ws-handler.test.ts
Normal file
484
packages/remote-control-server/src/__tests__/ws-handler.test.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Mock config before imports
|
||||||
|
const mockConfig = {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
apiKeys: ["test-api-key"],
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
pollTimeout: 8,
|
||||||
|
heartbeatInterval: 20,
|
||||||
|
jwtExpiresIn: 3600,
|
||||||
|
disconnectTimeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
getBaseUrl: () => "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { storeReset } from "../store";
|
||||||
|
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||||
|
import {
|
||||||
|
ingestBridgeMessage,
|
||||||
|
handleWebSocketOpen,
|
||||||
|
handleWebSocketMessage,
|
||||||
|
handleWebSocketClose,
|
||||||
|
closeAllConnections,
|
||||||
|
} from "../transport/ws-handler";
|
||||||
|
|
||||||
|
// Minimal WSContext mock
|
||||||
|
function createMockWs(readyState = 1) {
|
||||||
|
const sent: string[] = [];
|
||||||
|
return {
|
||||||
|
readyState,
|
||||||
|
send: (data: string) => sent.push(data),
|
||||||
|
close: (_code?: number, _reason?: string) => {},
|
||||||
|
getSentData: () => sent,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ws-handler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
closeAllConnections();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ingestBridgeMessage", () => {
|
||||||
|
test("ignores keep_alive messages", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", { type: "keep_alive" });
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives type from message.role for user messages", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", {
|
||||||
|
message: { role: "user", content: "hello" },
|
||||||
|
uuid: "u1",
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("user");
|
||||||
|
expect((events[0] as any).direction).toBe("inbound");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives type from message.role for assistant messages", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", {
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: "response" }] },
|
||||||
|
uuid: "u2",
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("assistant");
|
||||||
|
const payload = (events[0] as any).payload as Record<string, unknown>;
|
||||||
|
expect(payload.content).toBe("response");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives type from explicit type field", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", { type: "control_request", request_id: "r1", request: { subtype: "interrupt" } });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("control_request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives result type from subtype/result fields", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", { subtype: "success", uuid: "u3", result: "done" });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("result");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives system type from session_id field", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", { session_id: "s1", init: true });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles control_response type", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", {
|
||||||
|
type: "control_response",
|
||||||
|
response: { subtype: "success" },
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("control_response");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles partial_assistant type", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", {
|
||||||
|
type: "partial_assistant",
|
||||||
|
message: { content: "partial..." },
|
||||||
|
uuid: "u4",
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("partial_assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to unknown type", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
ingestBridgeMessage("s1", { data: "something" });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0] as any).type).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleWebSocketOpen", () => {
|
||||||
|
test("subscribes to event bus and replays missed events", () => {
|
||||||
|
// Publish some events before WS connects
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hello" }, direction: "outbound" });
|
||||||
|
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
|
||||||
|
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "s1");
|
||||||
|
|
||||||
|
// Should have replayed the outbound event (only outbound events are forwarded to WS)
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
expect(sent.length).toBeGreaterThanOrEqual(1);
|
||||||
|
// First message should be the outbound user event
|
||||||
|
const msg = JSON.parse(sent[0]);
|
||||||
|
expect(msg.type).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaces existing connection for same session", () => {
|
||||||
|
const ws1 = createMockWs();
|
||||||
|
const ws2 = createMockWs();
|
||||||
|
handleWebSocketOpen(ws1, "s2");
|
||||||
|
handleWebSocketOpen(ws2, "s2");
|
||||||
|
|
||||||
|
// ws2 should be the active connection
|
||||||
|
const bus = getEventBus("s2");
|
||||||
|
bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: { content: "test" }, direction: "outbound" });
|
||||||
|
expect(ws2.getSentData().length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleWebSocketMessage", () => {
|
||||||
|
test("parses NDJSON and ingests each message", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
const ws = createMockWs();
|
||||||
|
const data = JSON.stringify({ type: "user", message: { role: "user", content: "hello" } }) + "\n" +
|
||||||
|
JSON.stringify({ type: "assistant", message: { role: "assistant", content: "hi" } }) + "\n";
|
||||||
|
handleWebSocketMessage(ws, "s1", data);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores malformed JSON lines", () => {
|
||||||
|
const bus = getEventBus("s1");
|
||||||
|
const events: unknown[] = [];
|
||||||
|
bus.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketMessage(ws, "s1", "not json\n");
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleWebSocketClose", () => {
|
||||||
|
test("cleans up on close", () => {
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "s3");
|
||||||
|
handleWebSocketClose(ws, "s3", 1000, "done");
|
||||||
|
|
||||||
|
// After close, publishing events should not cause errors
|
||||||
|
const bus = getEventBus("s3");
|
||||||
|
expect(() =>
|
||||||
|
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: {}, direction: "outbound" })
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toSDKMessage (via handleWebSocketOpen outbound delivery)", () => {
|
||||||
|
test("converts permission_response with approved=true", () => {
|
||||||
|
const bus = getEventBus("pr1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "pr1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e1",
|
||||||
|
sessionId: "pr1",
|
||||||
|
type: "permission_response",
|
||||||
|
payload: { approved: true, request_id: "req1" },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_response");
|
||||||
|
expect(lastMsg.response.subtype).toBe("success");
|
||||||
|
expect(lastMsg.response.request_id).toBe("req1");
|
||||||
|
expect(lastMsg.response.response.behavior).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts permission_response with approved=false", () => {
|
||||||
|
const bus = getEventBus("pr2");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "pr2");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e2",
|
||||||
|
sessionId: "pr2",
|
||||||
|
type: "permission_response",
|
||||||
|
payload: { approved: false, request_id: "req2" },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_response");
|
||||||
|
expect(lastMsg.response.subtype).toBe("error");
|
||||||
|
expect(lastMsg.response.error).toBe("Permission denied by user");
|
||||||
|
expect(lastMsg.response.response.behavior).toBe("deny");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts permission_response with existing response object", () => {
|
||||||
|
const bus = getEventBus("pr3");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "pr3");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e3",
|
||||||
|
sessionId: "pr3",
|
||||||
|
type: "control_response",
|
||||||
|
payload: { response: { subtype: "success", data: "custom" } },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_response");
|
||||||
|
expect(lastMsg.response.subtype).toBe("success");
|
||||||
|
expect(lastMsg.response.data).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts interrupt event", () => {
|
||||||
|
const bus = getEventBus("int1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "int1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e4",
|
||||||
|
sessionId: "int1",
|
||||||
|
type: "interrupt",
|
||||||
|
payload: { action: "interrupt" },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_request");
|
||||||
|
expect(lastMsg.request_id).toBe("e4");
|
||||||
|
expect(lastMsg.request.subtype).toBe("interrupt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts control_request event", () => {
|
||||||
|
const bus = getEventBus("cr1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "cr1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e5",
|
||||||
|
sessionId: "cr1",
|
||||||
|
type: "control_request",
|
||||||
|
payload: { request_id: "req5", request: { subtype: "permission", tool_name: "Bash" } },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_request");
|
||||||
|
expect(lastMsg.request_id).toBe("req5");
|
||||||
|
expect(lastMsg.request.subtype).toBe("permission");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts user_message event type", () => {
|
||||||
|
const bus = getEventBus("um1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "um1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e6",
|
||||||
|
sessionId: "um1",
|
||||||
|
type: "user_message",
|
||||||
|
payload: { content: "hello world" },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("user");
|
||||||
|
expect(lastMsg.message.content).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts generic event type", () => {
|
||||||
|
const bus = getEventBus("gen1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "gen1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e7",
|
||||||
|
sessionId: "gen1",
|
||||||
|
type: "status",
|
||||||
|
payload: { state: "running" },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("status");
|
||||||
|
expect(lastMsg.message).toEqual({ state: "running" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permission_response with updated_input", () => {
|
||||||
|
const bus = getEventBus("ui1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "ui1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e8",
|
||||||
|
sessionId: "ui1",
|
||||||
|
type: "permission_response",
|
||||||
|
payload: { approved: true, request_id: "req8", updated_input: { cmd: "ls -la" } },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.response.response.behavior).toBe("allow");
|
||||||
|
expect(lastMsg.response.response.updatedInput).toEqual({ cmd: "ls -la" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permission_response with updated_permissions", () => {
|
||||||
|
const bus = getEventBus("up1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "up1");
|
||||||
|
|
||||||
|
const permissions = [{ type: "setMode", mode: "acceptEdits", destination: "session" }];
|
||||||
|
bus.publish({
|
||||||
|
id: "ep1",
|
||||||
|
sessionId: "up1",
|
||||||
|
type: "permission_response",
|
||||||
|
payload: {
|
||||||
|
approved: true,
|
||||||
|
request_id: "req-ep1",
|
||||||
|
updated_input: { plan: "my plan" },
|
||||||
|
updated_permissions: permissions,
|
||||||
|
},
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_response");
|
||||||
|
expect(lastMsg.response.subtype).toBe("success");
|
||||||
|
expect(lastMsg.response.response.behavior).toBe("allow");
|
||||||
|
expect(lastMsg.response.response.updatedInput).toEqual({ plan: "my plan" });
|
||||||
|
expect(lastMsg.response.response.updatedPermissions).toEqual(permissions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permission_response denied with feedback message", () => {
|
||||||
|
const bus = getEventBus("dm1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "dm1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "dm1",
|
||||||
|
sessionId: "dm1",
|
||||||
|
type: "permission_response",
|
||||||
|
payload: {
|
||||||
|
approved: false,
|
||||||
|
request_id: "req-dm1",
|
||||||
|
message: "Please add more tests",
|
||||||
|
},
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_response");
|
||||||
|
expect(lastMsg.response.subtype).toBe("error");
|
||||||
|
expect(lastMsg.response.response.behavior).toBe("deny");
|
||||||
|
expect(lastMsg.response.message).toBe("Please add more tests");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not forward inbound events to WS", () => {
|
||||||
|
const bus = getEventBus("no_in");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "no_in");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e9",
|
||||||
|
sessionId: "no_in",
|
||||||
|
type: "assistant",
|
||||||
|
payload: { content: "reply" },
|
||||||
|
direction: "inbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only replayed events, no new inbound delivery
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
// No outbound events were published, so only replay (if any)
|
||||||
|
// Since the bus was fresh, no replay
|
||||||
|
expect(sent).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("control_request falls back to payload when no request field", () => {
|
||||||
|
const bus = getEventBus("cf1");
|
||||||
|
const ws = createMockWs();
|
||||||
|
handleWebSocketOpen(ws, "cf1");
|
||||||
|
|
||||||
|
bus.publish({
|
||||||
|
id: "e10",
|
||||||
|
sessionId: "cf1",
|
||||||
|
type: "control_request",
|
||||||
|
payload: { request_id: "req10", subtype: "custom", data: "test" },
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = ws.getSentData();
|
||||||
|
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||||
|
expect(lastMsg.type).toBe("control_request");
|
||||||
|
expect(lastMsg.request_id).toBe("req10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("closeAllConnections", () => {
|
||||||
|
test("closes all active connections", () => {
|
||||||
|
const ws1 = createMockWs();
|
||||||
|
const ws2 = createMockWs();
|
||||||
|
handleWebSocketOpen(ws1, "s1");
|
||||||
|
handleWebSocketOpen(ws2, "s2");
|
||||||
|
closeAllConnections();
|
||||||
|
// No errors thrown
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no-op when no connections", () => {
|
||||||
|
expect(() => closeAllConnections()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
packages/remote-control-server/src/auth/api-key.ts
Normal file
12
packages/remote-control-server/src/auth/api-key.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
/** Validate a raw API key token string */
|
||||||
|
export function validateApiKey(token: string | undefined): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
return config.apiKeys.includes(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashApiKey(key: string): string {
|
||||||
|
return createHash("sha256").update(key).digest("hex");
|
||||||
|
}
|
||||||
92
packages/remote-control-server/src/auth/jwt.ts
Normal file
92
packages/remote-control-server/src/auth/jwt.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight JWT implementation using HMAC-SHA256.
|
||||||
|
* No external dependencies — uses Node.js crypto.
|
||||||
|
*
|
||||||
|
* Token format: base64url(header).base64url(payload).base64url(signature)
|
||||||
|
* Used for V2 worker authentication (session ingress / SSE / CCR).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
session_id: string;
|
||||||
|
role: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(data: string | Buffer): string {
|
||||||
|
return Buffer.from(data as unknown as ArrayLike<number>)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlDecode(str: string): string {
|
||||||
|
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
return Buffer.from(padded, "base64").toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSigningKey(): string {
|
||||||
|
const key = process.env.RCS_API_KEYS?.split(",").filter(Boolean)[0];
|
||||||
|
if (!key) throw new Error("No API key configured for JWT signing");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a JWT for worker authentication. */
|
||||||
|
export function generateWorkerJwt(
|
||||||
|
sessionId: string,
|
||||||
|
expiresInSeconds: number,
|
||||||
|
): string {
|
||||||
|
const header = { alg: "HS256", typ: "JWT" };
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
role: "worker",
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerB64 = base64url(JSON.stringify(header));
|
||||||
|
const payloadB64 = base64url(JSON.stringify(payload));
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const signature = createHmac("sha256", getSigningKey())
|
||||||
|
.update(signingInput)
|
||||||
|
.digest();
|
||||||
|
|
||||||
|
return `${signingInput}.${base64url(signature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a JWT and return its payload, or null if invalid/expired.
|
||||||
|
* Uses timing-safe comparison to prevent timing attacks.
|
||||||
|
*/
|
||||||
|
export function verifyWorkerJwt(token: string): JwtPayload | null {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
const expectedSig = createHmac("sha256", getSigningKey())
|
||||||
|
.update(signingInput)
|
||||||
|
.digest();
|
||||||
|
const actualSig = Buffer.from(
|
||||||
|
signatureB64.replace(/-/g, "+").replace(/_/g, "/"),
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (expectedSig.length !== actualSig.length) return null;
|
||||||
|
if (!timingSafeEqual(expectedSig, actualSig)) return null;
|
||||||
|
|
||||||
|
// Decode payload
|
||||||
|
try {
|
||||||
|
const payload: JwtPayload = JSON.parse(base64urlDecode(payloadB64));
|
||||||
|
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/remote-control-server/src/auth/middleware.ts
Normal file
102
packages/remote-control-server/src/auth/middleware.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Context, Next } from "hono";
|
||||||
|
import { validateApiKey } from "./api-key";
|
||||||
|
import { verifyWorkerJwt } from "./jwt";
|
||||||
|
import { resolveToken } from "./token";
|
||||||
|
|
||||||
|
/** Extract Bearer token from Authorization header or ?token= query param */
|
||||||
|
function extractBearerToken(c: Context): string | undefined {
|
||||||
|
const authHeader = c.req.header("Authorization");
|
||||||
|
const queryToken = c.req.query("token");
|
||||||
|
return authHeader?.replace("Bearer ", "") || queryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified authentication middleware — supports two modes:
|
||||||
|
*
|
||||||
|
* 1. **Token mode** (Web UI): Bearer token resolved via server-side lookup → username injected
|
||||||
|
* 2. **API Key mode** (CLI bridge): Valid API key + X-Username header → username injected
|
||||||
|
*/
|
||||||
|
export async function apiKeyAuth(c: Context, next: Next) {
|
||||||
|
const token = extractBearerToken(c);
|
||||||
|
|
||||||
|
// Try token authentication (Web UI)
|
||||||
|
const tokenUsername = resolveToken(token);
|
||||||
|
if (tokenUsername) {
|
||||||
|
c.set("username", tokenUsername);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try API Key authentication (CLI bridge)
|
||||||
|
if (validateApiKey(token)) {
|
||||||
|
// Extract username from X-Username header or ?username= query param
|
||||||
|
const username = c.req.header("X-Username") || c.req.query("username");
|
||||||
|
if (username) {
|
||||||
|
c.set("username", username);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: { type: "unauthorized", message: "Invalid or missing auth token" } }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session ingress authentication — accepts both API key and worker JWT.
|
||||||
|
*
|
||||||
|
* Used for SSE stream, CCR worker events, and WebSocket ingress endpoints.
|
||||||
|
* On JWT validation, stores the decoded payload in c.set("jwtPayload") for
|
||||||
|
* downstream handlers to inspect session_id if needed.
|
||||||
|
*/
|
||||||
|
export async function sessionIngressAuth(c: Context, next: Next) {
|
||||||
|
const token = extractBearerToken(c);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try API key first (backward compatible)
|
||||||
|
if (validateApiKey(token)) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JWT verification — validate session_id matches route param
|
||||||
|
const payload = verifyWorkerJwt(token);
|
||||||
|
if (payload) {
|
||||||
|
const routeSessionId = c.req.param("id") || c.req.param("sessionId");
|
||||||
|
if (routeSessionId && payload.session_id !== routeSessionId) {
|
||||||
|
return c.json({ error: { type: "forbidden", message: "JWT session_id does not match target session" } }, 403);
|
||||||
|
}
|
||||||
|
c.set("jwtPayload", payload);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: { type: "unauthorized", message: "Invalid API key or JWT" } }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept CLI headers but don't validate them */
|
||||||
|
export async function acceptCliHeaders(c: Context, next: Next) {
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract UUID from request — query param ?uuid= or header X-UUID
|
||||||
|
*/
|
||||||
|
export function getUuidFromRequest(c: Context): string | undefined {
|
||||||
|
return c.req.query("uuid") || c.req.header("X-UUID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID-based auth for Web UI routes (no-login mode).
|
||||||
|
* Requires a UUID in query param or header, injects it into context as c.set("uuid").
|
||||||
|
*/
|
||||||
|
export async function uuidAuth(c: Context, next: Next) {
|
||||||
|
const uuid = getUuidFromRequest(c);
|
||||||
|
if (!uuid) {
|
||||||
|
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
|
||||||
|
}
|
||||||
|
c.set("uuid", uuid);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
24
packages/remote-control-server/src/auth/token.ts
Normal file
24
packages/remote-control-server/src/auth/token.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { storeCreateToken, storeGetUserByToken } from "../store";
|
||||||
|
|
||||||
|
let tokenCounter = 0;
|
||||||
|
|
||||||
|
/** Generate a random session token and associate it with a user */
|
||||||
|
export function issueToken(username: string): { token: string; expires_in: number } {
|
||||||
|
// Use crypto.getRandomValues for uniqueness
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
const hex = Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
const token = `rct_${tokenCounter++}_${hex}`;
|
||||||
|
storeCreateToken(username, token);
|
||||||
|
return { token, expires_in: 86400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a token to a username. Returns null if invalid. */
|
||||||
|
export function resolveToken(token: string | undefined): string | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const entry = storeGetUserByToken(token);
|
||||||
|
if (!entry) return null;
|
||||||
|
return entry.username;
|
||||||
|
}
|
||||||
16
packages/remote-control-server/src/config.ts
Normal file
16
packages/remote-control-server/src/config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const config = {
|
||||||
|
version: process.env.RCS_VERSION || "0.1.0",
|
||||||
|
port: parseInt(process.env.RCS_PORT || "3000"),
|
||||||
|
host: process.env.RCS_HOST || "0.0.0.0",
|
||||||
|
apiKeys: (process.env.RCS_API_KEYS || "").split(",").filter(Boolean),
|
||||||
|
baseUrl: process.env.RCS_BASE_URL || "",
|
||||||
|
pollTimeout: parseInt(process.env.RCS_POLL_TIMEOUT || "8"),
|
||||||
|
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
|
||||||
|
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
||||||
|
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getBaseUrl(): string {
|
||||||
|
if (config.baseUrl) return config.baseUrl;
|
||||||
|
return `http://localhost:${config.port}`;
|
||||||
|
}
|
||||||
103
packages/remote-control-server/src/index.ts
Normal file
103
packages/remote-control-server/src/index.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { logger } from "hono/logger";
|
||||||
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { config } from "./config";
|
||||||
|
import { closeAllConnections } from "./transport/ws-handler";
|
||||||
|
import { startDisconnectMonitor } from "./services/disconnect-monitor";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
import v1Environments from "./routes/v1/environments";
|
||||||
|
import v1EnvironmentsWork from "./routes/v1/environments.work";
|
||||||
|
import v1Sessions from "./routes/v1/sessions";
|
||||||
|
import v1SessionIngress, { websocket } from "./routes/v1/session-ingress";
|
||||||
|
import v2CodeSessions from "./routes/v2/code-sessions";
|
||||||
|
import v2Worker from "./routes/v2/worker";
|
||||||
|
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
|
||||||
|
import v2WorkerEvents from "./routes/v2/worker-events";
|
||||||
|
import webAuth from "./routes/web/auth";
|
||||||
|
import webSessions from "./routes/web/sessions";
|
||||||
|
import webControl from "./routes/web/control";
|
||||||
|
import webEnvironments from "./routes/web/environments";
|
||||||
|
|
||||||
|
console.log("[RCS] In-memory store ready (no SQLite)");
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use("*", logger());
|
||||||
|
app.use("/web/*", cors());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
||||||
|
|
||||||
|
// Static files — serve web/ directory under /code path
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webDir = resolve(__dirname, "../web");
|
||||||
|
|
||||||
|
const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
|
||||||
|
|
||||||
|
// Serve all static files under /code/* from web/ directory
|
||||||
|
app.use("/code/*", serveStatic({ root: webDir, rewriteRequestPath: stripCodePrefix }));
|
||||||
|
// /code, /code/, and /code/:sessionId — SPA fallback
|
||||||
|
app.get("/code", serveStatic({ root: webDir, path: "index.html" }));
|
||||||
|
app.get("/code/", serveStatic({ root: webDir, path: "index.html" }));
|
||||||
|
app.get("/code/:sessionId", serveStatic({ root: webDir, path: "index.html" }));
|
||||||
|
|
||||||
|
// v1 Environment routes
|
||||||
|
app.route("/v1/environments", v1Environments);
|
||||||
|
app.route("/v1/environments", v1EnvironmentsWork);
|
||||||
|
|
||||||
|
// v1 Session routes
|
||||||
|
app.route("/v1/sessions", v1Sessions);
|
||||||
|
|
||||||
|
// Session Ingress (WebSocket) — mounted at both /v1 and /v2 so the bridge
|
||||||
|
// client's buildSdkUrl works with or without an Envoy proxy rewriting /v1→/v2.
|
||||||
|
app.route("/v1/session_ingress", v1SessionIngress);
|
||||||
|
app.route("/v2/session_ingress", v1SessionIngress);
|
||||||
|
|
||||||
|
// v2 Code Sessions routes
|
||||||
|
app.route("/v1/code/sessions", v2CodeSessions);
|
||||||
|
app.route("/v1/code/sessions", v2Worker);
|
||||||
|
app.route("/v1/code/sessions", v2WorkerEventsStream);
|
||||||
|
app.route("/v1/code/sessions", v2WorkerEvents);
|
||||||
|
|
||||||
|
// Web control panel routes
|
||||||
|
app.route("/web", webAuth);
|
||||||
|
app.route("/web", webSessions);
|
||||||
|
app.route("/web", webControl);
|
||||||
|
app.route("/web", webEnvironments);
|
||||||
|
|
||||||
|
const port = config.port;
|
||||||
|
const host = config.host;
|
||||||
|
|
||||||
|
console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
|
||||||
|
console.log("[RCS] API key configuration loaded");
|
||||||
|
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
|
||||||
|
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
|
||||||
|
|
||||||
|
// Start disconnect monitor
|
||||||
|
startDisconnectMonitor();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port,
|
||||||
|
hostname: host,
|
||||||
|
fetch: app.fetch,
|
||||||
|
websocket: {
|
||||||
|
...websocket,
|
||||||
|
idleTimeout: 255, // WS idle timeout (seconds) — must be inside websocket object
|
||||||
|
},
|
||||||
|
idleTimeout: 255, // HTTP server idle timeout (seconds) — needed for long-polling endpoints
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
async function gracefulShutdown(signal: string) {
|
||||||
|
console.log(`\n[RCS] Received ${signal}, shutting down...`);
|
||||||
|
closeAllConnections();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||||
31
packages/remote-control-server/src/routes/v1/environments.ts
Normal file
31
packages/remote-control-server/src/routes/v1/environments.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
|
||||||
|
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** POST /v1/environments/bridge — Register an environment */
|
||||||
|
app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const username = c.get("username");
|
||||||
|
const result = registerEnvironment({ ...body, username });
|
||||||
|
return c.json(result, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /v1/environments/bridge/:id — Deregister */
|
||||||
|
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const envId = c.req.param("id");
|
||||||
|
deregisterEnvironment(envId);
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */
|
||||||
|
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const envId = c.req.param("id");
|
||||||
|
reconnectEnvironment(envId);
|
||||||
|
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
|
||||||
|
await reconnectWorkForEnvironment(envId);
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { pollWork, ackWork, stopWork, heartbeatWork } from "../../services/work-dispatch";
|
||||||
|
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
import { updatePollTime } from "../../services/environment";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** GET /v1/environments/:id/work/poll — Long-poll for work */
|
||||||
|
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const envId = c.req.param("id");
|
||||||
|
updatePollTime(envId);
|
||||||
|
const result = await pollWork(envId);
|
||||||
|
if (!result) {
|
||||||
|
// Return 204 No Content so the client's axios parses it as null
|
||||||
|
return c.body(null, 204);
|
||||||
|
}
|
||||||
|
return c.json(result, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
|
||||||
|
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const workId = c.req.param("workId");
|
||||||
|
ackWork(workId);
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/environments/:id/work/:workId/stop — Stop work */
|
||||||
|
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const workId = c.req.param("workId");
|
||||||
|
stopWork(workId);
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
|
||||||
|
app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const workId = c.req.param("workId");
|
||||||
|
const result = heartbeatWork(workId);
|
||||||
|
return c.json(result, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
119
packages/remote-control-server/src/routes/v1/session-ingress.ts
Normal file
119
packages/remote-control-server/src/routes/v1/session-ingress.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { createBunWebSocket } from "hono/bun";
|
||||||
|
import { validateApiKey } from "../../auth/api-key";
|
||||||
|
import { verifyWorkerJwt } from "../../auth/jwt";
|
||||||
|
import {
|
||||||
|
handleWebSocketOpen,
|
||||||
|
handleWebSocketMessage,
|
||||||
|
handleWebSocketClose,
|
||||||
|
ingestBridgeMessage,
|
||||||
|
} from "../../transport/ws-handler";
|
||||||
|
import { getSession } from "../../services/session";
|
||||||
|
|
||||||
|
const { upgradeWebSocket, websocket } = createBunWebSocket();
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
||||||
|
function authenticateRequest(c: any, label: string, expectedSessionId?: string): boolean {
|
||||||
|
const authHeader = c.req.header("Authorization");
|
||||||
|
const queryToken = c.req.query("token");
|
||||||
|
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||||
|
|
||||||
|
// Try API key first
|
||||||
|
if (validateApiKey(token)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JWT verification — validate session_id matches if provided
|
||||||
|
if (token) {
|
||||||
|
const payload = verifyWorkerJwt(token);
|
||||||
|
if (payload) {
|
||||||
|
if (expectedSessionId && payload.session_id !== expectedSessionId) {
|
||||||
|
console.log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */
|
||||||
|
app.post("/session/:sessionId/events", async (c) => {
|
||||||
|
const sessionId = c.req.param("sessionId")!;
|
||||||
|
|
||||||
|
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
|
||||||
|
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const events = Array.isArray(body.events) ? body.events : [body];
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const msg of events) {
|
||||||
|
if (!msg || typeof msg !== "object") continue;
|
||||||
|
ingestBridgeMessage(sessionId, msg as Record<string, unknown>);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** WS /v2/session_ingress/ws/:sessionId — WebSocket transport */
|
||||||
|
app.get(
|
||||||
|
"/ws/:sessionId",
|
||||||
|
upgradeWebSocket(async (c) => {
|
||||||
|
const sessionId = c.req.param("sessionId")!;
|
||||||
|
|
||||||
|
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
||||||
|
return {
|
||||||
|
onOpen(_evt, ws) {
|
||||||
|
ws.close(4003, "unauthorized");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
console.log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||||
|
return {
|
||||||
|
onOpen(_evt, ws) {
|
||||||
|
ws.close(4001, "session not found");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||||
|
return {
|
||||||
|
onOpen(_evt, ws) {
|
||||||
|
handleWebSocketOpen(ws as any, sessionId);
|
||||||
|
},
|
||||||
|
onMessage(evt, ws) {
|
||||||
|
const data =
|
||||||
|
typeof evt.data === "string"
|
||||||
|
? evt.data
|
||||||
|
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||||
|
handleWebSocketMessage(ws as any, sessionId, data);
|
||||||
|
},
|
||||||
|
onClose(evt, ws) {
|
||||||
|
const closeEvt = evt as unknown as CloseEvent;
|
||||||
|
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
||||||
|
},
|
||||||
|
onError(evt, ws) {
|
||||||
|
console.error(`[WS] Error on session=${sessionId}:`, evt);
|
||||||
|
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export { websocket };
|
||||||
|
export default app;
|
||||||
85
packages/remote-control-server/src/routes/v1/sessions.ts
Normal file
85
packages/remote-control-server/src/routes/v1/sessions.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
updateSessionTitle,
|
||||||
|
archiveSession,
|
||||||
|
} from "../../services/session";
|
||||||
|
import { createWorkItem } from "../../services/work-dispatch";
|
||||||
|
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
import { publishSessionEvent } from "../../services/transport";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** POST /v1/sessions — Create session */
|
||||||
|
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const username = c.get("username");
|
||||||
|
const session = createSession({ ...body, username });
|
||||||
|
|
||||||
|
// Create work item if environment is specified
|
||||||
|
if (body.environment_id) {
|
||||||
|
try {
|
||||||
|
await createWorkItem(body.environment_id, session.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish initial events if provided
|
||||||
|
if (body.events && Array.isArray(body.events)) {
|
||||||
|
for (const evt of body.events) {
|
||||||
|
publishSessionEvent(session.id, evt.type || "init", evt, "outbound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(session, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /v1/sessions/:id — Get session */
|
||||||
|
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const session = getSession(c.req.param("id"));
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
return c.json(session, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PATCH /v1/sessions/:id — Update session title */
|
||||||
|
app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
if (body.title) {
|
||||||
|
updateSessionTitle(c.req.param("id"), body.title);
|
||||||
|
}
|
||||||
|
const session = getSession(c.req.param("id"));
|
||||||
|
return c.json(session, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/sessions/:id/archive — Archive session */
|
||||||
|
app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
try {
|
||||||
|
archiveSession(c.req.param("id"));
|
||||||
|
} catch {
|
||||||
|
return c.json({ status: "ok" }, 409);
|
||||||
|
}
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/sessions/:id/events — Send event to session */
|
||||||
|
app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id");
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const events = body.events
|
||||||
|
? Array.isArray(body.events) ? body.events : [body.events]
|
||||||
|
: Array.isArray(body) ? body : [body];
|
||||||
|
const published = [];
|
||||||
|
for (const evt of events) {
|
||||||
|
const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
|
||||||
|
published.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ status: "ok", events: published.length }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { createCodeSession, getSession, incrementEpoch } from "../../services/session";
|
||||||
|
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
import { generateWorkerJwt } from "../../auth/jwt";
|
||||||
|
import { getBaseUrl, config } from "../../config";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** POST /v1/code/sessions — Create code session (wrapped response for TUI compat) */
|
||||||
|
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const session = createCodeSession(body);
|
||||||
|
return c.json({ session }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
|
||||||
|
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id");
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const epoch = incrementEpoch(sessionId);
|
||||||
|
const expiresInSeconds = config.jwtExpiresIn;
|
||||||
|
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
api_base_url: getBaseUrl(),
|
||||||
|
worker_epoch: epoch,
|
||||||
|
worker_jwt: workerJwt,
|
||||||
|
expires_in: expiresInSeconds,
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
import { createSSEStream } from "../../transport/sse-writer";
|
||||||
|
import { getSession } from "../../services/session";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */
|
||||||
|
app.get("/:id/worker/events/stream", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id");
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support Last-Event-ID / from_sequence_num for reconnection
|
||||||
|
const lastEventId = c.req.header("Last-Event-ID");
|
||||||
|
const fromSeq = c.req.query("from_sequence_num");
|
||||||
|
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
|
||||||
|
|
||||||
|
return createSSEStream(c, sessionId, fromSeqNum);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
import { publishSessionEvent } from "../../services/transport";
|
||||||
|
import { getSession, updateSessionStatus } from "../../services/session";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** POST /v1/code/sessions/:id/worker/events — Write events */
|
||||||
|
app.post("/:id/worker/events", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id");
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const events = Array.isArray(body) ? body : [body];
|
||||||
|
const published = [];
|
||||||
|
for (const evt of events) {
|
||||||
|
const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
|
||||||
|
published.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ status: "ok", count: published.length }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /v1/code/sessions/:id/worker/state — Report worker state */
|
||||||
|
app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id");
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (body.status) {
|
||||||
|
updateSessionStatus(sessionId, body.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /v1/code/sessions/:id/worker/external_metadata — Report worker metadata (no-op) */
|
||||||
|
app.put("/:id/worker/external_metadata", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||||
|
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /v1/code/sessions/:id/worker/events/:eventId/delivery — Delivery tracking (no-op) */
|
||||||
|
app.post("/:id/worker/events/:eventId/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||||
|
// TUI's CCRClient reports event delivery status (received/processing/processed).
|
||||||
|
// Accept and discard — event bus doesn't track per-event delivery.
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
19
packages/remote-control-server/src/routes/v2/worker.ts
Normal file
19
packages/remote-control-server/src/routes/v2/worker.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { getSession, incrementEpoch } from "../../services/session";
|
||||||
|
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** POST /v1/code/sessions/:id/worker/register — Register worker */
|
||||||
|
app.post("/:id/worker/register", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id");
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const epoch = incrementEpoch(sessionId);
|
||||||
|
return c.json({ worker_epoch: epoch }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
26
packages/remote-control-server/src/routes/web/auth.ts
Normal file
26
packages/remote-control-server/src/routes/web/auth.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { storeGetSession, storeBindSession } from "../../store";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** POST /web/bind — Bind a session to a UUID (no-login auth) */
|
||||||
|
app.post("/bind", async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const sessionId = body.sessionId;
|
||||||
|
// UUID can come from query param (api.js sends it in URL) or body
|
||||||
|
const uuid = c.req.query("uuid") || body.uuid;
|
||||||
|
|
||||||
|
if (!sessionId || !uuid) {
|
||||||
|
return c.json({ error: "sessionId and uuid are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = storeGetSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Session not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
storeBindSession(sessionId, uuid);
|
||||||
|
return c.json({ ok: true, sessionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
64
packages/remote-control-server/src/routes/web/control.ts
Normal file
64
packages/remote-control-server/src/routes/web/control.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { uuidAuth } from "../../auth/middleware";
|
||||||
|
import { getSession, updateSessionStatus } from "../../services/session";
|
||||||
|
import { publishSessionEvent } from "../../services/transport";
|
||||||
|
import { getEventBus } from "../../transport/event-bus";
|
||||||
|
import { storeIsSessionOwner } from "../../store";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string) {
|
||||||
|
const uuid = c.get("uuid");
|
||||||
|
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||||
|
return { error: true, session: null };
|
||||||
|
}
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return { error: true, session: null };
|
||||||
|
}
|
||||||
|
return { error: false, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /web/sessions/:id/events — Send user message to session */
|
||||||
|
app.post("/sessions/:id/events", uuidAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id")!;
|
||||||
|
const { error } = checkOwnership(c, sessionId);
|
||||||
|
if (error) {
|
||||||
|
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const eventType = body.type || "user";
|
||||||
|
console.log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
|
||||||
|
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
|
||||||
|
console.log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
|
||||||
|
return c.json({ status: "ok", event }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /web/sessions/:id/control — Send control request (permission approval etc) */
|
||||||
|
app.post("/sessions/:id/control", uuidAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id")!;
|
||||||
|
const { error } = checkOwnership(c, sessionId);
|
||||||
|
if (error) {
|
||||||
|
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const event = publishSessionEvent(sessionId, body.type || "control_request", body, "outbound");
|
||||||
|
return c.json({ status: "ok", event }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /web/sessions/:id/interrupt — Interrupt session */
|
||||||
|
app.post("/sessions/:id/interrupt", uuidAuth, async (c) => {
|
||||||
|
const sessionId = c.req.param("id")!;
|
||||||
|
const { error } = checkOwnership(c, sessionId);
|
||||||
|
if (error) {
|
||||||
|
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound");
|
||||||
|
updateSessionStatus(sessionId, "idle");
|
||||||
|
return c.json({ status: "ok" }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { uuidAuth } from "../../auth/middleware";
|
||||||
|
import { listActiveEnvironmentsResponse } from "../../services/environment";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** GET /web/environments — List active environments (UUID-based, no user filtering) */
|
||||||
|
app.get("/environments", uuidAuth, async (c) => {
|
||||||
|
// Environments are shared across all UUIDs for now
|
||||||
|
const envs = listActiveEnvironmentsResponse();
|
||||||
|
return c.json(envs, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
100
packages/remote-control-server/src/routes/web/sessions.ts
Normal file
100
packages/remote-control-server/src/routes/web/sessions.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { uuidAuth } from "../../auth/middleware";
|
||||||
|
import { getSession, createSession } from "../../services/session";
|
||||||
|
import { storeListSessionsByOwnerUuid, storeIsSessionOwner, storeBindSession } from "../../store";
|
||||||
|
import { createWorkItem } from "../../services/work-dispatch";
|
||||||
|
import { listSessionSummariesByOwnerUuid } from "../../services/session";
|
||||||
|
import { createSSEStream } from "../../transport/sse-writer";
|
||||||
|
import { getEventBus } from "../../transport/event-bus";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** POST /web/sessions — Create a session from web UI */
|
||||||
|
app.post("/sessions", uuidAuth, async (c) => {
|
||||||
|
const uuid = c.get("uuid");
|
||||||
|
const body = await c.req.json();
|
||||||
|
const session = createSession({
|
||||||
|
environment_id: body.environment_id || null,
|
||||||
|
title: body.title || "New Session",
|
||||||
|
source: "web",
|
||||||
|
permission_mode: body.permission_mode || "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-bind to creator's UUID
|
||||||
|
storeBindSession(session.id, uuid);
|
||||||
|
|
||||||
|
// Dispatch work to environment if specified
|
||||||
|
if (body.environment_id) {
|
||||||
|
try {
|
||||||
|
await createWorkItem(body.environment_id, session.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(session, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /web/sessions — List sessions owned by the requesting UUID */
|
||||||
|
app.get("/sessions", uuidAuth, async (c) => {
|
||||||
|
const uuid = c.get("uuid");
|
||||||
|
const sessions = storeListSessionsByOwnerUuid(uuid);
|
||||||
|
return c.json(sessions, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */
|
||||||
|
app.get("/sessions/all", uuidAuth, async (c) => {
|
||||||
|
const uuid = c.get("uuid");
|
||||||
|
const sessions = listSessionSummariesByOwnerUuid(uuid);
|
||||||
|
return c.json(sessions, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /web/sessions/:id — Session detail */
|
||||||
|
app.get("/sessions/:id", uuidAuth, async (c) => {
|
||||||
|
const uuid = c.get("uuid");
|
||||||
|
const sessionId = c.req.param("id")!;
|
||||||
|
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||||
|
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||||
|
}
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
return c.json(session, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /web/sessions/:id/history — Historical events for session */
|
||||||
|
app.get("/sessions/:id/history", uuidAuth, async (c) => {
|
||||||
|
const uuid = c.get("uuid");
|
||||||
|
const sessionId = c.req.param("id")!;
|
||||||
|
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||||
|
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||||
|
}
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bus = getEventBus(sessionId);
|
||||||
|
const events = bus.getEventsSince(0);
|
||||||
|
return c.json({ events }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** SSE /web/sessions/:id/events — Real-time event stream */
|
||||||
|
app.get("/sessions/:id/events", uuidAuth, async (c) => {
|
||||||
|
const uuid = c.get("uuid");
|
||||||
|
const sessionId = c.req.param("id")!;
|
||||||
|
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||||
|
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||||
|
}
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEventId = c.req.header("Last-Event-ID");
|
||||||
|
const fromSeqNum = lastEventId ? parseInt(lastEventId) : 0;
|
||||||
|
return createSSEStream(c, sessionId, fromSeqNum);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
|
||||||
|
import { storeListSessions, storeUpdateSession } from "../store";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
export function startDisconnectMonitor() {
|
||||||
|
const timeoutMs = config.disconnectTimeout * 1000;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check environment heartbeat timeout
|
||||||
|
const envs = storeListActiveEnvironments();
|
||||||
|
for (const env of envs) {
|
||||||
|
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||||
|
console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||||
|
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check session timeout (2x disconnect timeout with no update)
|
||||||
|
const sessions = storeListSessions();
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.status === "running" || session.status === "idle") {
|
||||||
|
const elapsed = now - session.updatedAt.getTime();
|
||||||
|
if (elapsed > timeoutMs * 2) {
|
||||||
|
console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
||||||
|
storeUpdateSession(session.id, { status: "inactive" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60_000); // Check every minute
|
||||||
|
}
|
||||||
68
packages/remote-control-server/src/services/environment.ts
Normal file
68
packages/remote-control-server/src/services/environment.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { config } from "../config";
|
||||||
|
import {
|
||||||
|
storeCreateEnvironment,
|
||||||
|
storeGetEnvironment,
|
||||||
|
storeUpdateEnvironment,
|
||||||
|
storeListActiveEnvironments,
|
||||||
|
storeListActiveEnvironmentsByUsername,
|
||||||
|
} from "../store";
|
||||||
|
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
|
||||||
|
import type { EnvironmentRecord } from "../store";
|
||||||
|
|
||||||
|
function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
machine_name: row.machineName,
|
||||||
|
directory: row.directory,
|
||||||
|
branch: row.branch,
|
||||||
|
status: row.status,
|
||||||
|
username: row.username,
|
||||||
|
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata?: { worker_type?: string }; username?: string }) {
|
||||||
|
const secret = config.apiKeys[0] || "";
|
||||||
|
const workerType = req.worker_type || req.metadata?.worker_type;
|
||||||
|
const record = storeCreateEnvironment({
|
||||||
|
secret,
|
||||||
|
machineName: req.machine_name,
|
||||||
|
directory: req.directory,
|
||||||
|
branch: req.branch,
|
||||||
|
gitRepoUrl: req.git_repo_url,
|
||||||
|
maxSessions: req.max_sessions,
|
||||||
|
workerType,
|
||||||
|
bridgeId: req.bridge_id,
|
||||||
|
username: req.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deregisterEnvironment(envId: string) {
|
||||||
|
storeUpdateEnvironment(envId, { status: "deregistered" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvironment(envId: string) {
|
||||||
|
return storeGetEnvironment(envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePollTime(envId: string) {
|
||||||
|
storeUpdateEnvironment(envId, { lastPollAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActiveEnvironments() {
|
||||||
|
return storeListActiveEnvironments();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActiveEnvironmentsResponse(): EnvironmentResponse[] {
|
||||||
|
return storeListActiveEnvironments().map(toResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActiveEnvironmentsByUsername(username: string): EnvironmentResponse[] {
|
||||||
|
return storeListActiveEnvironmentsByUsername(username).map(toResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconnectEnvironment(envId: string) {
|
||||||
|
storeUpdateEnvironment(envId, { status: "active" });
|
||||||
|
}
|
||||||
103
packages/remote-control-server/src/services/session.ts
Normal file
103
packages/remote-control-server/src/services/session.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
storeCreateSession,
|
||||||
|
storeGetSession,
|
||||||
|
storeUpdateSession,
|
||||||
|
storeListSessions,
|
||||||
|
storeListSessionsByUsername,
|
||||||
|
storeListSessionsByEnvironment,
|
||||||
|
storeListSessionsByOwnerUuid,
|
||||||
|
} from "../store";
|
||||||
|
import { removeEventBus } from "../transport/event-bus";
|
||||||
|
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
|
||||||
|
|
||||||
|
function toResponse(row: { id: string; environmentId: string | null; title: string | null; status: string; source: string; permissionMode: string | null; workerEpoch: number; username: string | null; createdAt: Date; updatedAt: Date }): SessionResponse {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
environment_id: row.environmentId,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status,
|
||||||
|
source: row.source,
|
||||||
|
permission_mode: row.permissionMode,
|
||||||
|
worker_epoch: row.workerEpoch,
|
||||||
|
username: row.username,
|
||||||
|
created_at: row.createdAt.getTime() / 1000,
|
||||||
|
updated_at: row.updatedAt.getTime() / 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(req: CreateSessionRequest & { username?: string }): SessionResponse {
|
||||||
|
const record = storeCreateSession({
|
||||||
|
environmentId: req.environment_id,
|
||||||
|
title: req.title,
|
||||||
|
source: req.source,
|
||||||
|
permissionMode: req.permission_mode,
|
||||||
|
username: req.username,
|
||||||
|
});
|
||||||
|
return toResponse(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodeSession(req: CreateCodeSessionRequest): SessionResponse {
|
||||||
|
const record = storeCreateSession({
|
||||||
|
idPrefix: "cse_",
|
||||||
|
title: req.title,
|
||||||
|
source: req.source,
|
||||||
|
permissionMode: req.permission_mode,
|
||||||
|
});
|
||||||
|
return toResponse(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(sessionId: string): SessionResponse | null {
|
||||||
|
const record = storeGetSession(sessionId);
|
||||||
|
return record ? toResponse(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSessionTitle(sessionId: string, title: string) {
|
||||||
|
storeUpdateSession(sessionId, { title });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSessionStatus(sessionId: string, status: string) {
|
||||||
|
storeUpdateSession(sessionId, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archiveSession(sessionId: string) {
|
||||||
|
storeUpdateSession(sessionId, { status: "archived" });
|
||||||
|
removeEventBus(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementEpoch(sessionId: string): number {
|
||||||
|
const record = storeGetSession(sessionId);
|
||||||
|
if (!record) throw new Error("Session not found");
|
||||||
|
const newEpoch = record.workerEpoch + 1;
|
||||||
|
storeUpdateSession(sessionId, { workerEpoch: newEpoch });
|
||||||
|
return newEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions() {
|
||||||
|
return storeListSessions().map(toResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummaryResponse(row: { id: string; title: string | null; status: string; username: string | null; updatedAt: Date }): SessionSummaryResponse {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status,
|
||||||
|
username: row.username,
|
||||||
|
updated_at: row.updatedAt.getTime() / 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessionSummaries(): SessionSummaryResponse[] {
|
||||||
|
return storeListSessions().map(toSummaryResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessionSummariesByOwnerUuid(uuid: string): SessionSummaryResponse[] {
|
||||||
|
return storeListSessionsByOwnerUuid(uuid).map(toSummaryResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessionSummariesByUsername(username: string): SessionSummaryResponse[] {
|
||||||
|
return storeListSessionsByUsername(username).map(toSummaryResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessionsByEnvironment(envId: string) {
|
||||||
|
return storeListSessionsByEnvironment(envId).map(toResponse);
|
||||||
|
}
|
||||||
93
packages/remote-control-server/src/services/transport.ts
Normal file
93
packages/remote-control-server/src/services/transport.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { getEventBus } from "../transport/event-bus";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text from various message payload formats.
|
||||||
|
* Handles:
|
||||||
|
* { content: "text" }
|
||||||
|
* { message: { role: "user", content: "text" } }
|
||||||
|
* { message: { content: [{type:"text",text:"..."}] } }
|
||||||
|
*/
|
||||||
|
function extractContent(payload: unknown): string {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return typeof payload === "string" ? payload : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Direct content field
|
||||||
|
if (typeof p.content === "string" && p.content) return p.content;
|
||||||
|
|
||||||
|
// message.content (child process format)
|
||||||
|
const msg = p.message;
|
||||||
|
if (msg && typeof msg === "object") {
|
||||||
|
const mc = (msg as Record<string, unknown>).content;
|
||||||
|
if (typeof mc === "string") return mc;
|
||||||
|
if (Array.isArray(mc)) {
|
||||||
|
return mc
|
||||||
|
.filter((b: unknown) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "text")
|
||||||
|
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize event payload into a flat structure with guaranteed `content` string.
|
||||||
|
* Preserves original payload in `raw` field and keeps tool-specific fields.
|
||||||
|
*/
|
||||||
|
export function normalizePayload(type: string, payload: unknown): Record<string, unknown> {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return { content: typeof payload === "string" ? payload : "", raw: payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
const content = extractContent(payload);
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = {
|
||||||
|
content,
|
||||||
|
raw: payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preserve tool fields
|
||||||
|
if (p.tool_name) normalized.tool_name = p.tool_name;
|
||||||
|
if (p.name) normalized.tool_name = p.name;
|
||||||
|
if (p.tool_input) normalized.tool_input = p.tool_input;
|
||||||
|
if (p.input) normalized.tool_input = p.input;
|
||||||
|
|
||||||
|
// Preserve permission fields
|
||||||
|
if (p.request_id) normalized.request_id = p.request_id;
|
||||||
|
if (p.request) normalized.request = p.request;
|
||||||
|
if (p.approved !== undefined) normalized.approved = p.approved;
|
||||||
|
if (p.updated_input) normalized.updated_input = p.updated_input;
|
||||||
|
|
||||||
|
// Preserve message field for backward compat
|
||||||
|
if (p.message) normalized.message = p.message;
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish an event to a session's bus (in-memory only) */
|
||||||
|
export function publishSessionEvent(
|
||||||
|
sessionId: string,
|
||||||
|
type: string,
|
||||||
|
payload: unknown,
|
||||||
|
direction: "inbound" | "outbound",
|
||||||
|
) {
|
||||||
|
const bus = getEventBus(sessionId);
|
||||||
|
const eventId = uuid();
|
||||||
|
|
||||||
|
const normalized = normalizePayload(type, payload);
|
||||||
|
|
||||||
|
const event = bus.publish({
|
||||||
|
id: eventId,
|
||||||
|
sessionId,
|
||||||
|
type,
|
||||||
|
payload: normalized,
|
||||||
|
direction,
|
||||||
|
});
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
98
packages/remote-control-server/src/services/work-dispatch.ts
Normal file
98
packages/remote-control-server/src/services/work-dispatch.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
storeCreateWorkItem,
|
||||||
|
storeGetWorkItem,
|
||||||
|
storeGetPendingWorkItem,
|
||||||
|
storeUpdateWorkItem,
|
||||||
|
storeListSessionsByEnvironment,
|
||||||
|
storeGetEnvironment,
|
||||||
|
} from "../store";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { getBaseUrl } from "../config";
|
||||||
|
import type { WorkResponse } from "../types/api";
|
||||||
|
|
||||||
|
/** Encode work secret as base64 JSON (no JWT — just API key as token) */
|
||||||
|
function encodeWorkSecret(): string {
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
session_ingress_token: config.apiKeys[0] || "",
|
||||||
|
api_base_url: getBaseUrl(),
|
||||||
|
sources: [] as string[],
|
||||||
|
auth: [] as string[],
|
||||||
|
use_code_sessions: false,
|
||||||
|
};
|
||||||
|
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkItem(environmentId: string, sessionId: string): Promise<string> {
|
||||||
|
// Validate environment exists and is active
|
||||||
|
const env = storeGetEnvironment(environmentId);
|
||||||
|
if (!env) {
|
||||||
|
throw new Error(`Environment ${environmentId} not found`);
|
||||||
|
}
|
||||||
|
if (env.status !== "active") {
|
||||||
|
throw new Error(`Environment ${environmentId} is not active (status: ${env.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = encodeWorkSecret();
|
||||||
|
const record = storeCreateWorkItem({ environmentId, sessionId, secret });
|
||||||
|
console.log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
|
||||||
|
return record.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-poll for work — blocks until work is available or timeout.
|
||||||
|
* Returns null when no work is available, matching the CLI bridge client protocol. */
|
||||||
|
export async function pollWork(environmentId: string, timeoutSeconds = config.pollTimeout): Promise<WorkResponse | null> {
|
||||||
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const item = storeGetPendingWorkItem(environmentId);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
storeUpdateWorkItem(item.id, { state: "dispatched" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: "work",
|
||||||
|
environment_id: environmentId,
|
||||||
|
state: "dispatched",
|
||||||
|
data: {
|
||||||
|
type: "session",
|
||||||
|
id: item.sessionId,
|
||||||
|
},
|
||||||
|
secret: item.secret,
|
||||||
|
created_at: item.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ackWork(workId: string) {
|
||||||
|
storeUpdateWorkItem(workId, { state: "acked" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopWork(workId: string) {
|
||||||
|
storeUpdateWorkItem(workId, { state: "completed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function heartbeatWork(workId: string): { lease_extended: boolean; state: string; last_heartbeat: string; ttl_seconds: number } {
|
||||||
|
storeUpdateWorkItem(workId, {} as any); // just bump updatedAt
|
||||||
|
const item = storeGetWorkItem(workId);
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
lease_extended: true,
|
||||||
|
state: item?.state ?? "acked",
|
||||||
|
last_heartbeat: now.toISOString(),
|
||||||
|
ttl_seconds: config.heartbeatInterval * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconnect: re-queue sessions associated with an environment */
|
||||||
|
export function reconnectWorkForEnvironment(envId: string) {
|
||||||
|
const activeSessions = storeListSessionsByEnvironment(envId).filter((s) => s.status === "idle");
|
||||||
|
const promises = activeSessions.map((s) => createWorkItem(envId, s.id));
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
276
packages/remote-control-server/src/store.ts
Normal file
276
packages/remote-control-server/src/store.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
|
// ---------- Types ----------
|
||||||
|
|
||||||
|
export interface UserRecord {
|
||||||
|
username: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentRecord {
|
||||||
|
id: string;
|
||||||
|
secret: string;
|
||||||
|
machineName: string | null;
|
||||||
|
directory: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
gitRepoUrl: string | null;
|
||||||
|
maxSessions: number;
|
||||||
|
workerType: string;
|
||||||
|
bridgeId: string | null;
|
||||||
|
status: string;
|
||||||
|
username: string | null;
|
||||||
|
lastPollAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecord {
|
||||||
|
id: string;
|
||||||
|
environmentId: string | null;
|
||||||
|
title: string | null;
|
||||||
|
status: string;
|
||||||
|
source: string;
|
||||||
|
permissionMode: string | null;
|
||||||
|
workerEpoch: number;
|
||||||
|
username: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkItemRecord {
|
||||||
|
id: string;
|
||||||
|
environmentId: string;
|
||||||
|
sessionId: string;
|
||||||
|
state: string;
|
||||||
|
secret: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Stores (in-memory Maps) ----------
|
||||||
|
|
||||||
|
const users = new Map<string, UserRecord>();
|
||||||
|
const tokenToUser = new Map<string, { username: string; createdAt: Date }>();
|
||||||
|
const environments = new Map<string, EnvironmentRecord>();
|
||||||
|
const sessions = new Map<string, SessionRecord>();
|
||||||
|
const workItems = new Map<string, WorkItemRecord>();
|
||||||
|
|
||||||
|
// UUID → session ownership: sessionId → Set of UUIDs
|
||||||
|
const sessionOwners = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
// ---------- User ----------
|
||||||
|
|
||||||
|
export function storeCreateUser(username: string): UserRecord {
|
||||||
|
const existing = users.get(username);
|
||||||
|
if (existing) return existing;
|
||||||
|
const record: UserRecord = { username, createdAt: new Date() };
|
||||||
|
users.set(username, record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeGetUser(username: string): UserRecord | undefined {
|
||||||
|
return users.get(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeCreateToken(username: string, token: string): void {
|
||||||
|
tokenToUser.set(token, { username, createdAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeGetUserByToken(token: string): { username: string; createdAt: Date } | undefined {
|
||||||
|
return tokenToUser.get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeDeleteToken(token: string): boolean {
|
||||||
|
return tokenToUser.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Environment ----------
|
||||||
|
|
||||||
|
export function storeCreateEnvironment(req: {
|
||||||
|
secret: string;
|
||||||
|
machineName?: string;
|
||||||
|
directory?: string;
|
||||||
|
branch?: string;
|
||||||
|
gitRepoUrl?: string;
|
||||||
|
maxSessions?: number;
|
||||||
|
workerType?: string;
|
||||||
|
bridgeId?: string;
|
||||||
|
username?: string;
|
||||||
|
}): EnvironmentRecord {
|
||||||
|
const id = `env_${uuid().replace(/-/g, "")}`;
|
||||||
|
const now = new Date();
|
||||||
|
const record: EnvironmentRecord = {
|
||||||
|
id,
|
||||||
|
secret: req.secret,
|
||||||
|
machineName: req.machineName ?? null,
|
||||||
|
directory: req.directory ?? null,
|
||||||
|
branch: req.branch ?? null,
|
||||||
|
gitRepoUrl: req.gitRepoUrl ?? null,
|
||||||
|
maxSessions: req.maxSessions ?? 1,
|
||||||
|
workerType: req.workerType ?? "claude_code",
|
||||||
|
bridgeId: req.bridgeId ?? null,
|
||||||
|
status: "active",
|
||||||
|
username: req.username ?? null,
|
||||||
|
lastPollAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
environments.set(id, record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeGetEnvironment(id: string): EnvironmentRecord | undefined {
|
||||||
|
return environments.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt">>): boolean {
|
||||||
|
const rec = environments.get(id);
|
||||||
|
if (!rec) return false;
|
||||||
|
Object.assign(rec, patch, { updatedAt: new Date() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeListActiveEnvironments(): EnvironmentRecord[] {
|
||||||
|
return [...environments.values()].filter((e) => e.status === "active");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeListActiveEnvironmentsByUsername(username: string): EnvironmentRecord[] {
|
||||||
|
return [...environments.values()].filter((e) => e.status === "active" && e.username === username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Session ----------
|
||||||
|
|
||||||
|
export function storeCreateSession(req: {
|
||||||
|
environmentId?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
source?: string;
|
||||||
|
permissionMode?: string | null;
|
||||||
|
idPrefix?: string;
|
||||||
|
username?: string | null;
|
||||||
|
}): SessionRecord {
|
||||||
|
const id = `${req.idPrefix || "session_"}${uuid().replace(/-/g, "")}`;
|
||||||
|
const now = new Date();
|
||||||
|
const record: SessionRecord = {
|
||||||
|
id,
|
||||||
|
environmentId: req.environmentId ?? null,
|
||||||
|
title: req.title ?? null,
|
||||||
|
status: "idle",
|
||||||
|
source: req.source ?? "remote-control",
|
||||||
|
permissionMode: req.permissionMode ?? null,
|
||||||
|
workerEpoch: 0,
|
||||||
|
username: req.username ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
sessions.set(id, record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeGetSession(id: string): SessionRecord | undefined {
|
||||||
|
return sessions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeUpdateSession(id: string, patch: Partial<Pick<SessionRecord, "title" | "status" | "workerEpoch" | "updatedAt">>): boolean {
|
||||||
|
const rec = sessions.get(id);
|
||||||
|
if (!rec) return false;
|
||||||
|
Object.assign(rec, patch, { updatedAt: new Date() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeListSessions(): SessionRecord[] {
|
||||||
|
return [...sessions.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeListSessionsByUsername(username: string): SessionRecord[] {
|
||||||
|
return [...sessions.values()].filter((s) => s.username === username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
|
||||||
|
return [...sessions.values()].filter((s) => s.environmentId === envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeDeleteSession(id: string): boolean {
|
||||||
|
return sessions.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Work Items ----------
|
||||||
|
|
||||||
|
// ---------- Session Ownership (UUID-based) ----------
|
||||||
|
|
||||||
|
export function storeBindSession(sessionId: string, uuid: string): void {
|
||||||
|
let owners = sessionOwners.get(sessionId);
|
||||||
|
if (!owners) {
|
||||||
|
owners = new Set();
|
||||||
|
sessionOwners.set(sessionId, owners);
|
||||||
|
}
|
||||||
|
owners.add(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeIsSessionOwner(sessionId: string, uuid: string): boolean {
|
||||||
|
const owners = sessionOwners.get(sessionId);
|
||||||
|
return owners ? owners.has(uuid) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
||||||
|
const result: SessionRecord[] = [];
|
||||||
|
for (const [sessionId, owners] of sessionOwners) {
|
||||||
|
if (owners.has(uuid)) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (session) result.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Work Items (cont.) ----------
|
||||||
|
|
||||||
|
export function storeCreateWorkItem(req: {
|
||||||
|
environmentId: string;
|
||||||
|
sessionId: string;
|
||||||
|
secret: string;
|
||||||
|
}): WorkItemRecord {
|
||||||
|
const id = `work_${uuid().replace(/-/g, "")}`;
|
||||||
|
const now = new Date();
|
||||||
|
const record: WorkItemRecord = {
|
||||||
|
id,
|
||||||
|
environmentId: req.environmentId,
|
||||||
|
sessionId: req.sessionId,
|
||||||
|
state: "pending",
|
||||||
|
secret: req.secret,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
workItems.set(id, record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeGetWorkItem(id: string): WorkItemRecord | undefined {
|
||||||
|
return workItems.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeGetPendingWorkItem(environmentId: string): WorkItemRecord | undefined {
|
||||||
|
for (const item of workItems.values()) {
|
||||||
|
if (item.environmentId === environmentId && item.state === "pending") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeUpdateWorkItem(id: string, patch: Partial<Pick<WorkItemRecord, "state" | "updatedAt">>): boolean {
|
||||||
|
const rec = workItems.get(id);
|
||||||
|
if (!rec) return false;
|
||||||
|
Object.assign(rec, patch, { updatedAt: new Date() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Reset (for tests) ----------
|
||||||
|
|
||||||
|
export function storeReset() {
|
||||||
|
users.clear();
|
||||||
|
tokenToUser.clear();
|
||||||
|
environments.clear();
|
||||||
|
sessions.clear();
|
||||||
|
workItems.clear();
|
||||||
|
sessionOwners.clear();
|
||||||
|
}
|
||||||
85
packages/remote-control-server/src/transport/event-bus.ts
Normal file
85
packages/remote-control-server/src/transport/event-bus.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export interface SessionEvent {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
type: string;
|
||||||
|
payload: unknown;
|
||||||
|
direction: "inbound" | "outbound";
|
||||||
|
seqNum: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscriber = (event: SessionEvent) => void;
|
||||||
|
|
||||||
|
export class EventBus {
|
||||||
|
private subscribers = new Set<Subscriber>();
|
||||||
|
private events: SessionEvent[] = [];
|
||||||
|
private seqNum = 0;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
subscribe(callback: Subscriber): () => void {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
return () => this.subscribers.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriberCount(): number {
|
||||||
|
return this.subscribers.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(event: Omit<SessionEvent, "seqNum" | "createdAt">): SessionEvent {
|
||||||
|
if (this.closed) throw new Error("EventBus is closed");
|
||||||
|
const full: SessionEvent = {
|
||||||
|
...event,
|
||||||
|
seqNum: ++this.seqNum,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
this.events.push(full);
|
||||||
|
console.log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
|
||||||
|
for (const cb of this.subscribers) {
|
||||||
|
try {
|
||||||
|
cb(full);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[RC-DEBUG] bus subscriber error:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastSeqNum(): number {
|
||||||
|
return this.seqNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventsSince(seqNum: number): SessionEvent[] {
|
||||||
|
const idx = this.events.findIndex((e) => e.seqNum > seqNum);
|
||||||
|
if (idx === -1) return [];
|
||||||
|
return this.events.slice(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
this.subscribers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global registry of per-session event buses */
|
||||||
|
const buses = new Map<string, EventBus>();
|
||||||
|
|
||||||
|
export function getEventBus(sessionId: string): EventBus {
|
||||||
|
let bus = buses.get(sessionId);
|
||||||
|
if (!bus) {
|
||||||
|
bus = new EventBus();
|
||||||
|
buses.set(sessionId, bus);
|
||||||
|
}
|
||||||
|
return bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEventBus(sessionId: string) {
|
||||||
|
const bus = buses.get(sessionId);
|
||||||
|
if (bus) {
|
||||||
|
bus.close();
|
||||||
|
buses.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllEventBuses(): Map<string, EventBus> {
|
||||||
|
return buses;
|
||||||
|
}
|
||||||
117
packages/remote-control-server/src/transport/sse-writer.ts
Normal file
117
packages/remote-control-server/src/transport/sse-writer.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import type { SessionEvent } from "./event-bus";
|
||||||
|
import { getEventBus } from "./event-bus";
|
||||||
|
|
||||||
|
export interface SSEWriter {
|
||||||
|
send(event: SessionEvent): void;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSSEWriter(c: Context): SSEWriter {
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
c.req.raw.signal.addEventListener("abort", () => {
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store encoder and controller for later use
|
||||||
|
(c as any)._sseEncoder = encoder;
|
||||||
|
(c as any)._sseController = controller;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
send(event: SessionEvent) {
|
||||||
|
const encoder = (c as any)._sseEncoder as TextEncoder;
|
||||||
|
const controller = (c as any)._sseController as ReadableStreamDefaultController;
|
||||||
|
if (!encoder || !controller) return;
|
||||||
|
const data = JSON.stringify({
|
||||||
|
type: event.type,
|
||||||
|
payload: event.payload,
|
||||||
|
direction: event.direction,
|
||||||
|
seqNum: event.seqNum,
|
||||||
|
});
|
||||||
|
const msg = `id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`;
|
||||||
|
controller.enqueue(encoder.encode(msg));
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
const controller = (c as any)._sseController as ReadableStreamDefaultController;
|
||||||
|
controller?.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create SSE response stream for a session */
|
||||||
|
export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||||
|
const bus = getEventBus(sessionId);
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
// Send historical events if reconnecting
|
||||||
|
if (fromSeqNum > 0) {
|
||||||
|
const missed = bus.getEventsSince(fromSeqNum);
|
||||||
|
for (const event of missed) {
|
||||||
|
const data = JSON.stringify({
|
||||||
|
type: event.type,
|
||||||
|
payload: event.payload,
|
||||||
|
direction: event.direction,
|
||||||
|
seqNum: event.seqNum,
|
||||||
|
});
|
||||||
|
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial keepalive
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
|
||||||
|
// Subscribe to new events
|
||||||
|
const unsub = bus.subscribe((event) => {
|
||||||
|
const data = JSON.stringify({
|
||||||
|
type: event.type,
|
||||||
|
payload: event.payload,
|
||||||
|
direction: event.direction,
|
||||||
|
seqNum: event.seqNum,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
console.log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
|
||||||
|
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||||
|
} catch {
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keepalive interval
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
} catch {
|
||||||
|
clearInterval(keepalive);
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
// Cleanup on abort
|
||||||
|
c.req.raw.signal.addEventListener("abort", () => {
|
||||||
|
unsub();
|
||||||
|
clearInterval(keepalive);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// already closed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
273
packages/remote-control-server/src/transport/ws-handler.ts
Normal file
273
packages/remote-control-server/src/transport/ws-handler.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import type { WSContext } from "hono/ws";
|
||||||
|
import { getEventBus } from "./event-bus";
|
||||||
|
import type { SessionEvent } from "./event-bus";
|
||||||
|
import { publishSessionEvent } from "../services/transport";
|
||||||
|
|
||||||
|
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||||
|
interface CleanupEntry {
|
||||||
|
unsub: () => void;
|
||||||
|
keepalive: ReturnType<typeof setInterval>;
|
||||||
|
ws: WSContext;
|
||||||
|
openTime: number;
|
||||||
|
}
|
||||||
|
const cleanupBySession = new Map<string, CleanupEntry>();
|
||||||
|
|
||||||
|
// Track all active WS connections for graceful shutdown
|
||||||
|
const activeConnections = new Set<WSContext>();
|
||||||
|
|
||||||
|
// Bridge sends keep_alive data frames every 120s. Send server-side keep_alive
|
||||||
|
// every 60s to ensure the connection stays alive even without user messages.
|
||||||
|
const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert internal EventBus event -> SDK message for bridge client.
|
||||||
|
*/
|
||||||
|
function toSDKMessage(event: SessionEvent): string {
|
||||||
|
const payload = event.payload as Record<string, unknown> | null;
|
||||||
|
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
|
||||||
|
if (event.type === "user" || event.type === "user_message") {
|
||||||
|
msg = {
|
||||||
|
type: "user",
|
||||||
|
uuid: event.id,
|
||||||
|
session_id: event.sessionId,
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: payload?.content ?? payload?.message ?? "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (event.type === "permission_response" || event.type === "control_response") {
|
||||||
|
const approved = !!payload?.approved;
|
||||||
|
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||||
|
if (existingResponse) {
|
||||||
|
msg = { type: "control_response", response: existingResponse };
|
||||||
|
} else {
|
||||||
|
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||||
|
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||||
|
const feedbackMessage = payload?.message as string | undefined;
|
||||||
|
msg = {
|
||||||
|
type: "control_response",
|
||||||
|
response: {
|
||||||
|
subtype: approved ? "success" : "error",
|
||||||
|
request_id: payload?.request_id ?? "",
|
||||||
|
...(approved
|
||||||
|
? {
|
||||||
|
response: {
|
||||||
|
behavior: "allow" as const,
|
||||||
|
...(updatedInput ? { updatedInput } : {}),
|
||||||
|
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
error: "Permission denied by user",
|
||||||
|
response: { behavior: "deny" as const },
|
||||||
|
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (event.type === "interrupt") {
|
||||||
|
msg = {
|
||||||
|
type: "control_request",
|
||||||
|
request_id: event.id,
|
||||||
|
request: { subtype: "interrupt" },
|
||||||
|
};
|
||||||
|
} else if (event.type === "control_request") {
|
||||||
|
msg = {
|
||||||
|
type: "control_request",
|
||||||
|
request_id: payload?.request_id ?? event.id,
|
||||||
|
request: payload?.request ?? payload,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
msg = {
|
||||||
|
type: event.type,
|
||||||
|
uuid: event.id,
|
||||||
|
session_id: event.sessionId,
|
||||||
|
message: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// NDJSON format: each message MUST end with \n so the child process's
|
||||||
|
// line-based parser can split messages correctly.
|
||||||
|
return JSON.stringify(msg) + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||||
|
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||||
|
const openTime = Date.now();
|
||||||
|
console.log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||||
|
activeConnections.add(ws);
|
||||||
|
|
||||||
|
// If there's an existing connection for this session, clean it up first
|
||||||
|
const existing = cleanupBySession.get(sessionId);
|
||||||
|
if (existing) {
|
||||||
|
console.log(`[WS] Replacing existing connection for session=${sessionId}`);
|
||||||
|
existing.unsub();
|
||||||
|
clearInterval(existing.keepalive);
|
||||||
|
activeConnections.delete(existing.ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bus = getEventBus(sessionId);
|
||||||
|
|
||||||
|
// Replay ALL events (inbound + outbound) so the bridge can reconstruct
|
||||||
|
// the full conversation history — assistant replies are inbound events.
|
||||||
|
const missed = bus.getEventsSince(0);
|
||||||
|
if (missed.length > 0) {
|
||||||
|
console.log(`[WS] Replaying ${missed.length} missed event(s)`);
|
||||||
|
for (const event of missed) {
|
||||||
|
if (ws.readyState !== 1) break;
|
||||||
|
try {
|
||||||
|
ws.send(toSDKMessage(event));
|
||||||
|
} catch {
|
||||||
|
// ignore send errors during replay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||||
|
if (ws.readyState !== 1) return;
|
||||||
|
if (event.direction !== "outbound") return;
|
||||||
|
try {
|
||||||
|
const sdkMsg = toSDKMessage(event);
|
||||||
|
console.log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
|
||||||
|
ws.send(sdkMsg);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[RC-DEBUG] [WS] send error:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
if (ws.readyState !== 1) {
|
||||||
|
clearInterval(keepalive);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.send('{"type":"keep_alive"}\n');
|
||||||
|
} catch {
|
||||||
|
clearInterval(keepalive);
|
||||||
|
}
|
||||||
|
}, SERVER_KEEPALIVE_INTERVAL_MS);
|
||||||
|
|
||||||
|
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from onMessage — bridge sends newline-delimited JSON.
|
||||||
|
*/
|
||||||
|
export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: string) {
|
||||||
|
const lines = data.split("\n").filter((l) => l.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
ingestBridgeMessage(sessionId, JSON.parse(line));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WS] parse error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from onClose — unsubscribes from event bus */
|
||||||
|
export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: number, reason?: string) {
|
||||||
|
activeConnections.delete(ws);
|
||||||
|
|
||||||
|
const entry = cleanupBySession.get(sessionId);
|
||||||
|
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1;
|
||||||
|
|
||||||
|
console.log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
entry.unsub();
|
||||||
|
clearInterval(entry.keepalive);
|
||||||
|
cleanupBySession.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive event type from a child process message that may lack an explicit
|
||||||
|
* `type` field. The child's --print --output-format stream-json mode sends:
|
||||||
|
* {"message":{"role":"user",...},"uuid":"..."} → type "user"
|
||||||
|
* {"message":{"role":"assistant",...},"uuid":"..."} → type "assistant"
|
||||||
|
* {"subtype":"success","uuid":"...","result":"..."} → type "result"
|
||||||
|
*/
|
||||||
|
function deriveEventType(msg: Record<string, unknown>): string {
|
||||||
|
if (msg.type && typeof msg.type === "string") return msg.type;
|
||||||
|
|
||||||
|
// Child process stream-json format: message.role determines type
|
||||||
|
const message = msg.message as Record<string, unknown> | undefined;
|
||||||
|
if (message && typeof message.role === "string") {
|
||||||
|
return message.role; // "user", "assistant", "system"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result message
|
||||||
|
if (msg.subtype || msg.result !== undefined) return "result";
|
||||||
|
|
||||||
|
// System/init message
|
||||||
|
if (msg.session_id) return "system";
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single SDK message from bridge -> publish to EventBus as inbound.
|
||||||
|
*/
|
||||||
|
export function ingestBridgeMessage(sessionId: string, msg: Record<string, unknown>) {
|
||||||
|
if (msg.type === "keep_alive") return;
|
||||||
|
|
||||||
|
const eventType = deriveEventType(msg);
|
||||||
|
|
||||||
|
console.log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
|
||||||
|
if (eventType === "assistant" || eventType === "partial_assistant") {
|
||||||
|
const message = msg.message as Record<string, unknown> | undefined;
|
||||||
|
const content = message?.content;
|
||||||
|
// Extract text from content blocks for simple display
|
||||||
|
let text = "";
|
||||||
|
if (typeof content === "string") {
|
||||||
|
text = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
text = content
|
||||||
|
.filter((b: unknown) => b && typeof b === "object" && "type" in (b as Record<string, unknown>) && (b as Record<string, unknown>).type === "text")
|
||||||
|
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
payload = { message: msg.message, uuid: msg.uuid, content: text };
|
||||||
|
} else if (eventType === "user" || eventType === "system") {
|
||||||
|
payload = { message: msg.message, uuid: msg.uuid };
|
||||||
|
} else if (eventType === "control_request") {
|
||||||
|
payload = { request_id: msg.request_id, request: msg.request };
|
||||||
|
} else if (eventType === "control_response") {
|
||||||
|
payload = { response: msg.response };
|
||||||
|
} else if (eventType === "result" || eventType === "result_success") {
|
||||||
|
payload = { subtype: msg.subtype, uuid: msg.uuid, result: msg.result };
|
||||||
|
} else {
|
||||||
|
payload = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSessionEvent(sessionId, eventType, payload, "inbound");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully close all active WebSocket connections.
|
||||||
|
*/
|
||||||
|
export function closeAllConnections(): void {
|
||||||
|
const count = activeConnections.size;
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
console.log(`[WS] Gracefully closing ${count} active connection(s)...`);
|
||||||
|
for (const [sessionId, entry] of cleanupBySession) {
|
||||||
|
try {
|
||||||
|
entry.unsub();
|
||||||
|
clearInterval(entry.keepalive);
|
||||||
|
if (entry.ws.readyState === 1) {
|
||||||
|
entry.ws.close(1001, "server_shutdown");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors during shutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupBySession.clear();
|
||||||
|
activeConnections.clear();
|
||||||
|
console.log("[WS] All connections closed");
|
||||||
|
}
|
||||||
147
packages/remote-control-server/src/types/api.ts
Normal file
147
packages/remote-control-server/src/types/api.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/** API 请求/响应类型定义 */
|
||||||
|
|
||||||
|
// Hono context variable types
|
||||||
|
declare module "hono" {
|
||||||
|
interface ContextVariableMap {
|
||||||
|
username?: string;
|
||||||
|
uuid?: string;
|
||||||
|
jwtPayload?: { session_id: string; role: string; iat: number; exp: number };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Environment ---
|
||||||
|
|
||||||
|
export interface RegisterEnvironmentRequest {
|
||||||
|
machine_name?: string;
|
||||||
|
directory?: string;
|
||||||
|
branch?: string;
|
||||||
|
git_repo_url?: string;
|
||||||
|
max_sessions?: number;
|
||||||
|
worker_type?: string;
|
||||||
|
bridge_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterEnvironmentResponse {
|
||||||
|
id: string;
|
||||||
|
secret: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkResponse {
|
||||||
|
id: string;
|
||||||
|
type: "work";
|
||||||
|
environment_id: string;
|
||||||
|
state: string;
|
||||||
|
data: {
|
||||||
|
type: "session" | "healthcheck";
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
secret: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkSecretPayload {
|
||||||
|
version: number;
|
||||||
|
session_ingress_token: string;
|
||||||
|
api_base_url: string;
|
||||||
|
sources: string[];
|
||||||
|
auth: string[];
|
||||||
|
use_code_sessions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session ---
|
||||||
|
|
||||||
|
export interface CreateSessionRequest {
|
||||||
|
environment_id?: string | null;
|
||||||
|
title?: string;
|
||||||
|
events?: unknown[];
|
||||||
|
source?: string;
|
||||||
|
permission_mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResponse {
|
||||||
|
id: string;
|
||||||
|
environment_id: string | null;
|
||||||
|
title: string | null;
|
||||||
|
status: string;
|
||||||
|
source: string;
|
||||||
|
permission_mode: string | null;
|
||||||
|
worker_epoch: number;
|
||||||
|
username: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v2 Code Sessions ---
|
||||||
|
|
||||||
|
export interface CreateCodeSessionRequest {
|
||||||
|
title?: string;
|
||||||
|
source?: string;
|
||||||
|
permission_mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeResponse {
|
||||||
|
api_base_url: string;
|
||||||
|
worker_epoch: number;
|
||||||
|
worker_jwt: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Web ---
|
||||||
|
|
||||||
|
export interface EnvironmentResponse {
|
||||||
|
id: string;
|
||||||
|
machine_name: string | null;
|
||||||
|
directory: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
status: string;
|
||||||
|
username: string | null;
|
||||||
|
last_poll_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSummaryResponse {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
status: string;
|
||||||
|
username: string | null;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Web Auth ---
|
||||||
|
|
||||||
|
export interface WebLoginRequest {
|
||||||
|
apiKey: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebLoginResponse {
|
||||||
|
token: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebControlRequest {
|
||||||
|
type: string;
|
||||||
|
content?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error ---
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event ---
|
||||||
|
|
||||||
|
export interface SessionEventPayload {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
type: string;
|
||||||
|
payload: unknown;
|
||||||
|
direction: "inbound" | "outbound";
|
||||||
|
seq_num: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
81
packages/remote-control-server/src/types/messages.ts
Normal file
81
packages/remote-control-server/src/types/messages.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/** SDK 消息类型 — 与 CC CLI bridge 模块兼容 */
|
||||||
|
export interface SDKMessage {
|
||||||
|
type: string;
|
||||||
|
content?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMessage extends SDKMessage {
|
||||||
|
type: "user";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantMessage extends SDKMessage {
|
||||||
|
type: "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionRequest extends SDKMessage {
|
||||||
|
type: "permission_request";
|
||||||
|
tool_name: string;
|
||||||
|
tool_input: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionResponse extends SDKMessage {
|
||||||
|
type: "permission_response";
|
||||||
|
approved: boolean;
|
||||||
|
request_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlRequest extends SDKMessage {
|
||||||
|
type: "control_request";
|
||||||
|
action: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionEventType =
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "permission_request"
|
||||||
|
| "permission_response"
|
||||||
|
| "control_request"
|
||||||
|
| "tool_use"
|
||||||
|
| "tool_result"
|
||||||
|
| "status"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
// --- Normalized Event Payloads (SSE contract) ---
|
||||||
|
|
||||||
|
export interface NormalizedEventPayload {
|
||||||
|
content: string;
|
||||||
|
raw?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserEventPayload extends NormalizedEventPayload {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantEventPayload extends NormalizedEventPayload {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolUseEventPayload extends NormalizedEventPayload {
|
||||||
|
content: string;
|
||||||
|
tool_name: string;
|
||||||
|
tool_input: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultEventPayload extends NormalizedEventPayload {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionEventPayload extends NormalizedEventPayload {
|
||||||
|
content: string;
|
||||||
|
request_id: string;
|
||||||
|
request: {
|
||||||
|
subtype: string;
|
||||||
|
tool_name: string;
|
||||||
|
tool_input: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
17
packages/remote-control-server/tsconfig.json
Normal file
17
packages/remote-control-server/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"declaration": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "web"]
|
||||||
|
}
|
||||||
89
packages/remote-control-server/web/api.js
Normal file
89
packages/remote-control-server/web/api.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Remote Control — API Client (UUID-based auth)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = ""; // same origin
|
||||||
|
|
||||||
|
function generateUuid() {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback for non-secure contexts (HTTP without localhost)
|
||||||
|
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||||||
|
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUuid() {
|
||||||
|
let uuid = localStorage.getItem("rcs_uuid");
|
||||||
|
if (!uuid) {
|
||||||
|
uuid = generateUuid();
|
||||||
|
localStorage.setItem("rcs_uuid", uuid);
|
||||||
|
}
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUuid(uuid) {
|
||||||
|
localStorage.setItem("rcs_uuid", uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
const uuid = getUuid();
|
||||||
|
|
||||||
|
// Append uuid as query param for auth
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||||
|
|
||||||
|
const opts = { method, headers };
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = data.error || { type: "unknown", message: res.statusText };
|
||||||
|
throw new Error(err.message || err.type);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiBind(sessionId) {
|
||||||
|
return api("POST", "/web/bind", { sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiFetchSessions() {
|
||||||
|
return api("GET", "/web/sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiFetchAllSessions() {
|
||||||
|
return api("GET", "/web/sessions/all");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiFetchSession(id) {
|
||||||
|
return api("GET", `/web/sessions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiFetchSessionHistory(id) {
|
||||||
|
return api("GET", `/web/sessions/${id}/history`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiFetchEnvironments() {
|
||||||
|
return api("GET", "/web/environments");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiSendEvent(sessionId, body) {
|
||||||
|
return api("POST", `/web/sessions/${sessionId}/events`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiSendControl(sessionId, body) {
|
||||||
|
return api("POST", `/web/sessions/${sessionId}/control`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiInterrupt(sessionId) {
|
||||||
|
return api("POST", `/web/sessions/${sessionId}/interrupt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiCreateSession(body) {
|
||||||
|
return api("POST", "/web/sessions", body);
|
||||||
|
}
|
||||||
618
packages/remote-control-server/web/app.js
Normal file
618
packages/remote-control-server/web/app.js
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
/**
|
||||||
|
* Remote Control — Main App (Router + Orchestrator)
|
||||||
|
* UUID-based auth — no login required
|
||||||
|
*/
|
||||||
|
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
|
||||||
|
import { connectSSE, disconnectSSE } from "./sse.js";
|
||||||
|
import { appendEvent, renderPermissionRequest, showLoading, isLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
|
||||||
|
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
|
||||||
|
import { esc, formatTime, statusClass } from "./utils.js";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// State
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let currentSessionId = null;
|
||||||
|
let dashboardInterval = null;
|
||||||
|
let cachedEnvs = [];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Router
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function getPathSessionId() {
|
||||||
|
const match = window.location.pathname.match(/^\/code\/([^/]+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrlParam(name) {
|
||||||
|
return new URLSearchParams(window.location.search).get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPage(name) {
|
||||||
|
const pages = ["dashboard", "session"];
|
||||||
|
for (const p of pages) {
|
||||||
|
const el = document.getElementById(`page-${p}`);
|
||||||
|
if (el) el.classList.toggle("hidden", p !== name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(path) {
|
||||||
|
history.pushState(null, "", path);
|
||||||
|
handleRoute();
|
||||||
|
}
|
||||||
|
window.navigate = navigate;
|
||||||
|
|
||||||
|
async function handleRoute() {
|
||||||
|
// Ensure we have a UUID
|
||||||
|
getUuid();
|
||||||
|
|
||||||
|
// Check for UUID import from QR scan (?uuid=xxx)
|
||||||
|
const importUuid = getUrlParam("uuid");
|
||||||
|
if (importUuid) {
|
||||||
|
setUuid(importUuid);
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.delete("uuid");
|
||||||
|
history.replaceState(null, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for CLI session bind (?sid=xxx)
|
||||||
|
const sid = getUrlParam("sid");
|
||||||
|
if (sid) {
|
||||||
|
try {
|
||||||
|
await apiBind(sid);
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.delete("sid");
|
||||||
|
history.replaceState(null, "", `/code/${sid}`);
|
||||||
|
showPage("session");
|
||||||
|
stopDashboardRefresh();
|
||||||
|
renderSessionDetail(sid);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to bind session:", err);
|
||||||
|
alert("Session not found or bind failed: " + err.message);
|
||||||
|
history.replaceState(null, "", "/code/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path-based routing: /code/session_xxx → session detail
|
||||||
|
const pathSessionId = getPathSessionId();
|
||||||
|
if (pathSessionId) {
|
||||||
|
try { await apiBind(pathSessionId); } catch { /* may already be bound */ }
|
||||||
|
showPage("session");
|
||||||
|
stopDashboardRefresh();
|
||||||
|
renderSessionDetail(pathSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: /code → dashboard
|
||||||
|
showPage("dashboard");
|
||||||
|
disconnectSSE();
|
||||||
|
renderDashboard();
|
||||||
|
startDashboardRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("popstate", handleRoute);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Dashboard
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function renderDashboard() {
|
||||||
|
try {
|
||||||
|
const [sessions, envs] = await Promise.all([apiFetchAllSessions(), apiFetchEnvironments()]);
|
||||||
|
cachedEnvs = envs || [];
|
||||||
|
renderEnvironmentList(cachedEnvs);
|
||||||
|
renderSessionList(sessions);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Dashboard render error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnvironmentList(envs) {
|
||||||
|
const container = document.getElementById("env-list");
|
||||||
|
if (!envs || envs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No active environments</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = envs.map((e) => `
|
||||||
|
<div class="env-card">
|
||||||
|
<div>
|
||||||
|
<div class="env-name">${esc(e.machine_name || e.id)}</div>
|
||||||
|
<div class="env-dir">${esc(e.directory || "")}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<span class="status-badge status-${statusClass(e.status)}">${esc(e.status)}</span>
|
||||||
|
<div class="env-branch">${e.branch ? esc(e.branch) : ""}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionList(sessions) {
|
||||||
|
const container = document.getElementById("session-list");
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No sessions</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessions.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
||||||
|
container.innerHTML = sessions.map((s) => `
|
||||||
|
<div class="session-card" onclick="navigate('/code/${esc(s.id)}')">
|
||||||
|
<div>
|
||||||
|
<div class="session-title-text">${esc(s.title || s.id)}</div>
|
||||||
|
<div class="session-id-text">${esc(s.id)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-${statusClass(s.status)}">${esc(s.status)}</span>
|
||||||
|
<span class="meta-item">${formatTime(s.created_at || s.updated_at)}</span>
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDashboardRefresh() {
|
||||||
|
stopDashboardRefresh();
|
||||||
|
dashboardInterval = setInterval(renderDashboard, 10000);
|
||||||
|
}
|
||||||
|
function stopDashboardRefresh() {
|
||||||
|
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Session Detail
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function renderSessionDetail(id) {
|
||||||
|
currentSessionId = id;
|
||||||
|
|
||||||
|
// Reset task state for new session and init panel
|
||||||
|
resetTaskState();
|
||||||
|
const taskPanelEl = document.getElementById("task-panel");
|
||||||
|
if (taskPanelEl) initTaskPanel(taskPanelEl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await apiFetchSession(id);
|
||||||
|
document.getElementById("session-title").textContent = session.title || session.id;
|
||||||
|
document.getElementById("session-id").textContent = session.id;
|
||||||
|
document.getElementById("session-env").textContent = session.environment_id || "";
|
||||||
|
document.getElementById("session-time").textContent = formatTime(session.created_at);
|
||||||
|
const badge = document.getElementById("session-status");
|
||||||
|
badge.textContent = session.status;
|
||||||
|
badge.className = `status-badge status-${statusClass(session.status)}`;
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to load session: " + err.message);
|
||||||
|
navigate("/code/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("event-stream").innerHTML = "";
|
||||||
|
document.getElementById("permission-area").innerHTML = "";
|
||||||
|
document.getElementById("permission-area").classList.add("hidden");
|
||||||
|
|
||||||
|
// Load historical events before connecting to live stream
|
||||||
|
resetReplayState();
|
||||||
|
let lastSeqNum = 0;
|
||||||
|
try {
|
||||||
|
const { events } = await apiFetchSessionHistory(id);
|
||||||
|
if (events && events.length > 0) {
|
||||||
|
for (const event of events) {
|
||||||
|
appendEvent(event, { replay: true });
|
||||||
|
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load session history:", err);
|
||||||
|
}
|
||||||
|
// Re-render any still-unresolved permission prompts from history
|
||||||
|
renderReplayPendingRequests();
|
||||||
|
|
||||||
|
connectSSE(id, appendEvent, lastSeqNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Control Bar
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupControlBar() {
|
||||||
|
const input = document.getElementById("msg-input");
|
||||||
|
const actionBtn = document.getElementById("action-btn");
|
||||||
|
const iconSend = document.getElementById("action-icon-send");
|
||||||
|
const iconStop = document.getElementById("action-icon-stop");
|
||||||
|
|
||||||
|
function setBtnState(loading) {
|
||||||
|
actionBtn.classList.toggle("loading", loading);
|
||||||
|
actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send");
|
||||||
|
iconSend.classList.toggle("hidden", loading);
|
||||||
|
iconStop.classList.toggle("hidden", !loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__updateActionBtn = setBtnState;
|
||||||
|
|
||||||
|
actionBtn.addEventListener("click", () => {
|
||||||
|
if (isLoading()) {
|
||||||
|
doInterrupt();
|
||||||
|
} else {
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doInterrupt() {
|
||||||
|
if (!currentSessionId) return;
|
||||||
|
const btn = document.getElementById("action-btn");
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiInterrupt(currentSessionId);
|
||||||
|
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
|
||||||
|
} catch (err) {
|
||||||
|
alert("Interrupt failed: " + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const input = document.getElementById("msg-input");
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text || !currentSessionId) return;
|
||||||
|
input.value = "";
|
||||||
|
try {
|
||||||
|
await apiSendEvent(currentSessionId, { type: "user", content: text });
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to send: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Permission Actions (exposed globally for onclick)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window._approvePerm = async function (requestId, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiSendControl(currentSessionId, { type: "permission_response", approved: true, request_id: requestId });
|
||||||
|
removePermissionPrompt(btn);
|
||||||
|
showLoading();
|
||||||
|
} catch (err) { alert("Failed to approve: " + err.message); btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
window._rejectPerm = async function (requestId, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiSendControl(currentSessionId, { type: "permission_response", approved: false, request_id: requestId });
|
||||||
|
removePermissionPrompt(btn);
|
||||||
|
} catch (err) { alert("Failed to reject: " + err.message); btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AskUserQuestion interactions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window._selectOption = function (btn, qIdx, oIdx, multiSelect) {
|
||||||
|
const panel = btn.closest(".ask-panel");
|
||||||
|
if (!panel) return;
|
||||||
|
if (!panel._answers) panel._answers = {};
|
||||||
|
|
||||||
|
if (multiSelect) {
|
||||||
|
// Toggle multi-select
|
||||||
|
btn.classList.toggle("selected");
|
||||||
|
if (!panel._answers[qIdx]) panel._answers[qIdx] = [];
|
||||||
|
const arr = panel._answers[qIdx];
|
||||||
|
const pos = arr.indexOf(oIdx);
|
||||||
|
if (pos >= 0) arr.splice(pos, 1);
|
||||||
|
else arr.push(oIdx);
|
||||||
|
} else {
|
||||||
|
// Single select — deselect siblings
|
||||||
|
const siblings = panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`);
|
||||||
|
siblings.forEach((s) => s.classList.remove("selected"));
|
||||||
|
btn.classList.add("selected");
|
||||||
|
panel._answers[qIdx] = oIdx;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window._submitOther = function (btn, qIdx) {
|
||||||
|
const row = btn.closest(".ask-other-row");
|
||||||
|
const input = row.querySelector(".ask-other-input");
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const panel = btn.closest(".ask-panel");
|
||||||
|
if (!panel) return;
|
||||||
|
if (!panel._answers) panel._answers = {};
|
||||||
|
panel._answers[qIdx] = text;
|
||||||
|
// Deselect any option buttons
|
||||||
|
panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`).forEach((s) => s.classList.remove("selected"));
|
||||||
|
input.value = "";
|
||||||
|
btn.textContent = "Sent!";
|
||||||
|
setTimeout(() => { btn.textContent = "Send"; }, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
window._switchAskTab = function (btn, idx) {
|
||||||
|
const panel = btn.closest(".ask-panel");
|
||||||
|
if (!panel) return;
|
||||||
|
panel.querySelectorAll(".ask-tab").forEach((t) => t.classList.remove("active"));
|
||||||
|
panel.querySelectorAll(".ask-tab-page").forEach((p) => p.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
const page = panel.querySelector(`.ask-tab-page[data-tab="${idx}"]`);
|
||||||
|
if (page) page.classList.add("active");
|
||||||
|
const total = panel.querySelectorAll(".ask-tab").length;
|
||||||
|
const prog = panel.querySelector(".ask-progress");
|
||||||
|
if (prog) prog.textContent = `${idx + 1} / ${total}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
window._submitAnswers = async function (requestId, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
const panel = btn.closest(".ask-panel");
|
||||||
|
const rawAnswers = panel?._answers || {};
|
||||||
|
const questions = panel?._questions || [];
|
||||||
|
|
||||||
|
// Build updatedInput: merge original input with user's answers
|
||||||
|
const answers = {};
|
||||||
|
for (const [qIdx, val] of Object.entries(rawAnswers)) {
|
||||||
|
const q = questions[parseInt(qIdx)];
|
||||||
|
if (!q) continue;
|
||||||
|
if (typeof val === "string") {
|
||||||
|
// "Other" free-text answer
|
||||||
|
answers[qIdx] = val;
|
||||||
|
} else if (typeof val === "number") {
|
||||||
|
// Selected option index — use label text
|
||||||
|
const opt = q.options?.[val];
|
||||||
|
answers[qIdx] = opt?.label || String(val);
|
||||||
|
} else if (Array.isArray(val)) {
|
||||||
|
// Multi-select — join labels
|
||||||
|
answers[qIdx] = val.map((i) => q.options?.[i]?.label || String(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiSendControl(currentSessionId, {
|
||||||
|
type: "permission_response",
|
||||||
|
approved: true,
|
||||||
|
request_id: requestId,
|
||||||
|
updated_input: { questions, answers },
|
||||||
|
});
|
||||||
|
removePermissionPrompt(btn);
|
||||||
|
showLoading();
|
||||||
|
} catch (err) { alert("Failed to submit: " + err.message); btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
function removePermissionPrompt(btn) {
|
||||||
|
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
|
||||||
|
if (prompt) prompt.remove();
|
||||||
|
const area = document.getElementById("permission-area");
|
||||||
|
if (area && area.children.length === 0) area.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ExitPlanMode interactions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window._selectPlanOption = function (btn, value) {
|
||||||
|
const panel = btn.closest(".plan-panel");
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
// Deselect all siblings
|
||||||
|
panel.querySelectorAll(".plan-option").forEach((o) => o.classList.remove("selected"));
|
||||||
|
btn.classList.add("selected");
|
||||||
|
panel._selectedValue = value;
|
||||||
|
|
||||||
|
// Show/hide feedback textarea
|
||||||
|
const feedbackArea = panel.querySelector(".plan-feedback-area");
|
||||||
|
if (feedbackArea) {
|
||||||
|
feedbackArea.classList.toggle("visible", value === "no");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window._submitPlanResponse = async function (requestId, btn) {
|
||||||
|
const panel = btn.closest(".plan-panel");
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const selectedValue = panel._selectedValue;
|
||||||
|
if (!selectedValue) {
|
||||||
|
alert("Please select an option first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedValue === "no") {
|
||||||
|
// Rejection with optional feedback
|
||||||
|
const feedbackInput = panel.querySelector(".plan-feedback-input");
|
||||||
|
const feedback = feedbackInput ? feedbackInput.value.trim() : "";
|
||||||
|
await apiSendControl(currentSessionId, {
|
||||||
|
type: "permission_response",
|
||||||
|
approved: false,
|
||||||
|
request_id: requestId,
|
||||||
|
...(feedback ? { message: feedback } : {}),
|
||||||
|
});
|
||||||
|
removePermissionPrompt(btn);
|
||||||
|
} else {
|
||||||
|
// Approval with permission mode
|
||||||
|
const modeMap = {
|
||||||
|
"yes-accept-edits": "acceptEdits",
|
||||||
|
"yes-default": "default",
|
||||||
|
};
|
||||||
|
const mode = modeMap[selectedValue] || "default";
|
||||||
|
const planContent = panel._planContent || "";
|
||||||
|
|
||||||
|
await apiSendControl(currentSessionId, {
|
||||||
|
type: "permission_response",
|
||||||
|
approved: true,
|
||||||
|
request_id: requestId,
|
||||||
|
...(planContent ? { updated_input: { plan: planContent } } : {}),
|
||||||
|
updated_permissions: [
|
||||||
|
{ type: "setMode", mode, destination: "session" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
removePermissionPrompt(btn);
|
||||||
|
showLoading();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to submit: " + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// New Session Dialog
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupNewSessionDialog() {
|
||||||
|
const btn = document.getElementById("new-session-btn");
|
||||||
|
const dialog = document.getElementById("new-session-dialog");
|
||||||
|
const cancelBtn = document.getElementById("ns-cancel");
|
||||||
|
const createBtn = document.getElementById("ns-create");
|
||||||
|
const errorEl = document.getElementById("ns-error");
|
||||||
|
const titleInput = document.getElementById("ns-title");
|
||||||
|
const envSelect = document.getElementById("ns-env");
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
envSelect.innerHTML = '<option value="">-- None --</option>';
|
||||||
|
for (const e of cachedEnvs) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = e.id;
|
||||||
|
opt.textContent = `${e.machine_name || e.id} (${e.branch || "no branch"})`;
|
||||||
|
envSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
errorEl.classList.add("hidden");
|
||||||
|
titleInput.value = "";
|
||||||
|
dialog.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener("click", () => dialog.classList.add("hidden"));
|
||||||
|
|
||||||
|
createBtn.addEventListener("click", async () => {
|
||||||
|
createBtn.disabled = true;
|
||||||
|
errorEl.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
if (titleInput.value.trim()) body.title = titleInput.value.trim();
|
||||||
|
if (envSelect.value) body.environment_id = envSelect.value;
|
||||||
|
const session = await apiCreateSession(body);
|
||||||
|
dialog.classList.add("hidden");
|
||||||
|
navigate(`/code/${session.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message || "Failed to create session";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
} finally {
|
||||||
|
createBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Identity Panel (QR code display + scan)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupIdentityPanel() {
|
||||||
|
const btn = document.getElementById("nav-identity");
|
||||||
|
const panel = document.getElementById("identity-panel");
|
||||||
|
const closeBtn = panel.querySelector(".panel-close");
|
||||||
|
const uuidDisplay = document.getElementById("uuid-display");
|
||||||
|
const qrContainer = document.getElementById("qr-display");
|
||||||
|
|
||||||
|
// Show panel and generate QR code
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const uuid = getUuid();
|
||||||
|
uuidDisplay.textContent = uuid;
|
||||||
|
const qrUrl = `${window.location.origin}/code?uuid=${encodeURIComponent(uuid)}`;
|
||||||
|
qrContainer.innerHTML = "";
|
||||||
|
if (typeof QRCode !== "undefined") {
|
||||||
|
new QRCode(qrContainer, { text: qrUrl, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.M });
|
||||||
|
// qrcodejs generates both canvas and img, hide the duplicate img
|
||||||
|
const img = qrContainer.querySelector("img");
|
||||||
|
if (img) img.remove()
|
||||||
|
}
|
||||||
|
panel.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener("click", () => panel.classList.add("hidden"));
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
panel.addEventListener("click", (e) => {
|
||||||
|
if (e.target === panel) panel.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy UUID to clipboard
|
||||||
|
document.getElementById("uuid-copy-btn").addEventListener("click", () => {
|
||||||
|
const uuid = getUuid();
|
||||||
|
navigator.clipboard.writeText(uuid).then(() => {
|
||||||
|
const btn = document.getElementById("uuid-copy-btn");
|
||||||
|
btn.textContent = "Copied!";
|
||||||
|
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scan QR from uploaded image
|
||||||
|
document.getElementById("qr-scan-btn").addEventListener("click", () => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/*";
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
if (typeof jsQR !== "undefined") {
|
||||||
|
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||||
|
if (code && code.data) {
|
||||||
|
try {
|
||||||
|
const url = new URL(code.data);
|
||||||
|
const importedUuid = url.searchParams.get("uuid");
|
||||||
|
if (importedUuid) {
|
||||||
|
setUuid(importedUuid);
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
navigate("/code/");
|
||||||
|
renderDashboard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a valid URL — try using raw data as UUID
|
||||||
|
if (code.data.length >= 32) {
|
||||||
|
setUuid(code.data);
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
navigate("/code/");
|
||||||
|
renderDashboard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert("No valid UUID found in QR code");
|
||||||
|
} else {
|
||||||
|
alert("No QR code found in image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Task Panel Toggle
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupTaskPanelToggle() {
|
||||||
|
window.__toggleTaskPanel = toggleTaskPanel;
|
||||||
|
const toggleBtn = document.getElementById("task-panel-toggle");
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.addEventListener("click", () => toggleTaskPanel());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Init
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
setupControlBar();
|
||||||
|
setupNewSessionDialog();
|
||||||
|
setupIdentityPanel();
|
||||||
|
setupTaskPanelToggle();
|
||||||
|
handleRoute();
|
||||||
|
});
|
||||||
116
packages/remote-control-server/web/base.css
Normal file
116
packages/remote-control-server/web/base.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/* === CSS Variables — Anthropic Design System === */
|
||||||
|
:root {
|
||||||
|
/* Core palette — warm terracotta system */
|
||||||
|
--bg-primary: #FAF9F6;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--bg-dark: #1A1612;
|
||||||
|
--bg-dark-hover: #2A2520;
|
||||||
|
--bg-dark-elevated: #332E28;
|
||||||
|
--bg-input: #F2EFEA;
|
||||||
|
--bg-input-focus: #FFFFFF;
|
||||||
|
--bg-user-msg: #D97757;
|
||||||
|
--bg-assistant-msg: #FFFFFF;
|
||||||
|
--bg-tool-card: #F5F3EF;
|
||||||
|
--bg-permission: #FFF9F0;
|
||||||
|
--text-primary: #1A1612;
|
||||||
|
--text-secondary: #6B6560;
|
||||||
|
--text-light: #FFFFFF;
|
||||||
|
--text-muted: #9B9590;
|
||||||
|
--text-inverse: #FAF9F6;
|
||||||
|
--border: #E8E4DF;
|
||||||
|
--border-light: #F0ECE7;
|
||||||
|
--border-focus: #D97757;
|
||||||
|
--accent: #D97757;
|
||||||
|
--accent-hover: #C4684A;
|
||||||
|
--accent-subtle: #FDF0EB;
|
||||||
|
--green: #3B8A6A;
|
||||||
|
--green-bg: #E8F5EE;
|
||||||
|
--yellow: #C49A2C;
|
||||||
|
--yellow-bg: #FFF8E8;
|
||||||
|
--orange: #D07A3A;
|
||||||
|
--orange-bg: #FFF3E8;
|
||||||
|
--red: #C44040;
|
||||||
|
--red-bg: #FDE8E8;
|
||||||
|
--blue: #4A7FC4;
|
||||||
|
--blue-bg: #E8F0FD;
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--radius-xs: 6px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(26, 22, 18, 0.04);
|
||||||
|
--shadow: 0 1px 3px rgba(26, 22, 18, 0.06), 0 2px 8px rgba(26, 22, 18, 0.04);
|
||||||
|
--shadow-md: 0 4px 16px rgba(26, 22, 18, 0.08), 0 1px 4px rgba(26, 22, 18, 0.04);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(26, 22, 18, 0.10), 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
--font-display: "Bricolage Grotesque", system-ui, -apple-system, sans-serif;
|
||||||
|
--font-sans: "Figtree", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-mono: "Fira Code", "SF Mono", Menlo, monospace;
|
||||||
|
--max-width: 880px;
|
||||||
|
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Reset === */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html {
|
||||||
|
font-size: 15px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle warm ambient light */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 20% 50%, rgba(217, 119, 87, 0.03) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, rgba(217, 119, 87, 0.02) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 50% 80%, rgba(59, 138, 106, 0.02) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > * { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
button { cursor: pointer; font-family: inherit; }
|
||||||
|
input, select, textarea { font-family: inherit; }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* === Selection === */
|
||||||
|
::selection {
|
||||||
|
background: rgba(217, 119, 87, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Focus Ring === */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Scrollbar === */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||||
233
packages/remote-control-server/web/components.css
Normal file
233
packages/remote-control-server/web/components.css
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/* === Navbar — Anthropic === */
|
||||||
|
nav {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.nav-logo:hover { opacity: 0.7; text-decoration: none; }
|
||||||
|
.nav-logo svg { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.nav-links { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-input);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text { background: none; border: none; color: inherit; }
|
||||||
|
|
||||||
|
/* === Buttons — Anthropic === */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-light);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 11px 22px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
box-shadow: 0 1px 2px rgba(217, 119, 87, 0.2);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-primary:active { transform: translateY(0); box-shadow: none; }
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--red);
|
||||||
|
color: var(--text-light);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 11px 18px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #B33838; transform: translateY(-1px); }
|
||||||
|
.btn-danger:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.btn-sm { padding: 8px 16px; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-approve {
|
||||||
|
background: var(--green);
|
||||||
|
color: var(--text-light);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 9px 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.btn-approve:hover { background: #347A5E; transform: translateY(-1px); }
|
||||||
|
.btn-approve:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.btn-reject {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--red);
|
||||||
|
border: 1.5px solid var(--red);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 9px 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.btn-reject:hover { background: var(--red-bg); transform: translateY(-1px); }
|
||||||
|
.btn-reject:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* === Status Badge — Anthropic === */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active, .status-running { background: var(--green-bg); color: var(--green); }
|
||||||
|
.status-idle { background: var(--yellow-bg); color: var(--yellow); }
|
||||||
|
.status-requires_action { background: var(--orange-bg); color: var(--orange); }
|
||||||
|
.status-archived { background: #F0ECE7; color: var(--text-secondary); }
|
||||||
|
.status-error { background: var(--red-bg); color: var(--red); }
|
||||||
|
.status-default { background: #F0ECE7; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* === Dialog — Anthropic === */
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(26, 22, 18, 0.3);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
animation: fadeIn var(--transition-fast) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
animation: slideUp var(--transition-base) ease-out;
|
||||||
|
}
|
||||||
|
.dialog-card h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.dialog-card label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.dialog-card input,
|
||||||
|
.dialog-card select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.dialog-card input:focus,
|
||||||
|
.dialog-card select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-input-focus);
|
||||||
|
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||||
|
}
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Animations === */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
151
packages/remote-control-server/web/index.html
Normal file
151
packages/remote-control-server/web/index.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Remote Control — Claude Code</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Figtree:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" />
|
||||||
|
<link rel="stylesheet" href="./style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Nav Bar -->
|
||||||
|
<nav id="navbar">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/code/" class="nav-logo">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M10 1L12.2 7.8L19 10L12.2 12.2L10 19L7.8 12.2L1 10L7.8 7.8L10 1Z" fill="#D97757"/>
|
||||||
|
</svg>
|
||||||
|
Remote Control
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/code/" class="nav-link" id="nav-dashboard">Dashboard</a>
|
||||||
|
<button id="nav-identity" class="nav-link btn-text" title="Identity & QR">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:-2px;margin-right:4px;">
|
||||||
|
<path d="M6 8C7.66 8 9 6.66 9 5C9 3.34 7.66 2 6 2C4.34 2 3 3.34 3 5C3 6.66 4.34 8 6 8ZM6 10C3.99 10 0 11.01 0 13V14H12V13C12 11.01 8.01 10 6 10ZM13 8V5H11V8H8V10H11V13H13V10H16V8H13Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
Identity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Dashboard Page -->
|
||||||
|
<section id="page-dashboard" class="page hidden">
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- Environments -->
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h2 class="section-title">Environments</h2>
|
||||||
|
<div id="env-list" class="card-list">
|
||||||
|
<div class="empty-state">No active environments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sessions -->
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Sessions</h2>
|
||||||
|
<button id="new-session-btn" class="btn-primary btn-sm">+ New Session</button>
|
||||||
|
</div>
|
||||||
|
<div id="session-list" class="card-list">
|
||||||
|
<div class="empty-state">No sessions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Session Dialog -->
|
||||||
|
<div id="new-session-dialog" class="dialog-overlay hidden">
|
||||||
|
<div class="dialog-card">
|
||||||
|
<h3>New Session</h3>
|
||||||
|
<label for="ns-title">Title (optional)</label>
|
||||||
|
<input type="text" id="ns-title" placeholder="My session" />
|
||||||
|
<label for="ns-env">Environment</label>
|
||||||
|
<select id="ns-env"></select>
|
||||||
|
<div id="ns-error" class="error-msg hidden"></div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button id="ns-cancel" class="btn-outline">Cancel</button>
|
||||||
|
<button id="ns-create" class="btn-primary">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Session Detail Page -->
|
||||||
|
<section id="page-session" class="page hidden">
|
||||||
|
<div class="session-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="session-header">
|
||||||
|
<a href="/code/" class="back-link">← Dashboard</a>
|
||||||
|
<div class="session-meta">
|
||||||
|
<h2 id="session-title" class="session-detail-title">Session</h2>
|
||||||
|
<div class="session-meta-row">
|
||||||
|
<span id="session-id" class="meta-item"></span>
|
||||||
|
<span id="session-status" class="status-badge"></span>
|
||||||
|
<span id="session-env" class="meta-item"></span>
|
||||||
|
<span id="session-time" class="meta-item"></span>
|
||||||
|
<button id="task-panel-toggle" class="nav-link btn-text" title="Tasks & Todos">
|
||||||
|
Tasks <span id="task-badge" class="task-count-badge hidden">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Event Stream -->
|
||||||
|
<div id="event-stream" class="event-stream"></div>
|
||||||
|
<!-- Permission Prompt Area -->
|
||||||
|
<div id="permission-area" class="hidden"></div>
|
||||||
|
<!-- Control Bar -->
|
||||||
|
<div class="control-bar">
|
||||||
|
<input type="text" id="msg-input" placeholder="Type a message..." autocomplete="off" />
|
||||||
|
<button id="action-btn" class="action-btn" aria-label="Send">
|
||||||
|
<svg id="action-icon-send" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M3 10L17 3L10 17L9 11L3 10Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg id="action-icon-stop" class="hidden" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<rect x="3" y="3" width="12" height="12" rx="2" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Task Panel -->
|
||||||
|
<div id="task-panel" class="task-panel hidden"></div>
|
||||||
|
|
||||||
|
<!-- Identity Panel (QR display + scan) -->
|
||||||
|
<div id="identity-panel" class="identity-panel hidden">
|
||||||
|
<div class="identity-panel-inner">
|
||||||
|
<div class="identity-panel-header">
|
||||||
|
<h3>Identity</h3>
|
||||||
|
<button class="panel-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="identity-panel-body">
|
||||||
|
<div class="identity-section">
|
||||||
|
<label>Your UUID</label>
|
||||||
|
<div class="uuid-row">
|
||||||
|
<code id="uuid-display" class="uuid-text"></code>
|
||||||
|
<button id="uuid-copy-btn" class="btn-outline btn-sm">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="identity-section">
|
||||||
|
<label>Scan on another device</label>
|
||||||
|
<div id="qr-display" class="qr-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="identity-section">
|
||||||
|
<label>Import identity from QR</label>
|
||||||
|
<button id="qr-scan-btn" class="btn-outline">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:-2px;margin-right:4px;">
|
||||||
|
<path d="M1 1H5V3H3V5H1V1ZM11 1H15V5H13V3H11V1ZM1 11H3V13H5V15H1V11ZM13 11H15V15H11V13H13V11ZM6 6H10V10H6V6Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
Upload QR Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Libraries -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
481
packages/remote-control-server/web/messages.css
Normal file
481
packages/remote-control-server/web/messages.css
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
/* === Event Stream === */
|
||||||
|
.event-stream {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Message Bubbles — Anthropic / Claude === */
|
||||||
|
.msg-row {
|
||||||
|
display: flex;
|
||||||
|
max-width: 82%;
|
||||||
|
animation: msgIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes msgIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row.user { align-self: flex-end; }
|
||||||
|
.msg-row.assistant { align-self: flex-start; }
|
||||||
|
.msg-row.tool { align-self: flex-start; max-width: 95%; }
|
||||||
|
.msg-row.system { align-self: center; }
|
||||||
|
.msg-row.result { align-self: center; }
|
||||||
|
|
||||||
|
.msg-bubble {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row.user .msg-bubble {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-light);
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row.assistant .msg-bubble {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-bottom-left-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row.system .msg-bubble {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row.result .msg-bubble {
|
||||||
|
background: var(--green-bg);
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Tool Cards — Anthropic === */
|
||||||
|
.tool-card {
|
||||||
|
background: var(--bg-tool-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 16px;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.tool-card:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.tool-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.tool-card-header:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.tool-card-header .tool-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
.tool-card-header:hover .tool-icon { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
.tool-card-body {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
padding: 12px 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.tool-card-body.collapsed { display: none; }
|
||||||
|
|
||||||
|
/* === Permission Prompt — Anthropic === */
|
||||||
|
.permission-prompt {
|
||||||
|
background: var(--bg-permission);
|
||||||
|
border: 1px solid #F0D9A8;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 95%;
|
||||||
|
align-self: flex-start;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.permission-prompt .perm-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--orange);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.permission-prompt .perm-tool {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.permission-prompt .perm-actions { display: flex; gap: 10px; }
|
||||||
|
.permission-prompt .perm-desc {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.permission-prompt .perm-tool-name {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === AskUserQuestion Panel === */
|
||||||
|
.ask-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1.5px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 95%;
|
||||||
|
align-self: flex-start;
|
||||||
|
box-shadow: 0 2px 12px rgba(217, 119, 87, 0.15);
|
||||||
|
}
|
||||||
|
.ask-panel .ask-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.ask-question {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.ask-question:last-of-type { border-bottom: none; margin-bottom: 12px; }
|
||||||
|
.ask-question-text {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.ask-header {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ask-options { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ask-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.ask-option:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(217, 119, 87, 0.04);
|
||||||
|
}
|
||||||
|
.ask-option.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(217, 119, 87, 0.1);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
.ask-option-label { font-weight: 500; }
|
||||||
|
.ask-option-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.ask-other-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.ask-other-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.ask-other-input:focus { border-color: var(--accent); }
|
||||||
|
.ask-other-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.ask-other-btn:hover { border-color: var(--accent); }
|
||||||
|
.ask-actions { display: flex; gap: 10px; margin-top: 8px; }
|
||||||
|
.ask-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1.5px solid var(--border);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.ask-tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1.5px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.ask-tab:hover { color: var(--text-primary); }
|
||||||
|
.ask-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ask-tab-page { display: none; }
|
||||||
|
.ask-tab-page.active { display: block; }
|
||||||
|
.ask-tab-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.ask-progress {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === ExitPlanMode Panel === */
|
||||||
|
.plan-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1.5px solid #7C6FA0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 95%;
|
||||||
|
align-self: flex-start;
|
||||||
|
box-shadow: 0 2px 12px rgba(124, 111, 160, 0.18);
|
||||||
|
}
|
||||||
|
.plan-panel .plan-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #7C6FA0;
|
||||||
|
}
|
||||||
|
.plan-panel .plan-content {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.plan-panel .plan-content pre {
|
||||||
|
background: var(--bg-tool-card);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.plan-panel .plan-content code {
|
||||||
|
background: var(--bg-tool-card);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.plan-panel .plan-content strong { font-weight: 600; }
|
||||||
|
.plan-options { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.plan-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.plan-option:hover {
|
||||||
|
border-color: #7C6FA0;
|
||||||
|
background: rgba(124, 111, 160, 0.04);
|
||||||
|
}
|
||||||
|
.plan-option.selected {
|
||||||
|
border-color: #7C6FA0;
|
||||||
|
background: rgba(124, 111, 160, 0.1);
|
||||||
|
box-shadow: 0 0 0 1px #7C6FA0;
|
||||||
|
}
|
||||||
|
.plan-option-label { font-weight: 500; }
|
||||||
|
.plan-option-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.plan-feedback-area {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.plan-feedback-area.visible { display: block; }
|
||||||
|
.plan-feedback-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.plan-feedback-input:focus { border-color: #7C6FA0; }
|
||||||
|
.plan-actions { display: flex; gap: 10px; margin-top: 12px; }
|
||||||
|
.plan-actions .btn-plan-submit {
|
||||||
|
background: #7C6FA0;
|
||||||
|
color: var(--text-light);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 9px 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.plan-actions .btn-plan-submit:hover { background: #6B5E90; }
|
||||||
|
.plan-actions .btn-plan-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* === Timestamps === */
|
||||||
|
.event-time { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* === Loading Indicator — TUI star spinner === */
|
||||||
|
.msg-row.loading-row {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 82%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
animation: msgIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
.tui-spinner {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 1.2em;
|
||||||
|
transition: color 2s ease;
|
||||||
|
}
|
||||||
|
.stalled .tui-spinner { color: var(--red); }
|
||||||
|
.tui-verb {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 2s ease;
|
||||||
|
}
|
||||||
|
.stalled .tui-verb { color: var(--red); }
|
||||||
|
|
||||||
|
/* Glimmer — reverse sweep highlight (same visual as TUI) */
|
||||||
|
.glimmer-text {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--text-secondary) 0%,
|
||||||
|
var(--text-secondary) 40%,
|
||||||
|
var(--accent) 50%,
|
||||||
|
var(--text-secondary) 60%,
|
||||||
|
var(--text-secondary) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
animation: glimmerSweep 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes glimmerSweep {
|
||||||
|
0% { background-position: 100% 0; }
|
||||||
|
100% { background-position: -100% 0; }
|
||||||
|
}
|
||||||
|
.stalled .glimmer-text {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--red) 0%,
|
||||||
|
var(--red) 40%,
|
||||||
|
#E06060 50%,
|
||||||
|
var(--red) 60%,
|
||||||
|
var(--red) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
.tui-timer {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
427
packages/remote-control-server/web/pages.css
Normal file
427
packages/remote-control-server/web/pages.css
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
/* === Pages === */
|
||||||
|
.page {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
animation: pageIn var(--transition-slow) ease-out;
|
||||||
|
}
|
||||||
|
.page.no-nav { min-height: 100vh; }
|
||||||
|
|
||||||
|
@keyframes pageIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Login — Anthropic === */
|
||||||
|
#page-login {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 30% 20%, rgba(217, 119, 87, 0.06) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 70% 80%, rgba(59, 138, 106, 0.04) 0%, transparent 50%),
|
||||||
|
var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
animation: cardIn var(--transition-slow) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header { text-align: center; margin-bottom: 36px; }
|
||||||
|
.login-header h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
#login-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
#login-form input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-input-focus);
|
||||||
|
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--red-bg);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
#login-btn { margin-top: 20px; width: 100%; padding: 13px; font-size: 0.95rem; }
|
||||||
|
#login-form { margin-top: 24px; }
|
||||||
|
|
||||||
|
/* === Dashboard — Anthropic === */
|
||||||
|
.dashboard-container {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 32px;
|
||||||
|
}
|
||||||
|
.dashboard-section { margin-bottom: 40px; }
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.section-header .section-title { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.card-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1.5px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Environment Card */
|
||||||
|
.env-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.env-card:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.env-card .env-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.env-card .env-dir {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.env-card .env-branch {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session Card */
|
||||||
|
.session-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.session-card:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.session-card:active { transform: translateY(0); }
|
||||||
|
.session-card .session-title-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.session-card .session-id-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Session Detail — Anthropic === */
|
||||||
|
.session-container {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
.session-header { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.session-detail-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.session-meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.meta-item {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Control Bar — Claude-style === */
|
||||||
|
.control-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#msg-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
#msg-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.1), var(--shadow);
|
||||||
|
}
|
||||||
|
#msg-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Circular action button */
|
||||||
|
.action-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.25);
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
box-shadow: 0 3px 12px rgba(217, 119, 87, 0.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.action-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.action-btn.loading {
|
||||||
|
background: var(--red);
|
||||||
|
box-shadow: 0 2px 8px rgba(200, 60, 60, 0.25);
|
||||||
|
}
|
||||||
|
.action-btn.loading:hover {
|
||||||
|
background: #B33838;
|
||||||
|
box-shadow: 0 3px 12px rgba(200, 60, 60, 0.35);
|
||||||
|
}
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.action-btn svg { display: block; }
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.login-card { margin: 16px; padding: 32px 24px; }
|
||||||
|
.dashboard-container, .session-container { padding: 20px 16px; }
|
||||||
|
.session-card { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
.env-card { grid-template-columns: 1fr; }
|
||||||
|
.msg-row { max-width: 95%; }
|
||||||
|
.session-meta-row { flex-direction: column; gap: 4px; align-items: flex-start; }
|
||||||
|
.control-bar { flex-wrap: nowrap; }
|
||||||
|
#msg-input { min-width: 0; }
|
||||||
|
.identity-panel-inner { width: 100%; max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Identity Panel (QR code + scan) === */
|
||||||
|
.identity-panel {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn var(--transition-fast) ease-out;
|
||||||
|
}
|
||||||
|
.identity-panel.hidden { display: none; }
|
||||||
|
|
||||||
|
.identity-panel-inner {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg, 20px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
width: 380px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: cardIn var(--transition-slow) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.identity-panel-header h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.panel-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.panel-close:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.identity-panel-body {
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
}
|
||||||
|
.identity-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.identity-section:last-child { margin-bottom: 0; }
|
||||||
|
.identity-section label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uuid-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.uuid-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm, 8px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.qr-container canvas,
|
||||||
|
.qr-container img {
|
||||||
|
display: block !important;
|
||||||
|
border-radius: var(--radius-sm, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#qr-scan-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
637
packages/remote-control-server/web/render.js
Normal file
637
packages/remote-control-server/web/render.js
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
/**
|
||||||
|
* Remote Control — Event Rendering
|
||||||
|
*
|
||||||
|
* Renders session events into DOM elements for the event stream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { esc } from "./utils.js";
|
||||||
|
import { processAssistantEvent } from "./task-panel.js";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Replay state — tracks unresolved permission requests during history replay
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const replayPendingRequests = new Map(); // request_id → event data (unresolved)
|
||||||
|
const replayRespondedRequests = new Set(); // request_ids that have a response
|
||||||
|
|
||||||
|
/** Clear replay tracking state (call before each history load) */
|
||||||
|
export function resetReplayState() {
|
||||||
|
replayPendingRequests.clear();
|
||||||
|
replayRespondedRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After replay finishes, render any still-unresolved permission prompts */
|
||||||
|
export function renderReplayPendingRequests() {
|
||||||
|
if (replayPendingRequests.size === 0) return;
|
||||||
|
|
||||||
|
// Sort by seqNum to maintain order
|
||||||
|
const sorted = [...replayPendingRequests.entries()].sort((a, b) => (a[1].seqNum || 0) - (b[1].seqNum || 0));
|
||||||
|
for (const [, data] of sorted) {
|
||||||
|
// Re-invoke appendEvent without replay flag to go through the normal interactive path
|
||||||
|
appendEvent(data, { replay: false });
|
||||||
|
}
|
||||||
|
replayPendingRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function truncate(str, max) {
|
||||||
|
if (!str) return "";
|
||||||
|
const s = String(str);
|
||||||
|
return s.length > max ? s.slice(0, max) + "..." : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text from an event payload.
|
||||||
|
* Server-side normalization guarantees payload.content is a string.
|
||||||
|
* Falls back to raw/message parsing for backward compat.
|
||||||
|
*/
|
||||||
|
export function extractText(payload) {
|
||||||
|
if (!payload) return "";
|
||||||
|
|
||||||
|
// Normalized format (server standardized)
|
||||||
|
if (typeof payload.content === "string" && payload.content) return payload.content;
|
||||||
|
|
||||||
|
// Fallback: raw message.content (child process format)
|
||||||
|
const msg = payload.message;
|
||||||
|
if (msg && typeof msg === "object") {
|
||||||
|
const mc = msg.content;
|
||||||
|
if (typeof mc === "string") return mc;
|
||||||
|
if (Array.isArray(mc)) {
|
||||||
|
return mc
|
||||||
|
.filter((b) => b && typeof b === "object" && b.type === "text")
|
||||||
|
.map((b) => b.text || "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback
|
||||||
|
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAssistantContent(content) {
|
||||||
|
let html = esc(content);
|
||||||
|
// Code blocks: ```...```
|
||||||
|
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||||
|
return `<pre style="background:var(--bg-tool-card);padding:10px;border-radius:6px;overflow-x:auto;margin:6px 0;font-family:var(--font-mono);font-size:0.82rem;">${code.trim()}</pre>`;
|
||||||
|
});
|
||||||
|
// Inline code: `...`
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
|
||||||
|
// Bold: **...**
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Event Router
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function appendEvent(data, { replay = false } = {}) {
|
||||||
|
const stream = document.getElementById("event-stream");
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
const type = data.type || "unknown";
|
||||||
|
const payload = data.payload || {};
|
||||||
|
const direction = data.direction || "inbound";
|
||||||
|
|
||||||
|
// Early filter: skip bridge init noise regardless of event type
|
||||||
|
const serialized = JSON.stringify(data);
|
||||||
|
if (/Remote Control connecting/i.test(serialized)) return;
|
||||||
|
|
||||||
|
// During history replay, only render messages & tools — skip interactive/stateful events
|
||||||
|
// Exception: unresolved permission/control requests are re-shown as pending prompts.
|
||||||
|
if (replay) {
|
||||||
|
let histEl;
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
if (direction === "outbound") histEl = renderUserMessage(payload, direction);
|
||||||
|
break;
|
||||||
|
case "assistant":
|
||||||
|
{
|
||||||
|
const text = extractText(payload);
|
||||||
|
if (text && text.trim()) histEl = renderAssistantMessage(payload);
|
||||||
|
processAssistantEvent(payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "tool_use":
|
||||||
|
histEl = renderToolUse(payload);
|
||||||
|
break;
|
||||||
|
case "tool_result":
|
||||||
|
histEl = renderToolResult(payload);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
histEl = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
|
||||||
|
break;
|
||||||
|
case "control_request":
|
||||||
|
case "permission_request":
|
||||||
|
// Track unanswered permission/control requests for replay
|
||||||
|
if (payload.request && payload.request.subtype === "can_use_tool" && direction === "inbound") {
|
||||||
|
const rid = payload.request_id || data.id;
|
||||||
|
if (rid && !replayRespondedRequests.has(rid)) {
|
||||||
|
replayPendingRequests.set(rid, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "control_response":
|
||||||
|
case "permission_response":
|
||||||
|
// Mark the corresponding request as resolved
|
||||||
|
{
|
||||||
|
const respRid = payload.request_id;
|
||||||
|
if (respRid) {
|
||||||
|
replayRespondedRequests.add(respRid);
|
||||||
|
replayPendingRequests.delete(respRid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
// Skip: partial_assistant, result, status, interrupt, system, user inbound echoes
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (histEl) {
|
||||||
|
stream.appendChild(histEl);
|
||||||
|
stream.scrollTop = stream.scrollHeight;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let el;
|
||||||
|
let needLoading = false;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
// Skip inbound user messages — they're echoes of what we already sent
|
||||||
|
if (direction === "inbound") return;
|
||||||
|
el = renderUserMessage(payload, direction);
|
||||||
|
needLoading = true;
|
||||||
|
break;
|
||||||
|
case "partial_assistant":
|
||||||
|
// Skip partial assistant — wait for the final "assistant" event
|
||||||
|
// to avoid blank/duplicate messages during streaming
|
||||||
|
return;
|
||||||
|
case "assistant":
|
||||||
|
removeLoading();
|
||||||
|
{
|
||||||
|
const text = extractText(payload);
|
||||||
|
if (text && text.trim()) el = renderAssistantMessage(payload);
|
||||||
|
processAssistantEvent(payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "result":
|
||||||
|
case "result_success":
|
||||||
|
removeLoading();
|
||||||
|
// Skip result — it just repeats the assistant message content
|
||||||
|
return;
|
||||||
|
case "tool_use":
|
||||||
|
el = renderToolUse(payload);
|
||||||
|
break;
|
||||||
|
case "tool_result":
|
||||||
|
el = renderToolResult(payload);
|
||||||
|
break;
|
||||||
|
case "control_request":
|
||||||
|
case "permission_request":
|
||||||
|
if (payload.request && payload.request.subtype === "can_use_tool") {
|
||||||
|
const toolName = payload.request.tool_name || "unknown";
|
||||||
|
const toolInput = payload.request.input || payload.request.tool_input || {};
|
||||||
|
if (toolName === "AskUserQuestion") {
|
||||||
|
el = renderAskUserQuestion({
|
||||||
|
request_id: payload.request_id || data.id,
|
||||||
|
tool_input: toolInput,
|
||||||
|
description: payload.request.description || "",
|
||||||
|
});
|
||||||
|
} else if (toolName === "ExitPlanMode") {
|
||||||
|
el = renderExitPlanMode({
|
||||||
|
request_id: payload.request_id || data.id,
|
||||||
|
tool_input: toolInput,
|
||||||
|
description: payload.request.description || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
el = renderPermissionRequest({
|
||||||
|
request_id: payload.request_id || data.id,
|
||||||
|
tool_name: toolName,
|
||||||
|
tool_input: toolInput,
|
||||||
|
description: payload.request.description || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
el = renderSystemMessage(`Control: ${payload.request?.subtype || "unknown"}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "control_response":
|
||||||
|
case "permission_response":
|
||||||
|
// Skip — these are just acknowledgments, no need to show in stream
|
||||||
|
return;
|
||||||
|
case "status":
|
||||||
|
// Skip connecting/waiting status noise from bridge
|
||||||
|
{
|
||||||
|
const msg = payload.message || payload.content || "";
|
||||||
|
const fullText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||||
|
if (/connecting|waiting|initializing|Remote Control/i.test(msg + " " + fullText)) return;
|
||||||
|
if (!msg.trim()) return;
|
||||||
|
el = renderSystemMessage(msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
removeLoading();
|
||||||
|
el = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
|
||||||
|
break;
|
||||||
|
case "interrupt":
|
||||||
|
removeLoading();
|
||||||
|
el = renderSystemMessage("Session interrupted");
|
||||||
|
break;
|
||||||
|
case "system":
|
||||||
|
// Skip raw system/init messages — they're noise
|
||||||
|
return;
|
||||||
|
default: {
|
||||||
|
// Skip noise from bridge init
|
||||||
|
const raw = JSON.stringify(payload);
|
||||||
|
if (/Remote Control connecting/i.test(raw)) return;
|
||||||
|
el = renderSystemMessage(`${type}: ${truncate(raw, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
stream.appendChild(el);
|
||||||
|
stream.scrollTop = stream.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading after the message element is in the DOM so it renders below
|
||||||
|
if (needLoading) showLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Renderers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function renderUserMessage(payload, direction) {
|
||||||
|
const content = extractText(payload);
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "msg-row user";
|
||||||
|
row.innerHTML = `<div class="msg-bubble">${esc(content)}</div>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssistantMessage(payload) {
|
||||||
|
const content = extractText(payload);
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "msg-row assistant";
|
||||||
|
row.innerHTML = `<div class="msg-bubble">${formatAssistantContent(content)}</div>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResult(payload) {
|
||||||
|
const text = payload.result || payload.subtype || "Session completed";
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "msg-row system result";
|
||||||
|
row.innerHTML = `<div class="msg-bubble">✓ ${esc(text)}</div>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolUse(payload) {
|
||||||
|
const name = payload.tool_name || payload.name || "tool";
|
||||||
|
const input = payload.tool_input || payload.input || {};
|
||||||
|
const inputStr = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "msg-row tool";
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="tool-card">
|
||||||
|
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||||
|
<span class="tool-icon">▶</span> Tool: <strong>${esc(name)}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card-body collapsed">${esc(truncate(inputStr, 2000))}</div>
|
||||||
|
</div>`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolResult(payload) {
|
||||||
|
const content = payload.content || payload.output || "";
|
||||||
|
const contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "msg-row tool";
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="tool-card">
|
||||||
|
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||||
|
<span class="tool-icon">▶</span> Tool Result
|
||||||
|
</div>
|
||||||
|
<div class="tool-card-body collapsed">${esc(truncate(contentStr, 2000))}</div>
|
||||||
|
</div>`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPermissionRequest(payload) {
|
||||||
|
const requestId = payload.request_id || payload.id || "";
|
||||||
|
const toolName = payload.tool_name || "unknown";
|
||||||
|
const toolInput = payload.tool_input || payload.input || {};
|
||||||
|
const description = payload.description || "";
|
||||||
|
const inputStr = typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2);
|
||||||
|
|
||||||
|
const area = document.getElementById("permission-area");
|
||||||
|
area.classList.remove("hidden");
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "permission-prompt";
|
||||||
|
el.dataset.requestId = requestId;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="perm-title">Permission Request</div>
|
||||||
|
${description ? `<div class="perm-desc">${esc(description)}</div>` : ""}
|
||||||
|
<div class="perm-tool-name"><strong>${esc(toolName)}</strong></div>
|
||||||
|
${toolName !== "AskUserQuestion" ? `<div class="perm-tool">${esc(truncate(inputStr, 500))}</div>` : ""}
|
||||||
|
<div class="perm-actions">
|
||||||
|
<button class="btn-approve" onclick="window._approvePerm('${esc(requestId)}', this)">Approve</button>
|
||||||
|
<button class="btn-reject" onclick="window._rejectPerm('${esc(requestId)}', this)">Reject</button>
|
||||||
|
</div>`;
|
||||||
|
area.appendChild(el);
|
||||||
|
|
||||||
|
return renderSystemMessage(`Permission requested: ${toolName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAskUserQuestion(payload) {
|
||||||
|
const requestId = payload.request_id || payload.id || "";
|
||||||
|
const questions = payload.tool_input?.questions || [];
|
||||||
|
const description = payload.description || "";
|
||||||
|
|
||||||
|
const area = document.getElementById("permission-area");
|
||||||
|
area.classList.remove("hidden");
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "ask-panel";
|
||||||
|
el.dataset.requestId = requestId;
|
||||||
|
|
||||||
|
// Single question — no tabs needed
|
||||||
|
if (questions.length <= 1) {
|
||||||
|
const q = questions[0] || {};
|
||||||
|
const multiSelect = q.multiSelect || false;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ask-title">${esc(description || q.question || "Question")}</div>
|
||||||
|
<div class="ask-options">
|
||||||
|
${(q.options || []).map((opt, j) => `
|
||||||
|
<button class="ask-option${multiSelect ? " ask-multi" : ""}" data-qidx="0" data-oidx="${j}"
|
||||||
|
onclick="window._selectOption(this, 0, ${j}, ${multiSelect})">
|
||||||
|
<span class="ask-option-label">${esc(opt.label || "")}</span>
|
||||||
|
${opt.description ? `<span class="ask-option-desc">${esc(opt.description)}</span>` : ""}
|
||||||
|
</button>
|
||||||
|
`).join("")}
|
||||||
|
<div class="ask-other-row">
|
||||||
|
<input type="text" class="ask-other-input" data-qidx="0" placeholder="Other..." />
|
||||||
|
<button class="ask-other-btn" onclick="window._submitOther(this, 0)">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ask-actions">
|
||||||
|
<button class="btn-approve" onclick="window._submitAnswers('${esc(requestId)}', this)">Submit</button>
|
||||||
|
<button class="btn-reject" onclick="window._rejectPerm('${esc(requestId)}', this)">Skip</button>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
// Multiple questions — tab layout
|
||||||
|
const tabs = questions.map((q, i) => {
|
||||||
|
const multiSelect = q.multiSelect || false;
|
||||||
|
return `
|
||||||
|
<div class="ask-tab-page${i === 0 ? " active" : ""}" data-tab="${i}">
|
||||||
|
<div class="ask-question-text">${esc(q.question || "")}</div>
|
||||||
|
${q.header ? `<div class="ask-header">${esc(q.header)}</div>` : ""}
|
||||||
|
<div class="ask-options">
|
||||||
|
${(q.options || []).map((opt, j) => `
|
||||||
|
<button class="ask-option${multiSelect ? " ask-multi" : ""}" data-qidx="${i}" data-oidx="${j}"
|
||||||
|
onclick="window._selectOption(this, ${i}, ${j}, ${multiSelect})">
|
||||||
|
<span class="ask-option-label">${esc(opt.label || "")}</span>
|
||||||
|
${opt.description ? `<span class="ask-option-desc">${esc(opt.description)}</span>` : ""}
|
||||||
|
</button>
|
||||||
|
`).join("")}
|
||||||
|
<div class="ask-other-row">
|
||||||
|
<input type="text" class="ask-other-input" data-qidx="${i}" placeholder="Other..." />
|
||||||
|
<button class="ask-other-btn" onclick="window._submitOther(this, ${i})">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const tabBar = questions.map((q, i) =>
|
||||||
|
`<button class="ask-tab${i === 0 ? " active" : ""}" onclick="window._switchAskTab(this, ${i})">${esc(q.header || `Q${i + 1}`)}</button>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ask-title">${esc(description || "Questions")}</div>
|
||||||
|
<div class="ask-tabs">${tabBar}</div>
|
||||||
|
${tabs}
|
||||||
|
<div class="ask-tab-footer">
|
||||||
|
<span class="ask-progress">1 / ${questions.length}</span>
|
||||||
|
<div class="ask-actions">
|
||||||
|
<button class="btn-approve" onclick="window._submitAnswers('${esc(requestId)}', this)">Submit All</button>
|
||||||
|
<button class="btn-reject" onclick="window._rejectPerm('${esc(requestId)}', this)">Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
area.appendChild(el);
|
||||||
|
|
||||||
|
// Track selected options and store original questions for answer mapping
|
||||||
|
el._answers = {};
|
||||||
|
el._questions = questions;
|
||||||
|
|
||||||
|
return renderSystemMessage("Waiting for your response...");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderExitPlanMode(payload) {
|
||||||
|
const requestId = payload.request_id || payload.id || "";
|
||||||
|
const toolInput = payload.tool_input || {};
|
||||||
|
const description = payload.description || "";
|
||||||
|
const planContent = toolInput.plan || "";
|
||||||
|
|
||||||
|
const area = document.getElementById("permission-area");
|
||||||
|
area.classList.remove("hidden");
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "plan-panel";
|
||||||
|
el.dataset.requestId = requestId;
|
||||||
|
|
||||||
|
const isEmpty = !planContent || !planContent.trim();
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="plan-title">Exit plan mode?</div>
|
||||||
|
<div class="plan-options">
|
||||||
|
<button class="plan-option" data-value="yes-default" onclick="window._selectPlanOption(this, 'yes-default')">
|
||||||
|
<span class="plan-option-label">Yes</span>
|
||||||
|
</button>
|
||||||
|
<button class="plan-option" data-value="no" onclick="window._selectPlanOption(this, 'no')">
|
||||||
|
<span class="plan-option-label">No</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="plan-actions">
|
||||||
|
<button class="btn-plan-submit" onclick="window._submitPlanResponse('${esc(requestId)}', this)">Submit</button>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="plan-title">Ready to code?</div>
|
||||||
|
<div class="plan-content">${formatAssistantContent(planContent)}</div>
|
||||||
|
<div class="plan-options">
|
||||||
|
<button class="plan-option" data-value="yes-accept-edits" onclick="window._selectPlanOption(this, 'yes-accept-edits')">
|
||||||
|
<span class="plan-option-label">Yes, auto-accept edits</span>
|
||||||
|
<span class="plan-option-desc">Approve plan and auto-accept file edits</span>
|
||||||
|
</button>
|
||||||
|
<button class="plan-option" data-value="yes-default" onclick="window._selectPlanOption(this, 'yes-default')">
|
||||||
|
<span class="plan-option-label">Yes, manually approve edits</span>
|
||||||
|
<span class="plan-option-desc">Approve plan but confirm each edit</span>
|
||||||
|
</button>
|
||||||
|
<button class="plan-option" data-value="no" onclick="window._selectPlanOption(this, 'no')">
|
||||||
|
<span class="plan-option-label">No, keep planning</span>
|
||||||
|
<span class="plan-option-desc">Provide feedback to refine the plan</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="plan-feedback-area" data-for="no">
|
||||||
|
<textarea class="plan-feedback-input" placeholder="Tell Claude what to change..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="plan-actions">
|
||||||
|
<button class="btn-plan-submit" onclick="window._submitPlanResponse('${esc(requestId)}', this)">Submit</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
area.appendChild(el);
|
||||||
|
|
||||||
|
el._selectedValue = null;
|
||||||
|
el._planContent = planContent;
|
||||||
|
el._isEmpty = isEmpty;
|
||||||
|
|
||||||
|
return renderSystemMessage("Waiting for your response...");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemMessage(text) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "msg-row system";
|
||||||
|
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Loading Indicator — TUI star spinner style
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const LOADING_ID = "loading-indicator";
|
||||||
|
|
||||||
|
// TUI star spinner frames (same as Claude Code CLI)
|
||||||
|
const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽"];
|
||||||
|
const SPINNER_CYCLE = [...SPINNER_FRAMES, ...SPINNER_FRAMES.slice().reverse()];
|
||||||
|
|
||||||
|
// 204 verbs from TUI src/constants/spinnerVerbs.ts
|
||||||
|
const SPINNER_VERBS = [
|
||||||
|
"Accomplishing","Actioning","Actualizing","Architecting","Baking","Beaming",
|
||||||
|
"Beboppin'","Befuddling","Billowing","Blanching","Bloviating","Boogieing",
|
||||||
|
"Boondoggling","Booping","Bootstrapping","Brewing","Bunning","Burrowing",
|
||||||
|
"Calculating","Canoodling","Caramelizing","Cascading","Catapulting","Cerebrating",
|
||||||
|
"Channeling","Channelling","Choreographing","Churning","Clauding","Coalescing",
|
||||||
|
"Cogitating","Combobulating","Composing","Computing","Concocting","Considering",
|
||||||
|
"Contemplating","Cooking","Crafting","Creating","Crunching","Crystallizing",
|
||||||
|
"Cultivating","Deciphering","Deliberating","Determining","Dilly-dallying",
|
||||||
|
"Discombobulating","Doing","Doodling","Drizzling","Ebbing","Effecting",
|
||||||
|
"Elucidating","Embellishing","Enchanting","Envisioning","Evaporating",
|
||||||
|
"Fermenting","Fiddle-faddling","Finagling","Flambéing","Flibbertigibbeting",
|
||||||
|
"Flowing","Flummoxing","Fluttering","Forging","Forming","Frolicking","Frosting",
|
||||||
|
"Gallivanting","Galloping","Garnishing","Generating","Gesticulating",
|
||||||
|
"Germinating","Gitifying","Grooving","Gusting","Harmonizing","Hashing",
|
||||||
|
"Hatching","Herding","Honking","Hullaballooing","Hyperspacing","Ideating",
|
||||||
|
"Imagining","Improvising","Incubating","Inferring","Infusing","Ionizing",
|
||||||
|
"Jitterbugging","Julienning","Kneading","Leavening","Levitating","Lollygagging",
|
||||||
|
"Manifesting","Marinating","Meandering","Metamorphosing","Misting","Moonwalking",
|
||||||
|
"Moseying","Mulling","Mustering","Musing","Nebulizing","Nesting","Newspapering",
|
||||||
|
"Noodling","Nucleating","Orbiting","Orchestrating","Osmosing","Perambulating",
|
||||||
|
"Percolating","Perusing","Philosophising","Photosynthesizing","Pollinating",
|
||||||
|
"Pondering","Pontificating","Pouncing","Precipitating","Prestidigitating",
|
||||||
|
"Processing","Proofing","Propagating","Puttering","Puzzling","Quantumizing",
|
||||||
|
"Razzle-dazzling","Razzmatazzing","Recombobulating","Reticulating","Roosting",
|
||||||
|
"Ruminating","Sautéing","Scampering","Schlepping","Scurrying","Seasoning",
|
||||||
|
"Shenaniganing","Shimmying","Simmering","Skedaddling","Sketching","Slithering",
|
||||||
|
"Smooshing","Sock-hopping","Spelunking","Spinning","Sprouting","Stewing",
|
||||||
|
"Sublimating","Swirling","Swooping","Symbioting","Synthesizing","Tempering",
|
||||||
|
"Thinking","Thundering","Tinkering","Tomfoolering","Topsy-turvying",
|
||||||
|
"Transfiguring","Transmuting","Twisting","Undulating","Unfurling","Unravelling",
|
||||||
|
"Vibing","Waddling","Wandering","Warping","Whatchamacalliting","Whirlpooling",
|
||||||
|
"Whirring","Whisking","Wibbling","Working","Wrangling","Zesting","Zigzagging",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Animation state
|
||||||
|
let spinnerInterval = null;
|
||||||
|
let timerInterval = null;
|
||||||
|
let stalledCheckInterval = null;
|
||||||
|
let spinnerFrame = 0;
|
||||||
|
let loadingStartTime = 0;
|
||||||
|
let lastActivityTime = 0;
|
||||||
|
let isStalled = false;
|
||||||
|
let loadingActive = false;
|
||||||
|
|
||||||
|
export function isLoading() {
|
||||||
|
return loadingActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActionBtn(state) {
|
||||||
|
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showLoading() {
|
||||||
|
removeLoading();
|
||||||
|
const stream = document.getElementById("event-stream");
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
loadingActive = true;
|
||||||
|
syncActionBtn(true);
|
||||||
|
|
||||||
|
const verb = SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)];
|
||||||
|
loadingStartTime = Date.now();
|
||||||
|
lastActivityTime = Date.now();
|
||||||
|
isStalled = false;
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.id = LOADING_ID;
|
||||||
|
el.className = "msg-row loading-row";
|
||||||
|
el.innerHTML = `<span class="tui-spinner">${SPINNER_CYCLE[0]}</span><span class="tui-verb glimmer-text">${esc(verb)}…</span><span class="tui-timer">0s</span>`;
|
||||||
|
stream.appendChild(el);
|
||||||
|
stream.scrollTop = stream.scrollHeight;
|
||||||
|
|
||||||
|
const spinnerEl = el.querySelector(".tui-spinner");
|
||||||
|
const timerEl = el.querySelector(".tui-timer");
|
||||||
|
const loadingEl = el;
|
||||||
|
|
||||||
|
// Spinner animation — 120ms interval, same as TUI
|
||||||
|
spinnerFrame = 0;
|
||||||
|
spinnerInterval = setInterval(() => {
|
||||||
|
spinnerFrame = (spinnerFrame + 1) % SPINNER_CYCLE.length;
|
||||||
|
if (spinnerEl) spinnerEl.textContent = SPINNER_CYCLE[spinnerFrame];
|
||||||
|
}, 120);
|
||||||
|
|
||||||
|
// Timer — update every second
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
if (timerEl) {
|
||||||
|
const elapsed = Math.floor((Date.now() - loadingStartTime) / 1000);
|
||||||
|
timerEl.textContent = `${elapsed}s`;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Stalled detection — check every 120ms (aligned with spinner)
|
||||||
|
stalledCheckInterval = setInterval(() => {
|
||||||
|
if (!isStalled && Date.now() - lastActivityTime > 3000) {
|
||||||
|
isStalled = true;
|
||||||
|
if (loadingEl) loadingEl.classList.add("stalled");
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLoading() {
|
||||||
|
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
||||||
|
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||||
|
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
|
||||||
|
isStalled = false;
|
||||||
|
loadingActive = false;
|
||||||
|
syncActionBtn(false);
|
||||||
|
const el = document.getElementById(LOADING_ID);
|
||||||
|
if (el) el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset stalled timer — call when SSE events arrive */
|
||||||
|
export function refreshLoadingActivity() {
|
||||||
|
lastActivityTime = Date.now();
|
||||||
|
if (isStalled) {
|
||||||
|
isStalled = false;
|
||||||
|
const loadingEl = document.getElementById(LOADING_ID);
|
||||||
|
if (loadingEl) loadingEl.classList.remove("stalled");
|
||||||
|
}
|
||||||
|
}
|
||||||
53
packages/remote-control-server/web/sse.js
Normal file
53
packages/remote-control-server/web/sse.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Remote Control — SSE Connection Manager (UUID-based auth)
|
||||||
|
*/
|
||||||
|
import { getUuid } from "./api.js";
|
||||||
|
import { refreshLoadingActivity } from "./render.js";
|
||||||
|
|
||||||
|
let currentEventSource = null;
|
||||||
|
let currentSSESessionId = null;
|
||||||
|
let onEventCallback = null;
|
||||||
|
|
||||||
|
export function connectSSE(sessionId, onEvent, fromSeqNum = 0) {
|
||||||
|
disconnectSSE();
|
||||||
|
currentSSESessionId = sessionId;
|
||||||
|
onEventCallback = onEvent;
|
||||||
|
|
||||||
|
const uuid = getUuid();
|
||||||
|
let url = `/web/sessions/${sessionId}/events?uuid=${encodeURIComponent(uuid)}`;
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
|
currentEventSource = es;
|
||||||
|
|
||||||
|
// Track the last sequence number we've seen to avoid duplicates
|
||||||
|
let lastSeenSeq = fromSeqNum;
|
||||||
|
|
||||||
|
es.addEventListener("message", (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
// Skip events we've already rendered from history
|
||||||
|
if (data.seqNum !== undefined && data.seqNum <= lastSeenSeq) return;
|
||||||
|
if (data.seqNum !== undefined) lastSeenSeq = data.seqNum;
|
||||||
|
onEventCallback?.(data);
|
||||||
|
refreshLoadingActivity();
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("error", () => {
|
||||||
|
// EventSource auto-reconnects
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectSSE() {
|
||||||
|
if (currentEventSource) {
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
currentSSESessionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentSSESessionId() {
|
||||||
|
return currentSSESessionId;
|
||||||
|
}
|
||||||
6
packages/remote-control-server/web/style.css
Normal file
6
packages/remote-control-server/web/style.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* Main stylesheet — imports all modules */
|
||||||
|
@import url('./base.css');
|
||||||
|
@import url('./components.css');
|
||||||
|
@import url('./pages.css');
|
||||||
|
@import url('./messages.css');
|
||||||
|
@import url('./task-panel.css');
|
||||||
275
packages/remote-control-server/web/task-panel.css
Normal file
275
packages/remote-control-server/web/task-panel.css
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/* === Task/Todo Floating Panel — Anthropic === */
|
||||||
|
|
||||||
|
/* Panel container */
|
||||||
|
.task-panel {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 56px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 340px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-left: 1px solid var(--border-light);
|
||||||
|
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 90;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform var(--transition-base, 0.2s) ease, opacity var(--transition-base, 0.2s) ease;
|
||||||
|
}
|
||||||
|
.task-panel.hidden {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.task-panel.visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content shifts left when panel is open */
|
||||||
|
.session-container.panel-open {
|
||||||
|
margin-right: 340px;
|
||||||
|
transition: margin-right var(--transition-base, 0.2s) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.tp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tp-title {
|
||||||
|
font-family: var(--font-display, "Bricolage Grotesque", sans-serif);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.tp-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color var(--transition-fast, 0.12s);
|
||||||
|
}
|
||||||
|
.tp-close-btn:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Scrollable body */
|
||||||
|
.tp-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.tp-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.tp-progress {
|
||||||
|
position: relative;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--bg-input, #f5f1eb);
|
||||||
|
margin: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tp-progress-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--green, #3b8a6a);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
.tp-progress-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.tp-section {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.tp-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px 6px;
|
||||||
|
}
|
||||||
|
.tp-section-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.tp-section-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.tp-stat-dim {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.tp-section-body {
|
||||||
|
padding: 4px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task/Todo item */
|
||||||
|
.tp-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background var(--transition-fast, 0.12s);
|
||||||
|
}
|
||||||
|
.tp-item:hover {
|
||||||
|
background: var(--bg-input, #f5f1eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status icon */
|
||||||
|
.tp-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.tp-icon-done {
|
||||||
|
color: var(--green, #3b8a6a);
|
||||||
|
}
|
||||||
|
.tp-icon-active {
|
||||||
|
color: var(--accent, #d97757);
|
||||||
|
animation: tpPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.tp-icon-pending {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.tp-icon-deleted {
|
||||||
|
color: var(--red, #c83c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tpPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item content */
|
||||||
|
.tp-item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tp-item-subject {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.tp-status-completed .tp-item-subject {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.tp-status-deleted .tp-item-subject {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.tp-blocked .tp-item-subject {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active form (spinner text) */
|
||||||
|
.tp-item-active {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--accent, #d97757);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blocked indicator */
|
||||||
|
.tp-item-blocked {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 3px;
|
||||||
|
font-family: var(--font-mono, "Fira Code", monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Owner badge */
|
||||||
|
.tp-item-owner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-input, #f5f1eb);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle badge in nav */
|
||||||
|
.task-count-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-light, #fff);
|
||||||
|
background: var(--accent, #d97757);
|
||||||
|
border-radius: 9px;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.task-count-badge.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Active toggle button state */
|
||||||
|
#task-panel-toggle.active {
|
||||||
|
color: var(--accent, #d97757);
|
||||||
|
background: rgba(217, 119, 87, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.task-panel {
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.session-container.panel-open {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
400
packages/remote-control-server/web/task-panel.js
Normal file
400
packages/remote-control-server/web/task-panel.js
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* Remote Control — Task/Todo Floating Panel
|
||||||
|
*
|
||||||
|
* Parses tool_use blocks from assistant events to extract TaskCreate,
|
||||||
|
* TaskUpdate, and TodoWrite operations, then renders a floating panel
|
||||||
|
* showing the current task/todo state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// State
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** @type {Map<string, TaskItem>} V2 Tasks keyed by id */
|
||||||
|
const tasks = new Map();
|
||||||
|
|
||||||
|
/** @type {TodoItem[]} V1 Todos */
|
||||||
|
let todos = [];
|
||||||
|
|
||||||
|
/** @type {boolean} Panel visibility */
|
||||||
|
let panelVisible = false;
|
||||||
|
|
||||||
|
/** @type {HTMLElement|null} Panel root element */
|
||||||
|
let panelEl = null;
|
||||||
|
|
||||||
|
/** @type {HTMLElement|null} Badge element showing count */
|
||||||
|
let badgeEl = null;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Types (JSDoc for clarity)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TaskItem
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} subject
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} [activeForm]
|
||||||
|
* @property {'pending'|'in_progress'|'completed'|'deleted'} status
|
||||||
|
* @property {string} [owner]
|
||||||
|
* @property {string[]} blocks
|
||||||
|
* @property {string[]} blockedBy
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TodoItem
|
||||||
|
* @property {string} content
|
||||||
|
* @property {'pending'|'in_progress'|'completed'} status
|
||||||
|
* @property {string} activeForm
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// State mutations
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an assistant event payload, extracting tool_use blocks.
|
||||||
|
* @param {{ message?: { content?: unknown } }} payload
|
||||||
|
*/
|
||||||
|
export function processAssistantEvent(payload) {
|
||||||
|
if (!payload || !payload.message) return;
|
||||||
|
|
||||||
|
const content = payload.message.content;
|
||||||
|
if (!Array.isArray(content)) return;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== "object" || block.type !== "tool_use") continue;
|
||||||
|
|
||||||
|
const name = block.name;
|
||||||
|
const input = block.input || {};
|
||||||
|
|
||||||
|
if (name === "TaskCreate") {
|
||||||
|
handleTaskCreate(input);
|
||||||
|
changed = true;
|
||||||
|
} else if (name === "TaskUpdate") {
|
||||||
|
handleTaskUpdate(input);
|
||||||
|
changed = true;
|
||||||
|
} else if (name === "TodoWrite") {
|
||||||
|
handleTodoWrite(input);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
renderPanel();
|
||||||
|
updateBadge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ subject?: string, description?: string, activeForm?: string, metadata?: object }} input
|
||||||
|
*/
|
||||||
|
function handleTaskCreate(input) {
|
||||||
|
// TaskCreate creates a task; the tool itself generates the ID server-side.
|
||||||
|
// We extract from the tool output (tool_result) if available, or use a
|
||||||
|
// synthetic ID. The actual ID comes from the tool result event.
|
||||||
|
// Since we only see tool_use (not tool_result here), we create with a
|
||||||
|
// temporary key based on subject and let TaskUpdate resolve it.
|
||||||
|
const subject = input.subject || "Untitled task";
|
||||||
|
const description = input.description || "";
|
||||||
|
const activeForm = input.activeForm;
|
||||||
|
|
||||||
|
// Check if there's an id in the input (some versions include it)
|
||||||
|
const id = input.taskId || input.id || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
tasks.set(id, {
|
||||||
|
id,
|
||||||
|
subject,
|
||||||
|
description,
|
||||||
|
activeForm,
|
||||||
|
status: "pending",
|
||||||
|
owner: undefined,
|
||||||
|
blocks: [],
|
||||||
|
blockedBy: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ taskId?: string, status?: string, subject?: string, description?: string, activeForm?: string, owner?: string, addBlocks?: string[], addBlockedBy?: string[], metadata?: object }} input
|
||||||
|
*/
|
||||||
|
function handleTaskUpdate(input) {
|
||||||
|
const id = input.taskId;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const existing = tasks.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
// Task wasn't tracked yet — create it from the update
|
||||||
|
tasks.set(id, {
|
||||||
|
id,
|
||||||
|
subject: input.subject || "Untitled task",
|
||||||
|
description: input.description || "",
|
||||||
|
activeForm: input.activeForm,
|
||||||
|
status: input.status || "pending",
|
||||||
|
owner: input.owner,
|
||||||
|
blocks: [],
|
||||||
|
blockedBy: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.subject !== undefined) existing.subject = input.subject;
|
||||||
|
if (input.description !== undefined) existing.description = input.description;
|
||||||
|
if (input.activeForm !== undefined) existing.activeForm = input.activeForm;
|
||||||
|
if (input.status !== undefined) existing.status = input.status;
|
||||||
|
if (input.owner !== undefined) existing.owner = input.owner;
|
||||||
|
if (input.addBlocks) {
|
||||||
|
existing.blocks = [...new Set([...existing.blocks, ...input.addBlocks])];
|
||||||
|
}
|
||||||
|
if (input.addBlockedBy) {
|
||||||
|
existing.blockedBy = [...new Set([...existing.blockedBy, ...input.addBlockedBy])];
|
||||||
|
}
|
||||||
|
if (input.status === "deleted") {
|
||||||
|
tasks.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ todos?: Array<{ content: string, status: string, activeForm: string }> }} input
|
||||||
|
*/
|
||||||
|
function handleTodoWrite(input) {
|
||||||
|
if (!Array.isArray(input.todos)) return;
|
||||||
|
todos = input.todos.map((t) => ({
|
||||||
|
content: t.content || "",
|
||||||
|
status: t.status || "pending",
|
||||||
|
activeForm: t.activeForm || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all state (call when switching sessions).
|
||||||
|
*/
|
||||||
|
export function resetTaskState() {
|
||||||
|
tasks.clear();
|
||||||
|
todos = [];
|
||||||
|
if (panelEl) panelEl.innerHTML = "";
|
||||||
|
updateBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current state for debugging.
|
||||||
|
*/
|
||||||
|
export function getTaskState() {
|
||||||
|
return { tasks: [...tasks.values()], todos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the task panel DOM.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
export function initTaskPanel(container) {
|
||||||
|
if (panelEl) return; // already initialized
|
||||||
|
panelEl = container;
|
||||||
|
badgeEl = document.getElementById("task-badge");
|
||||||
|
renderPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle panel visibility.
|
||||||
|
*/
|
||||||
|
export function toggleTaskPanel() {
|
||||||
|
panelVisible = !panelVisible;
|
||||||
|
if (panelEl) {
|
||||||
|
panelEl.classList.toggle("hidden", !panelVisible);
|
||||||
|
panelEl.classList.toggle("visible", panelVisible);
|
||||||
|
}
|
||||||
|
// Adjust main content margin
|
||||||
|
const sessionContainer = document.querySelector(".session-container");
|
||||||
|
if (sessionContainer) {
|
||||||
|
sessionContainer.classList.toggle("panel-open", panelVisible);
|
||||||
|
}
|
||||||
|
// Toggle active state on the nav button
|
||||||
|
const toggleBtn = document.getElementById("task-panel-toggle");
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.classList.toggle("active", panelVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the panel.
|
||||||
|
*/
|
||||||
|
export function showTaskPanel() {
|
||||||
|
if (!panelVisible) toggleTaskPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the panel.
|
||||||
|
*/
|
||||||
|
export function hideTaskPanel() {
|
||||||
|
if (panelVisible) toggleTaskPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Rendering
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = String(str);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
if (!panelEl) return;
|
||||||
|
|
||||||
|
const allTasks = [...tasks.values()];
|
||||||
|
const hasTasks = allTasks.length > 0;
|
||||||
|
const hasTodos = todos.length > 0;
|
||||||
|
|
||||||
|
if (!hasTasks && !hasTodos) {
|
||||||
|
panelEl.innerHTML = `<div class="tp-empty">No tasks or todos yet</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Progress summary
|
||||||
|
const totalItems = allTasks.length + todos.length;
|
||||||
|
const completedTasks = allTasks.filter((t) => t.status === "completed").length;
|
||||||
|
const completedTodos = todos.filter((t) => t.status === "completed").length;
|
||||||
|
const completedTotal = completedTasks + completedTodos;
|
||||||
|
const pct = totalItems > 0 ? Math.round((completedTotal / totalItems) * 100) : 0;
|
||||||
|
|
||||||
|
parts.push(`
|
||||||
|
<div class="tp-progress">
|
||||||
|
<div class="tp-progress-bar" style="width:${pct}%"></div>
|
||||||
|
<span class="tp-progress-label">${completedTotal}/${totalItems} completed</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// V2 Tasks section
|
||||||
|
if (hasTasks) {
|
||||||
|
const inProgress = allTasks.filter((t) => t.status === "in_progress").length;
|
||||||
|
const pending = allTasks.filter((t) => t.status === "pending").length;
|
||||||
|
const completed = allTasks.filter((t) => t.status === "completed").length;
|
||||||
|
parts.push(`
|
||||||
|
<div class="tp-section">
|
||||||
|
<div class="tp-section-header">
|
||||||
|
<span class="tp-section-title">Tasks</span>
|
||||||
|
<span class="tp-section-stats">
|
||||||
|
${completed}<span class="tp-stat-dim">done</span>
|
||||||
|
${inProgress > 0 ? `${inProgress}<span class="tp-stat-dim">active</span>` : ""}
|
||||||
|
${pending > 0 ? `${pending}<span class="tp-stat-dim">open</span>` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="tp-section-body">
|
||||||
|
${allTasks.map(renderTaskItem).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1 Todos section
|
||||||
|
if (hasTodos) {
|
||||||
|
const inProgress = todos.filter((t) => t.status === "in_progress").length;
|
||||||
|
const pending = todos.filter((t) => t.status === "pending").length;
|
||||||
|
const completed = todos.filter((t) => t.status === "completed").length;
|
||||||
|
parts.push(`
|
||||||
|
<div class="tp-section">
|
||||||
|
<div class="tp-section-header">
|
||||||
|
<span class="tp-section-title">Todos</span>
|
||||||
|
<span class="tp-section-stats">
|
||||||
|
${completed}<span class="tp-stat-dim">done</span>
|
||||||
|
${inProgress > 0 ? `${inProgress}<span class="tp-stat-dim">active</span>` : ""}
|
||||||
|
${pending > 0 ? `${pending}<span class="tp-stat-dim">open</span>` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="tp-section-body">
|
||||||
|
${todos.map(renderTodoItem).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
panelEl.innerHTML = `
|
||||||
|
<div class="tp-header">
|
||||||
|
<span class="tp-title">Tasks & Todos</span>
|
||||||
|
<button class="tp-close-btn" onclick="window.__toggleTaskPanel()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="tp-body">${parts.join("")}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {TaskItem} task
|
||||||
|
*/
|
||||||
|
function renderTaskItem(task) {
|
||||||
|
const icon = statusIcon(task.status);
|
||||||
|
const isBlocked = task.blockedBy.length > 0 && task.status !== "completed";
|
||||||
|
const cls = [
|
||||||
|
"tp-item",
|
||||||
|
`tp-status-${task.status}`,
|
||||||
|
isBlocked ? "tp-blocked" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${cls}">
|
||||||
|
<span class="tp-item-icon ${icon.cls}">${icon.char}</span>
|
||||||
|
<div class="tp-item-content">
|
||||||
|
<div class="tp-item-subject">${esc(task.subject)}</div>
|
||||||
|
${task.activeForm && task.status === "in_progress" ? `<div class="tp-item-active">${esc(task.activeForm)}...</div>` : ""}
|
||||||
|
${isBlocked ? `<div class="tp-item-blocked">blocked by ${task.blockedBy.map((id) => `#${esc(id)}`).join(", ")}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
${task.owner ? `<span class="tp-item-owner">@${esc(task.owner)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {TodoItem} todo
|
||||||
|
*/
|
||||||
|
function renderTodoItem(todo) {
|
||||||
|
const icon = statusIcon(todo.status);
|
||||||
|
const cls = ["tp-item", `tp-status-${todo.status}`].join(" ");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${cls}">
|
||||||
|
<span class="tp-item-icon ${icon.cls}">${icon.char}</span>
|
||||||
|
<div class="tp-item-content">
|
||||||
|
<div class="tp-item-subject">${esc(todo.content)}</div>
|
||||||
|
${todo.activeForm && todo.status === "in_progress" ? `<div class="tp-item-active">${esc(todo.activeForm)}...</div>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} status
|
||||||
|
* @returns {{ char: string, cls: string }}
|
||||||
|
*/
|
||||||
|
function statusIcon(status) {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return { char: "\u2713", cls: "tp-icon-done" };
|
||||||
|
case "in_progress":
|
||||||
|
return { char: "\u25CF", cls: "tp-icon-active" };
|
||||||
|
case "deleted":
|
||||||
|
return { char: "\u2717", cls: "tp-icon-deleted" };
|
||||||
|
default:
|
||||||
|
return { char: "\u25CB", cls: "tp-icon-pending" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBadge() {
|
||||||
|
if (!badgeEl) return;
|
||||||
|
const total = tasks.size + todos.length;
|
||||||
|
if (total > 0) {
|
||||||
|
badgeEl.textContent = String(total);
|
||||||
|
badgeEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
badgeEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/remote-control-server/web/utils.js
Normal file
27
packages/remote-control-server/web/utils.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Remote Control — Shared Utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function esc(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = String(str);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(ts) {
|
||||||
|
if (!ts) return "";
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusClass(status) {
|
||||||
|
const map = {
|
||||||
|
active: "active",
|
||||||
|
running: "running",
|
||||||
|
idle: "idle",
|
||||||
|
requires_action: "requires_action",
|
||||||
|
archived: "archived",
|
||||||
|
error: "error",
|
||||||
|
};
|
||||||
|
return map[status] || "default";
|
||||||
|
}
|
||||||
20
scripts/rcs.ts
Normal file
20
scripts/rcs.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 启动 Remote Control Server
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run scripts/rcs.ts
|
||||||
|
* RCS_API_KEYS=key1,key2 RCS_PORT=4000 bun run scripts/rcs.ts
|
||||||
|
*/
|
||||||
|
import { config } from "../packages/remote-control-server/src/config";
|
||||||
|
|
||||||
|
console.log(`[RCS] Starting Remote Control Server...`);
|
||||||
|
console.log(`[RCS] Port: ${config.port}`);
|
||||||
|
console.log(`[RCS] API Key configuration loaded`);
|
||||||
|
console.log(`[RCS] JWT Secret: ${config.jwtSecret === "change-me-in-production" ? "default (set RCS_JWT_SECRET)" : "custom"}`);
|
||||||
|
console.log(`[RCS] DB: ${config.dbPath}`);
|
||||||
|
|
||||||
|
const server = await import("../packages/remote-control-server/src/index.ts");
|
||||||
|
|
||||||
|
Bun.serve(
|
||||||
|
server.default
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import { debugBody, extractErrorDetail } from './debugUtils.js'
|
import { debugBody, extractErrorDetail } from './debugUtils.js'
|
||||||
|
import { rcLog } from './rcDebugLog.js'
|
||||||
import {
|
import {
|
||||||
BRIDGE_LOGIN_INSTRUCTION,
|
BRIDGE_LOGIN_INSTRUCTION,
|
||||||
type BridgeApiClient,
|
type BridgeApiClient,
|
||||||
@@ -224,6 +225,7 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|||||||
)
|
)
|
||||||
|
|
||||||
handleErrorStatus(response.status, response.data, 'Poll')
|
handleErrorStatus(response.status, response.data, 'Poll')
|
||||||
|
rcLog(`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`)
|
||||||
|
|
||||||
// Empty body or null = no work available
|
// Empty body or null = no work available
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
|
|||||||
@@ -14,21 +14,14 @@
|
|||||||
import { getOauthConfig } from '../constants/oauth.js'
|
import { getOauthConfig } from '../constants/oauth.js'
|
||||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||||
|
|
||||||
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
/** Dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
||||||
export function getBridgeTokenOverride(): string | undefined {
|
export function getBridgeTokenOverride(): string | undefined {
|
||||||
return (
|
return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
|
||||||
(process.env.USER_TYPE === 'ant' &&
|
|
||||||
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
/** Dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
||||||
export function getBridgeBaseUrlOverride(): string | undefined {
|
export function getBridgeBaseUrlOverride(): string | undefined {
|
||||||
return (
|
return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
|
||||||
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,3 +39,8 @@ export function getBridgeAccessToken(): string | undefined {
|
|||||||
export function getBridgeBaseUrl(): string {
|
export function getBridgeBaseUrl(): string {
|
||||||
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
|
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the user has explicitly configured a custom bridge server. */
|
||||||
|
export function isSelfHostedBridge(): boolean {
|
||||||
|
return !!getBridgeBaseUrlOverride()
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getDynamicConfig_CACHED_MAY_BE_STALE,
|
getDynamicConfig_CACHED_MAY_BE_STALE,
|
||||||
getFeatureValue_CACHED_MAY_BE_STALE,
|
getFeatureValue_CACHED_MAY_BE_STALE,
|
||||||
} from '../services/analytics/growthbook.js'
|
} from '../services/analytics/growthbook.js'
|
||||||
|
import { isSelfHostedBridge } from './bridgeConfig.js'
|
||||||
// Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled
|
// Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled
|
||||||
// cycle — authModule.foo is a live binding, so by the time the helpers below
|
// cycle — authModule.foo is a live binding, so by the time the helpers below
|
||||||
// call it, auth.js is fully loaded. Previously used require() for the same
|
// call it, auth.js is fully loaded. Previously used require() for the same
|
||||||
@@ -26,6 +27,11 @@ import { lt } from '../utils/semver.js'
|
|||||||
* is only referenced when bridge mode is enabled at build time.
|
* is only referenced when bridge mode is enabled at build time.
|
||||||
*/
|
*/
|
||||||
export function isBridgeEnabled(): boolean {
|
export function isBridgeEnabled(): boolean {
|
||||||
|
// Self-hosted bridge: when the user has configured a custom server, bypass
|
||||||
|
// GrowthBook gates entirely.
|
||||||
|
if (feature('BRIDGE_MODE') && isSelfHostedBridge()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
// Positive ternary pattern — see docs/feature-gating.md.
|
// Positive ternary pattern — see docs/feature-gating.md.
|
||||||
// Negative pattern (if (!feature(...)) return) does not eliminate
|
// Negative pattern (if (!feature(...)) return) does not eliminate
|
||||||
// inline string literals from external builds.
|
// inline string literals from external builds.
|
||||||
@@ -48,6 +54,9 @@ export function isBridgeEnabled(): boolean {
|
|||||||
* `isBridgeEnabled()` instead.
|
* `isBridgeEnabled()` instead.
|
||||||
*/
|
*/
|
||||||
export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
||||||
|
if (feature('BRIDGE_MODE') && isSelfHostedBridge()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return feature('BRIDGE_MODE')
|
return feature('BRIDGE_MODE')
|
||||||
? isClaudeAISubscriber() &&
|
? isClaudeAISubscriber() &&
|
||||||
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))
|
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))
|
||||||
@@ -69,6 +78,10 @@ export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
|||||||
*/
|
*/
|
||||||
export async function getBridgeDisabledReason(): Promise<string | null> {
|
export async function getBridgeDisabledReason(): Promise<string | null> {
|
||||||
if (feature('BRIDGE_MODE')) {
|
if (feature('BRIDGE_MODE')) {
|
||||||
|
// Self-hosted bridge: no subscription/scope/gate checks needed.
|
||||||
|
if (isSelfHostedBridge()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (!isClaudeAISubscriber()) {
|
if (!isClaudeAISubscriber()) {
|
||||||
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
|
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../services/analytics/index.js'
|
} from '../services/analytics/index.js'
|
||||||
import { isInBundledMode } from '../utils/bundledMode.js'
|
import { isInBundledMode } from '../utils/bundledMode.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
import { rcLog } from './rcDebugLog.js'
|
||||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||||
import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
|
import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
import { errorMessage } from '../utils/errors.js'
|
||||||
@@ -202,6 +203,7 @@ export async function runBridgeLoop(
|
|||||||
async function heartbeatActiveWorkItems(): Promise<
|
async function heartbeatActiveWorkItems(): Promise<
|
||||||
'ok' | 'auth_failed' | 'fatal' | 'failed'
|
'ok' | 'auth_failed' | 'fatal' | 'failed'
|
||||||
> {
|
> {
|
||||||
|
rcLog(`heartbeat: checking ${activeSessions.size} active session(s)`)
|
||||||
let anySuccess = false
|
let anySuccess = false
|
||||||
let anyFatal = false
|
let anyFatal = false
|
||||||
const authFailedSessions: string[] = []
|
const authFailedSessions: string[] = []
|
||||||
@@ -446,6 +448,9 @@ export async function runBridgeLoop(
|
|||||||
): (status: SessionDoneStatus) => void {
|
): (status: SessionDoneStatus) => void {
|
||||||
return (rawStatus: SessionDoneStatus): void => {
|
return (rawStatus: SessionDoneStatus): void => {
|
||||||
const workId = sessionWorkIds.get(sessionId)
|
const workId = sessionWorkIds.get(sessionId)
|
||||||
|
rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
|
||||||
|
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
|
||||||
|
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`)
|
||||||
activeSessions.delete(sessionId)
|
activeSessions.delete(sessionId)
|
||||||
sessionStartTimes.delete(sessionId)
|
sessionStartTimes.delete(sessionId)
|
||||||
sessionWorkIds.delete(sessionId)
|
sessionWorkIds.delete(sessionId)
|
||||||
@@ -604,6 +609,7 @@ export async function runBridgeLoop(
|
|||||||
const pollConfig = getPollIntervalConfig()
|
const pollConfig = getPollIntervalConfig()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`)
|
||||||
const work = await api.pollForWork(
|
const work = await api.pollForWork(
|
||||||
environmentId,
|
environmentId,
|
||||||
environmentSecret,
|
environmentSecret,
|
||||||
@@ -858,6 +864,7 @@ export async function runBridgeLoop(
|
|||||||
break
|
break
|
||||||
case 'session': {
|
case 'session': {
|
||||||
const sessionId = work.data.id
|
const sessionId = work.data.id
|
||||||
|
rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`)
|
||||||
try {
|
try {
|
||||||
validateBridgeId(sessionId, 'session_id')
|
validateBridgeId(sessionId, 'session_id')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1023,6 +1030,12 @@ export async function runBridgeLoop(
|
|||||||
// the onFirstUserMessage callback can close over it.
|
// the onFirstUserMessage callback can close over it.
|
||||||
const compatSessionId = toCompatSessionId(sessionId)
|
const compatSessionId = toCompatSessionId(sessionId)
|
||||||
|
|
||||||
|
rcLog(
|
||||||
|
`spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` +
|
||||||
|
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
|
||||||
|
` dir=${sessionDir}` +
|
||||||
|
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
|
||||||
|
)
|
||||||
const spawnResult = safeSpawn(
|
const spawnResult = safeSpawn(
|
||||||
spawner,
|
spawner,
|
||||||
{
|
{
|
||||||
@@ -1266,6 +1279,11 @@ export async function runBridgeLoop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errMsg = describeAxiosError(err)
|
const errMsg = describeAxiosError(err)
|
||||||
|
rcLog(
|
||||||
|
`poll error: ${errMsg}` +
|
||||||
|
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
|
||||||
|
` activeSessions=${activeSessions.size}`,
|
||||||
|
)
|
||||||
|
|
||||||
if (isConnectionError(err) || isServerError(err)) {
|
if (isConnectionError(err) || isServerError(err)) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -2198,10 +2216,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be
|
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be
|
||||||
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
|
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
|
||||||
const sessionIngressUrl =
|
const sessionIngressUrl =
|
||||||
process.env.USER_TYPE === 'ant' &&
|
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
|
||||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
|
||||||
: baseUrl
|
|
||||||
|
|
||||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||||
'../utils/git.js'
|
'../utils/git.js'
|
||||||
@@ -2851,10 +2866,7 @@ export async function runBridgeHeadless(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const sessionIngressUrl =
|
const sessionIngressUrl =
|
||||||
process.env.USER_TYPE === 'ant' &&
|
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
|
||||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
|
||||||
: baseUrl
|
|
||||||
|
|
||||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||||
'../utils/git.js'
|
'../utils/git.js'
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
|
|||||||
import type { Message } from '../types/message.js'
|
import type { Message } from '../types/message.js'
|
||||||
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
import { rcLog } from './rcDebugLog.js'
|
||||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
import { errorMessage } from '../utils/errors.js'
|
||||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||||
@@ -386,6 +387,11 @@ export function handleServerControlRequest(
|
|||||||
|
|
||||||
const event = { ...response, session_id: sessionId }
|
const event = { ...response, session_id: sessionId }
|
||||||
void transport.write(event)
|
void transport.write(event)
|
||||||
|
rcLog(
|
||||||
|
`control_response: subtype=${req.subtype}` +
|
||||||
|
` request_id=${request.request_id}` +
|
||||||
|
` result=${(response.response as { subtype?: string }).subtype}`,
|
||||||
|
)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`,
|
`[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {
|
import { getClaudeAiBaseUrl } from '../constants/product.js'
|
||||||
getClaudeAiBaseUrl,
|
import { isSelfHostedBridge, getBridgeBaseUrl } from './bridgeConfig.js'
|
||||||
getRemoteSessionUrl,
|
|
||||||
} from '../constants/product.js'
|
|
||||||
import { stringWidth } from '@anthropic/ink'
|
import { stringWidth } from '@anthropic/ink'
|
||||||
import { formatDuration, truncateToWidth } from '../utils/format.js'
|
import { formatDuration, truncateToWidth } from '../utils/format.js'
|
||||||
import { getGraphemeSegmenter } from '../utils/intl.js'
|
import { getGraphemeSegmenter } from '../utils/intl.js'
|
||||||
@@ -40,7 +38,10 @@ export function buildBridgeConnectUrl(
|
|||||||
environmentId: string,
|
environmentId: string,
|
||||||
ingressUrl?: string,
|
ingressUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl)
|
// Self-hosted: use the configured server URL directly
|
||||||
|
const baseUrl = isSelfHostedBridge()
|
||||||
|
? getBridgeBaseUrl()
|
||||||
|
: getClaudeAiBaseUrl(undefined, ingressUrl)
|
||||||
return `${baseUrl}/code?bridge=${environmentId}`
|
return `${baseUrl}/code?bridge=${environmentId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,11 @@ export function buildBridgeSessionUrl(
|
|||||||
environmentId: string,
|
environmentId: string,
|
||||||
ingressUrl?: string,
|
ingressUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}`
|
// Self-hosted: use the configured server URL directly
|
||||||
|
const baseUrl = isSelfHostedBridge()
|
||||||
|
? getBridgeBaseUrl()
|
||||||
|
: getClaudeAiBaseUrl(undefined, ingressUrl)
|
||||||
|
return `${baseUrl}/code/${sessionId}?bridge=${environmentId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute the glimmer index for a reverse-sweep shimmer animation. */
|
/** Compute the glimmer index for a reverse-sweep shimmer animation. */
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export async function createBridgeSession({
|
|||||||
const { getDefaultBranch } = await import('../utils/git.js')
|
const { getDefaultBranch } = await import('../utils/git.js')
|
||||||
const { getMainLoopModel } = await import('../utils/model/model.js')
|
const { getMainLoopModel } = await import('../utils/model/model.js')
|
||||||
const { default: axios } = await import('axios')
|
const { default: axios } = await import('axios')
|
||||||
|
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||||
|
|
||||||
const accessToken =
|
const accessToken =
|
||||||
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||||
@@ -68,7 +69,11 @@ export async function createBridgeSession({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgUUID = await getOrganizationUUID()
|
// Self-hosted bridges don't require a claude.ai org UUID — the local server
|
||||||
|
// doesn't validate it. Use a placeholder to avoid blocking session creation.
|
||||||
|
const orgUUID = isSelfHostedBridge()
|
||||||
|
? 'self-hosted'
|
||||||
|
: await getOrganizationUUID()
|
||||||
if (!orgUUID) {
|
if (!orgUUID) {
|
||||||
logForDebugging('[bridge] No org UUID for session creation')
|
logForDebugging('[bridge] No org UUID for session creation')
|
||||||
return null
|
return null
|
||||||
@@ -196,6 +201,7 @@ export async function getBridgeSession(
|
|||||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||||
const { default: axios } = await import('axios')
|
const { default: axios } = await import('axios')
|
||||||
|
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||||
|
|
||||||
const accessToken =
|
const accessToken =
|
||||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||||
@@ -204,7 +210,9 @@ export async function getBridgeSession(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgUUID = await getOrganizationUUID()
|
const orgUUID = isSelfHostedBridge()
|
||||||
|
? 'self-hosted'
|
||||||
|
: await getOrganizationUUID()
|
||||||
if (!orgUUID) {
|
if (!orgUUID) {
|
||||||
logForDebugging('[bridge] No org UUID for session fetch')
|
logForDebugging('[bridge] No org UUID for session fetch')
|
||||||
return null
|
return null
|
||||||
@@ -273,6 +281,7 @@ export async function archiveBridgeSession(
|
|||||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||||
const { default: axios } = await import('axios')
|
const { default: axios } = await import('axios')
|
||||||
|
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||||
|
|
||||||
const accessToken =
|
const accessToken =
|
||||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||||
@@ -281,7 +290,9 @@ export async function archiveBridgeSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgUUID = await getOrganizationUUID()
|
const orgUUID = isSelfHostedBridge()
|
||||||
|
? 'self-hosted'
|
||||||
|
: await getOrganizationUUID()
|
||||||
if (!orgUUID) {
|
if (!orgUUID) {
|
||||||
logForDebugging('[bridge] No org UUID for session archive')
|
logForDebugging('[bridge] No org UUID for session archive')
|
||||||
return
|
return
|
||||||
@@ -334,6 +345,7 @@ export async function updateBridgeSessionTitle(
|
|||||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||||
const { default: axios } = await import('axios')
|
const { default: axios } = await import('axios')
|
||||||
|
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||||
|
|
||||||
const accessToken =
|
const accessToken =
|
||||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||||
@@ -342,7 +354,9 @@ export async function updateBridgeSessionTitle(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgUUID = await getOrganizationUUID()
|
const orgUUID = isSelfHostedBridge()
|
||||||
|
? 'self-hosted'
|
||||||
|
: await getOrganizationUUID()
|
||||||
if (!orgUUID) {
|
if (!orgUUID) {
|
||||||
logForDebugging('[bridge] No org UUID for session title update')
|
logForDebugging('[bridge] No org UUID for session title update')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
getBridgeAccessToken,
|
getBridgeAccessToken,
|
||||||
getBridgeBaseUrl,
|
getBridgeBaseUrl,
|
||||||
getBridgeTokenOverride,
|
getBridgeTokenOverride,
|
||||||
|
isSelfHostedBridge,
|
||||||
} from './bridgeConfig.js'
|
} from './bridgeConfig.js'
|
||||||
import {
|
import {
|
||||||
checkBridgeMinVersion,
|
checkBridgeMinVersion,
|
||||||
@@ -387,7 +388,11 @@ export async function initReplBridge(
|
|||||||
// environment registration; v2 for archive (which lives at the compat
|
// environment registration; v2 for archive (which lives at the compat
|
||||||
// /v1/sessions/{id}/archive, not /v1/code/sessions). Without it, v2
|
// /v1/sessions/{id}/archive, not /v1/code/sessions). Without it, v2
|
||||||
// archive 404s and sessions stay alive in CCR after /exit.
|
// archive 404s and sessions stay alive in CCR after /exit.
|
||||||
const orgUUID = await getOrganizationUUID()
|
// Self-hosted bridges skip this check — the local server doesn't require
|
||||||
|
// org-based auth.
|
||||||
|
const orgUUID = isSelfHostedBridge()
|
||||||
|
? 'self-hosted'
|
||||||
|
: await getOrganizationUUID()
|
||||||
if (!orgUUID) {
|
if (!orgUUID) {
|
||||||
logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID')
|
logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID')
|
||||||
onStateChange?.('failed', '/login')
|
onStateChange?.('failed', '/login')
|
||||||
@@ -465,10 +470,7 @@ export async function initReplBridge(
|
|||||||
const branch = await getBranch()
|
const branch = await getBranch()
|
||||||
const gitRepoUrl = await getRemoteUrl()
|
const gitRepoUrl = await getRemoteUrl()
|
||||||
const sessionIngressUrl =
|
const sessionIngressUrl =
|
||||||
process.env.USER_TYPE === 'ant' &&
|
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
|
||||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
|
||||||
: baseUrl
|
|
||||||
|
|
||||||
// Assistant-mode sessions advertise a distinct worker_type so the web UI
|
// Assistant-mode sessions advertise a distinct worker_type so the web UI
|
||||||
// can filter them into a dedicated picker. KAIROS guard keeps the
|
// can filter them into a dedicated picker. KAIROS guard keeps the
|
||||||
|
|||||||
39
src/bridge/rcDebugLog.ts
Normal file
39
src/bridge/rcDebugLog.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* File-based debug logger for Remote Control bridge diagnostics.
|
||||||
|
* Writes [RC-DEBUG] lines to ~/.claude/rc-debug.log so they survive
|
||||||
|
* Ink's stdout capture in the REPL / bridge UI.
|
||||||
|
*/
|
||||||
|
import { appendFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const LOG_PATH = join(homedir(), '.claude', 'rc-debug.log')
|
||||||
|
|
||||||
|
function ensureLogDir() {
|
||||||
|
const dir = join(homedir(), '.claude')
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
let headerWritten = false
|
||||||
|
|
||||||
|
export function rcLog(msg: string): void {
|
||||||
|
try {
|
||||||
|
if (!headerWritten) {
|
||||||
|
ensureLogDir()
|
||||||
|
appendFileSync(LOG_PATH, `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`)
|
||||||
|
headerWritten = true
|
||||||
|
}
|
||||||
|
const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS
|
||||||
|
appendFileSync(LOG_PATH, `[${ts}] ${msg}\n`)
|
||||||
|
} catch {
|
||||||
|
// best-effort — never crash the bridge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the log file at session start. */
|
||||||
|
export function rcLogClear(): void {
|
||||||
|
try {
|
||||||
|
ensureLogDir()
|
||||||
|
appendFileSync(LOG_PATH, '')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from './bridgeApi.js'
|
} from './bridgeApi.js'
|
||||||
import type { BridgeConfig, BridgeApiClient } from './types.js'
|
import type { BridgeConfig, BridgeApiClient } from './types.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
import { rcLog } from './rcDebugLog.js'
|
||||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||||
import {
|
import {
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
@@ -616,6 +617,12 @@ export async function initBridgeCore(
|
|||||||
|
|
||||||
async function doReconnect(): Promise<boolean> {
|
async function doReconnect(): Promise<boolean> {
|
||||||
environmentRecreations++
|
environmentRecreations++
|
||||||
|
rcLog(
|
||||||
|
`doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` +
|
||||||
|
` envId=${environmentId}` +
|
||||||
|
` sessionId=${currentSessionId}` +
|
||||||
|
` workId=${currentWorkId}`,
|
||||||
|
)
|
||||||
// Invalidate any in-flight v2 handshake — the environment is being
|
// Invalidate any in-flight v2 handshake — the environment is being
|
||||||
// recreated, so a stale transport arriving post-reconnect would be
|
// recreated, so a stale transport arriving post-reconnect would be
|
||||||
// pointed at a dead session.
|
// pointed at a dead session.
|
||||||
@@ -885,6 +892,11 @@ export async function initBridgeCore(
|
|||||||
* exhaustion. Transient drops are retried internally by the transport.
|
* exhaustion. Transient drops are retried internally by the transport.
|
||||||
*/
|
*/
|
||||||
function handleTransportPermanentClose(closeCode: number | undefined): void {
|
function handleTransportPermanentClose(closeCode: number | undefined): void {
|
||||||
|
rcLog(
|
||||||
|
`handleTransportPermanentClose: code=${closeCode}` +
|
||||||
|
` transport=${transport ? 'exists' : 'null'}` +
|
||||||
|
` pollAborted=${pollController.signal.aborted}`,
|
||||||
|
)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Transport permanently closed: code=${closeCode}`,
|
`[bridge:repl] Transport permanently closed: code=${closeCode}`,
|
||||||
)
|
)
|
||||||
@@ -1330,6 +1342,18 @@ export async function initBridgeCore(
|
|||||||
})
|
})
|
||||||
|
|
||||||
newTransport.setOnData(data => {
|
newTransport.setOnData(data => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
rcLog(
|
||||||
|
`ingress: type=${parsed.type}` +
|
||||||
|
`${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` +
|
||||||
|
`${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` +
|
||||||
|
`${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
|
||||||
|
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`)
|
||||||
|
}
|
||||||
handleIngressMessage(
|
handleIngressMessage(
|
||||||
data,
|
data,
|
||||||
recentPostedUUIDs,
|
recentPostedUUIDs,
|
||||||
@@ -1350,6 +1374,12 @@ export async function initBridgeCore(
|
|||||||
newTransport.setOnClose(closeCode => {
|
newTransport.setOnClose(closeCode => {
|
||||||
// Guard: if transport was replaced, ignore stale close.
|
// Guard: if transport was replaced, ignore stale close.
|
||||||
if (transport !== newTransport) return
|
if (transport !== newTransport) return
|
||||||
|
rcLog(
|
||||||
|
`transport onClose: code=${closeCode}` +
|
||||||
|
` connected=${newTransport.isConnectedStatus()}` +
|
||||||
|
` state=${newTransport.getStateLabel()}` +
|
||||||
|
` seq=${newTransport.getLastSequenceNum()}`,
|
||||||
|
)
|
||||||
handleTransportPermanentClose(closeCode)
|
handleTransportPermanentClose(closeCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function decodeWorkSecret(secret: string): WorkSecret {
|
|||||||
export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
|
export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
|
||||||
const isLocalhost =
|
const isLocalhost =
|
||||||
apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')
|
apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')
|
||||||
const protocol = isLocalhost ? 'ws' : 'wss'
|
const protocol = apiBaseUrl.startsWith('https') ? 'wss' : 'ws'
|
||||||
const version = isLocalhost ? 'v2' : 'v1'
|
const version = isLocalhost ? 'v2' : 'v1'
|
||||||
const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||||
return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}`
|
return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios, { type AxiosError } from 'axios'
|
import axios, { type AxiosError } from 'axios'
|
||||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import { rcLog } from '../../bridge/rcDebugLog.js'
|
||||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||||
import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'
|
import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'
|
||||||
import { SerialBatchEventUploader } from './SerialBatchEventUploader.js'
|
import { SerialBatchEventUploader } from './SerialBatchEventUploader.js'
|
||||||
@@ -241,6 +242,10 @@ export class HybridTransport extends WebSocketTransport {
|
|||||||
response.status < 500 &&
|
response.status < 500 &&
|
||||||
response.status !== 429
|
response.status !== 429
|
||||||
) {
|
) {
|
||||||
|
rcLog(
|
||||||
|
`Hybrid POST ${response.status}: url=${this.postUrl.replace(/token=[^&]+/, 'token=***')}` +
|
||||||
|
` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`,
|
||||||
|
)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`HybridTransport: POST returned ${response.status} (permanent), dropping`,
|
`HybridTransport: POST returned ${response.status} (permanent), dropping`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios, { type AxiosError } from 'axios'
|
import axios, { type AxiosError } from 'axios'
|
||||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import { rcLog } from '../../bridge/rcDebugLog.js'
|
||||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||||
import { errorMessage } from '../../utils/errors.js'
|
import { errorMessage } from '../../utils/errors.js'
|
||||||
import { getSessionIngressAuthHeaders } from '../../utils/sessionIngressAuth.js'
|
import { getSessionIngressAuthHeaders } from '../../utils/sessionIngressAuth.js'
|
||||||
@@ -468,6 +469,12 @@ export class SSETransport implements Transport {
|
|||||||
* Handle connection errors with exponential backoff and time budget.
|
* Handle connection errors with exponential backoff and time budget.
|
||||||
*/
|
*/
|
||||||
private handleConnectionError(): void {
|
private handleConnectionError(): void {
|
||||||
|
rcLog(
|
||||||
|
`SSE handleConnectionError: state=${this.state}` +
|
||||||
|
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||||
|
` reconnectAttempts=${this.reconnectAttempts}` +
|
||||||
|
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`,
|
||||||
|
)
|
||||||
this.clearLivenessTimer()
|
this.clearLivenessTimer()
|
||||||
|
|
||||||
if (this.state === 'closing' || this.state === 'closed') return
|
if (this.state === 'closing' || this.state === 'closed') return
|
||||||
@@ -541,6 +548,11 @@ export class SSETransport implements Transport {
|
|||||||
*/
|
*/
|
||||||
private readonly onLivenessTimeout = (): void => {
|
private readonly onLivenessTimeout = (): void => {
|
||||||
this.livenessTimer = null
|
this.livenessTimer = null
|
||||||
|
rcLog(
|
||||||
|
`SSE liveness timeout (${LIVENESS_TIMEOUT_MS}ms)` +
|
||||||
|
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||||
|
` state=${this.state}`,
|
||||||
|
)
|
||||||
logForDebugging('SSETransport: Liveness timeout, reconnecting', {
|
logForDebugging('SSETransport: Liveness timeout, reconnecting', {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type WsWebSocket from 'ws'
|
|||||||
import { logEvent } from '../../services/analytics/index.js'
|
import { logEvent } from '../../services/analytics/index.js'
|
||||||
import { CircularBuffer } from '../../utils/CircularBuffer.js'
|
import { CircularBuffer } from '../../utils/CircularBuffer.js'
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import { rcLog } from '../../bridge/rcDebugLog.js'
|
||||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||||
import { getWebSocketTLSOptions } from '../../utils/mtls.js'
|
import { getWebSocketTLSOptions } from '../../utils/mtls.js'
|
||||||
@@ -25,7 +26,7 @@ const DEFAULT_MAX_RECONNECT_DELAY = 30000
|
|||||||
/** Time budget for reconnection attempts before giving up (10 minutes). */
|
/** Time budget for reconnection attempts before giving up (10 minutes). */
|
||||||
const DEFAULT_RECONNECT_GIVE_UP_MS = 600_000
|
const DEFAULT_RECONNECT_GIVE_UP_MS = 600_000
|
||||||
const DEFAULT_PING_INTERVAL = 10000
|
const DEFAULT_PING_INTERVAL = 10000
|
||||||
const DEFAULT_KEEPALIVE_INTERVAL = 300_000 // 5 minutes
|
const DEFAULT_KEEPALIVE_INTERVAL = 120_000 // 2 minutes — must be under Bun's 255s idleTimeout
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Threshold for detecting system sleep/wake. If the gap between consecutive
|
* Threshold for detecting system sleep/wake. If the gap between consecutive
|
||||||
@@ -395,6 +396,13 @@ export class WebSocketTransport implements Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleConnectionError(closeCode?: number): void {
|
private handleConnectionError(closeCode?: number): void {
|
||||||
|
rcLog(
|
||||||
|
`WS handleConnectionError: code=${closeCode}` +
|
||||||
|
` state=${this.state}` +
|
||||||
|
` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` +
|
||||||
|
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` +
|
||||||
|
` reconnectAttempts=${this.reconnectAttempts}`,
|
||||||
|
)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`WebSocketTransport: Disconnected from ${this.url.href}` +
|
`WebSocketTransport: Disconnected from ${this.url.href}` +
|
||||||
(closeCode != null ? ` (code ${closeCode})` : ''),
|
(closeCode != null ? ` (code ${closeCode})` : ''),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StatsProvider, type StatsStore } from '../context/stats.js'
|
|||||||
import { type AppState, AppStateProvider } from '../state/AppState.js'
|
import { type AppState, AppStateProvider } from '../state/AppState.js'
|
||||||
import { onChangeAppState } from '../state/onChangeAppState.js'
|
import { onChangeAppState } from '../state/onChangeAppState.js'
|
||||||
import type { FpsMetrics } from '../utils/fpsTracker.js'
|
import type { FpsMetrics } from '../utils/fpsTracker.js'
|
||||||
|
import { ThemeProvider } from '@anthropic/ink'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
getFpsMetrics: () => FpsMetrics | undefined
|
getFpsMetrics: () => FpsMetrics | undefined
|
||||||
|
|||||||
@@ -35,12 +35,24 @@ export function isRemoteSessionLocal(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the base URL for Claude AI based on environment.
|
* Get the base URL for Claude AI based on environment.
|
||||||
|
* For localhost, derives the base URL from the ingress URL to preserve the
|
||||||
|
* actual server port instead of using the hardcoded default (4000).
|
||||||
*/
|
*/
|
||||||
export function getClaudeAiBaseUrl(
|
export function getClaudeAiBaseUrl(
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
ingressUrl?: string,
|
ingressUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
if (isRemoteSessionLocal(sessionId, ingressUrl)) {
|
if (isRemoteSessionLocal(sessionId, ingressUrl)) {
|
||||||
|
// If an ingress URL is available, extract its origin to keep the correct port.
|
||||||
|
// Self-hosted servers may run on any port (default 3000), not just 4000.
|
||||||
|
if (ingressUrl) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(ingressUrl)
|
||||||
|
return parsed.origin
|
||||||
|
} catch {
|
||||||
|
// Fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
return CLAUDE_AI_LOCAL_BASE_URL
|
return CLAUDE_AI_LOCAL_BASE_URL
|
||||||
}
|
}
|
||||||
if (isRemoteSessionStaging(sessionId, ingressUrl)) {
|
if (isRemoteSessionStaging(sessionId, ingressUrl)) {
|
||||||
@@ -71,6 +83,12 @@ export function getRemoteSessionUrl(
|
|||||||
require('../bridge/sessionIdCompat.js') as typeof import('../bridge/sessionIdCompat.js')
|
require('../bridge/sessionIdCompat.js') as typeof import('../bridge/sessionIdCompat.js')
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
const compatId = toCompatSessionId(sessionId)
|
const compatId = toCompatSessionId(sessionId)
|
||||||
|
// Use CLAUDE_BRIDGE_BASE_URL from env if available, otherwise fall back to default logic
|
||||||
|
const bridgeBaseUrl = process.env.CLAUDE_BRIDGE_BASE_URL
|
||||||
|
if (bridgeBaseUrl) {
|
||||||
|
const base = bridgeBaseUrl.replace(/\/+$/, '')
|
||||||
|
return `${base}/code/${compatId}`
|
||||||
|
}
|
||||||
const baseUrl = getClaudeAiBaseUrl(compatId, ingressUrl)
|
const baseUrl = getClaudeAiBaseUrl(compatId, ingressUrl)
|
||||||
return `${baseUrl}/code/${compatId}`
|
return `${baseUrl}/code/${compatId}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ async function main(): Promise<void> {
|
|||||||
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
|
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
|
||||||
// (not the stale disk cache), but init still needs auth headers to work.
|
// (not the stale disk cache), but init still needs auth headers to work.
|
||||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||||
if (!getClaudeAIOAuthTokens()?.accessToken) {
|
const { getBridgeAccessToken } = await import('../bridge/bridgeConfig.js')
|
||||||
|
if (!getClaudeAIOAuthTokens()?.accessToken && !getBridgeAccessToken()) {
|
||||||
exitWithError(BRIDGE_LOGIN_ERROR)
|
exitWithError(BRIDGE_LOGIN_ERROR)
|
||||||
}
|
}
|
||||||
const disabledReason = await getBridgeDisabledReason()
|
const disabledReason = await getBridgeDisabledReason()
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class SessionsWebSocket {
|
|||||||
|
|
||||||
this.state = 'connecting'
|
this.state = 'connecting'
|
||||||
|
|
||||||
const baseUrl = getOauthConfig().BASE_API_URL.replace('https://', 'wss://')
|
const baseUrl = getOauthConfig().BASE_API_URL.replace('http', 'ws')
|
||||||
const url = `${baseUrl}/v1/sessions/ws/${this.sessionId}/subscribe?organization_uuid=${this.orgUuid}`
|
const url = `${baseUrl}/v1/sessions/ws/${this.sessionId}/subscribe?organization_uuid=${this.orgUuid}`
|
||||||
|
|
||||||
logForDebugging(`[SessionsWebSocket] Connecting to ${url}`)
|
logForDebugging(`[SessionsWebSocket] Connecting to ${url}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user