Compare commits

..

1 Commits

Author SHA1 Message Date
Haileyesus
9a0c8b20e5 fix(shell): use correct session id 2026-06-11 20:38:30 +03:00
202 changed files with 1381 additions and 21068 deletions

View File

@@ -1,109 +0,0 @@
name: Desktop macOS Branch Build
on:
workflow_dispatch:
push:
branches:
- electron-app
jobs:
build-macos:
name: Build macOS desktop artifact
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Resolve artifact metadata
id: artifact
run: |
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
echo "name=CloudCLI-macOS-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
- name: Configure branch server bundle source
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
- name: Verify signing secrets are configured
run: |
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build signed and notarized macOS artifacts
run: npm run desktop:dist:mac -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build branch server bundle
run: node scripts/release/build-server-bundle.js
- name: Verify branch server runtime artifacts
run: |
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
- name: Publish branch server bundle
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Verify macOS artifacts
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
cat release/SHASUMS256.txt
- name: Upload branch build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ steps.artifact.outputs.name }}
path: |
release/desktop/*.dmg
release/SHASUMS256.txt
if-no-files-found: error
retention-days: 14

View File

@@ -1,305 +0,0 @@
name: Desktop Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to create or update (defaults to v<package version>)"
required: false
type: string
release_name:
description: 'Release name (defaults to "CloudCLI Desktop <tag>")'
required: false
type: string
prerelease:
description: "Mark the GitHub release as a prerelease"
required: true
default: false
type: boolean
jobs:
resolve-release:
name: Resolve release metadata
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
release_name: ${{ steps.release.outputs.release_name }}
server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Resolve release metadata
id: release
env:
TAG_INPUT: ${{ inputs.tag }}
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
run: |
VERSION="$(node -p "require('./package.json').version")"
TAG="$TAG_INPUT"
if [ -z "$TAG" ]; then
TAG="v${VERSION}"
fi
TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')"
if [ -z "$TAG" ]; then
echo "Resolved release tag is empty after normalization." >&2
exit 1
fi
RELEASE_NAME="$RELEASE_NAME_INPUT"
if [ -z "$RELEASE_NAME" ]; then
RELEASE_NAME="CloudCLI Desktop ${TAG}"
fi
RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
{
echo "tag=$TAG"
echo "release_name<<$RELEASE_NAME_DELIMITER"
printf '%s\n' "$RELEASE_NAME"
echo "$RELEASE_NAME_DELIMITER"
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
} >> "$GITHUB_OUTPUT"
build-macos:
name: Build signed macOS desktop app
needs: resolve-release
runs-on: macos-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Configure release server bundle source
env:
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
- name: Verify macOS signing secrets are configured
run: |
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build signed and notarized macOS artifacts
run: npm run desktop:dist:mac -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build macOS local server bundle
run: node scripts/release/build-server-bundle.js
- name: Stage macOS release assets
run: |
mkdir -p desktop-release-assets server-release-assets
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
shasum -a 256 release/desktop/*.dmg > desktop-release-assets/SHASUMS256-macos.txt
cp release/desktop/*.dmg desktop-release-assets/
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
cp release/local-server/* server-release-assets/
- name: Upload macOS desktop assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: desktop-release-macos
path: desktop-release-assets/*
if-no-files-found: error
- name: Upload macOS server assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: server-release-macos
path: server-release-assets/*
if-no-files-found: error
build-windows:
name: Build Windows desktop app
needs: resolve-release
runs-on: windows-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Configure release server bundle source
shell: bash
env:
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
- name: Check Windows signing secrets
id: windows-signing
shell: bash
env:
WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
run: |
if [ -n "$WINDOWS_CSC_LINK" ] && [ -n "$WINDOWS_CSC_KEY_PASSWORD" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Build signed Windows artifacts
if: steps.windows-signing.outputs.enabled == 'true'
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
- name: Build unsigned Windows artifacts
if: steps.windows-signing.outputs.enabled != 'true'
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Build Windows local server bundle
run: node scripts/release/build-server-bundle.js
- name: Stage Windows release assets
shell: bash
run: |
mkdir -p desktop-release-assets server-release-assets
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
sha256sum release/desktop/*.exe > desktop-release-assets/SHASUMS256-windows.txt
cp release/desktop/*.exe desktop-release-assets/
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
cp release/local-server/* server-release-assets/
- name: Upload Windows desktop assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: desktop-release-windows
path: desktop-release-assets/*
if-no-files-found: error
- name: Upload Windows server assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: server-release-windows
path: server-release-assets/*
if-no-files-found: error
publish:
name: Publish desktop release
needs:
- resolve-release
- build-macos
- build-windows
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download desktop assets
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: desktop-release-*
path: release/desktop
merge-multiple: true
- name: Download server assets
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: server-release-*
path: release/local-server
merge-multiple: true
- name: Verify release assets
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
test -f release/desktop/SHASUMS256-macos.txt
test -f release/desktop/SHASUMS256-windows.txt
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
find release -maxdepth 2 -type f -print | sort
- name: Publish local server runtime assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ needs.resolve-release.outputs.server_bundle_tag }}
target_commitish: ${{ github.sha }}
name: CloudCLI Local Server Runtime (${{ needs.resolve-release.outputs.tag }})
body: |
This prerelease contains the Local mode runtime for CloudCLI Desktop.
Download CloudCLI Desktop from the main ${{ needs.resolve-release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Publish GitHub release assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ needs.resolve-release.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ${{ needs.resolve-release.outputs.release_name }}
body: |
Download the CloudCLI Desktop installer for your platform.
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/desktop/*

View File

@@ -1,95 +0,0 @@
name: Desktop Windows Branch Build
on:
workflow_dispatch:
push:
branches:
- electron-app
jobs:
build-windows:
name: Build unsigned Windows desktop artifact
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Resolve artifact metadata
id: artifact
shell: bash
run: |
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
echo "name=CloudCLI-windows-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
- name: Configure branch server bundle source
shell: bash
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
- name: Build unsigned Windows artifacts
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Build branch server bundle
run: node scripts/release/build-server-bundle.js
- name: Verify branch server runtime artifacts
shell: bash
run: |
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
- name: Publish branch server bundle
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Verify Windows artifacts
shell: bash
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
sha256sum release/desktop/*.exe > release/SHASUMS256.txt
cat release/SHASUMS256.txt
- name: Upload branch build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ steps.artifact.outputs.name }}
path: |
release/desktop/*.exe
release/SHASUMS256.txt
if-no-files-found: error
retention-days: 14

View File

@@ -4,109 +4,28 @@ on:
workflow_dispatch:
inputs:
increment:
description: "Version bump: patch, minor, major, or explicit (e.g. 1.27.0)"
description: 'Version bump: patch, minor, major, or explicit (e.g. 1.27.0)'
required: true
default: "patch"
default: 'patch'
type: string
release_name:
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
required: false
type: string
permissions:
contents: read
# This workflow publishes releases with write credentials, so actions are pinned
# to immutable commit SHAs. The trailing comments keep the original major tag
# visible for maintenance context.
jobs:
build-macos-semantic-helper:
strategy:
fail-fast: false
matrix:
include:
- runs_on: macos-15
target_dir: darwin-arm64
- runs_on: macos-15-intel
target_dir: darwin-x64
runs-on: ${{ matrix.runs_on }}
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Build macOS semantic helper
run: node scripts/build-computer-semantics.mjs
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
- name: Verify macOS semantic helper target
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
- name: Stage macOS semantic helper artifact
run: |
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: semantic-helper-${{ matrix.target_dir }}
path: semantic-helper-artifact/*
if-no-files-found: error
build-windows-semantic-helper:
strategy:
fail-fast: false
matrix:
include:
- runs_on: windows-2025
target_dir: win32-x64
- runs_on: windows-11-arm
target_dir: win32-arm64
runs-on: ${{ matrix.runs_on }}
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Build Windows semantic helper
run: node scripts/build-computer-semantics.mjs
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
- name: Verify Windows semantic helper target
shell: bash
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
- name: Stage Windows semantic helper artifact
shell: bash
run: |
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: semantic-helper-${{ matrix.target_dir }}
path: semantic-helper-artifact/*
if-no-files-found: error
release:
needs:
- build-macos-semantic-helper
- build-windows-semantic-helper
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- uses: actions/setup-node@v6
with:
node-version: 22
registry-url: https://registry.npmjs.org
@@ -118,23 +37,6 @@ jobs:
- run: npm ci
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: semantic-helper-*
path: server/modules/computer-use/semantics/bin
merge-multiple: true
- name: Restore semantic helper permissions
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
- name: Verify bundled semantic helpers
run: |
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
- name: Release
run: |
ARGS="--ci --increment=${{ inputs.increment }}"

9
.gitignore vendored
View File

@@ -134,7 +134,6 @@ tasks/
# Translations
!src/i18n/locales/en/tasks.json
!src/i18n/locales/fr/tasks.json
!src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json
!src/i18n/locales/de/tasks.json
@@ -143,11 +142,3 @@ tasks/
# Git worktrees
.worktrees/
# Local desktop packaging artifacts
/.desktop-build/
/release/
/electron/server-bundle-config.json
cloudcli-sidebar-app-source.tar.gz
cloudcli-sidebar.html
electron/*.tar.gz

View File

@@ -3,59 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
### New Features
* add Electron desktop app ([97c9b67](https://github.com/siteboon/claudecodeui/commit/97c9b67bfc2d803560cd1559a4e79eea9731c7b5))
* **chat:** derive activity indicator from per-session state and unify provider lifecycle events ([afc717e](https://github.com/siteboon/claudecodeui/commit/afc717e69e67f53173c30d2230722236f9180d39))
* **chat:** unify session gateway with stable IDs and a single WS protocol ([f5eac2e](https://github.com/siteboon/claudecodeui/commit/f5eac2ec12c8575bf80202fafe807d9e04720105))
* **i18n:** add French (fr) locale ([#878](https://github.com/siteboon/claudecodeui/issues/878)) ([f319d2c](https://github.com/siteboon/claudecodeui/commit/f319d2cf8d61452deaf6adf345494dd3e6898284))
* play sound for pending tool requests ([#918](https://github.com/siteboon/claudecodeui/issues/918)) ([c947eaa](https://github.com/siteboon/claudecodeui/commit/c947eaaee5fbc959563efb917f4ec7c88847dd6b))
* render changelog as markdown in version upgrade modal ([6a53c31](https://github.com/siteboon/claudecodeui/commit/6a53c31e907fffa79320997c27f99660c946b4a6))
* **sidebar:** improve running session state tracking ([591b18e](https://github.com/siteboon/claudecodeui/commit/591b18e9e343fda23affe100a53911f76aaa8f57))
* **skills:** add provider skill management ([#909](https://github.com/siteboon/claudecodeui/issues/909)) ([c5fe127](https://github.com/siteboon/claudecodeui/commit/c5fe127958d830eee19d008d8634c0e7d77fe1b9))
* **version:** warn when the server was updated but not restarted ([#898](https://github.com/siteboon/claudecodeui/issues/898)) ([f6326c8](https://github.com/siteboon/claudecodeui/commit/f6326c8082dfbe8a65dcdb836d3e71c635594c26))
### Bug Fixes
* changes provider logos to svg for fast load ([7bed675](https://github.com/siteboon/claudecodeui/commit/7bed675ad5fd1ecf7912d1a04afe9db5b1032823))
* **chat:** prevent chat interface crash on malformed AskUserQuestion payload ([#920](https://github.com/siteboon/claudecodeui/issues/920)) ([ed4ae31](https://github.com/siteboon/claudecodeui/commit/ed4ae3114aafc1d4ecb0b621eaf9d3b26dbca5b1))
* **chat:** prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks ([#903](https://github.com/siteboon/claudecodeui/issues/903)) ([4712431](https://github.com/siteboon/claudecodeui/commit/4712431be81718dfb559ef43d7d7d5315bf4e01a))
* **chat:** sort messages appropriately ([123ae31](https://github.com/siteboon/claudecodeui/commit/123ae310207fe5969c3b313f62b9dee27e5d7489))
* **claude-sync:** skip subagent transcripts to prevent main session corruption ([#854](https://github.com/siteboon/claudecodeui/issues/854)) ([a12ca8e](https://github.com/siteboon/claudecodeui/commit/a12ca8eed373ef56cd37fbdd097845eaab34dee9))
* correct notification session id ([881e72d](https://github.com/siteboon/claudecodeui/commit/881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8))
* create one unified function for frontend session processing ([677d330](https://github.com/siteboon/claudecodeui/commit/677d330981ef29a856f09e62b9f69bac0fa580d4))
* **i18n:** add missing sidebar message keys to all locales ([#896](https://github.com/siteboon/claudecodeui/issues/896)) ([7ca3556](https://github.com/siteboon/claudecodeui/commit/7ca355651f0a805965bc27af3d75def626c5fb96))
* keep running-session polling active ([39b0473](https://github.com/siteboon/claudecodeui/commit/39b0473e38201c29ff1e5388946452d2eed44527))
* normalize project session payloads ([d0adddb](https://github.com/siteboon/claudecodeui/commit/d0adddbbdafecfd5713a8ac5b95c87a8f7fc54f8))
* **opencode:** bind watcher sessions to app rows early ([5b9adbb](https://github.com/siteboon/claudecodeui/commit/5b9adbbdee8561439a27ad90744388225823427b))
* **opencode:** pass workspace dir explicitly ([416a737](https://github.com/siteboon/claudecodeui/commit/416a737d76e654d2fc649206c2b921a7db150775))
* recover pending permission requests ([56b2e14](https://github.com/siteboon/claudecodeui/commit/56b2e1405967c50301d0c773567349763edc8560))
* remove provider specific token usage calculator ([2abb456](https://github.com/siteboon/claudecodeui/commit/2abb45636b5e1109733cfa58c8ab92fd4c812165))
* resolve session provider on backend reads ([9fb2d91](https://github.com/siteboon/claudecodeui/commit/9fb2d91b26bef9579337d953a29718802c466fed))
* **sessions:** canonicalize sidebar ids and timestamps ([3bbb42c](https://github.com/siteboon/claudecodeui/commit/3bbb42c23324c3cbb5587f2bcab09b1dc23086a8))
* **shell:** prioritize user npm binaries ([#913](https://github.com/siteboon/claudecodeui/issues/913)) ([4a503b1](https://github.com/siteboon/claudecodeui/commit/4a503b1dc87ff58821670c8bfb1d8a8c1dab2bcf))
* **shell:** use correct session id ([89f0524](https://github.com/siteboon/claudecodeui/commit/89f05247eddec4fe53bd1616c6a5563e3ae2427a))
* **sidebar:** align session status controls across layouts ([1b336e9](https://github.com/siteboon/claudecodeui/commit/1b336e9aa9d2cccf0676d852815d9ba613ac04d2))
* upgrade gemini logo ([9cb2afd](https://github.com/siteboon/claudecodeui/commit/9cb2afd67eb25a4f869b88abcf86f7748b2b6d71))
* voice tts format settings ([#919](https://github.com/siteboon/claudecodeui/issues/919)) ([591e8e7](https://github.com/siteboon/claudecodeui/commit/591e8e7642589b0584f9b29b46b881aaab54624e))
### Documentation
* update available plugin readmes ([f549bd9](https://github.com/siteboon/claudecodeui/commit/f549bd99e7106362a27cf4ccee6e9d434b8b5363))
* update session activity guard comment ([e23e6af](https://github.com/siteboon/claudecodeui/commit/e23e6af06a44cc4b016df5778984602d49e52629))
### Maintenance
* add github issues board plugin ([21b0f14](https://github.com/siteboon/claudecodeui/commit/21b0f14e7a86f257c65484742c43b9f85152b32c))
* add more plugins list ([bc34085](https://github.com/siteboon/claudecodeui/commit/bc34085af9912da8d8592881a5845cff84a53f7d))
* move tests to appropriate folder ([d7a38a5](https://github.com/siteboon/claudecodeui/commit/d7a38a567a5e9039935353a886310b3c32b25a79))
* move tests to appropriate folder ([c6c153e](https://github.com/siteboon/claudecodeui/commit/c6c153e7f2a60572b08d687b59f010b4ad4f5d72))
* remove a log ([00e526b](https://github.com/siteboon/claudecodeui/commit/00e526b6e90ee0baf09ebf48873bc10824ab80ba))
* remove unused modelConstants from the project ([92de0ed](https://github.com/siteboon/claudecodeui/commit/92de0ed6137bf4571056deb3b930cc9fd22e2a08))
* upgrade gemini models ([3d94821](https://github.com/siteboon/claudecodeui/commit/3d948217ef3084e764171ebc5dda55f663150b2c))
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
### New Features

View File

@@ -164,14 +164,6 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
| Plugin | Beschreibung |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
### Eigenes Plugin erstellen

View File

@@ -158,14 +158,6 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
| プラグイン | 説明 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
### 自作する

View File

@@ -158,14 +158,6 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
| 플러그인 | 설명 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
### 직접 만들기

View File

@@ -59,7 +59,6 @@
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Browser Use** - Open browser sessions for web research, testing, and agent-driven browser tasks
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
@@ -74,11 +73,6 @@ The fastest way to get started — no local setup required. Get a fully managed,
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### Desktop App
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
### Self-Hosted (Open source)
@@ -169,15 +163,8 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
| Plugin | Description |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI|
### Build Your Own
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.

View File

@@ -164,14 +164,6 @@ CloudCLI UI — это open source UI-слой, на котором постро
| Плагин | Описание |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
### Создать свой

View File

@@ -164,13 +164,6 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
### Kendi Eklentini Yaz

View File

@@ -158,14 +158,6 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
| 插件 | 描述 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
### 自行构建

View File

@@ -158,14 +158,6 @@ CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Nod
| 外掛 | 描述 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用於檢視、篩選和啟動代理任務的任務佇列儀表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用於 GitHub Issues 的看板,支援 TaskMaster 雙向同步和 /github-task CLI 技能自動安裝 |
### 自行建構

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,260 +0,0 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { safeStorage } from 'electron';
const CLOUD_API_TIMEOUT_MS = 15000;
function encryptSecret(secret) {
if (!safeStorage.isEncryptionAvailable()) {
return { encrypted: false, value: secret };
}
return {
encrypted: true,
value: safeStorage.encryptString(secret).toString('base64'),
};
}
function decryptSecret(record) {
if (!record?.value) return null;
if (!record.encrypted) return record.value;
try {
return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
} catch {
return null;
}
}
export class CloudController {
constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
this.storePath = storePath;
this.controlPlaneUrl = controlPlaneUrl;
this.callbackUrl = callbackUrl;
this.onChange = onChange;
this.cloudAccount = null;
this.cloudEnvironments = [];
this.authState = 'logged_out';
}
getAccount() {
return this.cloudAccount;
}
getAuthState() {
return this.authState;
}
getEnvironments() {
return this.cloudEnvironments;
}
getEnvironmentUrl(environment) {
return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
}
async getEnvironmentLaunchUrl(environment) {
if (!environment?.id) {
return this.getEnvironmentUrl(environment);
}
const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
method: 'POST',
});
return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
}
findEnvironment(environmentId) {
return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
}
async loadCloudAccount() {
try {
const raw = await fs.readFile(this.storePath, 'utf8');
const stored = JSON.parse(raw);
const apiKey = decryptSecret(stored.apiKey);
this.cloudAccount = {
deviceId: stored.deviceId || crypto.randomUUID(),
email: stored.email || null,
apiKey: apiKey || null,
};
this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
return this.cloudAccount;
} catch {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
this.authState = 'logged_out';
return this.cloudAccount;
}
}
async saveCloudAccount(account) {
const payload = {
deviceId: account.deviceId || crypto.randomUUID(),
email: account.email || null,
apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
};
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
this.cloudAccount = {
deviceId: payload.deviceId,
email: payload.email,
apiKey: account.apiKey || null,
};
this.authState = account.apiKey ? 'connected' : 'logged_out';
this.onChange?.();
return this.cloudAccount;
}
async clearCloudAccount() {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
this.cloudEnvironments = [];
this.authState = 'logged_out';
await fs.rm(this.storePath, { force: true });
this.onChange?.();
}
async invalidateCloudAccount() {
this.cloudEnvironments = [];
if (!this.cloudAccount) {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
} else {
this.cloudAccount = {
...this.cloudAccount,
apiKey: null,
};
}
this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
const payload = {
deviceId: this.cloudAccount.deviceId,
email: this.cloudAccount.email || null,
apiKey: null,
};
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
this.onChange?.();
}
async cloudApi(pathname, options = {}) {
if (!this.cloudAccount?.apiKey) {
throw new Error('Connect your CloudCLI account first.');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
let response;
try {
response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
...options,
signal: options.signal || controller.signal,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.cloudAccount.apiKey,
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
const body = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
await this.invalidateCloudAccount();
}
throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
}
return body;
}
async refreshCloudEnvironments() {
if (!this.cloudAccount?.apiKey) {
this.cloudEnvironments = [];
this.onChange?.();
return [];
}
const data = await this.cloudApi('/api/v1/environments');
this.cloudEnvironments = data.environments || [];
this.onChange?.();
return this.cloudEnvironments;
}
async startEnvironment(environment) {
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
method: 'POST',
});
}
async stopEnvironment(environment) {
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
method: 'POST',
});
}
async getEnvironmentCredentials(environment) {
return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
}
async startEnvironmentAndWait(environment, timeoutMs) {
await this.startEnvironment(environment);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const environments = await this.refreshCloudEnvironments();
const current = environments.find((env) => env.id === environment.id);
if (current?.status === 'running') {
return current;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error(`${environment.name} did not become ready in time.`);
}
buildConnectUrl() {
if (!this.cloudAccount?.deviceId) {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
}
const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
connectUrl.searchParams.set('callback_url', this.callbackUrl);
connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
connectUrl.searchParams.set('client_platform', 'desktop');
return connectUrl.toString();
}
async saveFromCallback({ apiKey, email }) {
await this.saveCloudAccount({
deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
email,
apiKey,
});
return this.cloudAccount;
}
}

View File

@@ -1,378 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { Notification } from 'electron';
import WebSocket from 'ws';
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const TARGET_REGISTER_TIMEOUT_MS = 8000;
function toNotificationsWsUrl(httpUrl) {
try {
const parsed = new URL(httpUrl);
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
parsed.pathname = '/desktop-notifications';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return null;
}
}
function readJsonMessage(raw) {
try {
return JSON.parse(String(raw));
} catch {
return null;
}
}
async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS);
try {
const response = await fetch(url, {
method,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...headers,
},
...(body == null ? {} : { body: JSON.stringify(body) }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `Request failed with status ${response.status}`);
}
return payload;
} finally {
clearTimeout(timeout);
}
}
export class DesktopNotificationsController {
constructor({
settingsPath,
appVersion,
appName,
getDeviceId,
getAccountEmail,
getRunningEnvironmentUrls,
getApiKey,
getAuthToken,
getIconPath,
openNotificationTarget,
onChange,
}) {
this.settingsPath = settingsPath;
this.appVersion = appVersion;
this.appName = appName;
this.getDeviceId = getDeviceId;
this.getAccountEmail = getAccountEmail;
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
this.getApiKey = getApiKey;
this.getAuthToken = getAuthToken;
this.getIconPath = getIconPath;
this.openNotificationTarget = openNotificationTarget;
this.onChange = onChange;
this.settings = { enabled: false };
this.connections = new Map();
this.lastEvent = null;
this.lastError = null;
}
getState() {
const connectedTargets = [];
for (const [url, connection] of this.connections.entries()) {
if (connection.ws?.readyState === WebSocket.OPEN) {
connectedTargets.push(url);
}
}
return {
enabled: this.settings.enabled,
supported: Notification.isSupported(),
targetCount: this.connections.size,
connectedCount: connectedTargets.length,
connectedTargets,
lastEvent: this.lastEvent,
lastError: this.lastError,
};
}
async loadSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.settings = { enabled: Boolean(stored.enabled) };
} catch {
this.settings = { enabled: false };
}
return this.settings;
}
async saveSettings(next) {
const enabled = Boolean(next?.enabled);
if (!enabled && this.settings.enabled) {
await this.disableCurrentTargets();
}
this.settings = { enabled };
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
await this.sync();
this.onChange?.();
return this.settings;
}
async sync() {
if (!this.settings.enabled) {
this.stop();
this.lastEvent = 'disabled';
this.onChange?.();
return;
}
if (!Notification.isSupported()) {
this.stop();
this.lastEvent = 'unsupported';
this.lastError = 'Native notifications are not supported on this system.';
this.onChange?.();
return;
}
const deviceId = this.getDeviceId?.();
if (!deviceId) {
this.stop();
this.lastEvent = 'missing-device';
this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.';
this.onChange?.();
return;
}
const targets = (this.getRunningEnvironmentUrls?.() || [])
.map((httpUrl) => ({
httpUrl,
wsUrl: toNotificationsWsUrl(httpUrl),
}))
.filter((target) => target.wsUrl);
const nextWsUrls = new Set(targets.map((target) => target.wsUrl));
for (const [wsUrl, connection] of this.connections.entries()) {
if (!nextWsUrls.has(wsUrl)) {
this.closeConnection(connection);
this.connections.delete(wsUrl);
}
}
for (const target of targets) {
if (!this.connections.has(target.wsUrl)) {
void this.connect(target).catch((error) => {
this.lastEvent = 'connect-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}
}
this.lastEvent = targets.length ? 'sync' : 'no-targets';
this.onChange?.();
}
async connect(target, attempt = 0) {
const existing = this.connections.get(target.wsUrl);
if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) {
return;
}
const connection = {
...target,
ws: null,
reconnectTimer: null,
closed: false,
attempt,
};
this.connections.set(target.wsUrl, connection);
const headers = await this.getTargetAuthHeaders(target.httpUrl);
if (connection.closed || this.connections.get(target.wsUrl) !== connection) {
return;
}
const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined });
connection.ws = ws;
ws.on('open', async () => {
try {
await this.registerTarget(target.httpUrl);
ws.send(JSON.stringify({
type: 'register',
deviceId: this.getDeviceId?.(),
label: this.getAccountEmail?.() || this.appName,
platform: process.platform,
appVersion: this.appVersion,
}));
connection.attempt = 0;
this.lastEvent = 'connected';
this.lastError = null;
this.onChange?.();
} catch (error) {
this.lastEvent = 'register-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
try { ws.close(); } catch {}
}
});
ws.on('message', (raw) => this.handleMessage(target, ws, raw));
ws.on('close', () => this.scheduleReconnect(target.wsUrl));
ws.on('error', (error) => {
this.lastEvent = 'socket-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}
async registerTarget(httpUrl) {
const url = new URL('/api/notifications/endpoints/current', httpUrl).toString();
await requestJson(url, {
method: 'POST',
headers: await this.getTargetAuthHeaders(httpUrl),
body: {
channel: 'desktop',
endpointId: this.getDeviceId?.(),
label: this.getAccountEmail?.() || this.appName,
metadata: {
platform: process.platform,
appVersion: this.appVersion,
},
enabled: true,
},
});
}
async disableCurrentTargets() {
const deviceId = this.getDeviceId?.();
if (!deviceId) return;
const targets = new Set([
...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean),
...(this.getRunningEnvironmentUrls?.() || []),
]);
const results = await Promise.allSettled([...targets].map(async (httpUrl) => {
const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString();
await requestJson(url, {
method: 'PATCH',
headers: await this.getTargetAuthHeaders(httpUrl),
body: { enabled: false },
});
}));
const rejected = results.find((result) => result.status === 'rejected');
if (rejected) {
this.lastEvent = 'disable-endpoint-error';
this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
}
}
async getTargetAuthHeaders(httpUrl) {
const headers = {};
const apiKey = this.getApiKey?.();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null);
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
return headers;
}
handleMessage(target, ws, raw) {
const message = readJsonMessage(raw);
if (!message || message.type !== 'notification' || !message.payload) {
return;
}
const shown = this.showNativeNotification(target, message.payload);
if (shown && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'notification_ack',
id: message.id || message.payload?.data?.tag || null,
action: 'shown',
}));
}
}
showNativeNotification(target, payload) {
if (!Notification.isSupported()) return false;
const notification = new Notification({
title: payload.title || this.appName,
body: payload.body || '',
icon: this.getIconPath?.(),
silent: false,
});
notification.on('click', () => {
void this.openNotificationTarget?.({
environmentUrl: target.httpUrl,
sessionId: payload.data?.sessionId || null,
provider: payload.data?.provider || null,
}).catch((error) => {
this.lastEvent = 'click-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
});
notification.show();
this.lastEvent = 'notification-shown';
this.lastError = null;
this.onChange?.();
return true;
}
scheduleReconnect(wsUrl) {
const connection = this.connections.get(wsUrl);
if (!connection || connection.closed || !this.settings.enabled) {
return;
}
const attempt = connection.attempt + 1;
connection.attempt = attempt;
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5)));
connection.reconnectTimer = setTimeout(() => {
if (!this.connections.has(wsUrl) || !this.settings.enabled) return;
void this.connect({
httpUrl: connection.httpUrl,
wsUrl: connection.wsUrl,
}, attempt).catch((error) => {
this.lastEvent = 'connect-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}, delay);
this.lastEvent = 'reconnecting';
this.onChange?.();
}
closeConnection(connection) {
connection.closed = true;
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer);
connection.reconnectTimer = null;
}
try { connection.ws?.close(); } catch {}
}
stop() {
for (const connection of this.connections.values()) {
this.closeConnection(connection);
}
this.connections.clear();
this.onChange?.();
}
}

View File

@@ -1,766 +0,0 @@
import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session, webContents as electronWebContents } from 'electron';
import { ViewHost } from './viewHost.js';
const TITLEBAR_HEIGHT = 44;
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
try {
const source = new URL(sourceUrl);
if ((source.hostname === '127.0.0.1' || source.hostname === 'localhost') && source.protocol === 'http:') {
return true;
}
if (source.protocol !== 'https:') {
return false;
}
const controlPlane = new URL(controlPlaneUrl);
return source.origin === controlPlane.origin || source.hostname.endsWith('.cloudcli.ai');
} catch {
return false;
}
}
function getWebContentsProcessId(contents) {
return {
osProcessId: typeof contents.getOSProcessId === 'function' ? contents.getOSProcessId() : null,
processId: typeof contents.getProcessId === 'function' ? contents.getProcessId() : null,
};
}
export class DesktopWindowManager {
constructor({
appName,
getWindowIconPath,
getLauncherPath,
getPreloadPath,
openExternalUrl,
getDesktopState,
getDisplayTargetName,
getRemoteEnvironmentMenuItems,
getCloudState,
getLocalState,
actions,
tabs,
}) {
this.appName = appName;
this.getWindowIconPath = getWindowIconPath;
this.getLauncherPath = getLauncherPath;
this.getPreloadPath = getPreloadPath;
this.openExternalUrl = openExternalUrl;
this.getDesktopState = getDesktopState;
this.getDisplayTargetName = getDisplayTargetName;
this.getRemoteEnvironmentMenuItems = getRemoteEnvironmentMenuItems;
this.getCloudState = getCloudState;
this.getLocalState = getLocalState;
this.actions = actions;
this.tabs = tabs;
this.mainWindow = null;
this.settingsWindow = null;
this.tray = null;
this.launcherLoaded = false;
this.viewHost = new ViewHost({
appName: this.appName,
getMainWindow: () => this.mainWindow,
getContentViewBounds: () => this.getContentViewBounds(),
getPreloadPath: this.getPreloadPath,
openExternalUrl: this.openExternalUrl,
showError: this.actions.showError,
});
}
getMainWindow() {
return this.mainWindow;
}
getTrayImage() {
const image = nativeImage.createFromPath(this.getWindowIconPath());
return image.resize({ width: 18, height: 18 });
}
getContentViewBounds() {
if (!this.mainWindow) return { x: 0, y: TITLEBAR_HEIGHT, width: 0, height: 0 };
const [width, height] = this.mainWindow.getContentSize();
return {
x: 0,
y: TITLEBAR_HEIGHT,
width,
height: Math.max(0, height - TITLEBAR_HEIGHT),
};
}
detachActiveContentView() {
this.viewHost.detachAll();
}
async showTabPlaceholder(target, message) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showTabPlaceholder(tabId, target, message);
}
async showLocalStartupTarget(target, logs) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showLocalStartupTarget(tabId, target, logs);
}
async showContentTarget(target) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showContentTarget(tabId, target);
}
destroyTabView(tabId) {
this.viewHost.destroyTabView(tabId);
}
emitDesktopState() {
const state = this.getDesktopState();
if (this.mainWindow && !this.mainWindow.webContents.isDestroyed()) {
this.mainWindow.webContents.send('cloudcli-desktop:state-updated', state);
}
if (this.settingsWindow && !this.settingsWindow.webContents.isDestroyed()) {
this.settingsWindow.webContents.send('cloudcli-desktop:state-updated', state);
}
}
emitLauncherCommand(command) {
if (!this.mainWindow || this.mainWindow.webContents.isDestroyed()) return;
this.mainWindow.webContents.send('cloudcli-desktop:launcher-command', command);
}
emitSettingsCommand(command) {
if (!this.settingsWindow || this.settingsWindow.webContents.isDestroyed()) return;
this.settingsWindow.webContents.send('cloudcli-desktop:launcher-command', command);
}
syncSettingsWindowBounds() {
if (!this.mainWindow || !this.settingsWindow || this.settingsWindow.isDestroyed()) return;
this.settingsWindow.setBounds(this.mainWindow.getBounds());
}
async ensureSettingsWindow(sheet = 'desktop-settings') {
if (!this.mainWindow) return null;
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.syncSettingsWindowBounds();
this.emitSettingsCommand({ type: 'open-sheet', sheet });
this.settingsWindow.focus();
return this.settingsWindow;
}
this.settingsWindow = new BrowserWindow({
parent: this.mainWindow,
show: false,
frame: false,
transparent: true,
hasShadow: false,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
movable: false,
skipTaskbar: true,
backgroundColor: '#00000000',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.syncSettingsWindowBounds();
this.viewHost.configureChildWebContents(this.settingsWindow.webContents);
this.settingsWindow.once('ready-to-show', () => this.settingsWindow?.show());
this.settingsWindow.on('closed', () => {
this.settingsWindow = null;
});
await this.settingsWindow.loadFile(this.getLauncherPath(), {
query: { modal: '1', sheet },
});
return this.settingsWindow;
}
closeSettingsWindow() {
if (!this.settingsWindow || this.settingsWindow.isDestroyed()) return;
this.settingsWindow.close();
}
async showTarget(target, { trackTab = true } = {}) {
if (!this.mainWindow) return;
if (trackTab) {
this.tabs.upsertTarget(target);
}
this.actions.setActiveTarget(target);
this.buildAppMenu();
this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
const finalUrl = await this.showContentTarget(target);
this.emitDesktopState();
return finalUrl;
}
async showLauncher() {
if (!this.mainWindow) return;
const target = { kind: 'launcher', name: this.appName, url: null };
this.tabs.upsertTarget(target);
this.actions.setActiveTarget(target);
this.detachActiveContentView();
this.buildAppMenu();
this.mainWindow.setTitle(this.appName);
this.mainWindow.webContents.focus();
if (!this.launcherLoaded) {
await this.mainWindow.loadFile(this.getLauncherPath());
this.launcherLoaded = true;
} else {
this.emitDesktopState();
}
}
async switchDesktopTab(tabId) {
const tab = this.tabs.activate(tabId);
if (!tab || !this.mainWindow) return this.getDesktopState();
if (tab.id === 'home' || tab.kind === 'launcher') {
await this.showLauncher();
return this.getDesktopState();
}
if (!tab.target?.url) {
throw new Error('This tab does not have a target URL.');
}
await this.showTarget(tab.target, { trackTab: false });
return this.getDesktopState();
}
async reloadActiveTab() {
const activeTab = this.tabs.getActiveTab();
if (!activeTab || activeTab.id === 'home' || activeTab.kind === 'launcher') {
this.emitDesktopState();
return this.getDesktopState();
}
const reloaded = this.viewHost.reloadTab(activeTab.id);
if (!reloaded && activeTab.target?.url) {
await this.showTarget(activeTab.target, { trackTab: false });
}
this.emitDesktopState();
return this.getDesktopState();
}
async navigateActiveView(url) {
const navigated = await this.viewHost.navigateActiveView(url);
this.emitDesktopState();
return navigated;
}
async readAuthTokenForTarget(url) {
return this.viewHost.readLocalStorageValueForOrigin(url, AUTH_TOKEN_STORAGE_KEY);
}
openActiveTabDevTools() {
if (this.viewHost.openActiveViewDevTools()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.'));
}
reloadActiveBrowserViewForDiagnostics() {
if (this.viewHost.reloadActiveView()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before reloading the active BrowserView.'));
}
detachActiveBrowserViewForDiagnostics() {
if (this.viewHost.detachActiveView()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before detaching the active BrowserView.'));
}
copyWebContentsDiagnostics() {
const tabViewDiagnostics = this.viewHost.getTabViewDiagnostics();
const tabViewByContentsId = new Map(
tabViewDiagnostics
.filter((item) => item.webContentsId != null)
.map((item) => [item.webContentsId, item])
);
const rows = electronWebContents.getAllWebContents().map((contents) => {
const destroyed = contents.isDestroyed();
const processIds = destroyed ? { osProcessId: null, processId: null } : getWebContentsProcessId(contents);
const tabView = tabViewByContentsId.get(contents.id);
let owner = 'unknown';
if (this.mainWindow?.webContents?.id === contents.id) {
owner = 'main-window';
} else if (this.settingsWindow?.webContents?.id === contents.id) {
owner = 'settings-window';
} else if (tabView) {
owner = `browser-view:${tabView.tabId}`;
}
return {
id: contents.id,
owner,
osProcessId: processIds.osProcessId,
processId: processIds.processId,
url: destroyed ? null : contents.getURL(),
title: destroyed ? null : contents.getTitle(),
destroyed,
focused: destroyed || typeof contents.isFocused !== 'function' ? false : contents.isFocused(),
attached: tabView ? tabView.attached : null,
active: tabView ? tabView.active : null,
};
});
const activeTab = this.tabs.getActiveTab();
const diagnostics = {
generatedAt: new Date().toISOString(),
activeTabId: this.tabs.activeTabId,
activeTab: activeTab
? {
id: activeTab.id,
title: activeTab.title,
kind: activeTab.kind,
targetUrl: activeTab.target?.url || null,
}
: null,
tabViews: tabViewDiagnostics,
webContents: rows,
};
clipboard.writeText(JSON.stringify(diagnostics, null, 2));
}
async closeDesktopTab(tabId) {
const tab = this.tabs.remove(tabId);
if (!tab) return this.getDesktopState();
this.destroyTabView(tabId);
if (this.tabs.activeTabId === 'home') {
await this.showLauncher();
} else {
this.emitDesktopState();
}
return this.getDesktopState();
}
buildEnvironmentActionsSubmenu(environment) {
const items = [];
const statusSuffix = environment.status === 'running' ? '' : ` (${environment.status})`;
items.push({
label: 'Open Environment',
click: () => void this.actions.openEnvironmentInDesktop(environment)
.catch((error) => this.actions.showError(`Could not open ${environment.name || environment.subdomain}${statusSuffix}`, error)),
});
items.push({
label: 'Open in Browser',
click: () => void this.actions.openEnvironmentInBrowser(environment)
.catch((error) => this.actions.showError('Could not open environment in browser', error)),
});
items.push({
label: 'Open in VS Code',
click: () => void this.actions.openEnvironmentInIde(environment, 'vscode')
.catch((error) => this.actions.showError('Could not open environment in VS Code', error)),
});
items.push({
label: 'Open in Cursor',
click: () => void this.actions.openEnvironmentInIde(environment, 'cursor')
.catch((error) => this.actions.showError('Could not open environment in Cursor', error)),
});
items.push({
label: 'Open SSH Terminal',
click: () => void this.actions.openEnvironmentInSsh(environment)
.catch((error) => this.actions.showError('Could not open SSH terminal', error)),
});
items.push({
label: 'Copy Mobile/Web URL',
click: () => this.actions.copyText(this.actions.getEnvironmentUrl(environment)),
});
if (environment.status !== 'running') {
items.unshift({
label: environment.status === 'paused' ? 'Resume' : 'Start',
click: () => void this.actions.startEnvironment(environment)
.catch((error) => this.actions.showError('Could not start environment', error)),
});
}
if (environment.status === 'running') {
items.push({
label: 'Stop',
click: () => void this.actions.stopEnvironment(environment)
.catch((error) => this.actions.showError('Could not stop environment', error)),
});
}
return items;
}
buildTrayEnvironmentSection() {
const cloudState = this.getCloudState();
if (!cloudState.account?.apiKey) {
return [
{
label: cloudState.account?.email ? `Reconnect ${cloudState.account.email}` : 'Login',
click: () => void this.actions.connectCloudAccount()
.catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
];
}
if (!cloudState.environments.length) {
return [{ label: 'No environments found', enabled: false }];
}
return cloudState.environments.map((environment) => ({
label: `${environment.name || environment.subdomain} - ${environment.status}`,
submenu: this.buildEnvironmentActionsSubmenu(environment),
}));
}
buildAppMenu() {
if (!this.mainWindow) return;
const cloudState = this.getCloudState();
const localState = this.getLocalState();
const remoteItems = this.getRemoteEnvironmentMenuItems();
const cloudAccountLabel = cloudState.account?.apiKey
? (cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'CloudCLI Connected')
: (cloudState.account?.email ? `Reconnect: ${cloudState.account.email}` : 'Connect CloudCLI Account...');
const template = [
{
label: this.appName,
submenu: [
{ label: `About ${this.appName}`, role: 'about' },
{ type: 'separator' },
{
label: 'Show Launcher',
accelerator: 'CmdOrCtrl+Shift+L',
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
},
{
label: 'Switch Environment',
accelerator: 'CmdOrCtrl+Shift+E',
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
},
{
label: 'Diagnostics',
submenu: [
{
label: 'Copy Diagnostics',
click: () => void this.actions.copyDiagnostics(),
},
],
},
{ type: 'separator' },
{
label: process.platform === 'darwin' ? `Hide ${this.appName}` : 'Hide',
role: 'hide',
visible: process.platform === 'darwin',
},
{ label: 'Hide Others', role: 'hideOthers', visible: process.platform === 'darwin' },
{ label: 'Show All', role: 'unhide', visible: process.platform === 'darwin' },
{ type: 'separator', visible: process.platform === 'darwin' },
{ label: `Quit ${this.appName}`, accelerator: 'CmdOrCtrl+Q', role: 'quit' },
],
},
{
label: 'Environment',
submenu: [
{
label: 'Show Launcher',
accelerator: 'CmdOrCtrl+Shift+L',
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
},
{
label: 'Switch Environment',
accelerator: 'CmdOrCtrl+Shift+E',
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
},
{ type: 'separator' },
{
label: 'Open Local CloudCLI',
accelerator: 'CmdOrCtrl+L',
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
},
{
label: 'Open Local Web UI in Browser',
accelerator: 'CmdOrCtrl+Shift+W',
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
},
{
label: 'Copy Local Web URL',
accelerator: 'CmdOrCtrl+Shift+U',
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
},
{ type: 'separator' },
{
label: 'Keep Local Server Running After Quit',
type: 'checkbox',
checked: localState.desktopSettings.keepLocalServerRunning,
click: (menuItem) => void this.actions.updateDesktopSetting('keepLocalServerRunning', menuItem.checked)
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
},
{
label: 'Allow LAN Access to Local Server',
type: 'checkbox',
checked: localState.desktopSettings.exposeLocalServerOnNetwork,
click: (menuItem) => void this.actions.updateDesktopSetting('exposeLocalServerOnNetwork', menuItem.checked)
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
},
],
},
{
label: 'Cloud',
submenu: [
{
label: cloudAccountLabel,
accelerator: 'CmdOrCtrl+Shift+C',
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
{
label: 'Refresh Cloud Environments',
click: () => void this.actions.refreshCloudEnvironments().catch((error) => this.actions.showError('Could not load CloudCLI environments', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{
label: 'Logout CloudCLI Account',
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{ type: 'separator' },
{
label: 'Remote Environments',
submenu: remoteItems,
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{
label: 'Open Active Tab DevTools',
click: () => this.openActiveTabDevTools(),
},
{
label: 'Copy WebContents Diagnostics',
click: () => this.copyWebContentsDiagnostics(),
},
{
label: 'Reload Active BrowserView',
click: () => this.reloadActiveBrowserViewForDiagnostics(),
},
{
label: 'Detach Active BrowserView',
click: () => this.detachActiveBrowserViewForDiagnostics(),
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }] : []),
],
},
{
label: 'Help',
submenu: [
{
label: 'Open cloudcli.ai',
click: () => void this.actions.openCloudDashboard(),
},
{
label: 'Copy Diagnostics',
click: () => void this.actions.copyDiagnostics(),
},
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
this.buildTrayMenu();
}
buildTrayMenu() {
if (!this.tray) return;
const cloudState = this.getCloudState();
const localState = this.getLocalState();
const template = [
{
label: 'Local',
submenu: [
{
label: localState.localServerRunning ? 'Open Local in CloudCLI' : 'Start Local in CloudCLI',
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
},
{
label: 'Open Local in Browser',
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
},
{
label: 'Copy Local URL',
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
},
],
},
{
label: 'Cloud Environments',
submenu: this.buildTrayEnvironmentSection(),
},
{ type: 'separator' },
{
label: cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'Login',
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
{
label: 'Logout CloudCLI Account',
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{ type: 'separator' },
{
label: `Quit ${this.appName}`,
role: 'quit',
},
];
this.tray.setToolTip(`${this.appName}${this.actions.getActiveTarget()?.name ? ` - ${this.actions.getActiveTarget().name}` : ''}`);
this.tray.setContextMenu(Menu.buildFromTemplate(template));
}
async showDesktopSettings() {
if (!this.mainWindow) return this.getDesktopState();
await this.ensureSettingsWindow('desktop-settings');
return this.getDesktopState();
}
async showLocalSettings() {
if (!this.mainWindow) return this.getDesktopState();
await this.ensureSettingsWindow('local-settings');
return this.getDesktopState();
}
async showActiveEnvironmentActionsMenu() {
if (!this.mainWindow) return this.getDesktopState();
const activeTarget = this.actions.getActiveTarget();
if (activeTarget?.kind !== 'remote') return this.getDesktopState();
const environment = this.getCloudState().environments.find((item) => item.id === activeTarget.id);
if (!environment) return this.getDesktopState();
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
menu.popup({ window: this.mainWindow });
return this.getDesktopState();
}
async showEnvironmentActionsMenu(environmentId) {
if (!this.mainWindow) return this.getDesktopState();
const environment = this.getCloudState().environments.find((item) => item.id === environmentId);
if (!environment) return this.getDesktopState();
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
menu.popup({ window: this.mainWindow });
return this.getDesktopState();
}
configurePermissions() {
const isAllowedPermission = (webContents, permission) => {
const sourceUrl = webContents.getURL();
const allowedPermissions = new Set(['clipboard-read', 'media', 'notifications']);
return isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission);
};
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
callback(isAllowedPermission(webContents, permission));
});
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
if (!webContents) return false;
return isAllowedPermission(webContents, permission);
});
}
createTray() {
if (this.tray) return;
this.tray = new Tray(this.getTrayImage());
this.tray.on('click', () => {
if (!this.mainWindow) return;
if (this.mainWindow.isVisible()) {
this.mainWindow.focus();
} else {
this.mainWindow.show();
}
});
this.buildTrayMenu();
}
async createWindow() {
this.mainWindow = new BrowserWindow({
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
show: false,
backgroundColor: '#0f172a',
title: this.appName,
icon: this.getWindowIconPath(),
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { trafficLightPosition: { x: 18, y: 14 } }
: {
titleBarOverlay: {
color: nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f8fa',
symbolColor: nativeTheme.shouldUseDarkColors ? '#a1a1a1' : '#5b6470',
height: 44,
},
}),
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.mainWindow.once('ready-to-show', () => {
this.mainWindow?.show();
});
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
return { action: 'deny' };
});
this.mainWindow.on('resize', () => {
this.viewHost.resizeActiveView();
this.syncSettingsWindowBounds();
});
this.mainWindow.on('move', () => {
this.syncSettingsWindowBounds();
});
this.mainWindow.on('closed', () => {
this.viewHost.clear();
this.settingsWindow = null;
this.mainWindow = null;
this.launcherLoaded = false;
});
this.buildAppMenu();
await this.showLauncher();
}
}

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; connect-src *; img-src 'self' data:" />
<title>CloudCLI Desktop</title>
<link rel="stylesheet" href="./launcher.css" />
</head>
<body>
<div id="app"></div>
<script src="./launcher.js"></script>
</body>
</html>

View File

@@ -1,801 +0,0 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
html.cc-modal-window,
body.cc-modal-window {
background: transparent;
}
:root {
--bg: #111315;
--s1: #171a1d;
--s2: #1e2328;
--s3: #262d34;
--b-subtle: #28303a;
--b: #313b46;
--b-strong: #42505f;
--tx: #f5f7fa;
--tx2: #adb8c5;
--tx3: #7f8b98;
--brand: #0a66d9;
--brand-2: #5fa5ff;
--brand-faint: rgba(10, 102, 217, 0.14);
--ok: #2aa775;
--warn: #d48b20;
--err: #d65252;
--tab-hover-bg: rgba(255, 255, 255, 0.08);
--tab-active-bg: rgba(255, 255, 255, 0.14);
--mono: "SF Mono", "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f3f5f8;
--s1: #ffffff;
--s2: #f7f9fb;
--s3: #edf1f5;
--b-subtle: #e5eaf0;
--b: #d8dee6;
--b-strong: #c3ccd6;
--tx: #11151a;
--tx2: #566171;
--tx3: #7f8b98;
--brand: #0a66d9;
--brand-2: #0f5fc6;
--brand-faint: rgba(10, 102, 217, 0.09);
--ok: #1f8e61;
--warn: #b67515;
--err: #c24747;
--tab-hover-bg: rgba(15, 23, 42, 0.05);
--tab-active-bg: rgba(15, 23, 42, 0.08);
color-scheme: light;
}
body {
background: var(--bg);
color: var(--tx);
font-family: var(--sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
input {
font: inherit;
user-select: text;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
border: 0;
background: none;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
min-height: 0;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--b);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
}
svg {
display: block;
}
.mono {
font-family: var(--mono);
}
.lbl {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 1.1px;
text-transform: uppercase;
color: var(--tx3);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tx3);
flex: 0 0 auto;
display: inline-block;
}
.titlebar {
-webkit-app-region: drag;
display: flex;
align-items: center;
gap: 12px;
height: 44px;
padding: 0 12px;
border-bottom: 1px solid var(--b-subtle);
background: color-mix(in srgb, var(--s1) 90%, transparent);
flex: 0 0 auto;
}
.titlebar button,
.titlebar input,
.titlebar .no-drag {
-webkit-app-region: no-drag;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.brand .mk {
width: 22px;
height: 22px;
display: block;
flex: 0 0 auto;
object-fit: contain;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 0;
height: 32px;
padding: 0 13px;
border-radius: 9px;
border: 1px solid var(--b);
background: var(--s1);
color: var(--tx);
font-weight: 500;
transition: border-color 0.12s, background 0.12s, filter 0.12s;
}
.btn:hover {
border-color: var(--b-strong);
background: var(--s2);
}
.btn.pri {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.btn.pri:hover {
filter: brightness(1.05);
}
.btn.sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.icon-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 9px;
border: 1px solid transparent;
color: var(--tx2);
}
.icon-btn:hover {
background: var(--s2);
border-color: var(--b);
color: var(--tx);
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 22px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
background: var(--s2);
color: var(--tx2);
border: 1px solid var(--b-subtle);
white-space: nowrap;
}
.badge.ok {
color: var(--ok);
}
.badge.warn {
color: var(--warn);
}
.badge.idle {
color: var(--tx3);
}
.cc-body {
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
}
.statusbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
height: 27px;
padding: 0 12px;
border-top: 1px solid var(--b-subtle);
background: var(--s1);
font-size: 11px;
color: var(--tx2);
font-family: var(--mono);
}
.statusbar .sep {
opacity: 0.4;
}
.status-msg.progress {
color: var(--brand-2);
}
.status-msg.error {
color: var(--err);
}
.cc-overlay {
position: fixed;
inset: 0;
background: rgba(6, 8, 11, 0.28);
display: none;
z-index: 50;
align-items: center;
justify-content: center;
padding: 24px;
}
.cc-overlay.open {
display: flex;
}
.cc-sheet {
width: 620px;
max-width: min(92vw, 620px);
max-height: min(720px, 82vh);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 14px;
border: 1px solid var(--b);
background: color-mix(in srgb, var(--s1) 98%, transparent);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.34);
}
.cc-sheet-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 20px 18px;
border-bottom: 1px solid var(--b-subtle);
}
.cc-sheet-copy {
min-width: 0;
}
.cc-sheet-title {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
}
.cc-sheet-subtitle {
margin-top: 6px;
color: var(--tx2);
line-height: 1.45;
}
.cc-sheet-close {
flex: 0 0 auto;
}
.cc-sheet-body {
overflow: auto;
padding: 16px 20px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.cc-sheet-footer {
padding: 14px 20px 18px;
border-top: 1px solid var(--b-subtle);
}
.cc-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.cc-section-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.cc-section-title {
font-size: 14px;
font-weight: 600;
color: var(--tx);
}
.cc-section-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.cc-surface {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--b-subtle);
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--s2) 86%, transparent), var(--s1));
}
.cc-row2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.cc-meta {
color: var(--tx2);
font-size: 12px;
line-height: 1.45;
}
.cc-toggle,
.cc-choice {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 12px;
align-items: start;
color: var(--tx2);
font-size: 13px;
line-height: 1.45;
}
.cc-toggle input,
.cc-choice input {
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: var(--brand);
}
.cc-toggle b,
.cc-choice b {
color: var(--tx);
font-weight: 600;
}
.cc-choice-group {
gap: 12px;
}
.cc-permissions {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--b-subtle);
border-radius: 12px;
background: var(--s1);
}
.cc-note {
color: var(--tx2);
font-size: 12px;
line-height: 1.45;
}
.cc-permission-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--b-subtle);
}
.cc-permission-title {
color: var(--tx);
font-size: 13px;
font-weight: 600;
}
.cc-permission-detail {
margin-top: 2px;
color: var(--tx2);
font-size: 12px;
line-height: 1.4;
}
.cc-permission-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.cc-kv {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
color: var(--tx2);
}
.cc-kv span:last-child {
color: var(--tx);
font-weight: 500;
}
.cc-actions-inline {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
}
.cc-status-badge {
display: inline-flex;
align-items: center;
align-self: flex-start;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--b-subtle);
background: var(--s2);
font-size: 12px;
font-weight: 600;
}
.cc-status-badge.ok {
color: var(--ok);
}
.cc-status-badge.warn {
color: var(--warn);
}
.cc-status-badge.idle {
color: var(--tx3);
}
.v-sidebar {
display: grid;
grid-template-columns: 248px 1fr;
overflow: hidden;
}
.sb {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 12px;
border-right: 1px solid var(--b-subtle);
background: var(--s1);
overflow: auto;
}
.sb-grp {
display: flex;
flex-direction: column;
gap: 3px;
}
.sb-grp .lbl {
padding: 6px 8px;
}
.sb-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
color: var(--tx2);
text-align: left;
}
.sb-item > span:nth-child(2) {
flex: 1;
}
.sb-item .sb-meta {
font-size: 11px;
color: var(--tx3);
font-family: var(--mono);
}
.sb-item:hover {
background: var(--s2);
}
.sb-item.active {
background: var(--brand-faint);
color: var(--tx);
}
.sb-item.active svg {
color: var(--brand-2);
}
.sb-main {
overflow: auto;
padding: 24px;
min-width: 0;
}
.pane-h {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.pane-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.pane-sub {
margin: 4px 0 0;
color: var(--tx2);
font-size: 13px;
}
.card {
border: 1px solid var(--b);
border-radius: 14px;
background: color-mix(in srgb, var(--s1) 94%, transparent);
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 620px;
margin-bottom: 12px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.08);
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.card-tools {
display: flex;
align-items: center;
gap: 8px;
}
.card-t {
font-size: 15px;
font-weight: 600;
}
.card-sub {
margin-top: 4px;
color: var(--tx2);
font-size: 13px;
line-height: 1.45;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.env {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 12px 14px;
border: 1px solid var(--b);
border-radius: 12px;
background: var(--s1);
margin-bottom: 8px;
}
.env:hover {
border-color: var(--b-strong);
}
.env-i {
flex: 1;
min-width: 0;
}
.env-n {
font-weight: 500;
}
.env-u {
font-size: 12px;
color: var(--tx3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.env-tags {
display: flex;
gap: 6px;
}
.tag {
font-family: var(--mono);
font-size: 11px;
color: var(--tx2);
background: var(--s2);
border: 1px solid var(--b-subtle);
border-radius: 999px;
padding: 2px 7px;
white-space: nowrap;
}
.empty {
border: 1px dashed var(--b);
border-radius: 12px;
padding: 28px;
text-align: center;
color: var(--tx2);
max-width: 560px;
}
body.mac .titlebar {
padding-left: 92px;
padding-right: 12px;
}
body.win .titlebar {
padding-right: 150px;
}
.titlebar .brand {
margin-right: 6px;
}
.tb-tabs {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
overflow: hidden;
}
.tb-tab {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 112px;
max-width: 232px;
flex: 0 0 auto;
height: 30px;
padding: 0 7px 0 12px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--tx2);
font-size: 12px;
background: transparent;
transition: background 0.12s, color 0.12s;
}
.tb-tab:hover {
background: var(--tab-hover-bg);
}
.tb-tab.active {
background: var(--tab-active-bg);
color: var(--tx);
}
.tb-tab span:first-child {
flex: 1;
min-width: 0;
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-close {
display: grid;
width: 20px;
height: 20px;
margin-left: 8px;
place-items: center;
border-radius: 6px;
color: var(--tx3);
font-size: 14px;
line-height: 1;
flex: 0 0 auto;
}
.tb-close:hover {
background: var(--tab-hover-bg);
color: var(--tx);
}
.tb-action {
flex: 0 0 auto;
}
@media (max-width: 760px) {
.v-sidebar {
grid-template-columns: 1fr;
}
.sb {
flex-direction: row;
align-items: center;
overflow: auto;
}
.env-tags {
display: none;
}
.cc-sheet {
max-width: 100%;
}
.cc-row2 {
grid-template-columns: 1fr;
}
}

View File

@@ -1,687 +0,0 @@
window.__APP_VERSION__ = '1.34.0';
window.__MOCK_STATE__ = {
account: { connected: true, email: 'you@cloudcli.ai' },
activeTarget: { kind: 'launcher', name: 'Launcher', url: null },
cloudLoading: false,
desktopSettings: { keepLocalServerRunning: false, exposeLocalServerOnNetwork: false, themeMode: 'system' },
localWebUrl: 'http://localhost:3001',
shareableWebUrl: 'http://localhost:3001',
localServerRunning: false,
localStartupLogs: [],
environments: [
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
{ id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' },
{ id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' },
],
};
(function cloudCliLauncher() {
var MOCK = window.__MOCK_STATE__ || {};
var VERSION = window.__APP_VERSION__ || '';
var LOGO_URL = new URL('../../public/logo-32.png', window.location.href).toString();
var SEARCH = new URLSearchParams(window.location.search || '');
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
var mockState = clone(MOCK);
var mockBridge = {
getState: function () { return Promise.resolve(clone(mockState)); },
openLocal: function () {
mockState.localServerRunning = true;
mockState.activeTarget = { kind: 'local', name: 'Local CloudCLI', url: mockState.localWebUrl };
return Promise.resolve(clone(mockState));
},
openLocalWebUi: function () {
mockState.localServerRunning = true;
return Promise.resolve(clone(mockState));
},
copyLocalWebUrl: function () { return Promise.resolve(clone(mockState)); },
connectCloud: function () {
mockState.account = { connected: true, email: 'you@cloudcli.ai' };
return Promise.resolve(clone(mockState));
},
disconnectCloud: function () {
mockState.account = { connected: false, email: null };
mockState.environments = [];
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.kind !== 'remote'; });
mockState.activeTabId = 'home';
mockState.activeTarget = { kind: 'launcher', name: 'Launcher', url: null };
return Promise.resolve(clone(mockState));
},
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
refreshActiveTab: function () { return Promise.resolve(clone(mockState)); },
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
showLauncher: function () { return Promise.resolve(clone(mockState)); },
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
showDesktopSettings: function () { return Promise.resolve(clone(mockState)); },
closeSettingsWindow: function () { return Promise.resolve(clone(mockState)); },
showActiveEnvironmentActionsMenu: function () { return Promise.resolve(clone(mockState)); },
openCloudDashboard: function () { return Promise.resolve(clone(mockState)); },
runActiveEnvironmentAction: function () { return Promise.resolve(clone(mockState)); },
switchTab: function (id) { mockState.activeTabId = id; return Promise.resolve(clone(mockState)); },
closeTab: function (id) {
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.id === 'home' || tab.id !== id; });
if (mockState.activeTabId === id) mockState.activeTabId = 'home';
return Promise.resolve(clone(mockState));
},
updateSetting: function (key, value) {
mockState.desktopSettings = mockState.desktopSettings || {};
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
return Promise.resolve(clone(mockState));
},
openEnvironment: function (id) {
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
if (env) {
env.status = 'starting';
setTimeout(function () {
env.status = 'running';
mockState.activeTarget = { kind: 'remote', id: id, name: env.name, url: env.access_url };
}, 1700);
}
return Promise.resolve(clone(mockState));
},
};
var bridge = window.cloudcliDesktop || mockBridge;
var ICONS = {
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
cloud: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/>',
refresh: '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
settings: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6l-.03.08a2 2 0 1 1-3.94 0L10 20a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1l-.08-.03a2 2 0 1 1 0-3.94L4 10a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6l.03-.08a2 2 0 1 1 3.94 0L14 4a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.2.36.4.7.6 1l.08.03a2 2 0 1 1 0 3.94L20 14a1.7 1.7 0 0 0-.6 1z"/>',
play: '<polygon points="6 4 20 12 6 20 6 4"/>',
arrow: '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="8 7 17 7 17 16"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
cloudPlus: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/><line x1="12" y1="9" x2="12" y2="15"/><line x1="9" y1="12" x2="15" y2="12"/>',
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
phone: '<rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/>',
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
};
var FILLED = { play: true };
function icon(name, size) {
size = size || 16;
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="' + (FILLED[name] ? 'currentColor' : 'none') + '" stroke="' + (FILLED[name] ? 'none' : 'currentColor') + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
}
function esc(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function statusMeta(status) {
var map = {
running: { label: 'Running', cls: 'ok', dot: '#10b981', verb: 'Opening', open: 'Open' },
starting: { label: 'Starting', cls: 'warn', dot: '#f59e0b', verb: 'Starting', open: 'Open', busy: true },
stopped: { label: 'Stopped', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' },
paused: { label: 'Paused', cls: 'warn', dot: '#f59e0b', verb: 'Resuming', open: 'Resume' },
};
return map[status] || { label: status || 'Unknown', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' };
}
function connected(state) {
return !!(state && state.account && state.account.connected);
}
function authState(state) {
return state && state.account ? (state.account.authState || (state.account.connected ? 'connected' : 'logged_out')) : 'logged_out';
}
function accountLabel(state) {
if (authState(state) === 'expired') return 'Reconnect';
if (state && state.account && state.account.email) return state.account.email;
if (connected(state)) return 'Connected';
return 'Log in';
}
function localUrl(state) {
return (state && (state.shareableWebUrl || state.localWebUrl)) || '';
}
function envCount(state) {
var count = state && state.environments ? state.environments.length : 0;
return count + ' environment' + (count === 1 ? '' : 's');
}
function errMsg(error) {
return error && error.message ? error.message : String(error);
}
function resolveTheme(state) {
var settings = state && state.desktopSettings ? state.desktopSettings : {};
var mode = settings.themeMode || 'system';
if (mode === 'light' || mode === 'dark') return mode;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
var CC = {
icon: icon,
esc: esc,
statusMeta: statusMeta,
connected: connected,
authState: authState,
accountLabel: accountLabel,
localUrl: localUrl,
envCount: envCount,
version: VERSION,
logoUrl: LOGO_URL,
platform: 'win',
state: clone(MOCK),
ui: {},
_busyEnv: null,
_status: { msg: '', tone: '' },
_reg: {},
_wired: false,
_poll: null,
modalMode: SEARCH.get('modal') === '1',
};
window.CC = CC;
var app;
var overlay;
CC.setState = function (state) {
var currentSheet = CC.ui.openSheet || (CC.modalMode ? (CC.ui.initialSheet || 'desktop-settings') : null);
var sheetBody = overlay ? overlay.querySelector('.cc-sheet-body') : null;
var scrollTop = sheetBody ? sheetBody.scrollTop : 0;
if (state && typeof state === 'object') CC.state = state;
CC.applyTheme(CC.state);
CC.render(CC.state);
if (currentSheet) {
CC.openSheet(currentSheet, { scrollTop: scrollTop });
}
};
CC.applyTheme = function (state) {
var settings = state && state.desktopSettings ? state.desktopSettings : {};
var themeMode = settings.themeMode || 'system';
var resolvedTheme = resolveTheme(state);
document.documentElement.setAttribute('data-theme', resolvedTheme);
document.documentElement.setAttribute('data-theme-mode', themeMode);
};
CC.refresh = function () {
return Promise.resolve(bridge.getState()).then(function (state) {
CC.setState(state);
return state;
});
};
CC.run = function (label, fn) {
CC._status = { msg: label, tone: 'progress' };
CC.render(CC.state);
return Promise.resolve()
.then(fn)
.then(function (state) {
if (state && state.environments) CC.state = state;
return CC.refresh();
})
.then(function () {
CC._status = { msg: '', tone: '' };
CC.render(CC.state);
})
.catch(function (error) {
CC._status = { msg: errMsg(error), tone: 'error' };
CC.render(CC.state);
});
};
CC.startPolling = function () {
if (CC._poll) return;
var ticks = 0;
CC._poll = setInterval(function () {
ticks += 1;
Promise.resolve(bridge.getState()).then(function (state) {
CC.setState(state);
var anyStarting = (state.environments || []).some(function (environment) { return environment.status === 'starting'; });
if (!anyStarting || ticks > 16) {
clearInterval(CC._poll);
CC._poll = null;
if (!anyStarting) {
CC._status = { msg: '', tone: '' };
CC.render(CC.state);
}
}
});
}, 1500);
};
CC.openEnv = function (id) {
var env = (CC.state.environments || []).filter(function (environment) { return environment.id === id; })[0];
var meta = statusMeta(env ? env.status : '');
CC._busyEnv = id;
CC._status = { msg: (meta.verb || 'Opening') + ' ' + ((env && (env.name || env.subdomain)) || 'environment') + '...', tone: 'progress' };
if (env) {
var tabId = 'remote:' + env.id;
var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Launcher', kind: 'launcher', closable: false }];
tabs = tabs.map(function (tab) {
tab.active = false;
return tab;
});
var existing = tabs.filter(function (tab) { return tab.id === tabId; })[0];
if (existing) {
existing.active = true;
existing.title = env.name || env.subdomain;
} else {
tabs.push({ id: tabId, title: env.name || env.subdomain, kind: 'remote', closable: true, active: true });
}
CC.state.tabs = tabs;
CC.state.activeTabId = tabId;
}
if (env && env.status !== 'running') env.status = 'starting';
CC.render(CC.state);
return Promise.resolve(bridge.openEnvironment(id)).then(function (state) {
if (state && state.environments) CC.setState(state);
CC.startPolling();
}).catch(function (error) {
CC._busyEnv = null;
if (env) env.status = 'stopped';
CC._status = { msg: errMsg(error), tone: 'error' };
CC.render(CC.state);
});
};
CC.act = function (name, node) {
switch (name) {
case 'local':
return CC.run('Starting Local CloudCLI...', function () { return bridge.openLocal(); });
case 'connect':
return CC.run('Opening cloudcli.ai to connect your account...', function () { return bridge.connectCloud(); });
case 'logout':
return CC.run('Logging out...', function () { return bridge.disconnectCloud(); });
case 'open-web':
return CC.run('Opening local web UI in your browser...', function () { return bridge.openLocalWebUi(); });
case 'copy-web':
return CC.run('Copied local URL to clipboard', function () { return bridge.copyLocalWebUrl(); });
case 'diagnostics':
return CC.run('Copied diagnostics to clipboard', function () { return bridge.copyDiagnostics(); });
case 'set-setting':
return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
case 'set-theme-mode':
return CC.run('Saved', function () { return bridge.updateSetting('themeMode', node.value); });
case 'settings-toggle':
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
case 'desktop-settings-toggle':
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
case 'local-settings-toggle':
return CC.run('Opening local settings...', function () { return bridge.showLocalSettings(); });
case 'settings-close':
return CC.closeSheet();
case 'dashboard':
return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); });
case 'refresh-environments':
return CC.run('Refreshing cloud environments...', function () { return bridge.refreshEnvironments(); });
case 'refresh-tab':
return CC.run('Refreshing tab...', function () { return bridge.refreshActiveTab(); });
case 'env-action':
return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); });
case 'env-menu':
return CC.run('Opening environment actions...', function () { return bridge.showActiveEnvironmentActionsMenu(); });
case 'env-row-menu':
return CC.run('Opening environment actions...', function () { return bridge.showEnvironmentActionsMenu(node.getAttribute('data-cc-environment-id')); });
default:
return;
}
};
function renderTabs(state) {
var tabs = state.tabs && state.tabs.length ? state.tabs : [{ id: 'home', title: 'Home', closable: false, active: true }];
return tabs.map(function (tab) {
var title = tab.title || '';
var visibleChars = Math.min(title.length, 20);
var tabWidth = Math.max(112, Math.min(232, (visibleChars * 8) + (tab.closable ? 56 : 38)));
return '<button class="tb-tab no-drag' + (tab.active ? ' active' : '') + '" data-cc-tab="' + esc(tab.id) + '" title="' + esc(title) + '" style="width:' + tabWidth + 'px;flex-basis:' + tabWidth + 'px">' +
'<span>' + esc(title) + '</span>' +
(tab.closable ? '<span class="tb-close" data-cc-close-tab="' + esc(tab.id) + '" title="Close tab">&times;</span>' : '') +
'</button>';
}).join('');
}
CC.titlebar = function (state) {
var conn = connected(state);
var activeTab = (state.tabs || []).filter(function (tab) { return tab.active; })[0] || null;
var activeEnvironmentId = state.activeTarget && state.activeTarget.kind === 'remote' ? state.activeTarget.id : null;
if (!activeEnvironmentId && activeTab && /^remote:/.test(activeTab.id || '')) {
activeEnvironmentId = activeTab.id.replace(/^remote:/, '');
}
var activeRefreshable = (state.activeTarget && (state.activeTarget.kind === 'remote' || state.activeTarget.kind === 'local')) ||
(activeTab && activeTab.id !== 'home');
var envActions = activeEnvironmentId ? '<button class="btn sm tb-action no-drag" data-cc-action="env-row-menu" data-cc-environment-id="' + esc(activeEnvironmentId) + '" title="Open environment actions">Open environment in...</button>' : '';
var refreshAction = activeRefreshable ? '<button class="icon-btn tb-action no-drag" data-cc-action="refresh-tab" title="Refresh tab">' + icon('refresh', 16) + '</button>' : '';
var logoutAction = (conn || authState(state) === 'expired') ? '<button class="icon-btn tb-action no-drag" data-cc-action="logout" title="Logout">' + icon('logOut', 16) + '</button>' : '';
return '<div class="titlebar">' +
'<div class="brand"><img class="mk" src="' + esc(LOGO_URL) + '" alt=""><span>CloudCLI</span></div>' +
'<div class="tb-tabs no-drag">' + renderTabs(state) + '</div>' +
'<span style="flex:1"></span>' +
refreshAction +
envActions +
'<button class="btn sm tb-action no-drag" data-cc-action="connect" title="' + esc(authState(state) === 'expired' ? 'Reconnect your CloudCLI account' : accountLabel(state)) + '"><span class="dot" style="background:' + (conn ? 'var(--ok)' : (authState(state) === 'expired' ? 'var(--warn)' : 'var(--tx3)')) + '"></span>' + esc(accountLabel(state)) + '</button>' +
logoutAction +
'<button class="icon-btn tb-action no-drag" data-cc-action="settings-toggle" title="Settings">' + icon('settings', 16) + '</button>' +
'</div>';
};
CC.statusbar = function (state) {
var status = CC._status || {};
var running = !!state.localServerRunning;
return '<div class="statusbar">' +
'<span><span class="dot" style="width:7px;height:7px;background:' + (running ? 'var(--ok)' : 'var(--tx3)') + '"></span> local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + '</span>' +
'<span class="sep">·</span><span>' + esc(envCount(state)) + '</span>' +
'<span class="sep">·</span><span>' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + '</span>' +
'<span style="flex:1"></span>' +
(status.msg ? '<span class="status-msg ' + esc(status.tone) + '">' + esc(status.msg) + '</span><span class="sep">·</span>' : '') +
'<span>v' + esc(VERSION) + '</span>' +
'</div>';
};
CC.renderSheet = function (title, subtitle, sections, footer) {
overlay.innerHTML =
'<div class="cc-sheet cc-modal">' +
'<div class="cc-sheet-header">' +
'<div class="cc-sheet-copy"><div class="cc-sheet-title">' + esc(title) + '</div><div class="cc-sheet-subtitle">' + esc(subtitle || '') + '</div></div>' +
'<button class="icon-btn cc-sheet-close" data-cc-action="settings-close" title="Close">' + icon('x', 16) + '</button>' +
'</div>' +
'<div class="cc-sheet-body">' + sections.join('') + '</div>' +
(footer ? '<div class="cc-sheet-footer">' + footer + '</div>' : '') +
'</div>';
};
CC.renderSection = function (eyebrow, title, body) {
return '<section class="cc-section">' +
'<div class="cc-section-head">' +
'<div class="lbl">' + esc(eyebrow) + '</div>' +
'<div class="cc-section-title">' + esc(title) + '</div>' +
'</div>' +
'<div class="cc-section-body">' + body + '</div>' +
'</section>';
};
CC.renderRadioOption = function (name, value, checked, title, description) {
return '<label class="cc-choice">' +
'<input type="radio" name="' + esc(name) + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + '>' +
'<span><b>' + esc(title) + '</b><br>' + esc(description) + '</span>' +
'</label>';
};
CC.openSheet = function (sheet, options) {
options = options || {};
if (sheet === 'desktop-settings') {
CC.renderDesktopSettings();
} else {
CC.renderLocalSettings();
}
CC.ui.openSheet = sheet;
overlay.classList.add('open');
if (typeof options.scrollTop === 'number') {
var body = overlay.querySelector('.cc-sheet-body');
if (body) body.scrollTop = options.scrollTop;
}
};
CC.closeSheet = function () {
if (CC.modalMode && bridge.closeSettingsWindow) {
CC.ui.openSheet = null;
return bridge.closeSettingsWindow();
}
CC.ui.openSheet = null;
overlay.classList.remove('open');
};
CC.buildLocalServerSection = function (state, options) {
options = options || {};
var settings = state.desktopSettings || {};
var url = localUrl(state) || 'starts on demand';
var body = '<div class="cc-surface">' +
'<div class="cc-meta mono">' + esc(url) + '</div>' +
'<div class="cc-row2"><button class="btn sm" data-cc-action="open-web">' + icon('arrow', 14) + 'Open in browser</button><button class="btn sm" data-cc-action="copy-web">' + icon('copy', 14) + 'Copy URL</button></div>';
if (options.includePreferences) {
body +=
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + (settings.keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + (settings.exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>';
}
body += '</div>';
return CC.renderSection(
options.eyebrow || 'LOCAL SERVER',
options.title || 'Run Local CloudCLI on this machine',
body
);
};
CC.buildThemeSection = function (state) {
var settings = state.desktopSettings || {};
return CC.renderSection('APPEARANCE', 'Desktop theme', '' +
'<div class="cc-surface cc-choice-group">' +
CC.renderRadioOption('desktop-theme', 'system', settings.themeMode === 'system', 'System', 'Follow the operating system appearance.') +
CC.renderRadioOption('desktop-theme', 'light', settings.themeMode === 'light', 'Light', 'Use the light interface appearance.') +
CC.renderRadioOption('desktop-theme', 'dark', settings.themeMode === 'dark', 'Dark', 'Use the dark interface appearance.') +
'</div>'
);
};
CC.renderLocalSettings = function () {
var state = CC.state || {};
var sections = [
CC.buildLocalServerSection(state, { includePreferences: false }),
CC.renderSection('PREFERENCES', 'How the local service behaves', '' +
'<div class="cc-surface">' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + ((state.desktopSettings || {}).keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + ((state.desktopSettings || {}).exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>' +
'</div>'
),
];
CC.renderSheet('Local Settings', 'Manage how Local CloudCLI runs on this computer.', sections);
};
CC.renderDesktopSettings = function () {
var sections = [
CC.buildThemeSection(CC.state || {}),
];
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', sections);
};
CC.render = function (state) {
state = state || CC.state;
var titlebar = (CC._reg.titlebar || CC.titlebar)(state);
var statusbar = (CC._reg.statusbar || CC.statusbar)(state);
var body = CC._reg.renderBody ? CC._reg.renderBody(state) : '';
if (CC.modalMode) {
app.innerHTML = '';
} else {
app.innerHTML = titlebar + '<div class="cc-body ' + (CC._reg.bodyClass || '') + '">' + body + '</div>' + statusbar;
}
if (CC._reg.afterRender) CC._reg.afterRender(state);
};
function wireEvents() {
if (CC._wired) return;
CC._wired = true;
document.addEventListener('click', function (event) {
if (CC._reg.onClick && CC._reg.onClick(event)) return;
var closeTab = event.target.closest('[data-cc-close-tab]');
if (closeTab) {
event.stopPropagation();
CC.run('Closing tab...', function () { return bridge.closeTab(closeTab.getAttribute('data-cc-close-tab')); });
return;
}
var tab = event.target.closest('[data-cc-tab]');
if (tab) {
CC.run('Switching tab...', function () { return bridge.switchTab(tab.getAttribute('data-cc-tab')); });
return;
}
var action = event.target.closest('[data-cc-action]');
if (action) {
CC.act(action.getAttribute('data-cc-action'), action);
return;
}
var env = event.target.closest('[data-cc-env]');
if (env) {
CC.openEnv(env.getAttribute('data-cc-env'));
return;
}
if (overlay.classList.contains('open') && !event.target.closest('.cc-sheet')) {
CC.closeSheet();
}
});
document.addEventListener('change', function (event) {
var setting = event.target.closest('[data-cc-setting]');
if (setting) {
CC.act('set-setting', {
key: setting.getAttribute('data-cc-setting'),
value: setting.checked,
});
return;
}
var theme = event.target.closest('[name="desktop-theme"]');
if (theme) {
CC.act('set-theme-mode', { value: theme.value });
return;
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && overlay.classList.contains('open')) {
CC.closeSheet();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
event.preventDefault();
CC.act('settings-toggle');
return;
}
if (overlay.classList.contains('open')) return;
if (CC._reg.onKey) CC._reg.onKey(event, CC.state);
});
}
function boot() {
app = document.getElementById('app');
overlay = document.createElement('div');
overlay.id = 'cc-overlay';
overlay.className = 'cc-overlay';
document.body.appendChild(overlay);
var isMac = /Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
var isWin = /Win/i.test(navigator.platform);
CC.platform = isMac ? 'mac' : (isWin ? 'win' : 'linux');
document.body.classList.add(CC.platform);
CC.ui.initialSheet = SEARCH.get('sheet') || 'desktop-settings';
if (CC.modalMode) {
document.documentElement.classList.add('cc-modal-window');
document.body.classList.add('cc-modal-window');
}
wireEvents();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
CC.applyTheme(CC.state);
});
}
if (bridge.onStateUpdated) {
bridge.onStateUpdated(function (state) { CC.setState(state); });
}
if (bridge.onLauncherCommand) {
bridge.onLauncherCommand(function (command) {
if (command && command.type === 'open-sheet') {
CC.ui.initialSheet = command.sheet || CC.ui.initialSheet || 'desktop-settings';
CC.openSheet(command.sheet);
}
});
}
CC.refresh().catch(function (error) {
CC._status = { msg: errMsg(error), tone: 'error' };
CC.render(CC.state);
});
}
CC.register = function (registry) {
CC._reg = registry || {};
};
CC.start = function () {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
};
})();
(function sidebarApp() {
var CC = window.CC;
function navItem(id, iconName, label, meta, selected) {
return '<button class="sb-item' + (selected === id ? ' active' : '') + '" data-cc-nav="' + id + '">' +
CC.icon(iconName, 16) + '<span>' + label + '</span><span class="sb-meta">' + CC.esc(meta) + '</span></button>';
}
function localPane(state) {
return '<div class="pane-h"><div><h2 class="pane-title">Local servers</h2><p class="pane-sub">Manage Local CloudCLI on this machine. No account required.</p></div></div>' +
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>';
}
function envRow(environment) {
var meta = CC.statusMeta(environment.status);
var tags = (environment.agent ? '<span class="tag">' + CC.esc(environment.agent) + '</span>' : '') + (environment.region ? '<span class="tag">' + CC.esc(environment.region) + '</span>' : '');
return '<div class="env" data-cc-env="' + environment.id + '"><span class="dot" style="background:' + meta.dot + '"></span>' +
'<div class="env-i"><div class="env-n">' + CC.esc(environment.name || environment.subdomain) + '</div><div class="env-u mono">' + CC.esc(environment.access_url || '') + '</div></div>' +
'<div class="env-tags">' + tags + '</div>' +
'<span class="badge ' + meta.cls + '">' + meta.label + '</span>' +
'<button class="btn sm" data-cc-action="env-row-menu" data-cc-environment-id="' + environment.id + '">Open environment in...</button>' +
'<button class="btn sm ' + (environment.status === 'running' ? 'pri' : '') + '">' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + '</button></div>';
}
function cloudPane(state) {
var header = '<div class="pane-h"><div><h2 class="pane-title">Environments</h2><p class="pane-sub">' + CC.esc(CC.envCount(state)) + '</p></div><button class="btn sm" data-cc-action="dashboard">' + CC.icon('arrow', 14) + 'Dashboard</button></div>';
if (CC.authState(state) === 'expired') {
return header + '<div class="empty">Your CloudCLI session expired.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Reconnect account</button></div></div>';
}
if (!CC.connected(state)) {
return header + '<div class="empty">Connect your CloudCLI account to list hosted environments.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Connect account</button></div></div>';
}
if (state.cloudLoading && !(state.environments || []).length) {
return header + '<div class="empty">Loading your CloudCLI environments...</div>';
}
var list = (state.environments || []).map(envRow).join('');
if (!list) list = '<div class="empty">No hosted environments yet.</div>';
return header + list;
}
function renderBody(state) {
var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
CC.ui.section = section;
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Launcher</div>' +
navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
navItem('cloud', 'cloud', 'Cloud environments', (state.environments || []).length, section) +
'</div></div>';
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
}
function onClick(event) {
var nav = event.target.closest('[data-cc-nav]');
if (!nav) return false;
CC.ui.section = nav.getAttribute('data-cc-nav');
CC.render(CC.state);
return true;
}
CC.register({
bodyClass: 'v-sidebar',
renderBody: renderBody,
onClick: onClick,
});
CC.start();
})();

View File

@@ -1,550 +0,0 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { ServerInstaller } from './serverInstaller.js';
const DEFAULT_PORT = 3001;
const HOST = '127.0.0.1';
const DISPLAY_HOST = 'localhost';
const HEALTH_TIMEOUT_MS = 1000;
const SERVER_START_TIMEOUT_MS = 30000;
const MAX_STARTUP_LOG_LINES = 300;
const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
const LOCAL_SERVER_URL_ENV_KEYS = [
'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
'CLOUDCLI_LOCAL_SERVER_URL',
'ELECTRON_LOCAL_SERVER_URL',
];
const LOCAL_SERVER_PORT_ENV_KEYS = [
'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
'CLOUDCLI_SERVER_PORT',
'SERVER_PORT',
'PORT',
];
function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
return new Promise((resolve) => {
const req = http.get(url, { timeout: timeoutMs }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
try {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
json: JSON.parse(body),
});
} catch {
resolve({ ok: false, json: null });
}
});
});
req.on('timeout', () => {
req.destroy();
resolve({ ok: false, json: null });
});
req.on('error', () => resolve({ ok: false, json: null }));
});
}
async function isCloudCliServer(baseUrl) {
const response = await requestJson(`${baseUrl}/health`);
return response.ok
&& response.json?.status === 'ok'
&& typeof response.json?.installMode === 'string';
}
function isPortAvailable(port, host = HOST) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, host);
});
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : DEFAULT_PORT;
server.close(() => resolve(port));
});
server.listen(0, HOST);
});
}
async function chooseServerPort(host) {
if (await isPortAvailable(DEFAULT_PORT, host)) {
return DEFAULT_PORT;
}
return getFreePort();
}
function getDesktopPath() {
const currentPath = process.env.PATH || '';
const commonPaths = process.platform === 'win32'
? []
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
}
function getNodeRuntime(usePackagedElectronRuntime) {
if (process.env.ELECTRON_NODE_PATH) {
return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
}
if (usePackagedElectronRuntime && process.versions.electron) {
return {
command: process.execPath,
env: { ELECTRON_RUN_AS_NODE: '1' },
label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
};
}
if (process.env.npm_node_execpath) {
return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
}
return { command: 'node', env: {}, label: 'PATH node' };
}
function stripTrailingSlash(value) {
return value.endsWith('/') ? value.slice(0, -1) : value;
}
function addCandidateUrl(urls, rawUrl) {
if (!rawUrl) return;
try {
const parsed = new URL(String(rawUrl));
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
parsed.hash = '';
parsed.search = '';
const normalized = stripTrailingSlash(parsed.toString());
if (!urls.includes(normalized)) urls.push(normalized);
} catch {
// Ignore invalid user-provided discovery values.
}
}
function addCandidatePort(urls, rawPort) {
const port = Number.parseInt(String(rawPort || ''), 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
addCandidateUrl(urls, `http://${HOST}:${port}`);
}
function getPortFromUrl(baseUrl) {
try {
const parsed = new URL(baseUrl);
if (parsed.port) return Number.parseInt(parsed.port, 10);
return parsed.protocol === 'https:' ? 443 : 80;
} catch {
return null;
}
}
function getDisplayUrl(baseUrl) {
try {
const parsed = new URL(baseUrl);
if (parsed.hostname === HOST) {
parsed.hostname = DISPLAY_HOST;
}
return stripTrailingSlash(parsed.toString());
} catch {
return baseUrl;
}
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function readServerBundleConfig(appRoot) {
try {
const raw = await fs.readFile(path.join(appRoot, 'electron', 'server-bundle-config.json'), 'utf8');
const config = JSON.parse(raw);
return {
releaseTag: typeof config.releaseTag === 'string' && config.releaseTag.trim()
? config.releaseTag.trim()
: '',
};
} catch {
return { releaseTag: '' };
}
}
function getServerCwd(appRoot, serverEntry) {
const normalizedEntry = path.resolve(serverEntry);
const bundledEntry = path.resolve(appRoot, 'dist-server', 'server', 'index.js');
if (normalizedEntry === bundledEntry) {
return appRoot;
}
// Installed server entries are laid out as <root>/dist-server/server/index.js.
return path.resolve(path.dirname(normalizedEntry), '..', '..');
}
async function readServerMarkerUrl() {
try {
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
const marker = JSON.parse(raw);
return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
} catch {
return null;
}
}
async function getExistingServerCandidateUrls(defaultUrl) {
const urls = [];
for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
addCandidateUrl(urls, process.env[key]);
}
addCandidateUrl(urls, await readServerMarkerUrl());
for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
addCandidatePort(urls, process.env[key]);
}
addCandidateUrl(urls, defaultUrl);
return urls;
}
async function waitForCloudCliServer(baseUrl, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isCloudCliServer(baseUrl)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
return false;
}
export class LocalServerController {
constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) {
this.appRoot = appRoot;
this.settingsPath = settingsPath;
this.isPackaged = isPackaged;
this.appVersion = appVersion;
this.onChange = onChange;
this.localServerUrl = null;
this.localServerPort = null;
this.ownedServerProcess = null;
this.startupLogs = [];
this.desktopSettings = {
keepLocalServerRunning: false,
exposeLocalServerOnNetwork: false,
themeMode: 'system',
};
}
getSettings() {
return this.desktopSettings;
}
getLocalServerUrl() {
return this.localServerUrl;
}
getHealthCheckUrl() {
if (!this.localServerPort) return this.localServerUrl;
return `http://${HOST}:${this.localServerPort}`;
}
appendStartupLog(line) {
const text = String(line || '').trimEnd();
if (!text) return;
const timestamp = new Date().toLocaleTimeString();
this.startupLogs.push(`[${timestamp}] ${text}`);
if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
}
this.onChange?.();
}
getStartupLogs() {
return [...this.startupLogs];
}
getPendingTarget() {
return {
kind: 'local',
name: 'Local CloudCLI',
url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
};
}
getLanAddress() {
const interfaces = os.networkInterfaces();
for (const entries of Object.values(interfaces)) {
for (const entry of entries || []) {
if (entry.family === 'IPv4' && !entry.internal) {
return entry.address;
}
}
}
return null;
}
getShareableWebUrl() {
if (!this.localServerUrl || !this.localServerPort) return null;
if (this.desktopSettings.exposeLocalServerOnNetwork) {
const lanAddress = this.getLanAddress();
if (lanAddress) {
return `http://${lanAddress}:${this.localServerPort}`;
}
}
return this.getLocalServerUrl();
}
getServerBindHost() {
return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
}
async loadDesktopSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.desktopSettings = {
keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
themeMode: stored.themeMode === 'light' || stored.themeMode === 'dark' ? stored.themeMode : 'system',
};
} catch {
this.desktopSettings = {
keepLocalServerRunning: false,
exposeLocalServerOnNetwork: false,
themeMode: 'system',
};
}
}
async saveDesktopSettings(nextSettings = this.desktopSettings) {
this.desktopSettings = {
keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
themeMode: nextSettings.themeMode === 'light' || nextSettings.themeMode === 'dark' ? nextSettings.themeMode : 'system',
};
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
this.onChange?.();
}
async updateDesktopSetting(key, value) {
if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
throw new Error(`Unknown desktop setting: ${key}`);
}
const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
const wasLocalRunning = Boolean(this.localServerUrl);
const nextValue = key === 'themeMode' ? value : Boolean(value);
await this.saveDesktopSettings({ ...this.desktopSettings, [key]: nextValue });
return {
desktopSettings: this.desktopSettings,
requiresRestartNotice: wasExposeSetting && wasLocalRunning,
};
}
/** Resolves the local server entry, installing the matching runtime if needed. */
async resolveServerEntry() {
if (process.env.ELECTRON_SERVER_ENTRY) {
return process.env.ELECTRON_SERVER_ENTRY;
}
const bundledEntry = path.join(this.appRoot, 'dist-server', 'server', 'index.js');
if (process.env.CLOUDCLI_USE_INSTALLED_SERVER !== '1' && await pathExists(bundledEntry)) {
return bundledEntry;
}
if (!this.appVersion) {
throw new Error('Cannot install local server: app version is unknown.');
}
const bundleConfig = await readServerBundleConfig(this.appRoot);
const installer = new ServerInstaller({
version: this.appVersion,
bundleReleaseTag: bundleConfig.releaseTag,
onLog: (line) => this.appendStartupLog(line),
});
return installer.ensureInstalled();
}
startBundledServer(port, serverEntry) {
const bindHost = this.getServerBindHost();
const runtime = getNodeRuntime(this.isPackaged);
const serverCwd = getServerCwd(this.appRoot, serverEntry);
const command = `${runtime.command} ${serverEntry}`;
this.appendStartupLog(`$ ${command}`);
this.appendStartupLog(`runtime: ${runtime.label}`);
this.appendStartupLog(`cwd: ${serverCwd}`);
this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
cwd: serverCwd,
detached: true,
env: {
...process.env,
...runtime.env,
HOST: bindHost,
SERVER_PORT: String(port),
NODE_ENV: 'production',
PATH: getDesktopPath(),
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
this.ownedServerProcess.once('error', (error) => {
this.appendStartupLog(`failed to start process: ${error.message}`);
this.ownedServerProcess = null;
});
this.ownedServerProcess.stdout?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
this.appendStartupLog(line);
}
});
this.ownedServerProcess.stderr?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
this.appendStartupLog(`stderr: ${line}`);
}
});
this.ownedServerProcess.once('exit', (code, signal) => {
this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
if (this.ownedServerProcess) {
console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
}
this.ownedServerProcess = null;
});
}
async resolveLocalServerUrl() {
const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
const devUrl = process.env.ELECTRON_DEV_URL;
const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
if (devUrl) {
const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
if (!ready) {
throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
}
this.localServerPort = DEFAULT_PORT;
return devUrl;
}
if (!forceOwnServer) {
const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
for (const candidateUrl of candidateUrls) {
if (await isCloudCliServer(candidateUrl)) {
const displayUrl = getDisplayUrl(candidateUrl);
this.localServerPort = getPortFromUrl(candidateUrl);
this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
return displayUrl;
}
}
}
const serverEntry = await this.resolveServerEntry();
const port = await chooseServerPort(this.getServerBindHost());
const serverUrl = `http://${HOST}:${port}`;
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
this.localServerPort = port;
this.startBundledServer(port, serverEntry);
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
if (!ready) {
const recentLogs = this.getStartupLogs().slice(-20).join('\n');
await this.shutdownOwnedServer();
this.localServerPort = null;
throw new Error([
`Bundled backend did not become ready at ${displayUrl}.`,
recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
].join('\n\n'));
}
this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
this.localServerUrl = displayUrl;
return displayUrl;
}
async ensureLocalServer() {
if (!this.localServerUrl) {
this.localServerUrl = await this.resolveLocalServerUrl();
}
return this.localServerUrl;
}
async getResolvedTarget() {
await this.ensureLocalServer();
return {
kind: 'local',
name: 'Local CloudCLI',
url: this.localServerUrl,
};
}
async loadLocalTarget() {
return {
pendingTarget: this.getPendingTarget(),
target: await this.getResolvedTarget(),
};
}
hasOwnedServer() {
return Boolean(this.ownedServerProcess);
}
detachOwnedServer() {
if (!this.ownedServerProcess) return;
this.ownedServerProcess.unref();
this.ownedServerProcess = null;
}
async shutdownOwnedServer() {
if (!this.ownedServerProcess) return;
const child = this.ownedServerProcess;
this.ownedServerProcess = null;
child.kill('SIGTERM');
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
}
export { DEFAULT_PORT, HOST };

View File

@@ -1,944 +0,0 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell } from 'electron';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CloudController } from './cloud.js';
import { DesktopWindowManager } from './desktopWindow.js';
import { DesktopNotificationsController } from './desktopNotifications.js';
import { LocalServerController } from './localServer.js';
import { TabsController } from './tabs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_NAME = 'CloudCLI';
const APP_USER_MODEL_ID = 'ai.cloudcli.desktop';
const CALLBACK_PROTOCOL = 'cloudcli';
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
const REMOTE_START_TIMEOUT_MS = 30000;
const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
const tabs = new TabsController();
if (process.platform === 'win32') {
app.setAppUserModelId(APP_USER_MODEL_ID);
}
let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
let desktopWindow = null;
let localServer = null;
let cloud = null;
let desktopNotifications = null;
let isQuitting = false;
let isRefreshingCloud = false;
let pendingCloudConnectStartedAt = 0;
function getAppRoot() {
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
}
function getLauncherPath() {
return path.join(__dirname, 'launcher', 'index.html');
}
function getPreloadPath() {
return path.join(__dirname, 'preload.cjs');
}
function getWindowIconPath() {
if (process.platform === 'darwin') {
return path.join(getAppRoot(), 'electron', 'assets', 'logo-macos.png');
}
return path.join(getAppRoot(), 'public', 'logo-512.png');
}
function getStorePath() {
return path.join(app.getPath('userData'), 'cloud-account.json');
}
function getSettingsPath() {
return path.join(app.getPath('userData'), 'desktop-settings.json');
}
function getDesktopNotificationsSettingsPath() {
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
}
function getRunningEnvironmentUrls() {
return cloud.getEnvironments()
.filter((environment) => environment.status === 'running')
.map((environment) => cloud.getEnvironmentUrl(environment))
.filter(Boolean);
}
function getDisplayTargetName() {
return activeTarget?.name || APP_NAME;
}
function getCloudState() {
return {
account: cloud.getAccount(),
environments: cloud.getEnvironments(),
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
};
}
function getLocalState() {
return {
desktopSettings: localServer.getSettings(),
localServerRunning: Boolean(localServer.getLocalServerUrl()),
localWebUrl: localServer.getLocalServerUrl(),
shareableWebUrl: localServer.getShareableWebUrl(),
};
}
function serializeEnvironment(environment) {
return {
id: environment.id,
name: environment.name,
subdomain: environment.subdomain,
access_url: cloud.getEnvironmentUrl(environment),
status: environment.status,
created_at: environment.created_at,
github_url: environment.github_url || null,
region: environment.region || null,
agent: environment.agent || null,
};
}
function getDesktopState() {
const cloudAccount = cloud.getAccount();
const localState = getLocalState();
const authState = cloud.getAuthState();
return {
account: {
connected: authState === 'connected',
email: cloudAccount?.email || null,
authState,
requiresReconnect: authState === 'expired',
},
activeTarget,
desktopSettings: localState.desktopSettings,
localWebUrl: localState.localWebUrl,
shareableWebUrl: localState.shareableWebUrl,
localServerRunning: localState.localServerRunning,
localStartupLogs: localServer.getStartupLogs(),
cloudLoading: isRefreshingCloud,
tabs: tabs.getSerializableTabs(),
activeTabId: tabs.activeTabId,
environments: cloud.getEnvironments().map(serializeEnvironment),
desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 },
};
}
async function openExternalUrl(url) {
if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) {
await handleDeepLink(url);
return;
}
await shell.openExternal(url);
}
async function showError(title, error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${title}: ${message}`);
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'error',
title,
message: title,
detail: message,
});
}
function isExpectedNavigationAbort(error) {
const message = error instanceof Error ? error.message : String(error);
return error?.code === 'ERR_ABORTED' || message.includes('ERR_ABORTED') || message.includes('(-3)');
}
function syncDesktopState() {
if (!desktopWindow) return;
desktopWindow.buildAppMenu();
desktopWindow.emitDesktopState();
if (activeTarget?.kind === 'local' && !localServer?.getLocalServerUrl()) {
void desktopWindow.showLocalStartupTarget(localServer.getPendingTarget(), localServer.getStartupLogs())
.catch((error) => {
if (isExpectedNavigationAbort(error)) return;
void showError('Could not update local startup log', error);
});
}
}
function setActiveTarget(target) {
activeTarget = target;
}
function getEnvironmentTarget(environment) {
return {
kind: 'remote',
id: environment.id,
name: environment.name || environment.subdomain,
url: cloud.getEnvironmentUrl(environment),
};
}
async function getEnvironmentLaunchTarget(environment) {
const environmentUrl = cloud.getEnvironmentUrl(environment);
return {
...getEnvironmentTarget(environment),
url: environmentUrl,
loadUrl: await cloud.getEnvironmentLaunchUrl(environment),
};
}
async function hasCloudWebSession() {
const cookies = await session.defaultSession.cookies.get({});
return cookies.some((cookie) => {
const cookieDomain = String(cookie.domain || '');
return cookieDomain.includes('cloudcli.ai')
&& /-auth-token(?:\.\d+)?$/.test(cookie.name)
&& Boolean(cookie.value);
});
}
function isCloudAuthRedirect(url) {
if (!url) return false;
try {
const parsed = new URL(url);
const controlPlane = new URL(CLOUDCLI_CONTROL_PLANE_URL);
return parsed.origin === controlPlane.origin
&& (parsed.pathname === '/login' || parsed.pathname.startsWith('/auth/'));
} catch {
return false;
}
}
function getDiagnosticsText() {
const cloudAccount = cloud.getAccount();
const localState = getLocalState();
return JSON.stringify({
app: APP_NAME,
version: app.getVersion(),
electron: process.versions.electron,
node: process.versions.node,
platform: process.platform,
arch: process.arch,
appPath: getAppRoot(),
userDataPath: app.getPath('userData'),
activeTarget,
localServerUrl: localState.localWebUrl,
localServerPort: localServer.localServerPort,
localWebUrl: localState.localWebUrl,
shareableWebUrl: localState.shareableWebUrl,
desktopSettings: localState.desktopSettings,
cloudConnected: Boolean(cloudAccount?.apiKey),
cloudEmail: cloudAccount?.email || null,
cloudEnvironmentCount: cloud.getEnvironments().length,
cloudRunningEnvironmentCount: getRunningEnvironmentUrls().length,
cloudAuthState: cloud.getAuthState(),
cloudAccountPath: getStorePath(),
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
}, null, 2);
}
async function copyDiagnostics() {
clipboard.writeText(getDiagnosticsText());
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Diagnostics copied',
message: 'CloudCLI desktop diagnostics were copied to the clipboard.',
});
}
async function refreshCloudEnvironments({ showErrors = false } = {}) {
isRefreshingCloud = true;
syncDesktopState();
try {
return await cloud.refreshCloudEnvironments();
} catch (error) {
const authState = cloud.getAuthState();
if (authState === 'expired') {
const expiredError = new Error('Your CloudCLI session expired. Reconnect your account.');
if (showErrors) {
await showError('CloudCLI login required', expiredError);
return [];
}
throw expiredError;
}
if (showErrors) {
await showError('Could not load CloudCLI environments', error);
return [];
}
throw error;
} finally {
isRefreshingCloud = false;
void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error));
syncDesktopState();
}
}
async function connectCloudAccount() {
const connectUrl = cloud.buildConnectUrl();
pendingCloudConnectStartedAt = Date.now();
clipboard.writeText(connectUrl);
await openExternalUrl(connectUrl);
return connectUrl;
}
async function handleDeepLink(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return;
}
if (parsed.protocol !== `${CALLBACK_PROTOCOL}:` || parsed.hostname !== 'auth') {
return;
}
if (!pendingCloudConnectStartedAt || Date.now() - pendingCloudConnectStartedAt > AUTH_CALLBACK_TTL_MS) {
await showError('CloudCLI account connection failed', new Error('No recent CloudCLI account connection was started from this app.'));
return;
}
const apiKey = parsed.searchParams.get('api_key');
if (!apiKey) {
await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
return;
}
await cloud.saveFromCallback({
apiKey,
email: parsed.searchParams.get('email'),
});
pendingCloudConnectStartedAt = 0;
await refreshCloudEnvironments({ showErrors: true });
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'CloudCLI account connected',
message: cloud.getAccount()?.email ? `Connected as ${cloud.getAccount().email}.` : 'CloudCLI account connected.',
}).catch(() => {});
}
async function copyLocalWebUrl() {
await localServer.ensureLocalServer();
const shareableUrl = localServer.getShareableWebUrl();
const localUrl = localServer.getLocalServerUrl();
if (!shareableUrl) {
throw new Error('Local CloudCLI URL is not available yet.');
}
clipboard.writeText(shareableUrl);
const isLanUrl = shareableUrl !== localUrl;
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Web URL copied',
message: isLanUrl ? 'LAN web URL copied.' : 'Local web URL copied.',
detail: isLanUrl
? `${shareableUrl}\n\nUse this URL from another device on the same network.`
: `${shareableUrl}\n\nThis URL works on this computer. Enable LAN access before starting Local CloudCLI to copy a phone-accessible URL.`,
});
return getDesktopState();
}
async function openLocalWebUi() {
await localServer.ensureLocalServer();
const url = localServer.getShareableWebUrl() || localServer.getLocalServerUrl();
if (!url) {
throw new Error('Local CloudCLI URL is not available yet.');
}
await openExternalUrl(url);
return getDesktopState();
}
async function updateDesktopSetting(key, value) {
const result = await localServer.updateDesktopSetting(key, value);
syncDesktopState();
if (result.requiresRestartNotice) {
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Restart local server to apply',
message: 'LAN access changes apply the next time the local server starts.',
detail: 'Quit CloudCLI and stop the local server, then open Local CloudCLI again.',
});
}
return getDesktopState();
}
async function showEnvironmentPicker() {
let environments = cloud.getEnvironments();
let refreshError = null;
if (cloud.getAccount()?.apiKey) {
try {
environments = await refreshCloudEnvironments({ showErrors: false });
} catch (error) {
refreshError = error;
console.warn('[Cloud] Could not refresh environments before showing picker:', error?.message || error);
}
}
const choices = ['Local CloudCLI', ...environments.map((environment) => {
const status = environment.status === 'running' ? '' : ` (${environment.status})`;
return `${environment.name || environment.subdomain}${status}`;
})];
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
type: 'question',
buttons: [...choices, 'Cancel'],
defaultId: 0,
cancelId: choices.length,
title: 'Switch CloudCLI Environment',
message: 'Choose where this desktop window should connect.',
detail: refreshError ? `Cloud environments could not be refreshed. Showing cached environments.\n\n${refreshError.message || refreshError}` : undefined,
});
if (response.response === choices.length) return getDesktopState();
if (response.response === 0) return openLocalInDesktop();
return openEnvironmentInDesktop(environments[response.response - 1]);
}
async function startEnvironment(environment) {
await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
await refreshCloudEnvironments({ showErrors: true });
return getDesktopState();
}
async function stopEnvironment(environment) {
await cloud.stopEnvironment(environment);
await refreshCloudEnvironments({ showErrors: true });
return getDesktopState();
}
async function openEnvironmentInBrowser(environment) {
await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment));
return getDesktopState();
}
function getProjectFolder(environment) {
return String(environment.name || environment.subdomain || 'workspace').replace(/[^a-zA-Z0-9-]/g, '');
}
function getSshTarget(credentials) {
if (credentials.ssh_command) {
const parts = String(credentials.ssh_command).split(/\s+/);
if (parts.length >= 2) return parts[1];
}
return `${credentials.username}@ssh.cloudcli.ai`;
}
function getSshHost(credentials) {
const target = getSshTarget(credentials);
const atIndex = target.indexOf('@');
return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
}
function getSafeSshUsername(credentials) {
const username = String(credentials.username || '');
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
throw new Error('Cloud environment returned an invalid SSH username.');
}
return username;
}
function getSafeSshHost(credentials) {
const host = getSshHost(credentials);
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
throw new Error('Cloud environment returned an invalid SSH host.');
}
return host;
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
async function getEnvironmentCredentials(environment) {
const credentials = await cloud.getEnvironmentCredentials(environment);
if (credentials.password) {
clipboard.writeText(credentials.password);
}
return credentials;
}
async function openEnvironmentInIde(environment, ide) {
const credentials = await getEnvironmentCredentials(environment);
const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
const remoteUri = `${scheme}://vscode-remote/ssh-remote+${getSafeSshUsername(credentials)}@${getSafeSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`;
await shell.openExternal(remoteUri);
return getDesktopState();
}
async function openEnvironmentInSsh(environment) {
const credentials = await getEnvironmentCredentials(environment);
const remoteCommand = `cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l`;
const sshCommand = `ssh -t ${shellQuote(getSshTarget(credentials))} ${shellQuote(remoteCommand)}`;
if (process.platform === 'darwin') {
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
spawn('osascript', ['-e', `tell application "Terminal" to do script "${escaped}"`], {
detached: true,
stdio: 'ignore',
}).unref();
} else {
clipboard.writeText(sshCommand);
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'SSH command copied',
message: 'The SSH command was copied to the clipboard.',
detail: sshCommand,
});
}
return getDesktopState();
}
async function copyEnvironmentMobileUrl(environment) {
const url = cloud.getEnvironmentUrl(environment);
clipboard.writeText(url);
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Environment URL copied',
message: 'Use this URL from your mobile browser.',
detail: url,
});
return getDesktopState();
}
async function openCloudDashboard() {
await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL);
return getDesktopState();
}
function getActiveRemoteEnvironment() {
if (activeTarget?.kind !== 'remote') return null;
return cloud.findEnvironment(activeTarget.id);
}
async function runActiveEnvironmentAction(action) {
const environment = getActiveRemoteEnvironment();
if (!environment) {
throw new Error('Open a cloud environment first.');
}
switch (action) {
case 'web':
return openEnvironmentInBrowser(environment);
case 'vscode':
return openEnvironmentInIde(environment, 'vscode');
case 'cursor':
return openEnvironmentInIde(environment, 'cursor');
case 'ssh':
return openEnvironmentInSsh(environment);
case 'mobile':
return copyEnvironmentMobileUrl(environment);
default:
throw new Error(`Unknown environment action: ${action}`);
}
}
async function openLocalInDesktop() {
const existingTab = tabs.getTab('local');
if (existingTab && localServer.getLocalServerUrl()) {
await desktopWindow.showTarget(await localServer.getResolvedTarget());
return getDesktopState();
}
const pendingTarget = localServer.getPendingTarget();
tabs.upsertTarget(pendingTarget);
setActiveTarget(pendingTarget);
await desktopWindow.showLocalStartupTarget(pendingTarget, localServer.getStartupLogs());
desktopWindow.emitDesktopState();
const target = await localServer.getResolvedTarget();
await desktopWindow.showTarget(target);
return getDesktopState();
}
async function openEnvironmentInDesktop(environment) {
const pendingTarget = getEnvironmentTarget(environment);
const tabId = tabs.getTabIdForTarget(pendingTarget);
const hadTab = Boolean(tabs.getTab(tabId));
const previousTabId = tabs.activeTabId;
if (!hadTab) {
await desktopWindow.showTabPlaceholder(
pendingTarget,
`${environment.status === 'running' ? 'Opening' : 'Starting'} ${pendingTarget.name}...`,
);
tabs.upsertTarget(pendingTarget);
desktopWindow.emitDesktopState();
}
let nextEnvironment = environment;
if (environment.status !== 'running') {
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
type: 'question',
buttons: ['Start Environment', 'Cancel'],
defaultId: 0,
cancelId: 1,
title: 'Start environment?',
message: `${pendingTarget.name} is ${environment.status}.`,
detail: 'CloudCLI can start it before opening the remote app.',
});
if (response.response !== 0) {
if (!hadTab) {
tabs.remove(tabId);
desktopWindow.destroyTabView(tabId);
if (previousTabId && previousTabId !== tabId) {
await desktopWindow.switchDesktopTab(previousTabId);
} else {
await desktopWindow.showLauncher();
}
}
return getDesktopState();
}
if (hadTab) {
await desktopWindow.showTabPlaceholder(pendingTarget, `Starting ${pendingTarget.name}...`);
tabs.upsertTarget(pendingTarget);
desktopWindow.emitDesktopState();
}
nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
}
let target = getEnvironmentTarget(nextEnvironment);
if (!(await hasCloudWebSession())) {
target = await getEnvironmentLaunchTarget(nextEnvironment);
}
const usedBootstrap = Boolean(target.loadUrl);
const finalUrl = await desktopWindow.showTarget(target);
if (!usedBootstrap && isCloudAuthRedirect(finalUrl)) {
const bootstrapTarget = await getEnvironmentLaunchTarget(nextEnvironment);
bootstrapTarget.forceLoad = true;
await desktopWindow.showTarget(bootstrapTarget);
}
return getDesktopState();
}
function findEnvironmentByUrl(environmentUrl) {
const targetOrigin = (() => {
try {
return new URL(environmentUrl).origin;
} catch {
return null;
}
})();
if (!targetOrigin) return null;
return cloud.getEnvironments().find((environment) => {
try {
return new URL(cloud.getEnvironmentUrl(environment)).origin === targetOrigin;
} catch {
return false;
}
}) || null;
}
async function openNotificationTarget({ environmentUrl, sessionId = null }) {
const window = desktopWindow?.getMainWindow();
if (window) {
if (window.isMinimized()) window.restore();
window.show();
window.focus();
}
const environment = findEnvironmentByUrl(environmentUrl);
if (environment) {
await openEnvironmentInDesktop(environment);
} else {
const parsed = new URL(environmentUrl);
await desktopWindow.showTarget({
kind: 'remote',
name: parsed.hostname,
url: parsed.origin,
});
}
const targetUrl = new URL(sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/', environmentUrl).toString();
await desktopWindow.navigateActiveView(targetUrl);
return getDesktopState();
}
async function getEnvironmentAuthToken(environmentUrl) {
return (await desktopWindow?.readAuthTokenForTarget(environmentUrl)) || null;
}
async function clearCloudAccount() {
await cloud.clearCloudAccount();
desktopNotifications?.stop();
const removedTabs = tabs.removeByKind('remote');
for (const tab of removedTabs) {
desktopWindow?.destroyTabView(tab.id);
}
if (activeTarget?.kind === 'remote') {
await desktopWindow?.showLauncher();
} else {
syncDesktopState();
}
return getDesktopState();
}
function getRemoteEnvironmentMenuItems() {
const cloudAccount = cloud.getAccount();
const environments = cloud.getEnvironments();
if (!cloudAccount?.apiKey) {
return [{ label: 'Connect CloudCLI Account...', click: () => void connectCloudAccount() }];
}
if (!environments.length) {
return [{ label: 'No environments found', enabled: false }];
}
return environments.map((environment) => ({
label: `${environment.name || environment.subdomain}${environment.status === 'running' ? '' : ` (${environment.status})`}`,
click: () => void openEnvironmentInDesktop(environment)
.catch((error) => showError('Could not open environment', error)),
}));
}
function registerProtocolHandler() {
const appEntry = path.join(getAppRoot(), 'electron', 'main.js');
if (process.defaultApp && process.argv.length >= 2) {
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL, process.execPath, [appEntry]);
} else {
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL);
}
}
function registerIpcHandlers() {
ipcMain.handle('cloudcli-desktop:connect-cloud', async () => ({
...getDesktopState(),
connectUrl: await connectCloudAccount(),
}));
ipcMain.handle('cloudcli-desktop:copy-diagnostics', async () => {
await copyDiagnostics();
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:copy-local-web-url', async () => copyLocalWebUrl());
ipcMain.handle('cloudcli-desktop:get-state', () => getDesktopState());
ipcMain.handle('cloudcli-desktop:open-cloud-dashboard', async () => openCloudDashboard());
ipcMain.handle('cloudcli-desktop:run-active-environment-action', async (_event, action) => runActiveEnvironmentAction(action));
ipcMain.handle('cloudcli-desktop:open-environment', async (_event, environmentId) => {
const environment = cloud.findEnvironment(environmentId);
if (!environment) {
throw new Error('Environment not found. Refresh and try again.');
}
return openEnvironmentInDesktop(environment);
});
ipcMain.handle('cloudcli-desktop:open-local', async () => openLocalInDesktop());
ipcMain.handle('cloudcli-desktop:open-local-web-ui', async () => openLocalWebUi());
ipcMain.handle('cloudcli-desktop:refresh-environments', async () => {
await refreshCloudEnvironments({ showErrors: true });
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:disconnect-cloud', async () => clearCloudAccount());
ipcMain.handle('cloudcli-desktop:reload-active-tab', async () => desktopWindow.reloadActiveTab());
ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker());
ipcMain.handle('cloudcli-desktop:show-launcher', async () => {
await desktopWindow.showLauncher();
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => {
await desktopNotifications?.saveSettings(settings);
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
desktopWindow.closeSettingsWindow();
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:show-active-environment-actions-menu', async () => desktopWindow.showActiveEnvironmentActionsMenu());
ipcMain.handle('cloudcli-desktop:show-environment-actions-menu', async (_event, environmentId) => desktopWindow.showEnvironmentActionsMenu(environmentId));
ipcMain.handle('cloudcli-desktop:switch-tab', async (_event, tabId) => desktopWindow.switchDesktopTab(tabId));
ipcMain.handle('cloudcli-desktop:close-tab', async (_event, tabId) => desktopWindow.closeDesktopTab(tabId));
ipcMain.handle('cloudcli-desktop:update-setting', async (_event, key, value) => updateDesktopSetting(key, value));
}
function registerAppEvents() {
app.on('open-url', (event, url) => {
event.preventDefault();
void handleDeepLink(url);
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
if (desktopWindow) {
void desktopWindow.createWindow();
} else {
void createDesktopWindow();
}
return;
}
const window = desktopWindow?.getMainWindow();
if (window) {
window.show();
window.focus();
}
});
app.on('before-quit', () => {
desktopNotifications?.stop();
});
app.on('before-quit', (event) => {
if (isQuitting || !localServer?.hasOwnedServer()) return;
if (localServer.getSettings().keepLocalServerRunning) {
localServer.detachOwnedServer();
return;
}
event.preventDefault();
isQuitting = true;
void localServer.shutdownOwnedServer().finally(() => app.quit());
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
}
async function createDesktopWindow() {
desktopWindow = new DesktopWindowManager({
appName: APP_NAME,
getWindowIconPath,
getLauncherPath,
getPreloadPath,
openExternalUrl,
getDesktopState,
getDisplayTargetName,
getRemoteEnvironmentMenuItems,
getCloudState,
getLocalState,
tabs,
actions: {
copyDiagnostics,
copyText: (text) => clipboard.writeText(text),
clearCloudAccount,
connectCloudAccount,
getActiveTarget: () => activeTarget,
getEnvironmentUrl: (environment) => cloud.getEnvironmentUrl(environment),
openEnvironmentInBrowser,
openEnvironmentInDesktop,
openEnvironmentInIde,
openEnvironmentInSsh,
openLocalInDesktop,
openLocalWebUi,
openCloudDashboard,
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
setActiveTarget,
showEnvironmentPicker,
showError,
startEnvironment,
stopEnvironment,
updateDesktopSetting,
copyLocalWebUrl,
openNotificationTarget,
},
});
desktopWindow.createTray();
desktopWindow.configurePermissions();
await desktopWindow.createWindow();
}
function registerSingleInstance() {
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
return false;
}
app.on('second-instance', (_event, argv) => {
const deepLink = argv.find((arg) => arg.startsWith(`${CALLBACK_PROTOCOL}://`));
if (deepLink) {
void handleDeepLink(deepLink);
}
const window = desktopWindow?.getMainWindow();
if (window) {
if (window.isMinimized()) window.restore();
window.show();
window.focus();
}
});
return true;
}
async function bootstrap() {
app.name = APP_NAME;
app.setName(APP_NAME);
process.title = APP_NAME;
await app.whenReady();
app.setName(APP_NAME);
app.setAboutPanelOptions({
applicationName: APP_NAME,
applicationVersion: app.getVersion(),
copyright: 'CloudCLI',
});
localServer = new LocalServerController({
appRoot: getAppRoot(),
settingsPath: getSettingsPath(),
isPackaged: app.isPackaged,
appVersion: app.getVersion(),
onChange: syncDesktopState,
});
cloud = new CloudController({
storePath: getStorePath(),
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
callbackUrl: CALLBACK_URL,
onChange: syncDesktopState,
});
desktopNotifications = new DesktopNotificationsController({
settingsPath: getDesktopNotificationsSettingsPath(),
appVersion: app.getVersion(),
appName: APP_NAME,
getDeviceId: () => cloud.getAccount()?.deviceId || '',
getAccountEmail: () => cloud.getAccount()?.email || null,
getRunningEnvironmentUrls,
getApiKey: () => cloud.getAccount()?.apiKey || '',
getAuthToken: getEnvironmentAuthToken,
getIconPath: getWindowIconPath,
openNotificationTarget,
onChange: syncDesktopState,
});
await localServer.loadDesktopSettings();
await cloud.loadCloudAccount();
await desktopNotifications.loadSettings();
registerProtocolHandler();
registerIpcHandlers();
registerAppEvents();
await createDesktopWindow();
void refreshCloudEnvironments({ showErrors: false });
}
if (registerSingleInstance()) {
bootstrap().catch(async (error) => {
await showError('CloudCLI failed to start', error);
app.quit();
});
}

View File

@@ -1,60 +0,0 @@
const { contextBridge, ipcRenderer } = require('electron');
function isCloudCliAppOrigin(location) {
if (location.protocol === 'file:') return true;
if (location.protocol === 'http:') {
return location.hostname === '127.0.0.1' || location.hostname === 'localhost';
}
return location.protocol === 'https:' && (
location.hostname === 'cloudcli.ai' || location.hostname.endsWith('.cloudcli.ai')
);
}
function onDesktopStateUpdated(callback) {
const listener = (_event, state) => callback(state);
ipcRenderer.on('cloudcli-desktop:state-updated', listener);
return () => {
ipcRenderer.removeListener('cloudcli-desktop:state-updated', listener);
};
}
if (isCloudCliAppOrigin(window.location)) {
contextBridge.exposeInMainWorld('cloudcliDesktopNotifications', {
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
update: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-desktop-notifications', settings),
onStateUpdated: onDesktopStateUpdated,
});
}
if (window.location.protocol === 'file:') {
contextBridge.exposeInMainWorld('cloudcliDesktop', {
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
disconnectCloud: () => ipcRenderer.invoke('cloudcli-desktop:disconnect-cloud'),
copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'),
copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'),
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
openCloudDashboard: () => ipcRenderer.invoke('cloudcli-desktop:open-cloud-dashboard'),
openEnvironment: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:open-environment', environmentId),
runActiveEnvironmentAction: (action) => ipcRenderer.invoke('cloudcli-desktop:run-active-environment-action', action),
openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'),
openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'),
refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'),
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
onStateUpdated: onDesktopStateUpdated,
onLauncherCommand: (callback) => {
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
},
});
}

View File

@@ -1,62 +0,0 @@
import fs from 'node:fs/promises';
import sharp from 'sharp';
const size = 1024;
const assetsDir = 'electron/assets';
const iconPath = 'electron/assets/logo-macos.png';
const icnsPath = 'electron/assets/logo-macos.icns';
function renderSvg(entrySize) {
const scale = entrySize / 32;
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${entrySize}" height="${entrySize}" viewBox="0 0 ${entrySize} ${entrySize}">
<rect width="${entrySize}" height="${entrySize}" fill="#2563eb"/>
<path
d="M${8 * scale} ${9 * scale}C${8 * scale} ${8.44772 * scale} ${8.44772 * scale} ${8 * scale} ${9 * scale} ${8 * scale}H${23 * scale}C${23.5523 * scale} ${8 * scale} ${24 * scale} ${8.44772 * scale} ${24 * scale} ${9 * scale}V${18 * scale}C${24 * scale} ${18.5523 * scale} ${23.5523 * scale} ${19 * scale} ${23 * scale} ${19 * scale}H${12 * scale}L${8 * scale} ${23 * scale}V${9 * scale}Z"
stroke="white"
stroke-width="${2 * scale}"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>`;
}
async function renderPng(entrySize) {
return sharp(Buffer.from(renderSvg(entrySize)))
.png()
.toBuffer();
}
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(iconPath, await renderPng(size));
const icnsEntries = [
['icp4', 16],
['icp5', 32],
['icp6', 64],
['ic07', 128],
['ic08', 256],
['ic09', 512],
['ic10', 1024],
['ic11', 32],
['ic12', 64],
['ic13', 256],
['ic14', 512],
];
const blocks = await Promise.all(icnsEntries.map(async ([type, entrySize]) => {
const png = await renderPng(entrySize);
const block = Buffer.alloc(8 + png.length);
block.write(type, 0, 4, 'ascii');
block.writeUInt32BE(block.length, 4);
png.copy(block, 8);
return block;
}));
const totalLength = 8 + blocks.reduce((sum, block) => sum + block.length, 0);
const header = Buffer.alloc(8);
header.write('icns', 0, 4, 'ascii');
header.writeUInt32BE(totalLength, 4);
await fs.writeFile(icnsPath, Buffer.concat([header, ...blocks], totalLength));

View File

@@ -1,277 +0,0 @@
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
/**
* Installs the versioned local server runtime used by CloudCLI Desktop.
*
* Server bundles are cached under:
* ~/.cloudcli/server/<version>/dist-server/server/index.js
*/
const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server');
const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download';
const MAX_REDIRECTS = 5;
const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
function mapArch(arch = process.arch) {
return arch === 'arm64' ? 'arm64' : 'x64';
}
function mapPlatform(platform = process.platform) {
if (platform === 'darwin') return 'mac';
if (platform === 'win32') return 'win';
return 'linux';
}
export class ServerInstaller {
constructor({
version,
platform = process.platform,
arch = process.arch,
installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT,
bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL,
bundleReleaseTag = process.env.CLOUDCLI_SERVER_BUNDLE_RELEASE_TAG || '',
onLog,
} = {}) {
if (!version) throw new Error('ServerInstaller requires the app version');
this.version = version;
this.platform = mapPlatform(platform);
this.arch = mapArch(arch);
this.installRoot = installRoot;
this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, '');
this.bundleReleaseTag = bundleReleaseTag || `v${this.version}`;
this.onLog = typeof onLog === 'function' ? onLog : () => {};
}
/** Directory the current version's server is (or will be) installed in. */
getVersionDir() {
return path.join(this.installRoot, this.version);
}
/** Absolute path to the server entry once installed. */
getServerEntry() {
return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js');
}
getBundleName() {
return `cloudcli-local-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
}
getBundleUrl() {
const url = new URL(`${this.bundleBaseUrl}/${this.bundleReleaseTag}/${this.getBundleName()}`);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) {
throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`);
}
return url.toString();
}
log(line) {
this.onLog(String(line));
}
async isInstalled() {
try {
const marker = JSON.parse(
await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'),
);
if (marker.version !== this.version) return false;
await fs.access(this.getServerEntry());
return true;
} catch {
return false;
}
}
/**
* Ensures the server for this version is installed, downloading + extracting
* it if needed. Returns the resolved server entry path.
*/
async ensureInstalled() {
if (await this.isInstalled()) {
this.log(`Local server ${this.version} already installed.`);
return this.getServerEntry();
}
const versionDir = this.getVersionDir();
const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`);
const archivePath = path.join(tmpDir, this.getBundleName());
await fs.mkdir(tmpDir, { recursive: true });
try {
const url = this.getBundleUrl();
this.log(`Downloading local server bundle…`);
this.log(url);
await this.#download(url, archivePath);
await this.#verifyChecksum(url, archivePath);
this.log('Extracting local server…');
await fs.rm(versionDir, { recursive: true, force: true });
await fs.mkdir(versionDir, { recursive: true });
await this.#validateArchive(archivePath);
await this.#extract(archivePath, versionDir);
const entry = this.getServerEntry();
await fs.access(entry);
await fs.writeFile(
path.join(versionDir, '.installed.json'),
JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2),
'utf8',
);
this.log(`Local server ${this.version} installed.`);
return entry;
} catch (error) {
await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {});
throw new Error(`Failed to install local server: ${error.message}`);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
#download(url, destPath, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirectsLeft <= 0) {
reject(new Error('Too many redirects'));
return;
}
const next = new URL(headers.location, url).toString();
resolve(this.#download(next, destPath, redirectsLeft - 1));
return;
}
if (statusCode !== 200) {
res.resume();
reject(new Error(`Download failed with HTTP ${statusCode}`));
return;
}
const total = Number(headers['content-length']) || 0;
let received = 0;
let lastPct = -1;
const out = createWriteStream(destPath);
res.on('data', (chunk) => {
received += chunk.length;
if (total) {
const pct = Math.floor((received / total) * 100);
if (pct !== lastPct && pct % 10 === 0) {
lastPct = pct;
this.log(`Downloading… ${pct}%`);
}
}
});
res.pipe(out);
out.on('finish', () => out.close(resolve));
out.on('error', reject);
res.on('error', reject);
});
req.on('error', reject);
});
}
async #verifyChecksum(url, archivePath) {
let expected;
try {
expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0];
} catch (error) {
throw new Error(`Could not verify server bundle checksum: ${error.message}`);
}
const actual = await this.#sha256(archivePath);
if (expected.toLowerCase() !== actual.toLowerCase()) {
throw new Error('Checksum mismatch — refusing to install');
}
this.log('Checksum verified.');
}
#fetchText(url, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirectsLeft <= 0) return reject(new Error('Too many redirects'));
return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1));
}
if (statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${statusCode}`));
}
let body = '';
res.setEncoding('utf8');
res.on('data', (c) => (body += c));
res.on('end', () => resolve(body));
res.on('error', reject);
})
.on('error', reject);
});
}
#sha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', (c) => hash.update(c));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
#extract(archivePath, destDir) {
return new Promise((resolve, reject) => {
const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
});
let stderr = '';
child.stderr?.on('data', (c) => (stderr += c));
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
});
});
}
#validateArchive(archivePath) {
return new Promise((resolve, reject) => {
const child = spawn('tar', ['-tzf', archivePath], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (c) => { stdout += c; });
child.stderr?.on('data', (c) => { stderr += c; });
child.once('error', reject);
child.once('exit', (code) => {
if (code !== 0) {
reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`));
return;
}
for (const entry of stdout.split(/\r?\n/).filter(Boolean)) {
const normalized = entry.replace(/\\/g, '/');
if (
path.isAbsolute(normalized)
|| /^[a-zA-Z]:\//.test(normalized)
|| normalized.split('/').includes('..')
) {
reject(new Error(`Refusing unsafe archive entry: ${entry}`));
return;
}
}
resolve();
});
});
}
}

View File

@@ -1,87 +0,0 @@
export class TabsController {
constructor() {
this.activeTabId = 'home';
this.tabs = [
{
id: 'home',
title: 'Launcher',
kind: 'launcher',
closable: false,
},
];
}
getTabIdForTarget(target) {
if (target.kind === 'launcher') return 'home';
if (target.kind === 'remote' && target.id) return `remote:${target.id}`;
return target.kind;
}
upsertTarget(target) {
const tabId = this.getTabIdForTarget(target);
const existingTab = this.tabs.find((tab) => tab.id === tabId);
const nextTab = {
id: tabId,
title: target.kind === 'launcher' ? 'Launcher' : target.name,
kind: target.kind,
target,
closable: tabId !== 'home',
};
if (existingTab) {
Object.assign(existingTab, nextTab);
} else {
this.tabs.push(nextTab);
}
this.activeTabId = tabId;
return nextTab;
}
activate(tabId) {
const tab = this.tabs.find((item) => item.id === tabId);
if (!tab) return null;
this.activeTabId = tab.id;
return tab;
}
remove(tabId) {
const tab = this.tabs.find((item) => item.id === tabId);
if (!tab || !tab.closable) return null;
this.tabs = this.tabs.filter((item) => item.id !== tabId);
if (this.activeTabId === tabId) {
this.activeTabId = 'home';
}
return tab;
}
removeByKind(kind) {
const removed = this.tabs.filter((tab) => tab.kind === kind && tab.closable);
if (!removed.length) return [];
const removedIds = new Set(removed.map((tab) => tab.id));
this.tabs = this.tabs.filter((tab) => !removedIds.has(tab.id));
if (removedIds.has(this.activeTabId)) {
this.activeTabId = 'home';
}
return removed;
}
getActiveTab() {
return this.getTab(this.activeTabId);
}
getTab(tabId) {
return this.tabs.find((item) => item.id === tabId) || null;
}
getSerializableTabs() {
return this.tabs.map((tab) => ({
id: tab.id,
title: tab.title,
kind: tab.kind,
closable: tab.closable,
active: tab.id === this.activeTabId,
}));
}
}

View File

@@ -1,331 +0,0 @@
import { BrowserView } from 'electron';
const TARGET_LOAD_TIMEOUT_MS = 20000;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildPlaceholderHtml(title, message, logs = []) {
const logHtml = logs.length
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
: '<pre>Waiting for process output...</pre>';
return [
'<!doctype html><meta charset="utf-8">',
'<style>',
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
'body{padding:28px;overflow:hidden}',
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
'</style>',
'<div class="shell">',
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
logHtml,
'</div>',
].join('');
}
function isHttpUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function loadUrlWithTimeout(webContents, url, timeoutMs = TARGET_LOAD_TIMEOUT_MS) {
let timedOut = false;
let timeout = null;
const loadPromise = webContents.loadURL(url);
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
timedOut = true;
try {
webContents.stop();
} catch {
// Ignore teardown races while reporting the original timeout.
}
reject(new Error(`Timed out loading ${url} after ${Math.round(timeoutMs / 1000)} seconds.`));
}, timeoutMs);
});
try {
await Promise.race([loadPromise, timeoutPromise]);
} catch (error) {
if (timedOut) {
loadPromise.catch(() => {});
}
throw error;
} finally {
if (timeout) clearTimeout(timeout);
}
}
export class ViewHost {
constructor({ appName, getMainWindow, getContentViewBounds, getPreloadPath, openExternalUrl, showError }) {
this.appName = appName;
this.getMainWindow = getMainWindow;
this.getContentViewBounds = getContentViewBounds;
this.getPreloadPath = getPreloadPath;
this.openExternalUrl = openExternalUrl;
this.showError = showError;
this.activeContentView = null;
this.tabViews = new Map();
}
configureChildWebContents(webContents) {
webContents.setWindowOpenHandler(({ url }) => {
void this.openExternalUrl(url).catch((error) => this.showError('Could not open external link', error));
return { action: 'deny' };
});
}
detachAll() {
const mainWindow = this.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
for (const view of mainWindow.getBrowserViews()) {
mainWindow.removeBrowserView(view);
}
} catch {
// BrowserViews may already be gone during BrowserWindow teardown.
}
this.activeContentView = null;
}
detachActiveView() {
const mainWindow = this.getMainWindow();
const view = this.activeContentView;
if (!mainWindow || mainWindow.isDestroyed() || !view) return false;
try {
if (mainWindow.getBrowserViews().includes(view)) {
mainWindow.removeBrowserView(view);
}
} catch {
return false;
}
this.activeContentView = null;
return true;
}
getActiveView() {
const view = this.activeContentView;
if (!view || view.webContents.isDestroyed()) return null;
return view;
}
openActiveViewDevTools() {
const view = this.getActiveView();
if (!view) return false;
view.webContents.openDevTools({ mode: 'detach' });
return true;
}
reloadActiveView() {
const view = this.getActiveView();
if (!view) return false;
view.webContents.reloadIgnoringCache();
return true;
}
async readLocalStorageValueForOrigin(originUrl, key) {
let targetOrigin;
try {
targetOrigin = new URL(originUrl).origin;
} catch {
return null;
}
for (const view of this.tabViews.values()) {
if (!view || view.webContents.isDestroyed()) continue;
let viewOrigin;
try {
viewOrigin = new URL(view.webContents.getURL()).origin;
} catch {
continue;
}
if (viewOrigin !== targetOrigin) continue;
try {
const value = await view.webContents.executeJavaScript(
`window.localStorage.getItem(${JSON.stringify(key)})`,
true
);
return typeof value === 'string' && value ? value : null;
} catch {
return null;
}
}
return null;
}
getTabViewDiagnostics() {
const mainWindow = this.getMainWindow();
const attachedViews = new Set();
if (mainWindow && !mainWindow.isDestroyed()) {
try {
for (const view of mainWindow.getBrowserViews()) {
attachedViews.add(view);
}
} catch {
// Ignore teardown races while gathering best-effort diagnostics.
}
}
return Array.from(this.tabViews.entries()).map(([tabId, view]) => {
const { webContents } = view;
const destroyed = webContents.isDestroyed();
return {
tabId,
webContentsId: destroyed ? null : webContents.id,
url: destroyed ? null : webContents.getURL(),
title: destroyed ? null : webContents.getTitle(),
osProcessId: destroyed || typeof webContents.getOSProcessId !== 'function' ? null : webContents.getOSProcessId(),
processId: destroyed || typeof webContents.getProcessId !== 'function' ? null : webContents.getProcessId(),
attached: attachedViews.has(view),
active: this.activeContentView === view,
destroyed,
};
});
}
getOrCreateTabView(tabId) {
let view = this.tabViews.get(tabId);
if (view) return view;
view = new BrowserView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.configureChildWebContents(view.webContents);
this.tabViews.set(tabId, view);
return view;
}
attach(view) {
const mainWindow = this.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (this.activeContentView && this.activeContentView !== view) {
this.detachAll();
}
this.activeContentView = view;
try {
if (!mainWindow.getBrowserViews().includes(view)) {
mainWindow.addBrowserView(view);
}
} catch {
return;
}
view.setBounds(this.getContentViewBounds());
view.setAutoResize({ width: true, height: true });
}
resizeActiveView() {
if (this.activeContentView) {
this.activeContentView.setBounds(this.getContentViewBounds());
}
}
async showTabPlaceholder(tabId, target, message) {
const view = this.getOrCreateTabView(tabId);
this.attach(view);
const html = buildPlaceholderHtml(target.name || this.appName, message);
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
view.__cloudcliStartupHtml = html;
view.__cloudcliLoadedUrl = null;
}
async showLocalStartupTarget(tabId, target, logs) {
const view = this.getOrCreateTabView(tabId);
if (view.__cloudcliLoadingUrl) return;
this.attach(view);
const html = buildPlaceholderHtml(target.name || this.appName, 'Starting Local CloudCLI...', logs);
if (view.__cloudcliStartupHtml === html) return;
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
view.__cloudcliStartupHtml = html;
view.__cloudcliLoadedUrl = null;
}
async showContentTarget(tabId, target) {
const loadUrl = target.loadUrl || target.url;
if (!isHttpUrl(loadUrl)) {
throw new Error(`Refusing to load unsupported app URL: ${loadUrl}`);
}
const view = this.getOrCreateTabView(tabId);
this.attach(view);
if (target.forceLoad || view.__cloudcliLoadedUrl !== target.url) {
view.__cloudcliLoadingUrl = loadUrl;
try {
await loadUrlWithTimeout(view.webContents, loadUrl);
view.__cloudcliLoadedUrl = target.url;
view.__cloudcliStartupHtml = null;
delete target.loadUrl;
delete target.forceLoad;
} finally {
if (view.__cloudcliLoadingUrl === loadUrl) {
view.__cloudcliLoadingUrl = null;
}
}
}
return view.webContents.getURL();
}
reloadTab(tabId) {
const view = this.tabViews.get(tabId);
if (!view || view.webContents.isDestroyed()) return false;
view.webContents.reloadIgnoringCache();
return true;
}
async navigateActiveView(url) {
const view = this.getActiveView();
if (!view) return false;
await loadUrlWithTimeout(view.webContents, url);
view.__cloudcliLoadedUrl = url;
view.__cloudcliStartupHtml = null;
return true;
}
destroyTabView(tabId) {
const view = this.tabViews.get(tabId);
if (!view) return;
const mainWindow = this.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
try {
if (mainWindow.getBrowserViews().includes(view)) {
mainWindow.removeBrowserView(view);
}
} catch {
// Ignore teardown races; Electron owns final destruction during quit.
}
}
if (this.activeContentView === view) {
this.activeContentView = null;
}
try {
if (!view.webContents.isDestroyed()) {
view.webContents.destroy();
}
} catch {
// The view may already be destroyed by its parent BrowserWindow.
}
this.tabViews.delete(tabId);
}
clear() {
this.tabViews.clear();
this.activeContentView = null;
}
}

4452
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.35.0",
"productName": "CloudCLI",
"version": "1.34.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -9,7 +8,6 @@
"cloudcli": "dist-server/server/cli.js"
},
"files": [
"electron/",
"server/",
"shared/",
"public/api-docs.html",
@@ -32,14 +30,6 @@
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
"client": "vite",
"desktop": "electron electron/main.js",
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
"desktop:dist:mac": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --mac dmg",
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
@@ -55,66 +45,6 @@
"prepare": "husky",
"update:platform": "./update-platform.sh"
},
"build": {
"appId": "ai.cloudcli.desktop",
"productName": "CloudCLI",
"asar": false,
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
"directories": {
"output": "release/desktop"
},
"extraMetadata": {
"main": "electron/main.js"
},
"files": [
"electron/",
"public/",
"dist/",
"dist-server/",
"shared/",
"server/",
"package.json",
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
],
"protocols": [
{
"name": "CloudCLI",
"schemes": [
"cloudcli"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"icon": "electron/assets/logo-macos.icns",
"notarize": true,
"target": [
"dmg"
],
"extendInfo": {
"CFBundleName": "CloudCLI",
"CFBundleDisplayName": "CloudCLI",
"CFBundleURLTypes": [
{
"CFBundleURLName": "CloudCLI",
"CFBundleURLSchemes": [
"cloudcli"
]
}
]
}
},
"win": {
"icon": "electron/assets/logo-windows.ico",
"target": [
"nsis"
]
},
"nsis": {
"installerIcon": "electron/assets/logo-windows.ico",
"uninstallerIcon": "electron/assets/logo-windows.ico"
}
},
"keywords": [
"claude code",
"claude-code",
@@ -147,7 +77,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@iarna/toml": "^2.2.5",
"@octokit/rest": "^22.0.0",
"@openai/codex-sdk": "^0.141.0",
"@openai/codex-sdk": "^0.125.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
@@ -211,9 +141,6 @@
"auto-changelog": "^2.5.0",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"cross-env": "^10.1.0",
"electron": "^38.0.0",
"electron-builder": "^26.15.3",
"eslint": "^9.39.3",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
@@ -240,9 +167,5 @@
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint",
"server/**/*.{js,ts}": "eslint"
},
"optionalDependencies": {
"@nut-tree-fork/nut-js": "^4.2.6",
"screenshot-desktop": "^1.15.4"
}
}

View File

@@ -1,176 +0,0 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import { createReadStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for server native rebuild.');
}
}
}
function mapArch(arch = process.arch) {
return arch === 'arm64' ? 'arm64' : 'x64';
}
function mapPlatform(platform = process.platform) {
if (platform === 'darwin') return 'mac';
if (platform === 'win32') return 'win';
return 'linux';
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
...options,
});
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
});
});
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required server bundle input is missing: ${relativePath}`);
}
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function copyIfExists(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function writeServerPackageJson(stageDir) {
const stagedPackageJson = {
...packageJson,
scripts: {
...(packageJson.scripts || {}),
},
};
// The bundle stage is not a git checkout with dev dependencies, so lifecycle
// scripts such as Husky prepare must not run there. Dependency install scripts
// still run; native modules need them before the Electron ABI rebuild below.
delete stagedPackageJson.scripts.postinstall;
delete stagedPackageJson.scripts.prepare;
delete stagedPackageJson.scripts.prepublishOnly;
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
'utf8',
);
}
function sha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
const platform = mapPlatform(process.env.CLOUDCLI_BUNDLE_PLATFORM || process.platform);
const arch = mapArch(process.env.CLOUDCLI_BUNDLE_ARCH || process.arch);
const version = packageJson.version;
const bundleName = `cloudcli-local-server-${version}-${platform}-${arch}.tar.gz`;
const bundleRoot = path.join(rootDir, 'release', 'local-server');
const stageDir = path.join(bundleRoot, `.stage-${version}-${platform}-${arch}`);
const archivePath = path.join(bundleRoot, bundleName);
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await fs.mkdir(bundleRoot, { recursive: true });
await copyRequired(stageDir, 'dist');
await copyRequired(stageDir, 'dist-server');
await copyRequired(stageDir, 'public');
await copyRequired(stageDir, 'shared');
await copyRequired(stageDir, 'package-lock.json');
await copyIfExists(stageDir, 'scripts/fix-node-pty.js');
await writeServerPackageJson(stageDir);
console.log('Installing production server dependencies into bundle stage...');
await run('npm', ['ci', '--omit=dev'], {
cwd: stageDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
const electronVersion = getElectronVersion();
const electronRebuild = process.platform === 'win32'
? path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild.cmd')
: path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild');
console.log(`Rebuilding native server dependencies for Electron ${electronVersion} (${arch})...`);
await run(electronRebuild, ['--version', electronVersion, '--module-dir', stageDir, '--arch', arch, '--force'], {
cwd: rootDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
if (await pathExists(path.join(stageDir, 'scripts', 'fix-node-pty.js'))) {
await run(process.execPath, ['scripts/fix-node-pty.js'], { cwd: stageDir });
}
await fs.writeFile(
path.join(stageDir, '.installed.json'),
JSON.stringify({ version, platform, arch, builtAt: new Date().toISOString() }, null, 2),
'utf8',
);
await fs.rm(archivePath, { force: true });
const tarArgs = process.platform === 'win32'
? ['-czf', archivePath, '-C', stageDir, '.']
: ['-czf', archivePath, '-C', stageDir, '.'];
await run('tar', tarArgs);
const digest = await sha256(archivePath);
const checksumPath = `${archivePath}.sha256`;
await fs.writeFile(checksumPath, `${digest} ${bundleName}\n`, 'utf8');
await fs.rm(stageDir, { recursive: true, force: true });
const size = (await fs.stat(archivePath)).size / 1024 / 1024;
console.log(`Wrote ${path.relative(rootDir, archivePath)} (${size.toFixed(1)} MB)`);
console.log(`Wrote ${path.relative(rootDir, checksumPath)}`);

View File

@@ -1,152 +0,0 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const stageDir = path.join(rootDir, '.desktop-build', 'desktop-app');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for desktop packaging.');
}
}
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(relativePath) {
const from = path.join(rootDir, relativePath);
const to = path.join(stageDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required desktop build input is missing: ${relativePath}`);
}
await fs.cp(from, to, { recursive: true });
}
async function copyIfExists(relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return false;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
return true;
}
async function copyNodeModule(packageName) {
const parts = packageName.split('/');
const source = path.join(rootDir, 'node_modules', ...parts);
if (!(await pathExists(source))) return false;
const target = path.join(stageDir, 'node_modules', ...parts);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.cp(source, target, { recursive: true });
return true;
}
function buildDesktopPackageJson(copiedOptionalDependencies) {
return {
name: `${packageJson.name}-desktop`,
version: packageJson.version,
productName: packageJson.productName,
description: `${packageJson.productName} desktop shell`,
author: packageJson.author,
license: packageJson.license,
type: 'module',
main: 'electron/main.js',
dependencies: {
ws: packageJson.dependencies.ws,
},
optionalDependencies: copiedOptionalDependencies,
build: {
appId: packageJson.build.appId,
productName: packageJson.build.productName,
asar: packageJson.build.asar,
artifactName: packageJson.build.artifactName,
electronVersion: getElectronVersion(),
directories: {
output: '../../release/desktop',
},
extraMetadata: {
main: 'electron/main.js',
},
files: [
'electron/**',
'public/**',
'dist/**',
'dist-server/**',
'node_modules/**',
'package.json',
],
protocols: packageJson.build.protocols,
mac: packageJson.build.mac,
win: packageJson.build.win,
nsis: packageJson.build.nsis,
},
};
}
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await copyRequired('electron');
await copyRequired('dist');
await copyRequired('public');
const copiedRuntimeDependencies = [];
if (await copyNodeModule('ws')) {
copiedRuntimeDependencies.push('ws');
} else {
throw new Error('Required desktop dependency is missing from node_modules: ws');
}
const copiedOptionalDependencies = {};
for (const [name, version] of Object.entries(packageJson.optionalDependencies || {})) {
if (await copyNodeModule(name)) {
copiedOptionalDependencies[name] = version;
}
}
for (const name of [
'@nut-tree-fork/default-clipboard-provider',
'@nut-tree-fork/libnut',
'@nut-tree-fork/provider-interfaces',
'@nut-tree-fork/shared',
'jimp',
'node-abort-controller',
'temp',
]) {
await copyNodeModule(name);
}
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(buildDesktopPackageJson(copiedOptionalDependencies), null, 2)}\n`,
'utf8',
);
console.log(`Prepared thin desktop app at ${path.relative(rootDir, stageDir)}`);
console.log(`Runtime dependencies: ${copiedRuntimeDependencies.join(', ')}`);
if (Object.keys(copiedOptionalDependencies).length) {
console.log(`Optional dependencies: ${Object.keys(copiedOptionalDependencies).join(', ')}`);
}

View File

@@ -1,384 +0,0 @@
#!/usr/bin/env node
import './load-env.js';
type JsonRpcRequest = {
jsonrpc: '2.0';
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
};
type ToolDefinition = {
name: string;
description: string;
inputSchema: Record<string, unknown>;
};
const textResponse = (text: string) => ({
content: [{ type: 'text', text }],
});
const jsonResponse = (value: unknown) => textResponse(JSON.stringify(value, null, 2));
const readString = (value: unknown, name: string): string => {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${name} is required.`);
}
return value.trim();
};
const readOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const readNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const apiUrl = (process.env.CLOUDCLI_BROWSER_USE_API_URL || 'http://127.0.0.1:3001/api/browser-use-mcp').replace(/\/$/, '');
const apiToken = process.env.CLOUDCLI_BROWSER_USE_MCP_TOKEN || '';
const API_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_API_TIMEOUT_MS || '60000', 10);
async function callBrowserUseApi(toolName: string, input: Record<string, unknown>) {
if (!apiToken) {
throw new Error('CLOUDCLI_BROWSER_USE_MCP_TOKEN is not configured.');
}
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
signal: AbortSignal.timeout(API_TIMEOUT_MS),
});
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
if (!response.ok || data.success === false) {
throw new Error(data.error || `Browser API request failed (${response.status})`);
}
return data.data;
}
const sessionIdSchema = {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Browser session id.' },
},
required: ['sessionId'],
};
const tools: ToolDefinition[] = [
{
name: 'browser_create_session',
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
inputSchema: {
type: 'object',
properties: {
profileName: { type: 'string', description: 'Optional background profile name for persistent browser storage.' },
},
},
},
{
name: 'browser_list_sessions',
description: 'List Browser sessions currently available to agents.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browser_snapshot',
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_take_screenshot',
description: 'Capture the latest screenshot for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_navigate',
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
url: { type: 'string' },
},
required: ['sessionId', 'url'],
},
},
{
name: 'browser_click',
description: 'Click an element by CSS selector, visible text, or x/y coordinates.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
x: { type: 'number' },
y: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_type',
description: 'Type text into the focused page or fill a CSS selector. Set submit to press Enter after typing.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
submit: { type: 'boolean' },
},
required: ['sessionId', 'text'],
},
},
{
name: 'browser_fill_form',
description: 'Fill multiple form fields using CSS selectors.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
fields: {
type: 'array',
items: {
type: 'object',
properties: {
selector: { type: 'string' },
value: { type: 'string' },
},
required: ['selector', 'value'],
},
},
},
required: ['sessionId', 'fields'],
},
},
{
name: 'browser_press_key',
description: 'Press a keyboard key, for example Enter, Escape, Tab, or Control+A.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
key: { type: 'string' },
},
required: ['sessionId', 'key'],
},
},
{
name: 'browser_select_option',
description: 'Select option values in a select element found by CSS selector.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
values: { type: 'array', items: { type: 'string' } },
},
required: ['sessionId', 'selector', 'values'],
},
},
{
name: 'browser_wait_for',
description: 'Wait for visible text, a URL pattern, or a short timeout.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
text: { type: 'string' },
url: { type: 'string' },
timeoutMs: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_tabs',
description: 'List, open, select, or close tabs in a Browser session.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
action: { type: 'string', enum: ['list', 'new', 'select', 'close'] },
index: { type: 'number' },
url: { type: 'string' },
},
required: ['sessionId'],
},
},
{
name: 'browser_close_session',
description: 'Stop a Browser session controlled by agents.',
inputSchema: sessionIdSchema,
},
];
async function callTool(name: string, args: Record<string, unknown>) {
switch (name) {
case 'browser_create_session':
return jsonResponse(await callBrowserUseApi(name, {
profileName: readOptionalString(args.profileName),
}));
case 'browser_list_sessions':
return jsonResponse(await callBrowserUseApi(name, {}));
case 'browser_snapshot':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
case 'browser_take_screenshot': {
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
}
case 'browser_navigate':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
url: readString(args.url, 'url'),
}));
case 'browser_click':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readOptionalString(args.text),
x: readNumber(args.x),
y: readNumber(args.y),
}));
case 'browser_type':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readString(args.text, 'text'),
submit: args.submit === true,
}));
case 'browser_fill_form': {
const fields = Array.isArray(args.fields)
? args.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: readString(record.selector, 'field.selector'),
value: readString(record.value, 'field.value'),
};
})
: [];
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
fields,
}));
}
case 'browser_press_key':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
key: readString(args.key, 'key'),
}));
case 'browser_select_option':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readString(args.selector, 'selector'),
values: Array.isArray(args.values) ? args.values.filter((value): value is string => typeof value === 'string') : [],
}));
case 'browser_wait_for':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
text: readOptionalString(args.text),
url: readOptionalString(args.url),
timeoutMs: readNumber(args.timeoutMs),
}));
case 'browser_tabs':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
action: args.action === 'new' || args.action === 'select' || args.action === 'close' || args.action === 'list'
? args.action
: undefined,
index: readNumber(args.index),
url: readOptionalString(args.url),
}));
case 'browser_close_session':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async function handleMessage(message: JsonRpcRequest) {
if (message.method === 'initialize') {
return {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'cloudcli-browser', version: '1.0.0' },
};
}
if (message.method === 'tools/list') {
return { tools };
}
if (message.method === 'tools/call') {
const params = message.params || {};
const name = readString(params.name, 'name');
const args = (params.arguments && typeof params.arguments === 'object'
? params.arguments
: {}) as Record<string, unknown>;
return callTool(name, args);
}
if (message.method.startsWith('notifications/')) {
return undefined;
}
throw new Error(`Unsupported method: ${message.method}`);
}
function writeMessage(message: Record<string, unknown>) {
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
// no embedded newlines). This is NOT the LSP Content-Length framing.
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendResult(id: string | number | null | undefined, result: unknown) {
if (id === undefined) {
return;
}
writeMessage({ jsonrpc: '2.0', id, result });
}
function sendError(id: string | number | null | undefined, error: unknown) {
if (id === undefined) {
return;
}
writeMessage({
jsonrpc: '2.0',
id,
error: {
code: -32000,
message: error instanceof Error ? error.message : String(error),
},
});
}
let buffer = '';
process.stdin.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!rawMessage) {
continue;
}
void (async () => {
let request: JsonRpcRequest;
try {
request = JSON.parse(rawMessage) as JsonRpcRequest;
} catch (error) {
sendError(null, error);
return;
}
try {
const result = await handleMessage(request);
sendResult(request.id, result);
} catch (error) {
sendError(request.id, error);
}
})();
}
});

View File

@@ -8,7 +8,6 @@
* (no args) - Start the server (default)
* start - Start the server
* sandbox - Manage Docker sandbox environments
* browser-use-mcp - Run Browser MCP stdio server
* status - Show configuration and data locations
* help - Show help information
* version - Show version information
@@ -155,13 +154,12 @@ Usage:
cloudcli [command] [options]
Commands:
start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
browser-use-mcp Run the Browser MCP stdio server
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
Options:
-p, --port <port> Set server port (default: 3001)
@@ -607,10 +605,6 @@ async function startServer() {
await import('./index.js');
}
async function startBrowserUseMcp() {
await import('./browser-use-mcp.js');
}
// Parse CLI arguments
function parseArgs(args) {
const parsed = { command: 'start', options: {} };
@@ -664,9 +658,6 @@ async function main() {
case 'sandbox':
await sandboxCommand(remainingArgs || []);
break;
case 'browser-use-mcp':
await startBrowserUseMcp();
break;
case 'status':
case 'info':
showStatus();

View File

@@ -57,15 +57,10 @@ import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectModuleRoutes from './modules/projects/projects.routes.js';
import notificationRoutes from './modules/notifications/notifications.routes.js';
import userRoutes from './routes/user.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js';
import voiceRoutes from './voice-proxy.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js';
@@ -78,19 +73,6 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
// Version of the code that is actually running, captured once at process
// startup. This intentionally does NOT re-read package.json per request: after
// an update replaces the files on disk, package.json reflects the NEW version
// while this long-lived process still runs the OLD code. The frontend bundle is
// rebuilt on update, so a mismatch between this value and the frontend's
// build-time version means the server was updated but not restarted.
const RUNNING_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
} catch {
return null;
}
})();
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
@@ -130,17 +112,7 @@ const wss = createWebSocketServer(server, {
getPendingApprovalsForSession,
},
shell: {
resolveProviderSessionId: (sessionId, provider) => {
const dbSession = sessionsDb.getSessionById(sessionId);
const legacyGeminiSession =
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
if (dbSession) {
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null;
}
return legacyGeminiSession?.cliSessionId;
},
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
stripAnsiSequences,
normalizeDetectedUrl,
extractUrlsFromText,
@@ -171,8 +143,7 @@ app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode,
version: RUNNING_VERSION
installMode
});
});
@@ -203,8 +174,6 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
// Settings API Routes (protected)
app.use('/api/settings', authenticateToken, settingsRoutes);
app.use('/api/notifications', authenticateToken, notificationRoutes);
// User API Routes (protected)
app.use('/api/user', authenticateToken, userRoutes);
@@ -214,20 +183,12 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Browser MCP bridge API (local token protected)
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
// Browser API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
// Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
app.use('/api/voice', authenticateToken, voiceRoutes);
// Serve public files (like api-docs.html)
app.use(express.static(path.join(APP_ROOT, 'public')));
@@ -1164,6 +1125,7 @@ app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req
app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { projectId, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir();
// Allow only safe characters in sessionId
@@ -1174,14 +1136,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
// are keyed by the provider-native session id, while the caller sends
// the app-facing id. Resolve provider and id mapping from the indexed
// session row so the frontend does not choose provider-specific paths.
// the app-facing id. Resolve the mapping once for all branches below.
const sessionRow = sessionsDb.getSessionById(safeSessionId);
if (!sessionRow) {
return res.status(404).json({ error: 'Session not found', sessionId: safeSessionId });
}
const provider = sessionRow.provider || 'claude';
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
// Handle Cursor sessions - they use SQLite and don't have token usage info
@@ -1685,40 +1641,6 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
const DISPLAY_HOST = getConnectableHost(HOST);
const VITE_PORT = process.env.VITE_PORT || 5173;
const LOCAL_SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
async function writeLocalServerMarker() {
const marker = {
pid: process.pid,
host: HOST,
port: Number.parseInt(String(SERVER_PORT), 10),
url: `http://${DISPLAY_HOST}:${SERVER_PORT}`,
installMode,
appRoot: APP_ROOT,
updatedAt: new Date().toISOString(),
};
await fsPromises.mkdir(path.dirname(LOCAL_SERVER_MARKER_PATH), { recursive: true });
await fsPromises.writeFile(LOCAL_SERVER_MARKER_PATH, JSON.stringify(marker, null, 2), 'utf8');
}
async function removeLocalServerMarker() {
try {
const raw = await fsPromises.readFile(LOCAL_SERVER_MARKER_PATH, 'utf8');
const marker = JSON.parse(raw);
if (marker.pid && marker.pid !== process.pid) return;
} catch (error) {
if (error.code === 'ENOENT') return;
}
try {
await fsPromises.unlink(LOCAL_SERVER_MARKER_PATH);
} catch (error) {
if (error.code !== 'ENOENT') {
console.warn('[WARN] Could not remove local server marker:', error.message);
}
}
}
// Initialize database and start server
async function startServer() {
@@ -1745,9 +1667,6 @@ async function startServer() {
server.listen(SERVER_PORT, HOST, async () => {
const appInstallPath = APP_ROOT;
await writeLocalServerMarker().catch((error) => {
console.warn('[WARN] Could not write local server marker:', error.message);
});
console.log('');
console.log(c.dim('═'.repeat(63)));
@@ -1770,26 +1689,12 @@ async function startServer() {
await closeSessionsWatcher();
// Clean up plugin processes on shutdown
const shutdownRuntimeServices = async () => {
try {
await browserUseService.stopAllSessions();
} catch (err) {
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
}
try {
await stopAllPlugins();
} catch (err) {
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
}
try {
await removeLocalServerMarker();
} catch (err) {
console.error('[Local Server] Error removing server marker during shutdown:', err?.message || err);
}
const shutdownPlugins = async () => {
await stopAllPlugins();
process.exit(0);
};
process.on('SIGTERM', () => void shutdownRuntimeServices());
process.on('SIGINT', () => void shutdownRuntimeServices());
process.on('SIGTERM', () => void shutdownPlugins());
process.on('SIGINT', () => void shutdownPlugins());
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);

View File

@@ -22,7 +22,7 @@ try {
}
});
} catch (e) {
console.error('No .env file found or error reading it:', e.message);
console.log('No .env file found or error reading it:', e.message);
}
// Keep the default database in a stable user-level location so rebuilding dist-server

View File

@@ -1,120 +0,0 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readBearerToken(header: unknown): string | null {
if (typeof header !== 'string') {
return null;
}
const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
return match?.[1]?.trim() || null;
}
router.use((req, res, next) => {
const expected = browserUseService.getMcpToken();
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
if (!token || token !== expected) {
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
return;
}
next();
});
router.post('/tools/:toolName', async (req, res) => {
try {
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
const toolName = req.params.toolName;
let result: unknown;
switch (toolName) {
case 'browser_create_session':
result = await browserUseService.createAgentSession({
profileName: typeof input.profileName === 'string' ? input.profileName : null,
});
break;
case 'browser_list_sessions':
result = await browserUseService.listAgentSessions();
break;
case 'browser_snapshot':
case 'browser_take_screenshot':
result = await browserUseService.agentSnapshot(sessionId);
break;
case 'browser_navigate':
result = await browserUseService.agentNavigate(sessionId, String(input.url || ''));
break;
case 'browser_click':
result = await browserUseService.agentClick(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: typeof input.text === 'string' ? input.text : undefined,
x: typeof input.x === 'number' ? input.x : undefined,
y: typeof input.y === 'number' ? input.y : undefined,
});
break;
case 'browser_type':
result = await browserUseService.agentType(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: String(input.text || ''),
submit: input.submit === true,
});
break;
case 'browser_fill_form':
result = await browserUseService.agentFillForm(
sessionId,
Array.isArray(input.fields)
? input.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: String(record.selector || ''),
value: String(record.value || ''),
};
})
: [],
);
break;
case 'browser_press_key':
result = await browserUseService.agentPressKey(sessionId, String(input.key || ''));
break;
case 'browser_select_option':
result = await browserUseService.agentSelectOption(
sessionId,
String(input.selector || ''),
Array.isArray(input.values) ? input.values.filter((value): value is string => typeof value === 'string') : [],
);
break;
case 'browser_wait_for':
result = await browserUseService.agentWaitFor(sessionId, {
text: typeof input.text === 'string' ? input.text : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
});
break;
case 'browser_tabs':
result = await browserUseService.agentTabs(sessionId, {
action: input.action === 'new' || input.action === 'select' || input.action === 'close' || input.action === 'list'
? input.action
: undefined,
index: typeof input.index === 'number' ? input.index : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
});
break;
case 'browser_close_session':
result = await browserUseService.agentStopSession(sessionId);
break;
default:
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
return;
}
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
});
}
});
export default router;

View File

@@ -1,96 +0,0 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
router.get('/status', async (_req, res) => {
try {
res.json({ success: true, data: await browserUseService.getStatus() });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
});
}
});
router.get('/settings', async (_req, res) => {
try {
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
});
}
});
router.put('/settings', async (req, res) => {
try {
const settings = await browserUseService.updateSettings(req.body || {});
res.json({ success: true, data: { settings } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Browser settings.',
});
}
});
router.post('/runtime/install', async (_req, res) => {
try {
const result = await browserUseService.installRuntime();
res.status(result.success ? 200 : 500).json({
success: result.success,
data: result,
error: result.success ? undefined : result.message,
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to install Browser runtime.',
});
}
});
router.get('/sessions', async (_req, res) => {
try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) {
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req, res) => {
try {
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to stop browser session.',
});
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const result = await browserUseService.deleteSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete browser session.',
});
}
});
export default router;

View File

@@ -1,836 +0,0 @@
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
const require = createRequire(import.meta.url);
const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
type BrowserUseRuntime = 'cloud' | 'local';
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
};
type BrowserUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false,
};
const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser';
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
function getRuntime(): BrowserUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local';
}
function readSettings(): BrowserUseSettings {
try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
};
} catch (error: any) {
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
}
}
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Browser is disabled in settings.';
}
if (!readiness.playwrightInstalled) {
return 'Install Playwright and Chromium to use browser sessions.';
}
if (!readiness.chromiumInstalled) {
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
}
return readiness.installMessage || 'Browser runtime is not ready.';
}
function getPlaywright(): any | null {
try {
return require('playwright');
} catch {
return null;
}
}
function getMcpCommand(): { command: string; args: string[] } {
const serverDir = path.resolve(__dirname, '..', '..');
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
if (fs.existsSync(mcpScriptPath)) {
return {
command: process.execPath,
args: [mcpScriptPath],
};
}
return {
command: 'cloudcli',
args: ['browser-use-mcp'],
};
}
function getMcpApiUrl(): string {
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
}
async function removeMcpServerFromAllProviders(name: string) {
const results = await providerMcpService.removeMcpServerFromAllProviders({
name,
scope: 'user',
});
return results.map((result) => ({ ...result, name }));
}
function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim();
if (!normalized) {
return null;
}
return normalized.slice(0, 80);
}
function getProfilePath(profileName: string): string {
const safeName = profileName
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'default';
return path.join(PROFILE_ROOT, safeName);
}
function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright();
const readiness: RuntimeProbe = {
playwright,
playwrightInstalled: Boolean(playwright),
chromiumInstalled: false,
chromiumExecutablePath: null,
};
if (!playwright) {
return readiness;
}
try {
const executablePath = playwright.chromium.executablePath();
readiness.chromiumExecutablePath = executablePath;
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
} catch {
readiness.chromiumInstalled = false;
}
return readiness;
}
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
const now = Date.now();
const cachedProbe = runtimeProbeCache;
const canUseCache = !options.force
&& !installPromise
&& cachedProbe
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
const probe = canUseCache ? cachedProbe.value : probeRuntime();
if (!canUseCache && !installPromise) {
runtimeProbeCache = { value: probe, updatedAt: now };
}
return {
...probe,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
}
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10,
);
function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
const output: string[] = [];
let settled = false;
const finish = (fn: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish(() => reject(new Error(
`${command} ${args.join(' ')} timed out after ${INSTALL_COMMAND_TIMEOUT_MS}ms.`,
)));
}, INSTALL_COMMAND_TIMEOUT_MS);
timer.unref?.();
child.stdout.on('data', (chunk) => output.push(String(chunk)));
child.stderr.on('data', (chunk) => output.push(String(chunk)));
child.on('error', (error) => finish(() => reject(error)));
child.on('close', (code) => finish(() => {
if (code === 0) {
resolve();
return;
}
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
}));
});
}
function formatInstallError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('sudo') && message.includes('password')) {
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
}
return message || 'Failed to install Browser runtime.';
}
async function installRuntime(): Promise<{ success: boolean; message: string }> {
if (installPromise) {
return installPromise;
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
runtimeProbeCache = null;
installPromise = (async () => {
try {
lastInstallMessage = 'Installing Playwright package...';
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
if (process.platform === 'linux') {
lastInstallMessage = 'Installing Chromium system dependencies...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
}
lastInstallMessage = 'Installing Chromium runtime...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
lastInstallMessage = 'Browser runtime installed.';
return { success: true, message: lastInstallMessage };
} catch (error) {
lastInstallMessage = formatInstallError(error);
return { success: false, message: lastInstallMessage };
}
})();
try {
return await installPromise;
} finally {
installPromise = null;
runtimeProbeCache = null;
}
}
function normalizeUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) {
throw new Error('URL is required.');
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
? trimmed
: `https://${trimmed}`;
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only http and https URLs are supported.');
}
return parsed.toString();
}
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
const { ownerId: _ownerId, ...publicFields } = session;
return publicFields;
}
function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
}
async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId);
handles.delete(sessionId);
await handle?.context?.close?.().catch(() => undefined);
await handle?.browser?.close().catch(() => undefined);
}
async function expireStaleSessions(now = Date.now()): Promise<void> {
await Promise.all([...sessions.values()].map(async (session) => {
if (session.status !== 'ready') {
return;
}
const updatedAt = Date.parse(session.updatedAt);
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
return;
}
await closeHandle(session.id);
session.status = 'stopped';
session.updatedAt = new Date(now).toISOString();
session.lastAction = 'expire';
session.message = 'Browser session expired after inactivity.';
}));
}
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
session.title = await page.title().catch(() => null);
session.url = page.url() || session.url;
session.viewport = page.viewportSize?.() || session.viewport;
session.updatedAt = new Date().toISOString();
}
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
if (typeof input.x === 'number' && typeof input.y === 'number') {
return { x: input.x, y: input.y };
}
const locator = input.selector
? page.locator(input.selector).first()
: input.text
? page.getByText(input.text, { exact: false }).first()
: null;
if (!locator) {
return null;
}
const box = await locator.boundingBox().catch(() => null);
if (!box) {
return null;
}
return {
x: Math.round(box.x + box.width / 2),
y: Math.round(box.y + box.height / 2),
};
}
export const browserUseService = {
async getSettings() {
return readSettings();
},
async updateSettings(settings: Partial<BrowserUseSettings>) {
const current = readSettings();
const nextSettings = {
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
};
const next = writeSettings(nextSettings);
if (next.enabled) {
await this.registerAgentMcp();
} else if (current.enabled) {
await this.unregisterAgentMcp();
await this.stopAllSessions();
}
return next;
},
async getStatus() {
const settings = readSettings();
const readiness = getRuntimeReadiness();
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
return {
enabled: settings.enabled,
runtime: getRuntime(),
available,
playwrightInstalled: readiness.playwrightInstalled,
chromiumInstalled: readiness.chromiumInstalled,
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
message: available
? 'Browser runtime is available.'
: getSetupMessage(settings, readiness),
};
},
async registerAgentMcp() {
const { command, args } = getMcpCommand();
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
const results = await providerMcpService.addMcpServerToAllProviders({
name: MCP_SERVER_NAME,
scope: 'user',
transport: 'stdio',
command,
args,
env: {
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
},
});
return { name: MCP_SERVER_NAME, command, args, results };
},
getMcpToken() {
return getOrCreateMcpToken();
},
async unregisterAgentMcp() {
const results = (await Promise.all(
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
)).flat();
return { name: MCP_SERVER_NAME, results };
},
async installRuntime() {
const result = await installRuntime();
return {
...result,
status: await this.getStatus(),
};
},
async listSessions() {
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async createAgentSession(options?: { profileName?: string | null }) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
await expireStaleSessions();
const profileName = normalizeProfileName(options?.profileName);
const now = new Date().toISOString();
const session: BrowserUseSession = {
id: randomUUID(),
ownerId: AGENT_OWNER_ID,
createdBy: 'agent',
runtime: getRuntime(),
status: 'unavailable',
url: null,
title: null,
screenshotDataUrl: null,
createdAt: now,
updatedAt: now,
lastAction: 'create',
message: null,
profileName,
viewport: { width: 1440, height: 900 },
cursor: null,
};
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
}
const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session);
return publicSession(session);
}
let browser: any | undefined;
let context: any | undefined;
let page: any;
const launchOptions = {
headless: true,
args: ['--disable-dev-shm-usage'],
};
const contextOptions = {
viewport: { width: 1440, height: 900 },
serviceWorkers: 'block',
};
if (profileName) {
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
...launchOptions,
...contextOptions,
});
page = context.pages()[0] || await context.newPage();
} else {
browser = await readiness.playwright.chromium.launch(launchOptions);
context = await browser.newContext(contextOptions);
page = await context.newPage();
}
session.status = 'ready';
session.message = 'Browser session is ready.';
sessions.set(session.id, session);
handles.set(session.id, { browser, context, page });
await captureSession(session, page);
return publicSession(session);
},
async listAgentSessions() {
const settings = readSettings();
if (!settings.enabled) {
return [];
}
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async getAgentSession(sessionId: string) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
return session;
},
async agentNavigate(sessionId: string, rawUrl: string) {
await this.getAgentSession(sessionId);
await expireStaleSessions();
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
if (session.status !== 'ready') {
throw new Error(session.message || 'Browser session is not available.');
}
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const url = normalizeUrl(rawUrl);
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
session.lastAction = `navigate:${url}`;
session.cursor = null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSnapshot(sessionId: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await captureSession(session, handle.page);
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
return {
session: publicSession(session),
text: text.slice(0, 30_000),
};
},
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const point = await getActionPoint(handle.page, input);
if (input.selector) {
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
} else if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
await handle.page.mouse.click(input.x, input.y);
} else {
throw new Error('Provide selector, text, or x/y coordinates.');
}
session.lastAction = 'click';
session.cursor = point ? { ...point, actor: 'agent' } : null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
if (input.selector) {
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
session.cursor = await getActionPoint(handle.page, input).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
} else {
await handle.page.keyboard.type(input.text);
}
if (input.submit) {
await handle.page.keyboard.press('Enter');
}
session.lastAction = 'type';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
for (const field of fields) {
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
}
session.lastAction = 'fill_form';
if (fields[0]) {
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
}
await captureSession(session, handle.page);
return publicSession(session);
},
async agentPressKey(sessionId: string, key: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.keyboard.press(key);
session.lastAction = `press_key:${key}`;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSelectOption(sessionId: string, selector: string, values: string[]) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
session.lastAction = 'select_option';
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
await captureSession(session, handle.page);
return publicSession(session);
},
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
} else if (input.url) {
await handle.page.waitForURL(input.url, { timeout });
} else {
await handle.page.waitForTimeout(timeout);
}
session.lastAction = 'wait_for';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.context || !handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const action = input.action || 'list';
if (action === 'new') {
const page = await handle.context.newPage();
handles.set(sessionId, { ...handle, page });
if (input.url) {
await this.agentNavigate(sessionId, input.url);
}
} else if (action === 'select') {
const page = handle.context.pages()[input.index || 0];
if (!page) {
throw new Error('Tab not found.');
}
handles.set(sessionId, { ...handle, page });
} else if (action === 'close') {
const pages = handle.context.pages();
const page = pages[input.index ?? pages.indexOf(handle.page)];
if (!page) {
throw new Error('Tab not found.');
}
await page.close();
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
}
const updatedHandle = handles.get(sessionId);
await captureSession(session, updatedHandle?.page || handle.page);
return {
session: publicSession(session),
tabs: handle.context.pages().map((page: any, index: number) => ({
index,
url: page.url(),
active: page === (updatedHandle?.page || handle.page),
})),
};
},
async stopSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { stopped: false };
}
await closeHandle(sessionId);
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'stop';
session.message = 'Browser session stopped. Create a new session to continue browsing.';
return { stopped: true, session: publicSession(session) };
},
async deleteSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { deleted: false };
}
await closeHandle(sessionId);
sessions.delete(sessionId);
return { deleted: true, sessionId };
},
async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId);
return this.stopSession(sessionId);
},
async stopAllSessions() {
await Promise.all([...sessions.keys()].map(async (sessionId) => {
await closeHandle(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'shutdown';
session.message = 'Browser session stopped during server shutdown.';
}
}));
},
};
process.once('beforeExit', () => {
void browserUseService.stopAllSessions();
});

View File

@@ -1,10 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
test('browser monitor list starts empty without agent sessions', async () => {
const sessions = await browserUseService.listSessions();
assert.deepEqual(sessions, []);
});

View File

@@ -4,7 +4,6 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
export { notificationChannelEndpointsDb } from '@/modules/database/repositories/notification-channel-endpoints.js';
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';

View File

@@ -3,7 +3,6 @@ import { Database } from 'better-sqlite3';
import {
APP_CONFIG_TABLE_SCHEMA_SQL,
LAST_SCANNED_AT_SQL,
NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
PROJECTS_TABLE_SCHEMA_SQL,
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
SESSIONS_TABLE_SCHEMA_SQL,
@@ -441,9 +440,6 @@ export const runMigrations = (db: Database) => {
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
db.exec(NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL);
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel)');
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled)');
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
rebuildProjectsTableWithPrimaryKeySchema(db);

View File

@@ -1,153 +0,0 @@
import { getConnection } from '@/modules/database/connection.js';
type NotificationChannelEndpointRow = {
id: number;
user_id: number;
channel: string;
endpoint_id: string;
label: string | null;
metadata_json: string | null;
enabled: number;
last_seen_at: string;
created_at: string;
updated_at: string;
};
type UpsertNotificationChannelEndpointInput = {
userId: number;
channel: string;
endpointId: string;
label?: string | null;
metadata?: Record<string, unknown> | null;
enabled?: boolean;
};
function normalizeRequiredText(value: unknown): string {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeNullableText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized || null;
}
function serializeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
if (!metadata || typeof metadata !== 'object') return null;
return JSON.stringify(metadata);
}
function parseMetadata(metadataJson: string | null): Record<string, unknown> {
if (!metadataJson) return {};
try {
const parsed = JSON.parse(metadataJson);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
export const notificationChannelEndpointsDb = {
upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow {
const channel = normalizeRequiredText(input.channel);
const endpointId = normalizeRequiredText(input.endpointId);
if (!channel) throw new Error('channel is required');
if (!endpointId) throw new Error('endpointId is required');
const enabled = input.enabled === false ? 0 : 1;
const db = getConnection();
db.prepare(
`INSERT INTO notification_channel_endpoints (
user_id,
channel,
endpoint_id,
label,
metadata_json,
enabled,
last_seen_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET
label = excluded.label,
metadata_json = excluded.metadata_json,
enabled = excluded.enabled,
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`
).run(
input.userId,
channel,
endpointId,
normalizeNullableText(input.label),
serializeMetadata(input.metadata),
enabled
);
return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!;
},
getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null {
const db = getConnection();
const row = db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).get(
userId,
normalizeRequiredText(channel),
normalizeRequiredText(endpointId)
) as NotificationChannelEndpointRow | undefined;
return row || null;
},
getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
const db = getConnection();
return db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ?
ORDER BY last_seen_at DESC`
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
},
getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
const db = getConnection();
return db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ? AND enabled = 1
ORDER BY last_seen_at DESC`
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
},
setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean {
const db = getConnection();
const result = db.prepare(
`UPDATE notification_channel_endpoints
SET enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
touchEndpoint(userId: number, channel: string, endpointId: string): boolean {
const db = getConnection();
const result = db.prepare(
`UPDATE notification_channel_endpoints
SET last_seen_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
removeEndpoint(userId: number, channel: string, endpointId: string): boolean {
const db = getConnection();
const result = db.prepare(
'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?'
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
parseMetadata,
};

View File

@@ -10,9 +10,7 @@ type NotificationPreferences = {
channels: {
inApp: boolean;
webPush: boolean;
desktop: boolean;
sound: boolean;
[key: string]: boolean;
};
events: {
actionRequired: boolean;
@@ -25,7 +23,6 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: {
inApp: false,
webPush: false,
desktop: false,
sound: true,
},
events: {
@@ -37,20 +34,11 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
const sourceChannels = source.channels && typeof source.channels === 'object'
? source.channels as Record<string, unknown>
: {};
const extraChannels = Object.fromEntries(
Object.entries(sourceChannels)
.filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean')
) as Record<string, boolean>;
return {
channels: {
...extraChannels,
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true,
desktop: source.channels?.desktop === true,
sound: source.channels?.sound !== false,
},
events: {
@@ -115,3 +103,4 @@ export const notificationPreferencesDb = {
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
},
};

View File

@@ -70,15 +70,3 @@ test('createSession reactivates archived rows when the session becomes active ag
assert.equal(restoredSession?.isArchived, 0);
});
});
test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project');
const row = sessionsDb.getSessionById('session-timezone');
assert.ok(row?.created_at.endsWith('Z'));
assert.ok(row?.updated_at.endsWith('Z'));
assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
});
});

View File

@@ -17,19 +17,10 @@ type SessionRow = {
const SESSION_ROW_COLUMNS =
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
function normalizeTimestamp(value?: string): string | null {
if (!value) return null;
// SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix.
// Normalize it here so every session reader returns canonical ISO strings
// and the sidebar never interprets fresh rows as local-time "hours old".
const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value)
? `${value.replace(' ', 'T')}Z`
: value;
const parsed = new Date(normalizedValue);
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
@@ -37,22 +28,6 @@ function normalizeTimestamp(value?: string): string | null {
return parsed.toISOString();
}
function normalizeSessionRow<T extends SessionRow | null | undefined>(row: T): T {
if (!row) {
return row;
}
return {
...row,
created_at: normalizeTimestamp(row.created_at) ?? row.created_at,
updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at,
};
}
function normalizeSessionRows(rows: SessionRow[]): SessionRow[] {
return rows.map((row) => normalizeSessionRow(row) as SessionRow);
}
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
void provider;
return normalizeProjectPath(projectPath);
@@ -232,7 +207,7 @@ export const sessionsDb = {
)
.get(sessionId) as SessionRow | undefined;
return normalizeSessionRow(row) ?? null;
return row ?? null;
},
/**
@@ -254,57 +229,18 @@ export const sessionsDb = {
)
.get(providerSessionId) as SessionRow | undefined;
return normalizeSessionRow(row) ?? null;
},
/**
* Finds the newest app-created session for a project that is still waiting
* for its provider-native id to be recorded.
*
* Primary intention: OpenCode can expose a new session in its shared
* `opencode.db` before the websocket runtime reports that same provider id
* back to our app. At that moment the sidebar already has an optimistic
* app-owned session row, but the watcher only knows the provider-native id.
*
* Without this lookup, the synchronizer would insert a second row keyed by
* the provider id, then `assignProviderSessionId()` would merge it a moment
* later. That eventually self-heals, but on slow networks the user can still
* briefly see two sidebar sessions for the same conversation.
*
* This helper lets the synchronizer claim the pending app row first, so the
* provider id is attached before any watcher-created row exists. The result
* is simpler than frontend dedupe and keeps the race resolved at the source.
*/
findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE provider = ?
AND project_path = ?
AND provider_session_id IS NULL
AND isArchived = 0
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
LIMIT 1`
)
.get(provider, normalizedProjectPath) as SessionRow | undefined;
return normalizeSessionRow(row) ?? null;
return row ?? null;
},
getAllSessions(): SessionRow[] {
const db = getConnection();
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE isArchived = 0`
)
.all() as SessionRow[];
return normalizeSessionRows(rows);
},
/**
@@ -313,7 +249,7 @@ export const sessionsDb = {
*/
getArchivedSessions(): SessionRow[] {
const db = getConnection();
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
@@ -321,14 +257,12 @@ export const sessionsDb = {
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
)
.all() as SessionRow[];
return normalizeSessionRows(rows);
},
getSessionsByProjectPath(projectPath: string): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
@@ -336,8 +270,6 @@ export const sessionsDb = {
AND isArchived = 0`
)
.all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
},
/**
@@ -347,21 +279,19 @@ export const sessionsDb = {
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE project_path = ?`
)
.all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
},
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
@@ -371,8 +301,6 @@ export const sessionsDb = {
LIMIT ? OFFSET ?`
)
.all(normalizedProjectPath, limit, offset) as SessionRow[];
return normalizeSessionRows(rows);
},
countSessionsByProjectPath(projectPath: string): number {

View File

@@ -69,23 +69,6 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
);
`;
export const NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS notification_channel_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
channel TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
label TEXT,
metadata_json TEXT,
enabled BOOLEAN DEFAULT 1,
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, channel, endpoint_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
export const PROJECTS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY NOT NULL,
@@ -161,10 +144,6 @@ ${VAPID_KEYS_TABLE_SCHEMA_SQL}
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
${NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel);
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled);
${PROJECTS_TABLE_SCHEMA_SQL}
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
-- Creating them here can fail on upgraded installs where projects lacks those columns.

View File

@@ -1,13 +0,0 @@
export {
buildNotificationPayload,
createNotificationEvent,
notifyUserIfEnabled,
notifyRunFailed,
notifyRunStopped,
} from '@/modules/notifications/services/notification-orchestrator.service.js';
export {
registerDesktopNotificationClient,
sendDesktopNotification,
unregisterDesktopNotificationClient,
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
export { handleDesktopNotificationsConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js';

View File

@@ -1,127 +0,0 @@
import express from 'express';
import { notificationChannelEndpointsDb, notificationPreferencesDb } from '@/modules/database/index.js';
const router = express.Router();
function readText(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function sanitizeEndpoint(endpoint: any) {
return {
id: endpoint.id,
channel: endpoint.channel,
endpointId: endpoint.endpoint_id,
label: endpoint.label,
metadata: notificationChannelEndpointsDb.parseMetadata(endpoint.metadata_json),
enabled: Boolean(endpoint.enabled),
lastSeenAt: endpoint.last_seen_at,
createdAt: endpoint.created_at,
updatedAt: endpoint.updated_at,
};
}
function readUserId(req: express.Request): number {
const userId = Number((req as any).user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
throw new Error('Authenticated user is missing');
}
return userId;
}
function updateChannelPreference(userId: number, channel: string): unknown {
const currentPrefs = notificationPreferencesDb.getPreferences(userId);
const hasEnabledEndpoint = notificationChannelEndpointsDb.getEnabledEndpoints(userId, channel).length > 0;
return notificationPreferencesDb.updatePreferences(userId, {
...currentPrefs,
channels: { ...currentPrefs.channels, [channel]: hasEnabledEndpoint },
});
}
router.get('/endpoints', (req, res) => {
try {
const channel = readText(req.query.channel);
if (!channel) {
return res.status(400).json({ error: 'channel is required' });
}
const userId = readUserId(req);
const endpoints = notificationChannelEndpointsDb
.getEndpoints(userId, channel)
.map(sanitizeEndpoint);
return res.json({ success: true, endpoints });
} catch (error) {
console.error('Error fetching notification endpoints:', error);
return res.status(500).json({ error: 'Failed to fetch notification endpoints' });
}
});
router.post('/endpoints/current', (req, res) => {
try {
const { channel, endpointId, label, metadata = {}, enabled = true } = req.body || {};
const normalizedChannel = readText(channel);
const normalizedEndpointId = readText(endpointId);
if (!normalizedChannel || !normalizedEndpointId) {
return res.status(400).json({ error: 'channel and endpointId are required' });
}
const userId = readUserId(req);
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
userId,
channel: normalizedChannel,
endpointId: normalizedEndpointId,
label,
metadata: metadata && typeof metadata === 'object' ? metadata : {},
enabled: enabled !== false,
});
const preferences = updateChannelPreference(userId, normalizedChannel);
return res.json({ success: true, endpoint: sanitizeEndpoint(endpoint), preferences });
} catch (error) {
console.error('Error registering notification endpoint:', error);
return res.status(500).json({ error: 'Failed to register notification endpoint' });
}
});
router.patch('/endpoints/:channel/:endpointId', (req, res) => {
try {
const { channel, endpointId } = req.params;
const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: 'enabled must be a boolean' });
}
const userId = readUserId(req);
const updated = notificationChannelEndpointsDb.setEndpointEnabled(userId, channel, endpointId, enabled);
if (!updated) {
return res.status(404).json({ error: 'Notification endpoint not found' });
}
const endpoint = notificationChannelEndpointsDb.getEndpoint(userId, channel, endpointId);
const preferences = updateChannelPreference(userId, channel);
return res.json({ success: true, endpoint: endpoint ? sanitizeEndpoint(endpoint) : null, preferences });
} catch (error) {
console.error('Error updating notification endpoint:', error);
return res.status(500).json({ error: 'Failed to update notification endpoint' });
}
});
router.delete('/endpoints/:channel/:endpointId', (req, res) => {
try {
const { channel, endpointId } = req.params;
const userId = readUserId(req);
const removed = notificationChannelEndpointsDb.removeEndpoint(userId, channel, endpointId);
if (!removed) {
return res.status(404).json({ error: 'Notification endpoint not found' });
}
const preferences = updateChannelPreference(userId, channel);
return res.json({ success: true, preferences });
} catch (error) {
console.error('Error removing notification endpoint:', error);
return res.status(500).json({ error: 'Failed to remove notification endpoint' });
}
});
export default router;

View File

@@ -1,124 +0,0 @@
import type { WebSocket } from 'ws';
import { notificationChannelEndpointsDb } from '@/modules/database/index.js';
const DESKTOP_CHANNEL = 'desktop';
const clientsByUserId = new Map<number, Map<string, WebSocket>>();
const clientBySocket = new WeakMap<WebSocket, { userId: number; endpointId: string }>();
function normalizeUserId(userId: unknown): number | null {
const numeric = Number(userId);
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
}
function normalizeEndpointId(endpointId: unknown): string {
if (typeof endpointId !== 'string') return '';
return endpointId.trim();
}
function getUserClients(userId: unknown, create = false): Map<string, WebSocket> | null {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) return null;
let clients = clientsByUserId.get(normalizedUserId);
if (!clients && create) {
clients = new Map();
clientsByUserId.set(normalizedUserId, clients);
}
return clients || null;
}
export function registerDesktopNotificationClient({
userId,
deviceId,
label = null,
platform = null,
appVersion = null,
ws,
}: {
userId: number;
deviceId: string;
label?: string | null;
platform?: string | null;
appVersion?: string | null;
ws: WebSocket;
}) {
const normalizedUserId = normalizeUserId(userId);
const endpointId = normalizeEndpointId(deviceId);
if (!normalizedUserId || !endpointId) {
return false;
}
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
userId: normalizedUserId,
channel: DESKTOP_CHANNEL,
endpointId,
label,
metadata: { platform, appVersion },
enabled: true,
});
const clients = getUserClients(normalizedUserId, true)!;
const previous = clients.get(endpointId);
if (previous && previous !== ws && previous.readyState === previous.OPEN) {
previous.close(4000, 'Device reconnected');
}
clients.set(endpointId, ws);
clientBySocket.set(ws, { userId: normalizedUserId, endpointId });
return endpoint;
}
export function unregisterDesktopNotificationClient(ws: WebSocket): void {
const registration = clientBySocket.get(ws);
if (!registration) return;
const clients = getUserClients(registration.userId);
if (clients?.get(registration.endpointId) === ws) {
clients.delete(registration.endpointId);
if (clients.size === 0) {
clientsByUserId.delete(registration.userId);
}
}
clientBySocket.delete(ws);
}
export function sendDesktopNotification(userId: unknown, payload: unknown): { attempted: number; sent: number } {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) return { attempted: 0, sent: 0 };
const clients = getUserClients(normalizedUserId);
if (!clients?.size) return { attempted: 0, sent: 0 };
const enabledEndpointIds = new Set(
notificationChannelEndpointsDb
.getEnabledEndpoints(normalizedUserId, DESKTOP_CHANNEL)
.map((endpoint) => endpoint.endpoint_id)
);
const message = JSON.stringify({
type: 'notification',
id: typeof (payload as any)?.data?.tag === 'string' ? (payload as any).data.tag : `${Date.now()}`,
payload,
});
let attempted = 0;
let sent = 0;
for (const [endpointId, ws] of clients.entries()) {
if (!enabledEndpointIds.has(endpointId)) continue;
attempted += 1;
if (ws.readyState !== ws.OPEN) {
unregisterDesktopNotificationClient(ws);
continue;
}
try {
ws.send(message);
notificationChannelEndpointsDb.touchEndpoint(normalizedUserId, DESKTOP_CHANNEL, endpointId);
sent += 1;
} catch {
unregisterDesktopNotificationClient(ws);
}
}
return { attempted, sent };
}

View File

@@ -1,288 +0,0 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '@/modules/database/index.js';
import { sendDesktopNotification as sendDesktopNotificationToClients } from '@/modules/notifications/services/desktop-notification-clients.service.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const PROVIDER_LABELS = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
system: 'System'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function isNotificationEventEnabled(preferences, event) {
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function normalizeErrorMessage(error) {
if (typeof error === 'string') {
return error;
}
if (error && typeof error.message === 'string') {
return error.message;
}
if (error == null) {
return 'Unknown error';
}
return String(error);
}
function normalizeSessionName(sessionName) {
if (typeof sessionName !== 'string') {
return null;
}
const normalized = sessionName.replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
function rowMatchesProvider(row, provider) {
return row && (!provider || row.provider === provider);
}
function resolveSessionRow(sessionId, provider) {
if (!sessionId) {
return null;
}
const appSessionRow = sessionsDb.getSessionById(sessionId);
if (rowMatchesProvider(appSessionRow, provider)) {
return appSessionRow;
}
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
if (rowMatchesProvider(providerSessionRow, provider)) {
return providerSessionRow;
}
return null;
}
function normalizeNotificationSession(event) {
if (!event?.sessionId || !event.provider || event.provider === 'system') {
return event;
}
const row = resolveSessionRow(event.sessionId, event.provider);
if (!row || row.session_id === event.sessionId) {
return event;
}
return {
...event,
sessionId: row.session_id
};
}
function resolveSessionName(event) {
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
if (explicitSessionName) {
return explicitSessionName;
}
if (!event.sessionId || !event.provider) {
return null;
}
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
}
function buildNotificationPayload(event) {
const normalizedEvent = normalizeNotificationSession(event);
const CODE_MAP = {
'permission.required': normalizedEvent.meta?.toolName
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
: 'Action Required: A tool needs your approval',
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
'push.enabled': 'Push notifications are now enabled!'
};
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
const sessionName = resolveSessionName(normalizedEvent);
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
return {
title: sessionName || 'CloudCLI',
body: `${providerLabel}: ${message}`,
data: {
sessionId: normalizedEvent.sessionId || null,
code: normalizedEvent.code,
provider: normalizedEvent.provider || null,
sessionName,
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
}
};
}
function sendWebPushPayload(userId, payload) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return Promise.resolve();
const serializedPayload = JSON.stringify(payload);
return Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
serializedPayload
)
)
).then((results) => {
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
});
}
const notificationChannels = [
{
id: 'webPush',
// TODO: Web push still uses push_subscriptions. Do not remove that table until
// browser push subscriptions are migrated into notification_channel_endpoints.
isEnabled: (preferences) => Boolean(preferences?.channels?.webPush),
send: ({ userId, payload }) => sendWebPushPayload(userId, payload)
},
{
id: 'desktop',
isEnabled: (preferences) => Boolean(preferences?.channels?.desktop),
send: ({ userId, payload }) => sendDesktopNotificationToClients(userId, payload)
}
];
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const normalizedEvent = normalizeNotificationSession(event);
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!isNotificationEventEnabled(preferences, normalizedEvent)) {
return;
}
if (isDuplicate(normalizedEvent)) {
return;
}
const payload = buildNotificationPayload(normalizedEvent);
for (const channel of notificationChannels) {
if (!channel.isEnabled(preferences)) {
continue;
}
Promise.resolve(channel.send({ userId, event: normalizedEvent, payload })).catch((err) => {
console.error(`Notification channel "${channel.id}" send error:`, err);
});
}
}
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason, sessionName },
severity: 'info',
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
})
});
}
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
const errorMessage = normalizeErrorMessage(error);
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'error',
code: 'run.failed',
meta: { error: errorMessage, sessionName },
severity: 'error',
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
})
});
}
export {
buildNotificationPayload,
createNotificationEvent,
notifyUserIfEnabled,
notifyRunStopped,
notifyRunFailed
};

View File

@@ -1,109 +0,0 @@
import type { WebSocket } from 'ws';
import {
registerDesktopNotificationClient,
unregisterDesktopNotificationClient,
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
import { parseIncomingJsonObject } from '@/shared/utils.js';
type DesktopNotificationRegisterMessage = {
type?: unknown;
kind?: unknown;
deviceId?: unknown;
label?: unknown;
platform?: unknown;
appVersion?: unknown;
};
function readRequestUserId(request: AuthenticatedWebSocketRequest): number | null {
const user = request.user;
const rawUserId = typeof user?.id === 'number' || typeof user?.id === 'string'
? user.id
: typeof user?.userId === 'number' || typeof user?.userId === 'string'
? user.userId
: null;
const numericUserId = Number(rawUserId);
return Number.isInteger(numericUserId) && numericUserId > 0 ? numericUserId : null;
}
function readOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized || null;
}
function sendJson(ws: WebSocket, payload: unknown): void {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(payload));
}
}
export function handleDesktopNotificationsConnection(
ws: WebSocket,
request: AuthenticatedWebSocketRequest
): void {
const userId = readRequestUserId(request);
if (!userId) {
ws.close(1008, 'Missing authenticated user');
return;
}
let registered = false;
ws.on('message', (rawMessage) => {
const data = parseIncomingJsonObject(rawMessage) as DesktopNotificationRegisterMessage | null;
if (!data) {
return;
}
const type = typeof data.type === 'string' ? data.type : typeof data.kind === 'string' ? data.kind : '';
if (type === 'notification_ack') {
return;
}
if (type !== 'register' || registered) {
return;
}
const deviceId = readOptionalString(data.deviceId);
if (!deviceId) {
sendJson(ws, {
type: 'error',
code: 'DEVICE_ID_REQUIRED',
message: 'Desktop notification registration requires deviceId.',
});
ws.close(1008, 'Missing deviceId');
return;
}
const device = registerDesktopNotificationClient({
userId,
deviceId,
label: readOptionalString(data.label),
platform: readOptionalString(data.platform),
appVersion: readOptionalString(data.appVersion),
ws,
});
if (!device) {
ws.close(1011, 'Registration failed');
return;
}
registered = true;
sendJson(ws, {
type: 'registered',
deviceId: device.endpoint_id,
enabled: Boolean(device.enabled),
});
});
ws.on('close', () => {
unregisterDesktopNotificationClient(ws);
});
ws.on('error', () => {
unregisterDesktopNotificationClient(ws);
});
}

View File

@@ -30,6 +30,10 @@ type ProjectApiView = {
isArchived: boolean;
isStarred: boolean;
sessions: [];
cursorSessions: [];
codexSessions: [];
geminiSessions: [];
opencodeSessions: [];
sessionMeta: {
hasMore: false;
total: 0;
@@ -78,6 +82,10 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
isArchived: Boolean(projectRow.isArchived),
isStarred: Boolean(projectRow.isStarred),
sessions: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
sessionMeta: {
hasMore: false,
total: 0,

View File

@@ -9,12 +9,13 @@ import { AppError } from '@/shared/utils.js';
type SessionSummary = {
id: string;
provider: string;
summary: string;
messageCount: number;
lastActivity: string;
};
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
type SessionRepositoryRow = {
provider: string;
session_id: string;
@@ -30,6 +31,10 @@ export type ProjectListItem = {
fullPath: string;
isStarred: boolean;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
@@ -59,7 +64,7 @@ type SessionPaginationOptions = {
};
type ProjectSessionsPageResult = {
sessions: SessionSummary[];
sessionsByProvider: SessionsByProvider;
total: number;
hasMore: boolean;
};
@@ -67,6 +72,10 @@ type ProjectSessionsPageResult = {
export type ProjectSessionsPageApiView = {
projectId: string;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
@@ -120,18 +129,39 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
return {
id: row.session_id,
provider: row.provider,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
};
}
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
const byProvider: SessionsByProvider = {
claude: [],
cursor: [],
codex: [],
gemini: [],
opencode: [],
};
for (const row of rows) {
const provider = row.provider as keyof SessionsByProvider;
const bucket = byProvider[provider];
if (!bucket) {
continue;
}
bucket.push(mapSessionRowToSummary(row));
}
return byProvider;
}
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
return {
sessions: rows.map(mapSessionRowToSummary),
sessionsByProvider: bucketSessionRowsByProvider(rows),
total: rows.length,
hasMore: false,
};
@@ -153,7 +183,7 @@ function readProjectSessionsPageByPath(
const total = sessionsDb.countSessionsByProjectPath(projectPath);
return {
sessions: rows.map(mapSessionRowToSummary),
sessionsByProvider: bucketSessionRowsByProvider(rows),
total,
hasMore: pagination.offset + rows.length < total,
};
@@ -175,7 +205,7 @@ function broadcastProgress(progress: ProgressUpdate) {
}
/**
* Reads all projects from DB and returns normalized session summaries.
* Reads all projects from DB and returns provider-bucketed session summaries.
*/
export async function getProjectsWithSessions(
options: GetProjectsWithSessionsOptions = {}
@@ -223,7 +253,11 @@ export async function getProjectsWithSessions(
displayName,
fullPath: projectPath,
isStarred: Boolean(row.isStarred),
sessions: sessionsPage.sessions,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
@@ -276,7 +310,11 @@ export async function getArchivedProjectsWithSessions(
fullPath: row.project_path,
isStarred: Boolean(row.isStarred),
isArchived: true,
sessions: sessionsPage.sessions,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
@@ -305,7 +343,11 @@ export async function getProjectSessionsPage(
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
return {
projectId: projectRow.project_id,
sessions: sessionsPage.sessions,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,

View File

@@ -1,6 +1,5 @@
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
export { providerSkillsService } from './services/skills.service.js';
export { providerMcpService } from './services/mcp.service.js';
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';

View File

@@ -25,21 +25,6 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'claude' as const;
private readonly claudeHome = path.join(os.homedir(), '.claude');
/**
* Returns true when a JSONL file is a subagent transcript rather than a
* top-level session.
*
* Claude stores subagent transcripts under a `subagents/` directory, e.g.
* `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`.
* Those files repeat the parent session's `sessionId`, so indexing them as
* standalone sessions overwrites the parent row's `jsonl_path` and corrupts
* the main session record. The recursive scan in `synchronize()` reaches
* them, so both entry points must skip them.
*/
private isSubagentTranscript(filePath: string): boolean {
return path.normalize(filePath).split(path.sep).includes('subagents');
}
/**
* Scans ~/.claude/projects and upserts discovered sessions into DB.
*/
@@ -53,10 +38,6 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
let processed = 0;
for (const filePath of files) {
if (this.isSubagentTranscript(filePath)) {
continue;
}
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
continue;
@@ -85,9 +66,6 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
if (!filePath.endsWith('.jsonl')) {
return null;
}
if (this.isSubagentTranscript(filePath)) {
return null;
}
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const parsed = await this.processSessionFile(filePath, nameMap);

View File

@@ -99,14 +99,6 @@ export class ClaudeSkillsProvider extends SkillsProvider {
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(getClaudeHomePath(), 'skills'),
commandPrefix: '/',
};
}
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
const enabledPlugins = readObjectRecord(settings.enabledPlugins);

View File

@@ -57,12 +57,4 @@ export class CodexSkillsProvider extends SkillsProvider {
return sources;
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '$',
};
}
}

View File

@@ -28,12 +28,4 @@ export class CursorSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -33,12 +33,4 @@ export class GeminiSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -112,17 +112,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
}
const fallbackTitle = 'Untitled OpenCode Session';
const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId)
?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath);
if (pendingAppSession && !pendingAppSession.provider_session_id) {
// Slow networks can let the sqlite watcher index opencode.db before the
// runtime reports its provider id back through the websocket mapping.
// Bind that id to the fresh app row first so the watcher does not create
// a temporary provider-id sidebar entry for the same session.
sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId);
}
// App-created sessions are keyed by an app id, so disk-discovered provider
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
@@ -134,9 +123,7 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
// OpenCode stores every session in one shared sqlite database, so jsonl_path
// must stay null to avoid deleting opencode.db when one app session is removed.
// Return the canonical stored row id so watcher-triggered sidebar updates
// stay on the app session once provider_session_id has already been mapped.
return sessionsDb.createSession(
sessionsDb.createSession(
sessionId,
this.provider,
projectPath,
@@ -145,6 +132,8 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
null,
);
return sessionId;
}
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {

View File

@@ -12,8 +12,6 @@ import type {
McpScope,
McpTransport,
ProviderChangeActiveModelInput,
ProviderSkillCreateFile,
ProviderSkillCreateInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
@@ -181,104 +179,6 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
};
};
const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const rawEntries = Array.isArray(body.entries)
? body.entries
: typeof body.content === 'string'
? [{
content: body.content,
directoryName: body.directoryName,
fileName: body.fileName,
files: body.files,
}]
: null;
if (!rawEntries || rawEntries.length === 0) {
throw new AppError('At least one skill entry is required.', {
code: 'PROVIDER_SKILLS_REQUIRED',
statusCode: 400,
});
}
const entries = rawEntries.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
throw new AppError(`Skill entry ${index + 1} must be an object.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const record = entry as Record<string, unknown>;
const content = typeof record.content === 'string' ? record.content : '';
const directoryName = readOptionalQueryString(record.directoryName);
const fileName = readOptionalQueryString(record.fileName);
const rawFiles = record.files;
if (!content.trim()) {
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
statusCode: 400,
});
}
if (rawFiles !== undefined && !Array.isArray(rawFiles)) {
throw new AppError(`Skill entry ${index + 1} files must be an array.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const files: ProviderSkillCreateFile[] | undefined = rawFiles?.map((file, fileIndex) => {
if (!file || typeof file !== 'object') {
throw new AppError(`Skill entry ${index + 1} file ${fileIndex + 1} must be an object.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const fileRecord = file as Record<string, unknown>;
const relativePath = readOptionalQueryString(fileRecord.relativePath);
const fileContent = typeof fileRecord.content === 'string' ? fileRecord.content : null;
const encoding = fileRecord.encoding === 'utf8' || fileRecord.encoding === 'base64'
? fileRecord.encoding
: null;
if (!relativePath || fileContent === null || !encoding) {
throw new AppError(
`Skill entry ${index + 1} file ${fileIndex + 1} requires relativePath, content, and encoding.`,
{
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
},
);
}
return {
relativePath,
content: fileContent,
encoding,
};
});
return {
content,
directoryName,
fileName,
files,
};
});
return { entries };
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (
@@ -420,27 +320,6 @@ router.get(
}),
);
router.post(
'/:provider/skills',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const input = parseProviderSkillCreatePayload(req.body);
const skills = await providerSkillsService.addProviderSkills(provider, input);
res.json(createApiSuccessResponse({ provider, skills }));
}),
);
router.delete(
'/:provider/skills/:directoryName',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.removeProviderSkill(provider, {
directoryName: readPathParam(req.params.directoryName, 'directoryName'),
});
res.json(createApiSuccessResponse(result));
}),
);
// ----------------- MCP routes -----------------
router.get(
'/:provider/mcp/servers',

View File

@@ -80,30 +80,4 @@ export const providerMcpService = {
return results;
},
/**
* Removes one MCP server from every provider. Mirrors `addMcpServerToAllProviders`
* by iterating the live provider registry, so callers stay in sync with which
* providers exist instead of maintaining their own provider list.
*/
async removeMcpServerFromAllProviders(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<Array<{ provider: LLMProvider; removed: boolean; error?: string }>> {
const results: Array<{ provider: LLMProvider; removed: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders();
for (const provider of providers) {
try {
const result = await provider.mcp.removeServer(input);
results.push({ provider: provider.id, removed: result.removed });
} catch (error) {
results.push({
provider: provider.id,
removed: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
},
};

View File

@@ -1,10 +1,5 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type {
ProviderSkill,
ProviderSkillCreateInput,
ProviderSkillListOptions,
ProviderSkillRemoveInput,
} from '@/shared/types.js';
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
export const providerSkillsService = {
/**
@@ -17,23 +12,4 @@ export const providerSkillsService = {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.listSkills(options);
},
/**
* Writes one or more global skills for one provider.
*/
async addProviderSkills(
providerName: string,
input: ProviderSkillCreateInput,
): Promise<ProviderSkill[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.addSkills(input);
},
async removeProviderSkill(
providerName: string,
input: ProviderSkillRemoveInput,
): Promise<{ removed: boolean; provider: string; directoryName: string }> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.removeSkill(input);
},
};

View File

@@ -1,87 +1,20 @@
import path from 'node:path';
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
import type { IProviderSkills } from '@/shared/interfaces.js';
import type {
LLMProvider,
ProviderSkillCreateInput,
ProviderSkillRemoveInput,
ProviderSkill,
ProviderSkillListOptions,
ProviderSkillSource,
} from '@/shared/types.js';
import {
findProviderSkillMarkdownFiles,
readOptionalString,
readProviderSkillMarkdownDefinitionFromContent,
readProviderSkillMarkdownDefinition,
AppError,
} from '@/shared/utils.js';
const resolveWorkspacePath = (workspacePath?: string): string =>
path.resolve(workspacePath ?? process.cwd());
const stripMarkdownExtension = (value: string): string => value.replace(/\.md$/i, '');
const normalizeSkillDirectoryName = (value: string): string => (
value
.trim()
.replace(/[\\/]+/g, '-')
.replace(/[<>:"|?*\x00-\x1F]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^\.+|\.+$/g, '')
.replace(/^-+|-+$/g, '')
);
type PendingSkillInstall = {
skillDirectoryPath: string;
skillPath: string;
content: string;
supportingFiles: Array<{
targetPath: string;
content: string | Buffer;
}>;
skill: ProviderSkill;
};
const resolveSkillSupportingFilePath = (
skillDirectoryPath: string,
relativePath: string,
entryIndex: number,
): string => {
const normalizedRelativePath = relativePath.trim().replace(/\\/g, '/');
const pathSegments = normalizedRelativePath.split('/');
if (
!normalizedRelativePath
|| path.isAbsolute(normalizedRelativePath)
|| pathSegments.some((segment) => !segment || segment === '.' || segment === '..')
|| normalizedRelativePath.toLowerCase() === 'skill.md'
) {
throw new AppError(
`Skill entry ${entryIndex + 1} includes an invalid supporting file path "${relativePath}".`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
const resolvedFilePath = path.resolve(resolvedSkillDirectoryPath, ...pathSegments);
if (!resolvedFilePath.startsWith(`${resolvedSkillDirectoryPath}${path.sep}`)) {
throw new AppError(
`Skill entry ${entryIndex + 1} supporting files must stay inside the skill directory.`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
return resolvedFilePath;
};
/**
* Shared skills provider for provider-specific skill source discovery.
*/
@@ -127,161 +60,5 @@ export abstract class SkillsProvider implements IProviderSkills {
return skills;
}
async addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]> {
const globalSkillSource = await this.getGlobalSkillSource();
if (!globalSkillSource) {
throw new AppError(`${this.provider} does not support managed global skills.`, {
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
statusCode: 400,
});
}
if (!Array.isArray(input.entries) || input.entries.length === 0) {
throw new AppError('At least one skill entry is required.', {
code: 'PROVIDER_SKILLS_REQUIRED',
statusCode: 400,
});
}
const seenSkillPaths = new Set<string>();
const pendingInstalls: PendingSkillInstall[] = [];
for (const [index, entry] of input.entries.entries()) {
const content = typeof entry.content === 'string' ? entry.content.trim() : '';
if (!content) {
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
statusCode: 400,
});
}
const fileNameFallback = readOptionalString(entry.fileName);
const requestedDirectoryName = readOptionalString(entry.directoryName);
const fallbackSkillName = normalizeSkillDirectoryName(
requestedDirectoryName
?? (fileNameFallback ? stripMarkdownExtension(fileNameFallback) : `skill-${index + 1}`),
);
const definition = readProviderSkillMarkdownDefinitionFromContent(content, fallbackSkillName);
const resolvedDirectoryName = normalizeSkillDirectoryName(
requestedDirectoryName ?? definition.name,
);
if (!resolvedDirectoryName) {
throw new AppError(`Skill entry ${index + 1} must include a valid skill name.`, {
code: 'PROVIDER_SKILL_NAME_REQUIRED',
statusCode: 400,
});
}
const skillDirectoryPath = path.join(globalSkillSource.rootDir, resolvedDirectoryName);
const skillPath = path.join(skillDirectoryPath, 'SKILL.md');
const normalizedSkillPath = path.resolve(skillPath);
if (seenSkillPaths.has(normalizedSkillPath)) {
throw new AppError(`Duplicate skill target "${resolvedDirectoryName}" in one request.`, {
code: 'PROVIDER_SKILL_DUPLICATE_TARGET',
statusCode: 400,
});
}
seenSkillPaths.add(normalizedSkillPath);
const supportingFiles = (entry.files ?? []).map((file) => ({
targetPath: resolveSkillSupportingFilePath(skillDirectoryPath, file.relativePath, index),
content: file.encoding === 'base64'
? Buffer.from(file.content, 'base64')
: file.content,
}));
const seenSupportingPaths = new Set<string>();
for (const file of supportingFiles) {
if (seenSupportingPaths.has(file.targetPath)) {
throw new AppError(`Skill entry ${index + 1} includes a duplicate supporting file path.`, {
code: 'PROVIDER_SKILL_DUPLICATE_FILE',
statusCode: 400,
});
}
seenSupportingPaths.add(file.targetPath);
}
const command = globalSkillSource.commandForSkill
? globalSkillSource.commandForSkill(definition.name)
: `${globalSkillSource.commandPrefix ?? '/'}${definition.name}`;
pendingInstalls.push({
skillDirectoryPath,
skillPath,
content,
supportingFiles,
skill: {
provider: this.provider,
name: definition.name,
description: definition.description,
command,
scope: globalSkillSource.scope,
sourcePath: skillPath,
pluginName: globalSkillSource.pluginName,
pluginId: globalSkillSource.pluginId,
},
});
}
for (const install of pendingInstalls) {
// Replace the complete skill directory so removed scripts or assets do not remain stale.
await rm(install.skillDirectoryPath, { recursive: true, force: true });
await mkdir(install.skillDirectoryPath, { recursive: true });
await writeFile(install.skillPath, `${install.content}\n`, 'utf8');
for (const file of install.supportingFiles) {
await mkdir(path.dirname(file.targetPath), { recursive: true });
await writeFile(file.targetPath, file.content);
}
}
return pendingInstalls.map((install) => install.skill);
}
async removeSkill(
input: ProviderSkillRemoveInput,
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }> {
const globalSkillSource = await this.getGlobalSkillSource();
if (!globalSkillSource) {
throw new AppError(`${this.provider} does not support managed global skills.`, {
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
statusCode: 400,
});
}
const directoryName = normalizeSkillDirectoryName(input.directoryName);
if (!directoryName) {
throw new AppError('Skill directoryName is required.', {
code: 'PROVIDER_SKILL_DIRECTORY_REQUIRED',
statusCode: 400,
});
}
const skillDirectoryPath = path.join(globalSkillSource.rootDir, directoryName);
const resolvedRoot = path.resolve(globalSkillSource.rootDir);
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
if (
resolvedSkillDirectoryPath !== resolvedRoot
&& !resolvedSkillDirectoryPath.startsWith(`${resolvedRoot}${path.sep}`)
) {
throw new AppError('Skill directory must stay inside the managed skill root.', {
code: 'PROVIDER_SKILL_DIRECTORY_INVALID',
statusCode: 400,
});
}
const removed = await stat(resolvedSkillDirectoryPath)
.then((stats) => stats.isDirectory())
.catch(() => false);
if (removed) {
await rm(resolvedSkillDirectoryPath, { recursive: true, force: true });
}
return { removed, provider: this.provider, directoryName };
}
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
return null;
}
}

View File

@@ -272,55 +272,6 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr
}
});
test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath);
sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1');
const synchronizer = new OpenCodeSessionSynchronizer();
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
assert.equal(sessionId, 'app-session-1');
assert.equal(sessionsDb.getAllSessions().length, 1);
assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1');
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath);
const synchronizer = new OpenCodeSessionSynchronizer();
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
assert.equal(sessionId, 'app-session-race');
assert.equal(sessionsDb.getAllSessions().length, 1);
assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1');
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
const provider = new OpenCodeSessionsProvider();
const normalized = provider.normalizeMessage({

View File

@@ -510,215 +510,3 @@ test('providerSkillsService lists gemini and cursor skills from their configured
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers managed global skill creation for providers that own a
* writable user skill directory.
*/
test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
const restoreHomeDir = patchHomeDir(tempRoot);
try {
const createdClaudeSkills = await providerSkillsService.addProviderSkills('claude', {
entries: [
{
directoryName: 'claude-global-dir',
content: '---\nname: claude-global\ndescription: Claude global skill\n---\n\nClaude body.\n',
},
],
});
const createdClaudeSkill = createdClaudeSkills[0];
assert.ok(createdClaudeSkill);
assert.equal(createdClaudeSkill.command, '/claude-global');
assert.equal(
createdClaudeSkill.sourcePath.endsWith(path.join('.claude', 'skills', 'claude-global-dir', 'SKILL.md')),
true,
);
assert.match(
await fs.readFile(createdClaudeSkill.sourcePath, 'utf8'),
/Claude body\./,
);
const createdCodexSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'uploaded-codex-folder',
fileName: 'SKILL.md',
content: '---\nname: codex-global\ndescription: Codex global skill\n---\n\nCodex body.\n',
files: [
{
relativePath: 'scripts/run.js',
content: Buffer.from('console.log("codex skill");\n').toString('base64'),
encoding: 'base64',
},
],
},
],
});
const createdCodexSkill = createdCodexSkills[0];
assert.ok(createdCodexSkill);
assert.equal(createdCodexSkill.command, '$codex-global');
assert.equal(
createdCodexSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'uploaded-codex-folder', 'SKILL.md')),
true,
);
assert.equal(
await fs.readFile(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js'), 'utf8'),
'console.log("codex skill");\n',
);
const fallbackNamedSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
fileName: 'fallback / skill.md',
content: '---\ndescription: Normalized fallback skill\n---\n\nFallback body.\n',
},
],
});
const fallbackNamedSkill = fallbackNamedSkills[0];
assert.ok(fallbackNamedSkill);
assert.equal(fallbackNamedSkill.name, 'fallback-skill');
assert.equal(fallbackNamedSkill.command, '$fallback-skill');
assert.equal(
fallbackNamedSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'fallback-skill', 'SKILL.md')),
true,
);
const replacedCodexSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'uploaded-codex-folder',
content: '---\nname: replacement\ndescription: Replacement skill\n---\n\nReplacement body.\n',
},
],
});
assert.equal(replacedCodexSkills[0]?.command, '$replacement');
assert.match(await fs.readFile(createdCodexSkill.sourcePath, 'utf8'), /Replacement body\./);
await assert.rejects(
fs.stat(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js')),
{ code: 'ENOENT' },
);
const pendingBatchSkillPath = path.join(tempRoot, '.agents', 'skills', 'pending-batch', 'SKILL.md');
await assert.rejects(
providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'pending-batch',
content: '---\nname: pending-batch\n---\n\nPending body.\n',
},
{
directoryName: 'pending-batch',
content: '---\nname: duplicate-batch\n---\n\nDuplicate body.\n',
},
],
}),
/duplicate skill target/i,
);
await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
entries: [
{
directoryName: 'gemini-global-dir',
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
},
],
});
const createdGeminiSkill = createdGeminiSkills[0];
assert.ok(createdGeminiSkill);
assert.equal(createdGeminiSkill.command, '/gemini-global');
assert.equal(
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
true,
);
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
entries: [
{
directoryName: 'cursor-global-dir',
content: '---\nname: cursor-global\ndescription: Cursor global skill\n---\n\nCursor body.\n',
},
],
});
const createdCursorSkill = createdCursorSkills[0];
assert.ok(createdCursorSkill);
assert.equal(createdCursorSkill.command, '/cursor-global');
assert.equal(
createdCursorSkill.sourcePath.endsWith(path.join('.cursor', 'skills', 'cursor-global-dir', 'SKILL.md')),
true,
);
const listedClaudeSkills = await providerSkillsService.listProviderSkills('claude');
assert.equal(listedClaudeSkills.some((skill) => skill.name === 'claude-global'), true);
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
const removedCodexSkill = await providerSkillsService.removeProviderSkill('codex', {
directoryName: 'uploaded-codex-folder',
});
assert.equal(removedCodexSkill.removed, true);
assert.equal(removedCodexSkill.provider, 'codex');
assert.equal(removedCodexSkill.directoryName, 'uploaded-codex-folder');
await assert.rejects(fs.stat(path.dirname(createdCodexSkill.sourcePath)), { code: 'ENOENT' });
const removedMissingSkill = await providerSkillsService.removeProviderSkill('codex', {
directoryName: 'uploaded-codex-folder',
});
assert.equal(removedMissingSkill.removed, false);
await assert.rejects(
providerSkillsService.addProviderSkills('codex', {
entries: [
{
content: '---\nname: unsafe-skill\n---\n',
files: [
{
relativePath: '../outside.js',
content: '',
encoding: 'utf8',
},
],
},
],
}),
/invalid supporting file path/i,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* OpenCode reuses other providers' skill folders, so it should not accept
* direct skill writes through the managed provider endpoint.
*/
test('providerSkillsService rejects managed skill creation for opencode', { concurrency: false }, async () => {
await assert.rejects(
providerSkillsService.addProviderSkills('opencode', {
entries: [
{
directoryName: 'opencode-global-dir',
content: '---\nname: opencode-global\ndescription: Unsupported skill\n---\n\nOpenCode body.\n',
},
],
}),
/does not support managed global skills/i,
);
await assert.rejects(
providerSkillsService.removeProviderSkill('opencode', {
directoryName: 'opencode-global-dir',
}),
/does not support managed global skills/i,
);
});

View File

@@ -1,9 +1,5 @@
import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { generateDisplayName } from '@/modules/projects/index.js';
import { sessionsDb } from '@/modules/database/index.js';
import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js';
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import type {
LLMProvider,
NormalizedMessage,
@@ -62,48 +58,6 @@ const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
*/
const runs = new Map<string, ChatRun>();
async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise<void> {
const row = sessionsDb.getSessionById(appSessionId);
if (!row || row.isArchived) {
return;
}
const projectPath = row.project_path;
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
const displayName = project?.custom_project_name?.trim()
? project.custom_project_name
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
const payload = JSON.stringify({
kind: 'session_upserted',
sessionId: row.session_id,
providerSessionId: row.provider_session_id,
provider: row.provider,
session: {
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
},
project: project
? {
projectId: project.project_id,
path: project.project_path,
fullPath: project.project_path,
displayName,
isStarred: Boolean(project.isStarred),
}
: null,
timestamp: new Date().toISOString(),
});
connectedClients.forEach((client) => {
if (client.readyState === WS_OPEN_STATE) {
client.send(payload);
}
});
}
function evictRunLater(appSessionId: string): void {
const timer = setTimeout(() => {
const run = runs.get(appSessionId);
@@ -178,14 +132,6 @@ function recordProviderSessionId(run: ChatRun, providerSessionId: string): void
try {
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId);
void broadcastCanonicalSessionUpsert(run.appSessionId).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error('[ChatRunRegistry] Failed to broadcast canonical session mapping', {
appSessionId: run.appSessionId,
providerSessionId,
error: message,
});
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {

View File

@@ -5,6 +5,7 @@ import path from 'node:path';
import pty, { type IPty } from 'node-pty';
import { WebSocket, type RawData } from 'ws';
import { sessionsDb } from '@/modules/database/index.js';
import { parseIncomingJsonObject } from '@/shared/utils.js';
type ShellIncomingMessage = {
@@ -35,10 +36,7 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
type ShellWebSocketDependencies = {
resolveProviderSessionId: (
sessionId: string,
provider: string,
) => string | null | undefined;
getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined;
stripAnsiSequences: (content: string) => string;
normalizeDetectedUrl: (url: string) => string | null;
extractUrlsFromText: (content: string) => string[];
@@ -81,32 +79,36 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
/**
* Maps the app-facing session id to the provider-native id used by CLIs.
*
* Chat history and provider artifacts on disk are keyed by the provider id,
* while the shell UI sends the stable app id from the session gateway.
*/
function resolveResumeSessionId(
message: ShellIncomingMessage,
appSessionId: string,
provider: string,
dependencies: ShellWebSocketDependencies
): string {
const hasSession = readBoolean(message.hasSession);
const sessionId = readString(message.sessionId);
const provider = readString(message.provider, 'claude');
if (!hasSession || !sessionId) {
return '';
}
let resumeSessionId: string | null | undefined;
): string | null {
try {
resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider);
const sessionRow = sessionsDb.getSessionById(appSessionId);
const providerSessionId = sessionRow?.provider_session_id;
if (providerSessionId && SAFE_SESSION_ID_PATTERN.test(providerSessionId)) {
return providerSessionId;
}
if (provider === 'gemini') {
const geminiSession = dependencies.getSessionById(appSessionId);
const cliSessionId = geminiSession?.cliSessionId;
if (cliSessionId && SAFE_SESSION_ID_PATTERN.test(cliSessionId)) {
return cliSessionId;
}
}
} catch (error) {
console.error('Failed to resolve provider session ID:', error);
resumeSessionId = undefined;
console.error(`Failed to resolve resume session id for ${provider}:`, error);
}
const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId;
if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) {
return '';
}
return resolvedSessionId;
return null;
}
/**
@@ -117,9 +119,9 @@ function buildShellCommand(
dependencies: ShellWebSocketDependencies
): string {
const hasSession = readBoolean(message.hasSession);
const sessionId = readString(message.sessionId);
const initialCommand = readString(message.initialCommand);
const provider = readString(message.provider, 'claude');
const resumeSessionId = resolveResumeSessionId(message, dependencies);
const isPlainShell =
readBoolean(message.isPlainShell) ||
(!!initialCommand && !hasSession) ||
@@ -129,104 +131,51 @@ function buildShellCommand(
return initialCommand;
}
const resumeId =
hasSession && sessionId ? resolveResumeSessionId(sessionId, provider, dependencies) : null;
if (provider === 'cursor') {
if (resumeSessionId) {
return `cursor-agent --resume="${resumeSessionId}"`;
if (resumeId) {
return `cursor-agent --resume="${resumeId}"`;
}
return 'cursor-agent';
}
if (provider === 'codex') {
if (resumeSessionId) {
if (resumeId) {
if (os.platform() === 'win32') {
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
return `codex resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { codex }`;
}
return `codex resume "${resumeSessionId}" || codex`;
return `codex resume "${resumeId}" || codex`;
}
return 'codex';
}
if (provider === 'gemini') {
const command = initialCommand || 'gemini';
if (resumeSessionId) {
return `${command} --resume "${resumeSessionId}"`;
if (resumeId) {
return `${command} --resume "${resumeId}"`;
}
return command;
}
if (provider === 'opencode') {
if (resumeSessionId) {
return `opencode --session "${resumeSessionId}"`;
if (resumeId) {
return `opencode --session "${resumeId}"`;
}
return initialCommand || 'opencode';
}
const command = initialCommand || 'claude';
if (resumeSessionId) {
if (resumeId) {
if (os.platform() === 'win32') {
return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
return `claude --resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { claude }`;
}
return `claude --resume "${resumeSessionId}" || claude`;
return `claude --resume "${resumeId}" || claude`;
}
return command;
}
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
return resolvedKey ? env[resolvedKey] : undefined;
}
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
}
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
const pathKey = getPathEnvKey(env);
const currentPath = env[pathKey];
if (!currentPath) {
return { key: pathKey, value: currentPath };
}
const delimiter = path.delimiter;
const pathEntries = currentPath.split(delimiter).filter(Boolean);
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
const appData = readEnvValue(env, 'APPDATA');
const candidates = [
npmPrefix || '',
npmPrefix ? path.join(npmPrefix, 'bin') : '',
appData ? path.join(appData, 'npm') : '',
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
path.join(os.homedir(), '.npm-global', 'bin'),
].filter(Boolean);
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
const preferredEntries = candidates.filter((candidate, index) => {
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
return (
candidates.indexOf(candidate) === index &&
normalizedPathEntries.includes(normalizedCandidate)
);
});
if (preferredEntries.length === 0) {
return { key: pathKey, value: currentPath };
}
const normalizedPreferredEntries = preferredEntries.map((entry) =>
os.platform() === 'win32' ? entry.toLowerCase() : entry
);
const value = [
...preferredEntries,
...pathEntries.filter((entry) => {
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
return !normalizedPreferredEntries.includes(normalizedEntry);
}),
].join(delimiter);
return { key: pathKey, value };
}
/**
* Handles websocket connections used by the standalone shell terminal UI.
*/
@@ -327,20 +276,17 @@ export function handleShellConnection(
return;
}
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
if (sessionId && !SAFE_SESSION_ID_PATTERN.test(sessionId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
return;
}
const shellCommand = buildShellCommand(data, dependencies);
const resumeSessionId = resolveResumeSessionId(data, dependencies);
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellArgs =
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
const termCols = readNumber(data.cols, 80);
const termRows = readNumber(data.rows, 24);
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
shellProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
@@ -349,7 +295,6 @@ export function handleShellConnection(
cwd: resolvedProjectPath,
env: {
...process.env,
[prioritizedPath.key]: prioritizedPath.value,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
@@ -482,8 +427,8 @@ export function handleShellConnection(
: provider === 'opencode'
? 'OpenCode'
: 'Claude';
welcomeMsg = hasSession && resumeSessionId
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
welcomeMsg = hasSession
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
}

View File

@@ -6,7 +6,6 @@ import { handleChatConnection } from '@/modules/websocket/services/chat-websocke
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js';
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
type WebSocketServerDependencies = {
@@ -64,11 +63,6 @@ export function createWebSocketServer(
return;
}
if (pathname === '/desktop-notifications') {
handleDesktopNotificationsConnection(ws, incomingRequest);
return;
}
if (pathname.startsWith('/plugin-ws/')) {
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
return;

View File

@@ -6,7 +6,7 @@ import test from 'node:test';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
import type { NormalizedMessage } from '@/shared/types.js';
/**
* Minimal stand-in for a websocket connection: collects every JSON frame the
@@ -14,10 +14,10 @@ import { connectedClients } from '@/modules/websocket/services/websocket-state.s
*/
class FakeConnection {
readyState = 1; // WS_OPEN_STATE
frames: Array<Record<string, unknown>> = [];
frames: NormalizedMessage[] = [];
send(data: string): void {
this.frames.push(JSON.parse(data) as Record<string, unknown>);
this.frames.push(JSON.parse(data) as NormalizedMessage);
}
}
@@ -33,7 +33,6 @@ async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promis
try {
await runTest();
} finally {
connectedClients.clear();
chatRunRegistry.clearAll();
closeConnection();
if (previousDatabasePath === undefined) {
@@ -73,7 +72,6 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
const connection = new FakeConnection();
connectedClients.add(connection as never);
const run = chatRunRegistry.startRun({
appSessionId: 'app-run-2',
provider: 'cursor',
@@ -90,12 +88,9 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
newSessionId: 'cursor-native-7',
});
// The provider-native event itself is never forwarded...
const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted');
assert.equal(sessionUpserts.length, 1);
assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2');
assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7');
// ...but the canonical mapping is recorded and persisted in the database.
// Never forwarded to the client...
assert.equal(connection.frames.length, 0);
// ...but recorded in the registry and persisted in the database.
assert.equal(run.providerSessionId, 'cursor-native-7');
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
});

View File

@@ -194,10 +194,6 @@ async function spawnOpenCode(command, options = {}, ws) {
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
const args = ['run', '--format', 'json'];
// OpenCode's `run` command owns workspace selection through `--dir`.
// Relying on the child-process cwd alone is not enough on Linux, where
// the CLI can still resolve the session under the server install dir.
args.push('--dir', workingDir);
if (sessionId) {
args.push('--session', sessionId);
}

View File

@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
@@ -12,11 +12,6 @@ const findEnvKey = (name) =>
async function createFakeOpenCodeExecutable(binDir) {
const scriptPath = path.join(binDir, 'opencode.js');
await writeFile(scriptPath, `
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
if (capturePath) {
require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
}
const events = [
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
{ type: 'step_finish', sessionID: 'open-live-1' },
@@ -40,12 +35,10 @@ for (const event of events) {
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
const argsCapturePath = path.join(tempRoot, 'opencode-args.json');
const pathKey = findEnvKey('PATH');
const pathExtKey = findEnvKey('PATHEXT');
const previousPath = process.env[pathKey];
const previousPathExt = process.env[pathExtKey];
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
const messages = [];
const writer = {
userId: null,
@@ -61,7 +54,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
try {
await createFakeOpenCodeExecutable(tempRoot);
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
if (process.platform === 'win32') {
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
? previousPathExt
@@ -85,11 +77,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
assert.equal(streamEnd?.sessionId, 'open-live-1');
assert.equal(complete?.sessionId, 'open-live-1');
assert.equal(messages.some((message) => message.kind === 'error'), false);
const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8'));
assert.ok(Array.isArray(launchedArgs));
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
assert.equal(launchedArgs[4], tempRoot);
} finally {
if (previousPath === undefined) {
delete process.env[pathKey];
@@ -103,12 +90,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
process.env[pathExtKey] = previousPathExt;
}
if (previousArgsCapture === undefined) {
delete process.env.OPENCODE_ARGS_CAPTURE;
} else {
process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
}
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -1,11 +1,6 @@
import express from 'express';
import {
apiKeysDb,
credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
} from '../modules/database/index.js';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
import { getPublicKey } from '../services/vapid-keys.js';
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';

View File

@@ -1,7 +1,268 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '../modules/database/index.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const PROVIDER_LABELS = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
system: 'System'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function shouldSendPush(preferences, event) {
const webPushEnabled = Boolean(preferences?.channels?.webPush);
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return webPushEnabled && eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function normalizeErrorMessage(error) {
if (typeof error === 'string') {
return error;
}
if (error && typeof error.message === 'string') {
return error.message;
}
if (error == null) {
return 'Unknown error';
}
return String(error);
}
function normalizeSessionName(sessionName) {
if (typeof sessionName !== 'string') {
return null;
}
const normalized = sessionName.replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
function rowMatchesProvider(row, provider) {
return row && (!provider || row.provider === provider);
}
function resolveSessionRow(sessionId, provider) {
if (!sessionId) {
return null;
}
const appSessionRow = sessionsDb.getSessionById(sessionId);
if (rowMatchesProvider(appSessionRow, provider)) {
return appSessionRow;
}
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
if (rowMatchesProvider(providerSessionRow, provider)) {
return providerSessionRow;
}
return null;
}
function normalizeNotificationSession(event) {
if (!event?.sessionId || !event.provider || event.provider === 'system') {
return event;
}
const row = resolveSessionRow(event.sessionId, event.provider);
if (!row || row.session_id === event.sessionId) {
return event;
}
return {
...event,
sessionId: row.session_id
};
}
function resolveSessionName(event) {
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
if (explicitSessionName) {
return explicitSessionName;
}
if (!event.sessionId || !event.provider) {
return null;
}
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
}
function buildPushBody(event) {
const normalizedEvent = normalizeNotificationSession(event);
const CODE_MAP = {
'permission.required': normalizedEvent.meta?.toolName
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
: 'Action Required: A tool needs your approval',
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
'push.enabled': 'Push notifications are now enabled!'
};
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
const sessionName = resolveSessionName(normalizedEvent);
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
return {
title: sessionName || 'CloudCLI',
body: `${providerLabel}: ${message}`,
data: {
sessionId: normalizedEvent.sessionId || null,
code: normalizedEvent.code,
provider: normalizedEvent.provider || null,
sessionName,
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
}
};
}
async function sendWebPush(userId, event) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return;
const payload = JSON.stringify(buildPushBody(event));
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
payload
)
)
);
// Clean up gone subscriptions (410 Gone or 404)
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
}
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const normalizedEvent = normalizeNotificationSession(event);
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!shouldSendPush(preferences, normalizedEvent)) {
return;
}
if (isDuplicate(normalizedEvent)) {
return;
}
sendWebPush(userId, normalizedEvent).catch((err) => {
console.error('Web push send error:', err);
});
}
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason, sessionName },
severity: 'info',
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
})
});
}
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
const errorMessage = normalizeErrorMessage(error);
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'error',
code: 'run.failed',
meta: { error: errorMessage, sessionName },
severity: 'error',
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
})
});
}
export {
buildNotificationPayload,
createNotificationEvent,
notifyUserIfEnabled,
notifyRunStopped,
notifyRunFailed,
} from '../modules/notifications/services/notification-orchestrator.service.js';
notifyRunFailed
};

View File

@@ -13,9 +13,9 @@ import {
pushSubscriptionsDb,
sessionsDb,
userDb,
} from '../../modules/database/index.js';
} from '../modules/database/index.js';
import { notifyRunStopped } from '../notification-orchestrator.js';
import { notifyRunStopped } from './notification-orchestrator.js';
async function withIsolatedDatabase(runTest) {
const previousDatabasePath = process.env.DATABASE_PATH;

View File

@@ -12,8 +12,6 @@ import type {
ProviderModelsDefinition,
ProviderMcpServer,
ProviderSessionActiveModelChange,
ProviderSkillCreateInput,
ProviderSkillRemoveInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
@@ -103,19 +101,6 @@ export interface IProviderSkills {
* Lists all skills visible to this provider for the optional workspace.
*/
listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]>;
/**
* Writes one or more global user-scoped skills for this provider.
*
* Implementations should install the supplied markdown entries into the
* provider's writable user skill folder and return the normalized skill
* records that were written.
*/
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
removeSkill(
input: ProviderSkillRemoveInput,
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
}
// ---------------------------

View File

@@ -320,51 +320,6 @@ export type ProviderSkillListOptions = {
workspacePath?: string;
};
/**
* One supporting file bundled with an uploaded provider skill.
*
* `relativePath` is resolved below the installed skill directory and must never
* be absolute or contain traversal segments. Text files may use `utf8`; binary
* scripts and assets should use `base64` so JSON transport does not corrupt
* their bytes.
*/
export type ProviderSkillCreateFile = {
relativePath: string;
content: string;
encoding: 'utf8' | 'base64';
};
/**
* One skill markdown payload submitted for provider-managed installation.
*
* `content` is the raw markdown body that will be written to `SKILL.md`.
* `directoryName` lets callers control the target folder name explicitly when
* they want stable filesystem paths that differ from the markdown front matter
* `name` field. `fileName` is optional upload metadata used only as a final
* fallback when no directory name or front matter name is present. `files`
* carries scripts, references, and other files from a complete skill folder.
*/
export type ProviderSkillCreateEntry = {
content: string;
directoryName?: string;
fileName?: string;
files?: ProviderSkillCreateFile[];
};
/**
* Shared input accepted by provider skill creation operations.
*
* The service layer batches multiple skill definitions in one request. Each
* entry can contain only markdown or a complete skill folder.
*/
export type ProviderSkillCreateInput = {
entries: ProviderSkillCreateEntry[];
};
export type ProviderSkillRemoveInput = {
directoryName: string;
};
/**
* Normalized skill record returned by provider skill adapters.
*

View File

@@ -957,25 +957,9 @@ export async function readProviderSkillMarkdownDefinition(
skillPath: string,
): Promise<{ name: string; description: string }> {
const content = await readFile(skillPath, 'utf8');
return readProviderSkillMarkdownDefinitionFromContent(
content,
path.basename(path.dirname(skillPath)),
);
}
/**
* Reads the `name` and `description` fields from raw skill markdown content.
*
* This keeps filesystem discovery and newly uploaded skill creation aligned on
* the same front matter parsing rules. `fallbackName` is used when the markdown
* omits a `name` field so callers still get a stable, non-empty skill id.
*/
export function readProviderSkillMarkdownDefinitionFromContent(
content: string,
fallbackName: string,
): { name: string; description: string } {
const parsed = parseFrontMatter(content);
const data = readObjectRecord(parsed.data) ?? {};
const fallbackName = path.basename(path.dirname(skillPath));
return {
name: readOptionalString(data.name) ?? fallbackName,

View File

@@ -1,224 +0,0 @@
// Optional voice proxy — forwards STT/TTS to an OpenAI-compatible audio backend.
//
// The backend is whatever the user points at: OpenAI, Groq, or a local server
// (LocalAI / Speaches / Kokoro-FastAPI / openedai-speech / etc.). It must expose the
// standard OpenAI audio endpoints:
// POST {base}/audio/transcriptions (multipart 'file' + 'model') -> { text }
// POST {base}/audio/speech ({ model, voice, input }) -> audio bytes
//
// Config is resolved per-request from headers (set by the client's voice settings),
// falling back to server env defaults. Mounted at /api/voice behind authenticateToken.
import { Readable } from 'node:stream';
import express from 'express';
const ENV = {
baseUrl: (process.env.VOICE_API_BASE_URL || '').replace(/\/$/, ''),
apiKey: process.env.VOICE_API_KEY || '',
sttModel: process.env.VOICE_STT_MODEL || 'whisper-1',
ttsModel: process.env.VOICE_TTS_MODEL || 'tts-1',
ttsVoice: process.env.VOICE_TTS_VOICE || 'alloy',
};
/**
* Resolve the voice backend config for a request. Client headers (set from the
* user's in-app voice settings) take precedence over the server env defaults.
* @param {import('express').Request} req
* @returns {{baseUrl: string, apiKey: string, sttModel: string, ttsModel: string, ttsVoice: string, ttsFormat: string}}
*/
function resolveConfig(req) {
const h = req.headers;
return {
// Security: do not allow clients to control the outbound backend host.
// Always use the server-side configured base URL.
baseUrl: ENV.baseUrl,
apiKey: String(h['x-voice-api-key'] || '') || ENV.apiKey,
sttModel: String(h['x-voice-stt-model'] || '') || ENV.sttModel,
ttsModel: String(h['x-voice-tts-model'] || '') || ENV.ttsModel,
ttsVoice: String(h['x-voice-tts-voice'] || '') || ENV.ttsVoice,
ttsFormat: String(h['x-voice-tts-format'] || '').trim(),
};
}
const router = express.Router();
// Generous by default — local TTS can synthesize long messages at ~real-time on CPU.
// Guard against a non-numeric/zero override that would make setTimeout fire immediately.
const DEFAULT_VOICE_TIMEOUT_MS = 300000;
const _parsedTimeout = Number(process.env.VOICE_TIMEOUT_MS);
const VOICE_TIMEOUT_MS = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0
? _parsedTimeout
: DEFAULT_VOICE_TIMEOUT_MS;
/**
* fetch() with an AbortController timeout so a stalled backend can't hold the
* request open indefinitely. Aborts after VOICE_TIMEOUT_MS.
* @param {string} url
* @param {RequestInit} [options]
* @returns {Promise<Response>}
*/
async function fetchWithTimeout(url, options = {}) {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol) || !isAllowedBackendUrl(parsed.origin)) {
throw new Error('Blocked outbound voice backend URL');
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS);
try {
return await fetch(parsed.toString(), { redirect: 'manual', ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
/**
* Turn a backend fetch failure into a clear, actionable client response:
* 504 on timeout (AbortError), 502 otherwise.
* @param {import('express').Response} res
* @param {Error} e
*/
function backendError(res, e) {
if (e && e.name === 'AbortError') {
return res.status(504).json({
error: `Voice backend timed out after ${Math.round(VOICE_TIMEOUT_MS / 1000)}s. Check your voice backend.`,
});
}
return res.status(502).json({ error: `Voice backend unreachable: ${e.message}` });
}
/**
* SSRF guard for the user-configurable backend URL: allow http/https only and
* block the link-local / cloud-metadata range (169.254.x). localhost and private
* ranges are allowed on purpose so users can point at a local voice server
* (LocalAI, Speaches, Kokoro-FastAPI, etc.).
* @param {string} raw
* @returns {boolean}
*/
function isAllowedBackendUrl(raw) {
let u;
try {
u = new URL(raw);
} catch {
return false;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
if (u.hostname === '169.254.169.254' || u.hostname.startsWith('169.254.')) return false;
return true;
}
/**
* Relay an upstream (backend) error to the client without making an upstream
* 401/403 look like the user's own app login failed.
* @param {import('express').Response} res
* @param {number} status
* @param {string} [text]
*/
function upstreamError(res, status, text) {
if (status === 401 || status === 403) {
return res.status(502).json({ error: 'Voice backend rejected the request (check the API key).' });
}
return res.status(status).json({ error: text || 'voice backend error' });
}
let _upload = null;
/**
* Lazily build a memory-storage multer instance (25 MB cap) for audio uploads,
* so multer is only imported when the voice feature is actually used.
* @returns {Promise<import('multer').Multer>}
*/
async function getUpload() {
if (!_upload) {
const multer = (await import('multer')).default;
_upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
}
return _upload;
}
/**
* Build the Authorization header for the backend, or an empty object when no
* key is configured (e.g. a local server that needs none).
* @param {string} apiKey
* @returns {Record<string, string>}
*/
function authHeader(apiKey) {
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
}
/**
* GET /api/voice/health -> { configured } (true when a backend base URL is set).
*/
router.get('/health', (req, res) => {
res.json({ configured: Boolean(resolveConfig(req).baseUrl) });
});
/**
* POST /api/voice/transcribe (multipart 'audio') -> { text }.
* Forwards the uploaded audio to the backend's /audio/transcriptions endpoint.
*/
router.post('/transcribe', async (req, res) => {
const cfg = resolveConfig(req);
if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' });
if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' });
const upload = await getUpload();
upload.single('audio')(req, res, async (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No audio uploaded' });
try {
const fd = new FormData();
fd.append(
'file',
new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' }),
req.file.originalname || 'recording.webm',
);
fd.append('model', cfg.sttModel);
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/transcriptions`, {
method: 'POST',
headers: authHeader(cfg.apiKey),
body: fd,
});
const text = await r.text();
if (!r.ok) return upstreamError(res, r.status, text);
let data;
try { data = JSON.parse(text); } catch { data = { text }; }
res.json({ text: data.text ?? '' });
} catch (e) {
backendError(res, e);
}
});
});
/**
* POST /api/voice/tts { text } -> audio bytes.
* Forwards the text to the backend's /audio/speech endpoint and streams the audio back.
*/
router.post('/tts', async (req, res) => {
const cfg = resolveConfig(req);
if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' });
if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' });
const text = req.body?.text;
if (typeof text !== 'string' || !text.trim()) return res.status(400).json({ error: 'text required' });
try {
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/speech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader(cfg.apiKey) },
body: JSON.stringify({
model: cfg.ttsModel,
voice: cfg.ttsVoice,
input: text,
...(cfg.ttsFormat ? { response_format: cfg.ttsFormat } : {}),
}),
});
if (!r.ok) {
const errText = await r.text().catch(() => 'tts failed');
return upstreamError(res, r.status, errText);
}
res.setHeader('Content-Type', r.headers.get('content-type') || 'audio/mpeg');
res.setHeader('Cache-Control', 'no-store');
if (!r.body) return res.end();
Readable.fromWeb(r.body).on('error', (error) => res.destroy(error)).pipe(res);
} catch (e) {
backendError(res, e);
}
});
export default router;

View File

@@ -71,6 +71,7 @@ function AppContentInner() {
setActiveTab,
setSidebarOpen,
setIsInputFocused,
setShowSettings,
openSettings,
refreshProjectsSilently,
registerOptimisticSession,
@@ -120,12 +121,16 @@ function AppContentInner() {
}, [refreshRunningSessions]);
useEffect(() => {
if (processingSessions.size === 0) {
return;
}
const interval = window.setInterval(() => {
void refreshRunningSessions();
}, 5000);
return () => window.clearInterval(interval);
}, [refreshRunningSessions]);
}, [processingSessions.size, refreshRunningSessions]);
usePaletteOpsRegister({
openSettings,
@@ -246,7 +251,7 @@ function AppContentInner() {
onSessionEstablished={(targetSessionId, context) =>
registerOptimisticSession({ sessionId: targetSessionId, ...context })
}
onShowSettings={openSettings}
onShowSettings={() => setShowSettings(true)}
externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}
/>

View File

@@ -1 +0,0 @@
export { default as BrowserUsePanel } from './view/BrowserUsePanel';

View File

@@ -1,536 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Bot,
Clock3,
Download,
Expand,
ExternalLink,
Loader2,
MonitorPlay,
RefreshCw,
Settings,
Square,
Trash2,
X,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api';
import type { SettingsMainTab } from '../../settings/types/types';
type BrowserUseStatus = {
enabled: boolean;
available: boolean;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
installInProgress: boolean;
sessionCount: number;
message: string;
};
type BrowserUseSession = {
id: string;
status: 'ready' | 'stopped' | 'unavailable';
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
createdBy: 'agent';
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type BrowserUsePanelProps = {
isVisible: boolean;
onShowSettings?: (tab?: SettingsMainTab) => void;
};
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
function formatRelativeTime(value: string | null): string {
if (!value) return 'Never';
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return 'Unknown';
const elapsedSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (elapsedSeconds < 10) return 'Just now';
if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`;
const elapsedMinutes = Math.round(elapsedSeconds / 60);
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
const elapsedHours = Math.round(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h ago`;
return `${Math.round(elapsedHours / 24)}d ago`;
}
function getDomain(url: string | null): string {
if (!url) return 'No page loaded';
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function formatAction(action: string | null): string {
if (!action) return 'Waiting';
return action.replace(/_/g, ' ').replace(/:/g, ': ');
}
function getStatusTone(status: BrowserUseSession['status']): string {
if (status === 'ready') {
return 'border-primary/30 bg-primary/5 text-foreground';
}
if (status === 'stopped') {
return 'border-border bg-muted text-muted-foreground';
}
return 'border-border bg-background text-muted-foreground';
}
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
return 'border-border bg-background text-muted-foreground';
}
function getStatusDot(status: BrowserUseSession['status']): string {
if (status === 'ready') return 'bg-primary';
if (status === 'stopped') return 'bg-muted-foreground/50';
return 'bg-border';
}
const PROMPTS = [
'Use Browser to inspect the checkout flow and report any broken UI states.',
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
];
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
[selectedSessionId, sessions],
);
const activeSessions = sessions.filter((session) => session.status === 'ready');
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
const runtimeLabel = !status?.enabled
? 'Disabled'
: status.available
? 'Ready'
: status.installInProgress || isInstalling
? 'Installing'
: 'Setup required';
const cursorStyle = selectedSession?.cursor && selectedSession.viewport
? {
left: `${(selectedSession.cursor.x / selectedSession.viewport.width) * 100}%`,
top: `${(selectedSession.cursor.y / selectedSession.viewport.height) * 100}%`,
}
: null;
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const [statusResponse, sessionsResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/status'),
authenticatedFetch('/api/browser-use/sessions'),
]);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
const nextSessions = sessionsData.data.sessions;
setStatus(statusData.data);
setSessions(nextSessions);
setSelectedSessionId((current) => (
current && nextSessions.some((session) => session.id === current)
? current
: nextSessions[0]?.id || null
));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Browser');
} finally {
setIsRefreshing(false);
}
}, []);
useEffect(() => {
if (!isVisible) return;
void refresh();
}, [isVisible, refresh]);
const runAction = useCallback(async (action: () => Promise<void>) => {
setIsBusy(true);
setError(null);
try {
await action();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Browser action failed');
} finally {
setIsBusy(false);
}
}, [refresh]);
const stopSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
await readJson(response);
});
const deleteSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}`, { method: 'DELETE' });
await readJson(response);
setIsFullscreen(false);
});
const installBrowserBinaries = () => runAction(async () => {
setIsInstalling(true);
try {
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
await readJson(response);
} finally {
setIsInstalling(false);
}
});
const renderSessionItem = (session: BrowserUseSession) => {
const isSelected = selectedSession?.id === session.id;
return (
<button
key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={cn(
'group w-full rounded-md border px-3 py-2.5 text-left transition-colors',
isSelected
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<div className="truncate text-sm font-medium">{session.title || getDomain(session.url)}</div>
</div>
<div className="mt-1 truncate pl-3.5 text-xs text-muted-foreground">{getDomain(session.url)}</div>
</div>
<Badge variant="outline" className="shrink-0 border-border bg-background text-[10px] text-muted-foreground">
{session.status}
</Badge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Clock3 className="h-3 w-3" />
<span>{formatRelativeTime(session.updatedAt)}</span>
<span className="truncate">- {formatAction(session.lastAction)}</span>
</div>
</button>
);
};
const renderEmptyState = () => (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="w-full max-w-2xl rounded-md border border-border bg-card/40 p-5 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<MonitorPlay className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{status?.enabled ? 'No browser sessions yet' : 'Browser is disabled'}
</div>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
{status?.enabled
? 'Agent browser sessions appear here while an AI task is using Browser.'
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
</p>
</div>
</div>
{needsBrowserBinaries && (
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
<div className="text-sm font-medium text-foreground">Runtime setup required</div>
<p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
<Button
type="button"
size="sm"
className="mt-3"
onClick={installBrowserBinaries}
disabled={isBusy || isInstalling || status?.installInProgress}
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
</Button>
</div>
)}
<div className="mt-5 grid gap-2 sm:grid-cols-2">
{PROMPTS.map((prompt) => (
<div key={prompt} className="rounded-md border border-border/70 bg-background/70 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Prompt
</div>
<p className="text-sm leading-6 text-foreground">{prompt}</p>
</div>
))}
</div>
</div>
</div>
);
const renderBrowserSurface = (fullscreen = false) => (
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
{selectedSession?.screenshotDataUrl ? (
<div className="relative inline-block max-h-full">
<img
src={selectedSession.screenshotDataUrl}
alt="Browser session screenshot"
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[72vh] w-auto max-w-full object-contain'}
/>
{cursorStyle && (
<div
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-primary/80 shadow-[0_0_0_6px_hsl(var(--primary)/0.18)]"
style={cursorStyle}
>
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
</div>
)}
</div>
) : (
<div className="px-6 text-center">
<MonitorPlay className="mx-auto h-9 w-9 text-neutral-500" />
<div className="mt-3 text-sm font-medium text-neutral-100">{selectedSession?.message || 'Waiting for screenshot'}</div>
<p className="mt-1 text-xs text-neutral-400">The next agent browser snapshot will render here.</p>
</div>
)}
</div>
);
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<MonitorPlay className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Browser</h3>
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel}
</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
</div>
<div className="flex items-center gap-1.5">
{onShowSettings && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onShowSettings('browser')}
title="Open Browser settings"
aria-label="Open Browser settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => void refresh()}
disabled={isRefreshing || isBusy}
title="Refresh browser sessions"
aria-label="Refresh browser sessions"
>
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
{error && (
<div className="border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{sessions.length > 0 && (
<div className="border-b border-border/60 bg-muted/20 px-3 py-2 lg:hidden">
<div className="flex gap-2 overflow-x-auto">
{sessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={cn(
'flex min-w-[180px] items-center gap-2 rounded-md border px-2.5 py-2 text-left',
selectedSession?.id === session.id
? 'border-primary/40 bg-primary/5'
: 'border-border bg-background',
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{session.title || getDomain(session.url)}
</span>
</button>
))}
</div>
</div>
)}
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<main className="flex min-h-0 flex-col overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
<div className="min-w-0 truncate">
{activeSessions.length} active
<span className="px-1.5">/</span>
{sessions.length} total
</div>
<div className="min-w-0 truncate">
Updated {formatRelativeTime(selectedSession?.updatedAt || null)}
</div>
</div>
{sessions.length === 0 ? (
renderEmptyState()
) : (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<Badge variant="outline" className={selectedSession ? cn('text-[10px]', getStatusTone(selectedSession.status)) : 'text-[10px]'}>
{selectedSession?.status || 'empty'}
</Badge>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{selectedSession?.title || getDomain(selectedSession?.url || null)}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
</div>
</div>
<div className="hidden text-xs text-muted-foreground md:block">
{formatAction(selectedSession?.lastAction || null)}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
<Expand className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
<Square className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{renderBrowserSurface()}
</div>
</div>
)}
</main>
<aside className="hidden min-h-0 flex-col border-l border-border/60 bg-background lg:flex">
<div className="border-b border-border/60 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-foreground">Sessions</div>
<div className="mt-0.5 text-xs text-muted-foreground">{sessions.length} total</div>
</div>
<Badge variant="outline" className="text-[10px]">{activeSessions.length} active</Badge>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{sessions.length > 0 ? (
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
) : (
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
No agent browser sessions.
</div>
)}
</div>
<div className="border-t border-border/60 p-3">
<div className="rounded-md border border-border/70 bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Selected
</div>
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<span>Status</span>
<span className="font-medium text-foreground">{selectedSession?.status || 'None'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Last action</span>
<span className="truncate font-medium text-foreground">{formatAction(selectedSession?.lastAction || null)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Profile</span>
<span className="truncate font-medium text-foreground">{selectedSession?.profileName || 'Temporary'}</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
</aside>
</div>
{isFullscreen && selectedSession && (
<div className="fixed inset-0 z-50 bg-black/90 p-6">
<div className="flex h-full flex-col rounded-md border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
<div className="min-w-0 truncate">{selectedSession.title || selectedSession.url || 'Browser session'}</div>
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
<X className="h-4 w-4" />
Close
</Button>
</div>
{renderBrowserSurface(true)}
</div>
</div>
)}
</div>
);
}

View File

@@ -204,8 +204,6 @@ export function useChatComposerState({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null);
const textareaLineHeightRef = useRef<number | null>(null);
const lastAutosizedInputRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
>(null);
@@ -459,22 +457,6 @@ export function useChatComposerState({
inputHighlightRef.current.scrollLeft = target.scrollLeft;
}, []);
const resizeTextarea = useCallback((target: HTMLTextAreaElement) => {
target.style.height = 'auto';
const nextHeight = Math.max(22, target.scrollHeight);
target.style.height = `${nextHeight}px`;
let lineHeight = textareaLineHeightRef.current;
if (!lineHeight) {
lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
textareaLineHeightRef.current = Number.isFinite(lineHeight) ? lineHeight : 24;
}
const expanded = nextHeight > (textareaLineHeightRef.current || 24) * 2;
setIsTextareaExpanded((previous) => previous === expanded ? previous : expanded);
lastAutosizedInputRef.current = target.value;
}, []);
const handleImageFiles = useCallback((files: File[]) => {
const validFiles = files.filter((file) => {
try {
@@ -793,17 +775,6 @@ export function useChatComposerState({
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
// A voice transcript either fills the input (to edit before sending) or, when the
// user tapped "stop and send", is submitted straight away. Mirror the value into
// inputValueRef synchronously so handleSubmit reads the new text, not the stale state.
const handleVoiceTranscript = useCallback((text: string, send?: boolean) => {
const base = inputValueRef.current.trim();
const next = base ? `${base} ${text}` : text;
setInput(next);
inputValueRef.current = next;
if (send) handleSubmitRef.current?.(createFakeSubmitEvent());
}, [setInput]);
useEffect(() => {
inputValueRef.current = input;
}, [input]);
@@ -835,13 +806,13 @@ export function useChatComposerState({
if (!textareaRef.current) {
return;
}
if (lastAutosizedInputRef.current === input) {
return;
}
// Re-run for restored drafts and programmatic input changes. User typing is
// already resized in onInput, so this avoids doing the same forced layout twice.
resizeTextarea(textareaRef.current);
}, [input, resizeTextarea]);
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
}, [input]);
useEffect(() => {
if (!textareaRef.current || input.trim()) {
@@ -923,11 +894,15 @@ export function useChatComposerState({
const handleTextareaInput = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
resizeTextarea(target);
target.style.height = 'auto';
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
},
[resizeTextarea, setCursorPosition, syncInputOverlayScroll],
[setCursorPosition, syncInputOverlayScroll],
);
const handleClearInput = useCallback(() => {
@@ -1038,7 +1013,6 @@ export function useChatComposerState({
isDragActive,
openImagePicker: open,
handleSubmit,
handleVoiceTranscript,
handleInputChange,
handleKeyDown,
handlePaste,

View File

@@ -207,15 +207,6 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
break;
}
// A result with a toolId but no matching tool_use in the loaded set is
// almost always a tool_use/tool_result pair split across a pagination
// boundary (older page not loaded yet). Rendering its raw content here
// produces an unstyled dump that "fixes itself" once the older page
// loads; skip it and let it attach to its tool_use when that arrives.
if (msg.toolId) {
break;
}
const content = formatToolResultContent(msg.content || '');
if (!content.trim()) {
break;

View File

@@ -114,6 +114,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
const lastProviderRef = useRef(provider);
const providerModelsRequestIdRef = useRef(0);
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
@@ -343,8 +344,14 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('selected-provider', selectedSession.__provider);
}, [provider, selectedSession]);
// Permission prompts belong to a session, not to the transient provider
// selection that is synchronized after navigation.
useEffect(() => {
if (lastProviderRef.current === provider) {
return;
}
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}, [provider]);
useEffect(() => {
setPendingPermissionRequests((previous) =>
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),

View File

@@ -1,29 +1,20 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound, playNotificationSound } from '../../../utils/notificationSound';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
const isActionablePermissionRequest = (request: { toolName?: unknown } | null | undefined): boolean => {
return request?.toolName !== 'ExitPlanMode' && request?.toolName !== 'exit_plan_mode';
};
const hasActionablePermissionRequests = (requests: Array<{ toolName?: unknown }> | null | undefined): boolean => {
return Array.isArray(requests) && requests.some((request) => isActionablePermissionRequest(request));
};
interface UseChatRealtimeHandlersArgs {
subscribe: (listener: (event: ServerEvent) => void) => () => void;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
pendingPermissionRequests: PendingPermissionRequest[];
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
@@ -61,7 +52,6 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
@@ -72,29 +62,13 @@ export function useChatRealtimeHandlers({
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
// Session switches can send `chat.subscribe` before this effect has a chance
// to rebind the websocket listener. Read the visible session id from a ref
// so a fast `chat_subscribed` ack is matched against the current view, not
// the previous render's closed-over selection.
const activeViewSessionIdRef = useRef<string | null>(selectedSession?.id || currentSessionId || null);
activeViewSessionIdRef.current = selectedSession?.id || currentSessionId || null;
// Keep the latest pending-permission snapshot available to the websocket
// listener so back-to-back permission events can dedupe and re-arm the
// notification sound before React finishes a rerender.
const pendingPermissionRequestsRef = useRef(pendingPermissionRequests);
useEffect(() => {
pendingPermissionRequestsRef.current = pendingPermissionRequests;
}, [pendingPermissionRequests]);
useEffect(() => {
const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) {
return;
}
const activeViewSessionId = activeViewSessionIdRef.current;
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
// Record replay progress for every sequenced live event.
@@ -127,16 +101,7 @@ export function useChatRealtimeHandlers({
const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
const nextPendingPermissionRequests = msg.pendingPermissions as PendingPermissionRequest[];
const hadActionablePermissionRequests = hasActionablePermissionRequests(pendingPermissionRequestsRef.current);
const hasPendingActionablePermissionRequests = hasActionablePermissionRequests(nextPendingPermissionRequests);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
if (hasPendingActionablePermissionRequests && !hadActionablePermissionRequests) {
void playNotificationSound();
}
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
}
return;
}
@@ -237,10 +202,7 @@ export function useChatRealtimeHandlers({
// indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically.
onSessionIdle?.(sid);
if (sid === activeViewSessionId) {
pendingPermissionRequestsRef.current = [];
setPendingPermissionRequests([]);
}
setPendingPermissionRequests([]);
if (msg.aborted) {
// Abort was requested — the complete event confirms it. No
@@ -270,26 +232,17 @@ export function useChatRealtimeHandlers({
case 'permission_request': {
if (!msg.requestId) break;
if (isActionablePermissionRequest({ toolName: msg.toolName })) {
void playNotificationSound();
}
if (sid === activeViewSessionId) {
const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
}
}
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
if (sid) {
onSessionProcessing?.(sid);
}
@@ -297,13 +250,8 @@ export function useChatRealtimeHandlers({
}
case 'permission_cancelled': {
if (msg.requestId && sid === activeViewSessionId) {
const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter(
(request: PendingPermissionRequest) => request.requestId !== msg.requestId,
);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
@@ -334,7 +282,6 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,

View File

@@ -5,7 +5,7 @@ import { authenticatedFetch } from '../../../utils/api';
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage } from '../types/types';
import type { ChatMessage, Provider } from '../types/types';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages';
@@ -328,12 +328,18 @@ export function useChatSessionState({
if (allMessagesLoadedRef.current) return false;
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
const sessionProvider = selectedSession.__provider || 'claude';
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
try {
const slot = await sessionStore.fetchMore(selectedSession.id, {
provider: sessionProvider as LLMProvider,
// DB-assigned projectId replaces the legacy folder-derived name.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE,
});
if (!slot || slot.serverMessages.length === 0) return false;
@@ -452,31 +458,15 @@ export function useChatSessionState({
return;
}
const selectedSessionId = selectedSession.id;
const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
const subscribeToSelectedSession = () => {
if (!ws) {
return;
}
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSessionId,
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
}],
});
};
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`;
// Skip if already loaded and fresh
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) {
subscribeToSelectedSession();
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
return;
}
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId;
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) {
resetStreamingState();
}
@@ -499,20 +489,32 @@ export function useChatSessionState({
setTokenBudget(null);
}
setCurrentSessionId(selectedSessionId);
setCurrentSessionId(selectedSession.id);
// Subscribe to the session's live run (if any): the ack reconciles the
// processing indicator, re-attaches a mid-flight stream to this socket,
// and replays any live events missed since `lastSeq`. Recording the send
// time lets the ack handler discard idle acks that a newer request has
// since outdated.
subscribeToSelectedSession();
if (ws) {
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSession.id,
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
}],
});
}
lastLoadedSessionKeyRef.current = sessionKey;
// Fetch from server → store updates → chatMessages re-derives automatically
setIsLoadingSessionMessages(true);
sessionStore.fetchFromServer(selectedSessionId, {
sessionStore.fetchFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE,
offset: 0,
}).then(slot => {
@@ -542,9 +544,15 @@ export function useChatSessionState({
const reloadExternalMessages = async () => {
try {
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
// Skip store refresh during active streaming
if (!isProcessing) {
await sessionStore.refreshFromServer(selectedSession.id);
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
if (Boolean(autoScrollToBottom) && isNearBottom()) {
setTimeout(() => scrollToBottom(), 200);
@@ -590,9 +598,13 @@ export function useChatSessionState({
const scrollToTarget = async () => {
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
const sessionProvider = selectedSession.__provider || 'claude';
try {
// Load all messages into the store for search navigation
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
provider: sessionProvider as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null,
offset: 0,
});
@@ -666,10 +678,17 @@ export function useChatSessionState({
setTokenBudget(null);
return;
}
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
setTokenBudget(null);
return;
}
const fetchInitialTokenUsage = async () => {
try {
// The backend resolves the provider from the indexed session row.
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
// Token usage endpoint is now keyed by the DB projectId.
const params = new URLSearchParams({ provider: sessionProvider });
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
const response = await authenticatedFetch(url);
if (response.ok) {
setTokenBudget(await response.json());
@@ -681,7 +700,7 @@ export function useChatSessionState({
}
};
fetchInitialTokenUsage();
}, [selectedProject, selectedSession?.id]);
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
const visibleMessages = useMemo(() => {
if (chatMessages.length <= visibleMessageCount) return chatMessages;
@@ -741,6 +760,8 @@ export function useChatSessionState({
const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return;
if (isLoadingAllMessages) return;
const sessionProvider = selectedSession.__provider || 'claude';
const requestSessionId = selectedSession.id;
allMessagesLoadedRef.current = true;
isLoadingMoreRef.current = true;
@@ -753,6 +774,9 @@ export function useChatSessionState({
try {
const slot = await sessionStore.fetchFromServer(requestSessionId, {
provider: sessionProvider as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null,
offset: 0,
});

View File

@@ -1,33 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { voicePlayer, voiceId, type VoiceSnapshot } from '../../../lib/voicePlayer';
export type TtsState = VoiceSnapshot['state'];
/**
* Thin adapter over the app-level voicePlayer. Playback lives outside React (see
* lib/voicePlayer), so switching chats or re-rendering a message no longer cuts the
* audio off. This hook just reflects the player's state for one message and forwards taps.
*/
export function useTts(getText: () => string) {
const content = getText();
const id = voiceId(content);
const [snap, setSnap] = useState<VoiceSnapshot>(() => voicePlayer.getSnapshot(id));
useEffect(() => {
const update = () =>
setSnap((prev) => {
const next = voicePlayer.getSnapshot(id);
return prev.state === next.state && prev.error === next.error ? prev : next;
});
update();
return voicePlayer.subscribe(update);
}, [id]);
const toggle = useCallback(() => {
voicePlayer.unlock(); // synchronous, within the click gesture (iOS)
voicePlayer.toggle(content);
}, [content]);
return { state: snap.state, toggle, error: snap.error };
}

View File

@@ -1,85 +0,0 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { readVoiceConfig, VOICE_CONFIG_SYNC_EVENT } from '../../../hooks/useVoiceConfig';
// Voice UI is gated on the `voiceEnabled` UI preference (toggled in Quick Settings /
// the Settings modal) and a configured voice backend.
const STORAGE_KEY = 'uiPreferences';
const SYNC_EVENT = 'ui-preferences:sync';
let healthRequest: Promise<boolean> | null = null;
function checkVoiceHealth(): Promise<boolean> {
if (healthRequest) return healthRequest;
const request = authenticatedFetch('/api/voice/health')
.then(async (response) => {
if (!response.ok) throw new Error(`Voice health check failed (${response.status})`);
const data = await response.json();
return data?.configured === true;
})
.finally(() => {
healthRequest = null;
});
healthRequest = request;
return request;
}
function readVoiceEnabled(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw);
return parsed?.voiceEnabled === true || parsed?.voiceEnabled === 'true';
} catch {
return false;
}
}
export function useVoiceAvailable(): boolean {
const [enabled, setEnabled] = useState<boolean>(() =>
typeof window === 'undefined' ? false : readVoiceEnabled(),
);
const [available, setAvailable] = useState(false);
useEffect(() => {
const update = () => setEnabled(readVoiceEnabled());
window.addEventListener('storage', update);
window.addEventListener(SYNC_EVENT, update as EventListener);
return () => {
window.removeEventListener('storage', update);
window.removeEventListener(SYNC_EVENT, update as EventListener);
};
}, []);
useEffect(() => {
let active = true;
let requestId = 0;
const check = async () => {
if (!enabled) {
setAvailable(false);
return;
}
if (readVoiceConfig().baseUrl.trim()) {
setAvailable(true);
return;
}
const id = ++requestId;
try {
const result = await checkVoiceHealth();
if (active && id === requestId) setAvailable(result);
} catch {
if (active && id === requestId) setAvailable(false);
}
};
void check();
window.addEventListener(VOICE_CONFIG_SYNC_EVENT, check);
return () => {
active = false;
window.removeEventListener(VOICE_CONFIG_SYNC_EVENT, check);
};
}, [enabled]);
return enabled && available;
}

View File

@@ -1,149 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { transcribeVoice } from '../../../lib/voiceApi';
// Mobile-safe recording: iOS Safari 18.4+ supports webm/opus; older iOS needs mp4.
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
];
function pickMime(): string {
for (const t of MIME_CANDIDATES) {
try {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) return t;
} catch {
/* isTypeSupported can throw on some iOS versions */
}
}
return '';
}
export type VoiceInputState = 'idle' | 'recording' | 'transcribing';
/**
* Push-to-talk dictation. Records the mic, uploads to /api/voice/transcribe
* (an OpenAI-compatible speech-to-text backend via the Express proxy), and
* returns the transcript through onTranscript.
*/
export function useVoiceInput(
onTranscript: (text: string, send?: boolean) => void,
onError?: (msg: string) => void,
) {
const [state, setState] = useState<VoiceInputState>('idle');
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const cancelledRef = useRef(false);
const startingRef = useRef(false);
// Whether the in-progress stop should auto-send the transcript (vs just fill the box).
const sendRef = useRef(false);
const stopTracks = () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
// Stop the mic if the component unmounts mid-recording.
useEffect(() => {
cancelledRef.current = false;
return () => {
cancelledRef.current = true;
startingRef.current = false;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
};
}, []);
const start = useCallback(async () => {
if (startingRef.current || (recorderRef.current && recorderRef.current.state !== 'inactive')) return;
startingRef.current = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
});
if (cancelledRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const mimeType = pickMime();
const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
recorderRef.current = rec;
chunksRef.current = [];
rec.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
rec.onstop = async () => {
stopTracks();
if (cancelledRef.current) return;
// Capture and clear the send intent for this stop before any async work.
const shouldSend = sendRef.current;
sendRef.current = false;
const type = rec.mimeType || 'audio/webm';
const blob = new Blob(chunksRef.current, { type });
if (blob.size < 800) {
setState('idle');
onError?.('Recording too short');
return;
}
setState('transcribing');
try {
const ext = type.includes('mp4') ? 'm4a' : type.includes('ogg') ? 'ogg' : 'webm';
const res = await transcribeVoice(blob, `recording.${ext}`);
if (!res.ok) throw new Error(`transcribe ${res.status}`);
const data = await res.json();
if (cancelledRef.current) return;
const text = String(data?.text || '').trim();
if (text) onTranscript(text, shouldSend);
else onError?.('No speech detected');
} catch (e) {
if (!cancelledRef.current) {
onError?.(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`);
}
} finally {
if (!cancelledRef.current) setState('idle');
}
};
rec.start();
setState('recording');
} catch (e) {
recorderRef.current = null;
stopTracks();
if (cancelledRef.current) return;
const err = e as { name?: string; message?: string };
let msg = `Mic error: ${err?.message || e}`;
if (err?.name === 'NotAllowedError') msg = 'Microphone access denied.';
else if (err?.name === 'NotFoundError') msg = 'No microphone found.';
onError?.(msg);
setState('idle');
} finally {
startingRef.current = false;
}
}, [onTranscript, onError]);
// Stop recording. Pass { send: true } to auto-send the transcript once it's ready.
// Guard on the recorder's own state (not React state) so a double tap, or the mic
// and Send buttons both firing, can't call stop() on an already-inactive recorder.
const stop = useCallback((opts?: { send?: boolean }) => {
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') {
sendRef.current = opts?.send ?? false;
rec.stop();
}
}, []);
const toggle = useCallback(() => {
if (state === 'recording') stop();
else if (state === 'idle') start();
}, [state, start, stop]);
return { state, toggle, stop };
}

View File

@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { PlanDisplay } from './components/PlanDisplay';
import { ToolStatusBadge } from './components/ToolStatusBadge';
import type { ToolStatus } from './components/ToolStatusBadge';
@@ -125,39 +125,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
if (!displayConfig) return null;
// Bash renders as a Codex-style command row: the command on a single line with
// a chevron that expands to show the output inline. The combined view lives on
// the input render; the separate result section is suppressed in MessageComponent.
if (toolName === 'Bash' && mode === 'input') {
const command = typeof parsedData === 'object' && parsedData !== null && 'command' in parsedData
? String(parsedData.command || '')
: typeof toolInput === 'string'
? toolInput
: typeof rawToolInput === 'string'
? rawToolInput
: '';
const description = typeof parsedData === 'object' && parsedData !== null && 'description' in parsedData
? String(parsedData.description || '')
: undefined;
const output = typeof toolResult?.content === 'string'
? toolResult.content
: toolResult?.content != null
? String(toolResult.content)
: '';
return (
<BashCommandDisplay
command={command}
description={description}
output={output}
isError={Boolean(toolResult?.isError)}
status={toolStatus !== 'completed' ? toolStatus : undefined}
// Commands stay collapsed by default (even consecutive ones); only
// failures auto-expand so they remain visible.
defaultOpen={false}
/>
);
}
if (displayConfig.type === 'one-line') {
const value = displayConfig.getValue?.(parsedData) || '';
const secondary = displayConfig.getSecondary?.(parsedData);

View File

@@ -1,156 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { ChevronRight, Copy, Check } from 'lucide-react';
import { cn } from '../../../../lib/utils';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
interface BashCommandDisplayProps {
command: string;
description?: string;
/** Combined stdout/stderr from the tool result (empty while running). */
output?: string;
isError?: boolean;
status?: ToolStatus;
defaultOpen?: boolean;
}
/**
* Codex-in-VSCode style command row: a compact, single-line command with a
* chevron on the left. When the command produced output, the row becomes a
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
* keep it clean in both light and dark mode; consecutive commands stack tightly
* into a clean list.
*/
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
command,
description,
output,
isError = false,
status,
defaultOpen = false,
}) => {
const trimmedOutput = (output || '').replace(/\s+$/, '');
const hasOutput = trimmedOutput.length > 0;
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
const isRunning = status === 'running';
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
// Output (and errors) often arrive after this component first mounts, so apply
// the auto-open intent once when there is finally something to show. After that
// the user is in control of the toggle.
const autoAppliedRef = useRef(false);
useEffect(() => {
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
autoAppliedRef.current = true;
setOpen(true);
}
}, [hasOutput, defaultOpen, isError]);
const toggle = () => {
if (hasOutput) {
setOpen((prev) => !prev);
}
};
const handleCopy = async (event: React.MouseEvent) => {
event.stopPropagation();
const didCopy = await copyTextToClipboard(command);
if (!didCopy) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
className={cn(
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
isError ? 'border-red-500/30' : 'border-border/60',
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
open && 'bg-muted/50 shadow-sm',
)}
>
{/* Command header — clickable when there is output to expand */}
<div
role={hasOutput ? 'button' : undefined}
tabIndex={hasOutput ? 0 : undefined}
aria-expanded={hasOutput ? open : undefined}
onClick={toggle}
onKeyDown={(event) => {
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
toggle();
}
}}
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
)}
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
open && 'rotate-90',
!hasOutput && 'opacity-0',
)}
/>
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
$
</span>
<code
className={cn(
'min-w-0 flex-1 font-mono text-xs text-foreground',
open ? 'whitespace-pre-wrap break-all' : 'truncate',
)}
>
{command}
</code>
{isRunning && (
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
)}
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
{!open && hasOutput && !isRunning && (
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
</span>
)}
<button
onClick={handleCopy}
onKeyDown={(event) => event.stopPropagation()}
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
title="Copy command"
aria-label="Copy command"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
{description && !open && (
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
{description}
</div>
)}
{/* Expanded output */}
{open && hasOutput && (
<div className="settings-content-enter border-t border-border/50 bg-background/50">
{description && (
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
)}
<pre
className={cn(
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
)}
>
{trimmedOutput}
</pre>
</div>
)}
</div>
);
};

View File

@@ -1,77 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { QuestionAnswerContent } from './QuestionAnswerContent';
// Regression coverage for the chat-interface crash where an AskUserQuestion
// payload loaded from a session transcript arrives with a non-array `questions`
// or a question missing its `options` array. Rendering must degrade gracefully
// instead of throwing "TypeError: e.map is not a function".
test('renders without throwing when questions is a non-array value', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
// Malformed: object instead of an array
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
answers: {},
}),
);
});
});
test('renders without throwing when a question is missing options[]', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', header: 'H' } as never],
answers: { 'Pick one?': 'X' },
}),
);
});
});
test('renders without throwing when options[] contains malformed entries', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
answers: { 'Pick one?': 'A, Custom' },
}),
);
});
});
test('renders without throwing when a questions entry is null/non-object', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
answers: {},
}),
);
});
});
test('renders without throwing when an answer is a non-string value', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
// Malformed: answer is an object instead of the expected string
answers: { 'Pick one?': { unexpected: true } } as never,
}),
);
});
});
test('still renders a well-formed question + answer', () => {
const html = renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
answers: { 'Pick one?': 'A' },
}),
);
assert.ok(html.includes('Pick one?'));
});

View File

@@ -15,11 +15,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
}) => {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
// Tool inputs are runtime data loaded from session transcripts and may be
// malformed (e.g. `questions` arriving as a non-array). Guard with
// Array.isArray so a single bad payload can't crash the whole chat view
// with "e.map is not a function".
if (!Array.isArray(questions) || questions.length === 0) {
if (!questions || questions.length === 0) {
return null;
}
@@ -28,23 +24,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
return (
<div className={`space-y-2 ${className}`}>
{questions.map((rawQuestion, idx) => {
// Entries come from session transcripts and may be malformed; skip
// anything that isn't a proper question object with a string prompt.
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
return null;
}
const q = rawQuestion;
{questions.map((q, idx) => {
const answer = answers?.[q.question];
// `answer` may be a non-string (or absent) in malformed payloads.
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
const answerLabels = answer ? answer.split(', ') : [];
const skipped = !answer;
const isExpanded = expandedIdx === idx;
// `options` is typed as an array but comes from untrusted runtime data;
// keep only valid entries so `.some`/`.map` below never throw.
const options = Array.isArray(q.options)
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
: [];
return (
<div
@@ -90,7 +74,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{!isExpanded && answerLabels.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{answerLabels.map((lbl) => {
const isCustom = !options.some(o => o.label === lbl);
const isCustom = !q.options.some(o => o.label === lbl);
return (
<span
key={lbl}
@@ -126,7 +110,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{isExpanded && (
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
<div className="ml-6.5 space-y-1">
{options.map((opt) => {
{q.options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label);
return (
<div
@@ -164,7 +148,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
);
})}
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
<div
key={lbl}
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"

View File

@@ -1,7 +1,6 @@
export { CollapsibleSection } from './CollapsibleSection';
export { ToolDiffViewer } from './ToolDiffViewer';
export { OneLineDisplay } from './OneLineDisplay';
export { BashCommandDisplay } from './BashCommandDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers';

Some files were not shown because too many files have changed in this diff Show More