Compare commits

..

2 Commits

Author SHA1 Message Date
Haileyesus
9d291d3efb fix: install claude watch adapter plugin 2026-05-29 20:56:15 +03:00
Haileyesus
6bf82a39bb fix: group plugin settings by source 2026-05-29 19:13:08 +03:00
287 changed files with 5071 additions and 28953 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,146 +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
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
### New Features
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
### Bug Fixes
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
### Maintenance
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
### New Features
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
### Bug Fixes
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
### New Features
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
### Bug Fixes
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
### Documentation
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
### Maintenance
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
### Bug Fixes
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
### Styling
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
### Bug Fixes

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Sitzungsverwaltung** Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
- **Plugin-System** CloudCLI mit eigenen Plugins erweitern neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`)
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
## Schnellstart
@@ -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

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -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

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -60,7 +60,7 @@
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
## 빠른 시작
@@ -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

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -59,11 +59,10 @@
- **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
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
## Quick Start
@@ -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

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
## Быстрый старт
@@ -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

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
---
@@ -62,7 +62,7 @@
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak)
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
## Hızlı Başlangıç
@@ -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

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -60,7 +60,7 @@
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js)
## 快速开始
@@ -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

@@ -1,250 +0,0 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI又名 Claude Code UI</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a><a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用從任何地方查看您的專案與工作階段。</p>
</div>
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
</p>
<p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
## 截圖
<div align="center">
<table>
<tr>
<td align="center">
<h3>桌面檢視</h3>
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
<br>
<em>顯示專案總覽和聊天的主介面</em>
</td>
<td align="center">
<h3>行動裝置體驗</h3>
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
<br>
<em>具有觸控導覽的響應式行動裝置設計</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI 選擇</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
<br>
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
</td>
</tr>
</table>
</div>
## 功能
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
- **互動聊天介面** — 內建聊天 UI輕鬆與 Agents 交流
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得)
## 快速開始
### CloudCLI Cloud推薦
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
### 自架(開源)
#### npm
啟動 CloudCLI UI只需一行 `npx`(需要 Node.js v22+
```bash
npx @cloudcli-ai/cloudcli
```
或進行全域安裝,便於日常使用:
```bash
npm install -g @cloudcli-ai/cloudcli
cloudcli
```
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
更多設定選項、PM2、遠端伺服器設定等請參閱 **[文件 →](https://cloudcli.ai/docs)**。
#### Docker Sandboxes實驗性
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
```bash
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
---
## 哪個選項更適合你?
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
| | CloudCLI UI自架 | CloudCLI Cloud |
|---|---|---|
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
| **機器需保持開機嗎** | 是 | 否 |
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
| **REST API** | 是 | 是 |
| **n8n 節點** | 否 | 是 |
| **團隊共享** | 否 | 是 |
| **平台費用** | 免費開源 | 起價 $7/月 |
> 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
---
## 安全與工具設定
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
### 啟用工具
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
2. **選擇性啟用** — 僅啟用所需工具
3. **套用設定** — 偏好設定儲存在本機
<div align="center">
![工具設定彈出視窗](public/screenshots/tools-modal.png)
*工具設定介面 — 只啟用你需要的內容*
</div>
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
---
## 外掛
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
### 可用外掛
| 外掛 | 描述 |
|---|---|
| **[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 技能自動安裝 |
### 自行建構
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
---
## 常見問題
<details>
<summary>與 Claude Code Remote Control 有何不同?</summary>
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
- **完整 UI** — 除了聊天介面還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
</details>
<details>
<summary>需要額外購買 AI 訂閱嗎?</summary>
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
</details>
<details>
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
</details>
<details>
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
會的。自架模式下CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
</details>
---
## 社群與支援
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
## 授權條款
GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
該專案為開源軟體,在 GPL v3 授權條款下可自由使用、修改與散布。
## 致謝
### 使用技術
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
- **[React](https://react.dev/)** — 使用者介面函式庫
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
- **[CodeMirror](https://codemirror.net/)** — 進階程式碼編輯器
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(選用)* — AI 驅動的專案管理與任務規劃
### 贊助商
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
</div>

View File

@@ -1,218 +0,0 @@
# CloudCLI UI Nginx subpath deployment template.
#
# Purpose:
# Serve CloudCLI UI from a path prefix such as:
# http://localhost/ai/
# https://example.com/ai/
#
# CloudCLI itself still runs at the root of its own HTTP server, for example:
# http://127.0.0.1:3001/
#
# Nginx receives public requests under /ai, strips that prefix, and forwards the
# remaining path to CloudCLI. For example:
# /ai/ -> /
# /ai/session/abc -> /session/abc
# /ai/assets/index.js -> /assets/index.js
#
# Important Nginx limitation:
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
# The configurable variables below are still useful for proxy/filter values,
# but if you change /ai to a different subpath, also update every line marked:
# [SUBPATH LITERAL]
#
# To use a different subpath, replace these literal matchers:
# location = /ai
# location ^~ /ai/
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
#
# Recommended deployment shape:
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
# are also proxied because the current frontend still calls those endpoints
# with root-relative URLs.
worker_processes 1;
events {
# Maximum simultaneous connections handled by each worker process.
# The default is enough for local testing and small self-hosted deployments.
worker_connections 1024;
}
http {
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
# This map gives us the right Connection header for both cases:
# Upgrade present -> "upgrade"
# Upgrade absent -> "close"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
# add ssl_certificate / ssl_certificate_key lines.
listen 80 default_server;
# Use your real hostname in production, for example:
# server_name cloudcli.example.com;
server_name localhost 127.0.0.1;
# ---- User settings -------------------------------------------------
#
# Public path prefix where users access CloudCLI.
# Do not add a trailing slash.
#
# This variable can be used in redirects and response rewrites. It
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
# lines too if you change it.
set $cloudcli_subpath /ai;
# Private upstream URL where the CloudCLI server is listening.
# For a default local server this is usually http://127.0.0.1:3001.
set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 200m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
location = /ai {
return 301 $cloudcli_subpath/;
}
# Main prefixed CloudCLI UI route.
#
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
# $cloudcli_subpath.
location ^~ /ai/ {
# Strip the public subpath before proxying. CloudCLI expects to see
# root paths such as /, /session/:id, /assets/..., /manifest.json.
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
# Forward the rewritten request to the private CloudCLI server.
proxy_pass $cloudcli_upstream;
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
# browser reaches a socket endpoint under the subpath.
proxy_http_version 1.1;
# Preserve useful request metadata for logs and future app support.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
# WebSocket upgrade headers. Harmless for normal HTTP requests.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long-running agent and terminal sessions can stay open for a long
# time, so avoid closing idle proxied connections too aggressively.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Disable gzip from the upstream response so sub_filter can inspect
# and rewrite HTML/JSON/JS response bodies.
proxy_set_header Accept-Encoding "";
# Rewrite browser-visible root-relative URLs so the runtime can
# discover that the app is mounted under the subpath.
#
# Examples:
# href="/manifest.json" -> href="/ai/manifest.json"
# src="/assets/app.js" -> src="/ai/assets/app.js"
#
# These rewrites are important for React Router basename detection.
sub_filter_once off;
sub_filter_types
application/json
application/manifest+json
application/javascript
text/javascript;
sub_filter 'href="/' 'href="$cloudcli_subpath/';
sub_filter 'src="/' 'src="$cloudcli_subpath/';
# The production HTML and JS register the service worker at /sw.js.
# Rewrite that registration so the worker is served from /ai/sw.js.
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
# The manifest and service worker contain root-relative paths too.
# Rewriting them keeps PWA metadata and cached manifest requests
# under the same public subpath.
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
}
# Root API proxy.
#
# The current CloudCLI frontend calls APIs with root-relative URLs such
# as /api/auth/login. Keep this location unless the frontend becomes
# fully prefix-aware for API requests.
location ^~ /api/ {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Main app WebSocket proxy.
#
# The frontend opens /ws for realtime chat/session/task updates.
location /ws {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Shell WebSocket proxy.
#
# The browser terminal uses /shell. It requires the same WebSocket
# upgrade handling as /ws.
location /shell {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Optional health endpoint proxy used by the frontend version checker.
location = /health {
proxy_pass $cloudcli_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
}
}
}

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;
}
}

4709
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.32.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -9,10 +8,10 @@
"cloudcli": "dist-server/server/cli.js"
},
"files": [
"electron/",
"server/",
"shared/",
"public/api-docs.html",
"public/modelConstants.js",
"dist/",
"dist-server/",
"scripts/",
@@ -32,14 +31,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 +46,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",
@@ -136,7 +67,7 @@
"author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -147,7 +78,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",
@@ -165,7 +96,6 @@
"cmdk": "^1.1.1",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
@@ -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

@@ -820,49 +820,31 @@ data: {"type":"done"}</code></pre>
</div>
</div>
<script>
<script type="module">
// Import model constants
import { PROVIDERS } from './modelConstants.js';
// Dynamic URL replacement
const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl;
});
// Populate model documentation from the live provider API
const PROVIDER_ORDER = [
{ id: 'claude', name: 'Anthropic' },
{ id: 'codex', name: 'OpenAI' },
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
];
async function populateModels() {
// Dynamically populate model documentation
window.addEventListener('DOMContentLoaded', () => {
const modelCell = document.getElementById('model-options-cell');
if (!modelCell) return;
if (modelCell) {
const providerModels = PROVIDERS.map(provider => {
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
}).join('<br><br>');
const token = localStorage.getItem('auth-token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const results = await Promise.allSettled(
PROVIDER_ORDER.map(({ id }) =>
fetch(`/api/providers/${id}/models`, { headers }).then(r => r.json())
)
);
const providerModels = results.map((result, i) => {
const { name } = PROVIDER_ORDER[i];
if (result.status === 'rejected' || !result.value?.data?.models) {
return `<strong>${name}:</strong> <em>unavailable</em>`;
}
const { OPTIONS, DEFAULT } = result.value.data.models;
const models = OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
return `<strong>${name}:</strong> ${models} (default: <code>${DEFAULT}</code>)`;
}).join('<br><br>');
modelCell.innerHTML = `Model identifier for the AI provider:<br><br>${providerModels}`;
}
document.addEventListener('DOMContentLoaded', populateModels);
modelCell.innerHTML = `
Model identifier for the AI provider:<br><br>
${providerModels}
`;
}
});
// Tab switching
window.showTab = function(tabName) {

841
public/modelConstants.js Normal file
View File

@@ -0,0 +1,841 @@
/**
* Documentation Model Definitions
* Used by README links and the public API docs.
*/
/**
* Claude (Anthropic) Models
*/
export const CLAUDE_MODELS = {
OPTIONS: [
{
value: "default",
label: "Default (recommended)",
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
},
{
value: "sonnet[1m]",
label: "Sonnet (1M context)",
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
},
{
value: "haiku",
label: "Haiku",
description: "Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok",
},
],
DEFAULT: "default",
};
/**
* Cursor Models
*/
export const CURSOR_MODELS = {
OPTIONS: [
{ value: "auto", label: "auto", description: "Auto" },
{
value: "composer-2-fast",
label: "composer-2-fast",
description: "Composer 2 Fast",
},
{
value: "composer-2",
label: "composer-2",
description: "Composer 2",
},
{
value: "gpt-5.3-codex-low",
label: "gpt-5.3-codex-low",
description: "Codex 5.3 Low",
},
{
value: "gpt-5.3-codex-low-fast",
label: "gpt-5.3-codex-low-fast",
description: "Codex 5.3 Low Fast",
},
{
value: "gpt-5.3-codex",
label: "gpt-5.3-codex",
description: "Codex 5.3",
},
{
value: "gpt-5.3-codex-fast",
label: "gpt-5.3-codex-fast",
description: "Codex 5.3 Fast",
},
{
value: "gpt-5.3-codex-high",
label: "gpt-5.3-codex-high",
description: "Codex 5.3 High",
},
{
value: "gpt-5.3-codex-high-fast",
label: "gpt-5.3-codex-high-fast",
description: "Codex 5.3 High Fast",
},
{
value: "gpt-5.3-codex-xhigh",
label: "gpt-5.3-codex-xhigh",
description: "Codex 5.3 Extra High",
},
{
value: "gpt-5.3-codex-xhigh-fast",
label: "gpt-5.3-codex-xhigh-fast",
description: "Codex 5.3 Extra High Fast",
},
{ value: "gpt-5.2", label: "gpt-5.2", description: "GPT-5.2" },
{
value: "gpt-5.2-codex-low",
label: "gpt-5.2-codex-low",
description: "Codex 5.2 Low",
},
{
value: "gpt-5.2-codex-low-fast",
label: "gpt-5.2-codex-low-fast",
description: "Codex 5.2 Low Fast",
},
{
value: "gpt-5.2-codex",
label: "gpt-5.2-codex",
description: "Codex 5.2",
},
{
value: "gpt-5.2-codex-fast",
label: "gpt-5.2-codex-fast",
description: "Codex 5.2 Fast",
},
{
value: "gpt-5.2-codex-high",
label: "gpt-5.2-codex-high",
description: "Codex 5.2 High",
},
{
value: "gpt-5.2-codex-high-fast",
label: "gpt-5.2-codex-high-fast",
description: "Codex 5.2 High Fast",
},
{
value: "gpt-5.2-codex-xhigh",
label: "gpt-5.2-codex-xhigh",
description: "Codex 5.2 Extra High",
},
{
value: "gpt-5.2-codex-xhigh-fast",
label: "gpt-5.2-codex-xhigh-fast",
description: "Codex 5.2 Extra High Fast",
},
{
value: "gpt-5.1-codex-max-low",
label: "gpt-5.1-codex-max-low",
description: "Codex 5.1 Max Low",
},
{
value: "gpt-5.1-codex-max-low-fast",
label: "gpt-5.1-codex-max-low-fast",
description: "Codex 5.1 Max Low Fast",
},
{
value: "gpt-5.1-codex-max-medium",
label: "gpt-5.1-codex-max-medium",
description: "Codex 5.1 Max",
},
{
value: "gpt-5.1-codex-max-medium-fast",
label: "gpt-5.1-codex-max-medium-fast",
description: "Codex 5.1 Max Medium Fast",
},
{
value: "gpt-5.1-codex-max-high",
label: "gpt-5.1-codex-max-high",
description: "Codex 5.1 Max High",
},
{
value: "gpt-5.1-codex-max-high-fast",
label: "gpt-5.1-codex-max-high-fast",
description: "Codex 5.1 Max High Fast",
},
{
value: "gpt-5.1-codex-max-xhigh",
label: "gpt-5.1-codex-max-xhigh",
description: "Codex 5.1 Max Extra High",
},
{
value: "gpt-5.1-codex-max-xhigh-fast",
label: "gpt-5.1-codex-max-xhigh-fast",
description: "Codex 5.1 Max Extra High Fast",
},
{
value: "composer-2.5",
label: "composer-2.5",
description: "Composer 2.5",
},
{
value: "gpt-5.5-high",
label: "gpt-5.5-high",
description: "GPT-5.5 1M High",
},
{
value: "gpt-5.5-high-fast",
label: "gpt-5.5-high-fast",
description: "GPT-5.5 High Fast",
},
{
value: "claude-opus-4-7-thinking-high",
label: "claude-opus-4-7-thinking-high",
description: "Opus 4.7 1M High Thinking",
},
{
value: "gpt-5.4-high",
label: "gpt-5.4-high",
description: "GPT-5.4 1M High",
},
{
value: "gpt-5.4-high-fast",
label: "gpt-5.4-high-fast",
description: "GPT-5.4 High Fast",
},
{
value: "claude-4.6-opus-high-thinking",
label: "claude-4.6-opus-high-thinking",
description: "Opus 4.6 1M Thinking",
},
{
value: "claude-4.6-opus-high-thinking-fast",
label: "claude-4.6-opus-high-thinking-fast",
description: "Opus 4.6 1M Thinking Fast",
},
{
value: "composer-2.5-fast",
label: "composer-2.5-fast",
description: "Composer 2.5 Fast",
},
{
value: "gpt-5.5-none",
label: "gpt-5.5-none",
description: "GPT-5.5 1M None",
},
{
value: "gpt-5.5-none-fast",
label: "gpt-5.5-none-fast",
description: "GPT-5.5 None Fast",
},
{
value: "gpt-5.5-low",
label: "gpt-5.5-low",
description: "GPT-5.5 1M Low",
},
{
value: "gpt-5.5-low-fast",
label: "gpt-5.5-low-fast",
description: "GPT-5.5 Low Fast",
},
{
value: "gpt-5.5-medium",
label: "gpt-5.5-medium",
description: "GPT-5.5 1M",
},
{
value: "gpt-5.5-medium-fast",
label: "gpt-5.5-medium-fast",
description: "GPT-5.5 Fast",
},
{
value: "gpt-5.5-extra-high",
label: "gpt-5.5-extra-high",
description: "GPT-5.5 1M Extra High",
},
{
value: "gpt-5.5-extra-high-fast",
label: "gpt-5.5-extra-high-fast",
description: "GPT-5.5 Extra High Fast",
},
{
value: "claude-4.6-sonnet-medium",
label: "claude-4.6-sonnet-medium",
description: "Sonnet 4.6 1M",
},
{
value: "claude-4.6-sonnet-medium-thinking",
label: "claude-4.6-sonnet-medium-thinking",
description: "Sonnet 4.6 1M Thinking",
},
{
value: "claude-opus-4-7-low",
label: "claude-opus-4-7-low",
description: "Opus 4.7 1M Low",
},
{
value: "claude-opus-4-7-low-fast",
label: "claude-opus-4-7-low-fast",
description: "Opus 4.7 1M Low Fast",
},
{
value: "claude-opus-4-7-medium",
label: "claude-opus-4-7-medium",
description: "Opus 4.7 1M Medium",
},
{
value: "claude-opus-4-7-medium-fast",
label: "claude-opus-4-7-medium-fast",
description: "Opus 4.7 1M Medium Fast",
},
{
value: "claude-opus-4-7-high",
label: "claude-opus-4-7-high",
description: "Opus 4.7 1M High",
},
{
value: "claude-opus-4-7-high-fast",
label: "claude-opus-4-7-high-fast",
description: "Opus 4.7 1M High Fast",
},
{
value: "claude-opus-4-7-xhigh",
label: "claude-opus-4-7-xhigh",
description: "Opus 4.7 1M",
},
{
value: "claude-opus-4-7-xhigh-fast",
label: "claude-opus-4-7-xhigh-fast",
description: "Opus 4.7 1M Fast",
},
{
value: "claude-opus-4-7-max",
label: "claude-opus-4-7-max",
description: "Opus 4.7 1M Max",
},
{
value: "claude-opus-4-7-max-fast",
label: "claude-opus-4-7-max-fast",
description: "Opus 4.7 1M Max Fast",
},
{
value: "claude-opus-4-7-thinking-low",
label: "claude-opus-4-7-thinking-low",
description: "Opus 4.7 1M Low Thinking",
},
{
value: "claude-opus-4-7-thinking-low-fast",
label: "claude-opus-4-7-thinking-low-fast",
description: "Opus 4.7 1M Low Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-medium",
label: "claude-opus-4-7-thinking-medium",
description: "Opus 4.7 1M Medium Thinking",
},
{
value: "claude-opus-4-7-thinking-medium-fast",
label: "claude-opus-4-7-thinking-medium-fast",
description: "Opus 4.7 1M Medium Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-high-fast",
label: "claude-opus-4-7-thinking-high-fast",
description: "Opus 4.7 1M High Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-xhigh",
label: "claude-opus-4-7-thinking-xhigh",
description: "Opus 4.7 1M Thinking",
},
{
value: "claude-opus-4-7-thinking-xhigh-fast",
label: "claude-opus-4-7-thinking-xhigh-fast",
description: "Opus 4.7 1M Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-max",
label: "claude-opus-4-7-thinking-max",
description: "Opus 4.7 1M Max Thinking",
},
{
value: "claude-opus-4-7-thinking-max-fast",
label: "claude-opus-4-7-thinking-max-fast",
description: "Opus 4.7 1M Max Thinking Fast",
},
{
value: "grok-build-0.1",
label: "grok-build-0.1",
description: "Grok Build 0.1 1M",
},
{
value: "gpt-5.4-low",
label: "gpt-5.4-low",
description: "GPT-5.4 1M Low",
},
{
value: "gpt-5.4-medium",
label: "gpt-5.4-medium",
description: "GPT-5.4 1M",
},
{
value: "gpt-5.4-medium-fast",
label: "gpt-5.4-medium-fast",
description: "GPT-5.4 Fast",
},
{
value: "gpt-5.4-xhigh",
label: "gpt-5.4-xhigh",
description: "GPT-5.4 1M Extra High",
},
{
value: "gpt-5.4-xhigh-fast",
label: "gpt-5.4-xhigh-fast",
description: "GPT-5.4 Extra High Fast",
},
{
value: "claude-4.6-opus-high",
label: "claude-4.6-opus-high",
description: "Opus 4.6 1M",
},
{
value: "claude-4.6-opus-max",
label: "claude-4.6-opus-max",
description: "Opus 4.6 1M Max",
},
{
value: "claude-4.6-opus-max-thinking",
label: "claude-4.6-opus-max-thinking",
description: "Opus 4.6 1M Max Thinking",
},
{
value: "claude-4.6-opus-max-thinking-fast",
label: "claude-4.6-opus-max-thinking-fast",
description: "Opus 4.6 1M Max Thinking Fast",
},
{
value: "claude-4.5-opus-high",
label: "claude-4.5-opus-high",
description: "Opus 4.5",
},
{
value: "claude-4.5-opus-high-thinking",
label: "claude-4.5-opus-high-thinking",
description: "Opus 4.5 Thinking",
},
{
value: "gpt-5.2-low",
label: "gpt-5.2-low",
description: "GPT-5.2 Low",
},
{
value: "gpt-5.2-low-fast",
label: "gpt-5.2-low-fast",
description: "GPT-5.2 Low Fast",
},
{
value: "gpt-5.2-fast",
label: "gpt-5.2-fast",
description: "GPT-5.2 Fast",
},
{
value: "gpt-5.2-high",
label: "gpt-5.2-high",
description: "GPT-5.2 High",
},
{
value: "gpt-5.2-high-fast",
label: "gpt-5.2-high-fast",
description: "GPT-5.2 High Fast",
},
{
value: "gpt-5.2-xhigh",
label: "gpt-5.2-xhigh",
description: "GPT-5.2 Extra High",
},
{
value: "gpt-5.2-xhigh-fast",
label: "gpt-5.2-xhigh-fast",
description: "GPT-5.2 Extra High Fast",
},
{
value: "gemini-3.1-pro",
label: "gemini-3.1-pro",
description: "Gemini 3.1 Pro",
},
{
value: "gpt-5.4-mini-none",
label: "gpt-5.4-mini-none",
description: "GPT-5.4 Mini None",
},
{
value: "gpt-5.4-mini-low",
label: "gpt-5.4-mini-low",
description: "GPT-5.4 Mini Low",
},
{
value: "gpt-5.4-mini-medium",
label: "gpt-5.4-mini-medium",
description: "GPT-5.4 Mini",
},
{
value: "gpt-5.4-mini-high",
label: "gpt-5.4-mini-high",
description: "GPT-5.4 Mini High",
},
{
value: "gpt-5.4-mini-xhigh",
label: "gpt-5.4-mini-xhigh",
description: "GPT-5.4 Mini Extra High",
},
{
value: "gpt-5.4-nano-none",
label: "gpt-5.4-nano-none",
description: "GPT-5.4 Nano None",
},
{
value: "gpt-5.4-nano-low",
label: "gpt-5.4-nano-low",
description: "GPT-5.4 Nano Low",
},
{
value: "gpt-5.4-nano-medium",
label: "gpt-5.4-nano-medium",
description: "GPT-5.4 Nano",
},
{
value: "gpt-5.4-nano-high",
label: "gpt-5.4-nano-high",
description: "GPT-5.4 Nano High",
},
{
value: "gpt-5.4-nano-xhigh",
label: "gpt-5.4-nano-xhigh",
description: "GPT-5.4 Nano Extra High",
},
{
value: "grok-4.3",
label: "grok-4.3",
description: "Grok 4.3 1M",
},
{
value: "claude-4.5-sonnet",
label: "claude-4.5-sonnet",
description: "Sonnet 4.5",
},
{
value: "claude-4.5-sonnet-thinking",
label: "claude-4.5-sonnet-thinking",
description: "Sonnet 4.5 Thinking",
},
{
value: "gpt-5.1-low",
label: "gpt-5.1-low",
description: "GPT-5.1 Low",
},
{
value: "gpt-5.1",
label: "gpt-5.1",
description: "GPT-5.1",
},
{
value: "gpt-5.1-high",
label: "gpt-5.1-high",
description: "GPT-5.1 High",
},
{
value: "gemini-3-flash",
label: "gemini-3-flash",
description: "Gemini 3 Flash",
},
{
value: "gemini-3.5-flash",
label: "gemini-3.5-flash",
description: "Gemini 3.5 Flash",
},
{
value: "gpt-5.1-codex-mini-low",
label: "gpt-5.1-codex-mini-low",
description: "Codex 5.1 Mini Low",
},
{
value: "gpt-5.1-codex-mini",
label: "gpt-5.1-codex-mini",
description: "Codex 5.1 Mini",
},
{
value: "gpt-5.1-codex-mini-high",
label: "gpt-5.1-codex-mini-high",
description: "Codex 5.1 Mini High",
},
{
value: "claude-4-sonnet",
label: "claude-4-sonnet",
description: "Sonnet 4",
},
{
value: "claude-4-sonnet-thinking",
label: "claude-4-sonnet-thinking",
description: "Sonnet 4 Thinking",
},
{
value: "gpt-5-mini",
label: "gpt-5-mini",
description: "GPT-5 Mini",
},
{
value: "kimi-k2.5",
label: "kimi-k2.5",
description: "Kimi K2.5",
},
],
DEFAULT: "composer-2.5-fast",
};
/**
* Codex (OpenAI) Models
*/
export const CODEX_MODELS = {
OPTIONS: [
{ value: "gpt-5.5", label: "gpt-5.5" },
{ value: "gpt-5.4", label: "gpt-5.4" },
{ value: "gpt-5.4-mini", label: "gpt-5.4-mini" },
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
{ value: "gpt-5.2", label: "gpt-5.2" },
],
DEFAULT: "gpt-5.4",
};
/**
* Gemini Models
*/
export const GEMINI_MODELS = {
OPTIONS: [
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
{
value: "gemini-2.0-flash-thinking-exp",
label: "Gemini 2.0 Flash Thinking",
},
],
DEFAULT: "gemini-3.1-pro-preview",
};
/**
* OpenCode Models
*
* OpenCode model ids include the upstream provider prefix.
*/
export const OPENCODE_MODELS = {
OPTIONS: [
{
value: "opencode/big-pickle",
label: "Big Pickle",
description: "opencode - opencode/big-pickle",
},
{
value: "opencode/deepseek-v4-flash-free",
label: "Deepseek V4 Flash Free",
description: "opencode - opencode/deepseek-v4-flash-free",
},
{
value: "opencode/nemotron-3-super-free",
label: "Nemotron 3 Super Free",
description: "opencode - opencode/nemotron-3-super-free",
},
{
value: "anthropic/claude-3-5-haiku-20241022",
label: "Claude 3.5 Haiku (2024-10-22)",
description: "anthropic - anthropic/claude-3-5-haiku-20241022",
},
{
value: "anthropic/claude-3-5-haiku-latest",
label: "Claude 3.5 Haiku Latest",
description: "anthropic - anthropic/claude-3-5-haiku-latest",
},
{
value: "anthropic/claude-3-5-sonnet-20240620",
label: "Claude 3.5 Sonnet (2024-06-20)",
description: "anthropic - anthropic/claude-3-5-sonnet-20240620",
},
{
value: "anthropic/claude-3-5-sonnet-20241022",
label: "Claude 3.5 Sonnet (2024-10-22)",
description: "anthropic - anthropic/claude-3-5-sonnet-20241022",
},
{
value: "anthropic/claude-3-7-sonnet-20250219",
label: "Claude 3.7 Sonnet (2025-02-19)",
description: "anthropic - anthropic/claude-3-7-sonnet-20250219",
},
{
value: "anthropic/claude-3-haiku-20240307",
label: "Claude 3 Haiku (2024-03-07)",
description: "anthropic - anthropic/claude-3-haiku-20240307",
},
{
value: "anthropic/claude-3-opus-20240229",
label: "Claude 3 Opus (2024-02-29)",
description: "anthropic - anthropic/claude-3-opus-20240229",
},
{
value: "anthropic/claude-3-sonnet-20240229",
label: "Claude 3 Sonnet (2024-02-29)",
description: "anthropic - anthropic/claude-3-sonnet-20240229",
},
{
value: "anthropic/claude-haiku-4-5",
label: "Claude Haiku 4.5",
description: "anthropic - anthropic/claude-haiku-4-5",
},
{
value: "anthropic/claude-haiku-4-5-20251001",
label: "Claude Haiku 4.5 (2025-10-01)",
description: "anthropic - anthropic/claude-haiku-4-5-20251001",
},
{
value: "anthropic/claude-opus-4-0",
label: "Claude Opus 4.0",
description: "anthropic - anthropic/claude-opus-4-0",
},
{
value: "anthropic/claude-opus-4-1",
label: "Claude Opus 4.1",
description: "anthropic - anthropic/claude-opus-4-1",
},
{
value: "anthropic/claude-opus-4-1-20250805",
label: "Claude Opus 4.1 (2025-08-05)",
description: "anthropic - anthropic/claude-opus-4-1-20250805",
},
{
value: "anthropic/claude-opus-4-20250514",
label: "Claude Opus 4 (2025-05-14)",
description: "anthropic - anthropic/claude-opus-4-20250514",
},
{
value: "anthropic/claude-opus-4-5",
label: "Claude Opus 4.5",
description: "anthropic - anthropic/claude-opus-4-5",
},
{
value: "anthropic/claude-opus-4-5-20251101",
label: "Claude Opus 4.5 (2025-11-01)",
description: "anthropic - anthropic/claude-opus-4-5-20251101",
},
{
value: "anthropic/claude-opus-4-6",
label: "Claude Opus 4.6",
description: "anthropic - anthropic/claude-opus-4-6",
},
{
value: "anthropic/claude-opus-4-6-fast",
label: "Claude Opus 4.6 Fast",
description: "anthropic - anthropic/claude-opus-4-6-fast",
},
{
value: "anthropic/claude-opus-4-7",
label: "Claude Opus 4.7",
description: "anthropic - anthropic/claude-opus-4-7",
},
{
value: "anthropic/claude-opus-4-7-fast",
label: "Claude Opus 4.7 Fast",
description: "anthropic - anthropic/claude-opus-4-7-fast",
},
{
value: "anthropic/claude-sonnet-4-0",
label: "Claude Sonnet 4.0",
description: "anthropic - anthropic/claude-sonnet-4-0",
},
{
value: "anthropic/claude-sonnet-4-20250514",
label: "Claude Sonnet 4 (2025-05-14)",
description: "anthropic - anthropic/claude-sonnet-4-20250514",
},
{
value: "anthropic/claude-sonnet-4-5",
label: "Claude Sonnet 4.5",
description: "anthropic - anthropic/claude-sonnet-4-5",
},
{
value: "anthropic/claude-sonnet-4-5-20250929",
label: "Claude Sonnet 4.5 (2025-09-29)",
description: "anthropic - anthropic/claude-sonnet-4-5-20250929",
},
{
value: "anthropic/claude-sonnet-4-6",
label: "Claude Sonnet 4.6",
description: "anthropic - anthropic/claude-sonnet-4-6",
},
{
value: "openai/gpt-5.2",
label: "GPT-5.2",
description: "openai - openai/gpt-5.2",
},
{
value: "openai/gpt-5.3-codex",
label: "GPT-5.3 Codex",
description: "openai - openai/gpt-5.3-codex",
},
{
value: "openai/gpt-5.3-codex-spark",
label: "GPT-5.3 Codex Spark",
description: "openai - openai/gpt-5.3-codex-spark",
},
{
value: "openai/gpt-5.4",
label: "GPT-5.4",
description: "openai - openai/gpt-5.4",
},
{
value: "openai/gpt-5.4-fast",
label: "GPT-5.4 Fast",
description: "openai - openai/gpt-5.4-fast",
},
{
value: "openai/gpt-5.4-mini",
label: "GPT-5.4 Mini",
description: "openai - openai/gpt-5.4-mini",
},
{
value: "openai/gpt-5.4-mini-fast",
label: "GPT-5.4 Mini Fast",
description: "openai - openai/gpt-5.4-mini-fast",
},
{
value: "openai/gpt-5.5",
label: "GPT-5.5",
description: "openai - openai/gpt-5.5",
},
{
value: "openai/gpt-5.5-fast",
label: "GPT-5.5 Fast",
description: "openai - openai/gpt-5.5-fast",
},
{
value: "openai/gpt-5.5-pro",
label: "GPT-5.5 Pro",
description: "openai - openai/gpt-5.5-pro",
},
],
DEFAULT: "anthropic/claude-sonnet-4-5",
};
/**
* Ordered provider registry. Display order in documentation.
*/
export const PROVIDERS = [
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
];

View File

@@ -75,7 +75,7 @@
- **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
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
## Quick Start

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

@@ -28,14 +28,10 @@ import {
} from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
const abortedSessionIds = new Set();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
@@ -208,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
// Model logged at query start below
@@ -289,75 +285,43 @@ function transformMessage(sdkMessage) {
return sdkMessage;
}
function readNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* Extracts token usage from SDK messages.
* Prefers per-step `message.usage` (Claude message payload), then falls back
* to result-level usage/modelUsage for compatibility across SDK versions.
* @param {Object} sdkMessage - SDK stream message
* Extracts token usage from SDK result messages
* @param {Object} resultMessage - SDK result message
* @returns {Object|null} Token budget object or null
*/
function extractTokenBudget(sdkMessage) {
if (!sdkMessage || typeof sdkMessage !== 'object') {
function extractTokenBudget(resultMessage) {
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
return null;
}
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
if (messageUsage && typeof messageUsage === 'object') {
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
const cacheTokens = cacheCreationTokens + cacheReadTokens;
const inputTokens = directInputTokens + cacheTokens;
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
// Get the first model's usage data
const modelKey = Object.keys(resultMessage.modelUsage)[0];
const modelData = resultMessage.modelUsage[modelKey];
return {
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
if (!modelData) {
return null;
}
// Fallback for older SDK messages with only modelUsage
const modelKey = Object.keys(sdkMessage.modelUsage)[0];
const modelData = sdkMessage.modelUsage[modelKey];
// Use cumulative tokens if available (tracks total for the session)
// Otherwise fall back to per-request tokens
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
if (!modelData || typeof modelData !== 'object') {
return null;
}
// Total used = input + output + cache tokens
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
// Use configured context window budget from environment (default 160000)
// This is the user's budget limit, not the model's context window
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
// Token calc logged via token-budget WS event
return {
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
total: contextWindow
};
}
@@ -720,10 +684,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
ws.send(msg);
}
// Extract and send token budget updates from assistant/result usage payloads
const tokenBudgetData = extractTokenBudget(message);
if (tokenBudgetData) {
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
// Model info available in result message
}
const tokenBudgetData = extractTokenBudget(message);
if (tokenBudgetData) {
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
}
}
}
@@ -735,18 +705,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir);
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (!wasAborted) {
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
}
// Send completion event
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
notifyRunStopped({
userId: ws?.userId || null,
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: wasAborted ? 'aborted' : 'completed'
stopReason: 'completed'
});
// Complete
@@ -761,22 +727,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) {
// The abort already produced the terminal complete; a generator throw
// caused by interrupt() is expected noise, not a user-facing error.
return;
}
// Check if Claude CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket, then the terminal complete
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -803,10 +761,6 @@ async function abortClaudeSDKSession(sessionId) {
try {
console.log(`Aborting SDK session: ${sessionId}`);
// Mark before interrupting so the run loop knows not to emit its own
// terminal complete (the abort handler sends the aborted one).
abortedSessionIds.add(sessionId);
// Call interrupt() on the query instance
await session.instance.interrupt();
@@ -822,8 +776,6 @@ async function abortClaudeSDKSession(sessionId) {
return true;
} catch (error) {
console.error(`Error aborting session ${sessionId}:`, error);
// The run keeps going; let it emit its own terminal complete.
abortedSessionIds.delete(sessionId);
return false;
}
}

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)
@@ -457,7 +455,7 @@ async function sandboxCommand(args) {
await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
@@ -556,7 +554,7 @@ async function sandboxCommand(args) {
// Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
// Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 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

@@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -34,10 +34,6 @@ async function spawnCursor(command, options = {}, ws) {
let sessionCreatedSent = false; // Track if we've already sent session-created event
let hasRetriedWithTrust = false;
let settled = false;
// The unified lifecycle contract requires exactly one terminal `complete`
// per run. Cursor surfaces completion twice (the `result` JSON line and
// the process close), so the first emission wins.
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -201,15 +197,15 @@ async function spawnCursor(command, options = {}, ws) {
break;
case 'result': {
// Session complete — terminal lifecycle event for this run
if (!completeSent) {
completeSent = true;
ws.send(createCompleteMessage({
provider: 'cursor',
sessionId: capturedSessionId || sessionId || null,
exitCode: response.subtype === 'success' ? 0 : 1,
}));
}
// Session complete — send stream end + lifecycle complete with result payload
const resultText = typeof response.result === 'string' ? response.result : '';
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: response.subtype === 'success' ? 0 : 1,
resultText,
isError: response.subtype !== 'success',
sessionId: capturedSessionId || sessionId, provider: 'cursor',
}));
break;
}
@@ -275,12 +271,7 @@ async function spawnCursor(command, options = {}, ws) {
return;
}
// Terminal complete — unless the `result` line already sent it, or the
// run was aborted (abort-session sent the aborted complete).
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
if (code === 0) {
notifyTerminalState({ code });
@@ -306,10 +297,6 @@ async function spawnCursor(command, options = {}, ws) {
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
}
notifyTerminalState({ error });
settleOnce(() => reject(error));
@@ -327,9 +314,6 @@ function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`Aborting Cursor session: ${sessionId}`);
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;

View File

@@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -129,9 +129,6 @@ async function spawnGemini(command, options = {}, ws) {
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -489,12 +486,7 @@ async function spawnGemini(command, options = {}, ws) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
}
// Terminal complete — skipped for aborted runs (abort-session
// already sent the aborted complete on this run's behalf).
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
@@ -574,10 +566,6 @@ async function spawnGemini(command, options = {}, ws) {
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
reject(error);
@@ -602,9 +590,6 @@ function abortGeminiSession(sessionId) {
if (geminiProc) {
try {
// The abort handler sends the terminal complete (aborted: true);
// flag the process so its close handler does not emit a second one.
geminiProc.aborted = true;
geminiProc.kill('SIGTERM');
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {

View File

@@ -1,32 +1,5 @@
// Gemini Response Handler - JSON Stream processing
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
function buildGeminiTokenBudget(tokens) {
if (!tokens || typeof tokens !== 'object') {
return null;
}
const parsedInputTokens = Number(tokens.input);
const parsedOutputTokens = Number(tokens.output);
const inputTokens = Number.isFinite(parsedInputTokens) ? parsedInputTokens : 0;
const outputTokens = Number.isFinite(parsedOutputTokens) ? parsedOutputTokens : 0;
const parsedUsed = Number(tokens.total);
const used = Number.isFinite(parsedUsed) ? parsedUsed : inputTokens + outputTokens;
if (!Number.isFinite(used) || used <= 0) {
return null;
}
return {
used,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
class GeminiResponseHandler {
constructor(ws, options = {}) {
@@ -87,17 +60,6 @@ class GeminiResponseHandler {
for (const msg of normalized) {
this.ws.send(msg);
}
const tokenBudget = buildGeminiTokenBudget(event.tokens);
if (tokenBudget) {
this.ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget,
sessionId: sid,
provider: 'gemini',
}));
}
}
forceFlush() {

View File

@@ -10,9 +10,8 @@ import { spawn } from 'child_process';
import express from 'express';
import cors from 'cors';
import mime from 'mime-types';
import Database from 'better-sqlite3';
import { AppError, WORKSPACES_ROOT, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js';
import { AppError, WORKSPACES_ROOT, validateWorkspacePath } from '@/shared/utils.js';
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
import { createWebSocketServer } from '@/modules/websocket/index.js';
@@ -22,24 +21,35 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions,
resolveToolApproval,
getPendingApprovalsForSession,
reconnectSessionWriter,
} from './claude-sdk.js';
import {
spawnCursor,
abortCursorSession,
isCursorSessionActive,
getActiveCursorSessions,
} from './cursor-cli.js';
import {
queryCodex,
abortCodexSession,
isCodexSessionActive,
getActiveCodexSessions,
} from './openai-codex.js';
import {
spawnGemini,
abortGeminiSession,
isGeminiSessionActive,
getActiveGeminiSessions,
} from './gemini-cli.js';
import {
spawnOpenCode,
abortOpenCodeSession,
isOpenCodeSessionActive,
getActiveOpenCodeSessions,
} from './opencode-cli.js';
import sessionManager from './sessionManager.js';
import {
@@ -57,17 +67,12 @@ 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 { initializeDatabase, projectsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
@@ -78,30 +83,9 @@ 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;
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
const app = express();
const server = http.createServer(app);
@@ -112,35 +96,32 @@ const wss = createWebSocketServer(server, {
authenticateWebSocket,
},
chat: {
spawnFns: {
claude: queryClaudeSDK,
cursor: spawnCursor,
codex: queryCodex,
gemini: spawnGemini,
opencode: spawnOpenCode,
},
abortFns: {
claude: abortClaudeSDKSession,
cursor: abortCursorSession,
codex: abortCodexSession,
gemini: abortGeminiSession,
opencode: abortOpenCodeSession,
},
queryClaudeSDK,
spawnCursor,
queryCodex,
spawnGemini,
spawnOpenCode,
abortClaudeSDKSession,
abortCursorSession,
abortCodexSession,
abortGeminiSession,
abortOpenCodeSession,
resolveToolApproval,
isClaudeSDKSessionActive,
isCursorSessionActive,
isCodexSessionActive,
isGeminiSessionActive,
isOpenCodeSessionActive,
reconnectSessionWriter,
getPendingApprovalsForSession,
getActiveClaudeSDKSessions,
getActiveCursorSessions,
getActiveCodexSessions,
getActiveGeminiSessions,
getActiveOpenCodeSessions,
},
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 +152,7 @@ app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode,
version: RUNNING_VERSION
installMode
});
});
@@ -203,8 +183,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 +192,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')));
@@ -921,27 +891,27 @@ const uploadFilesHandler = async (req, res) => {
}
}),
limits: {
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
files: MAX_FILE_UPLOAD_COUNT
fileSize: 50 * 1024 * 1024, // 50MB limit
files: 20 // Max 20 files at once
}
});
// Use multer middleware
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
uploadMiddleware.array('files', 20)(req, res, async (err) => {
if (err) {
console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
}
return res.status(500).json({ error: err.message });
}
try {
const { projectId } = req.params;
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads)
let filePaths = [];
@@ -965,11 +935,6 @@ const uploadFilesHandler = async (req, res) => {
return res.status(400).json({ error: 'No files provided' });
}
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
? parsedRequestedFileCount
: req.files.length;
// Resolve the project directory through the DB using the new projectId.
const projectRoot = await projectsDb.getProjectPathById(projectId);
if (!projectRoot) {
@@ -1048,10 +1013,8 @@ const uploadFilesHandler = async (req, res) => {
res.json({
success: true,
files: uploadedFiles,
uploadedCount: uploadedFiles.length,
requestedFileCount,
targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
});
} catch (error) {
console.error('Error uploading files:', error);
@@ -1164,6 +1127,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
@@ -1172,144 +1136,38 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
return res.status(400).json({ error: 'Invalid sessionId' });
}
// 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.
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
if (provider === 'cursor') {
return res.json({
used: 0,
total: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Cursor sessions'
});
}
// Handle Gemini sessions - they are raw logs in our current setup
if (provider === 'gemini') {
const session = sessionsDb.getSessionById(safeSessionId);
const sessionFilePath = session?.jsonl_path;
if (!sessionFilePath) {
return res.json({
used: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true,
message: 'Token usage tracking not available for this Gemini session'
});
}
let fileContent;
try {
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
}
throw error;
}
const lines = fileContent.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let totalTokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (!entry.tokens || typeof entry.tokens !== 'object') {
continue;
}
inputTokens = Number(entry.tokens.input || 0);
outputTokens = Number(entry.tokens.output || 0);
totalTokens = Number(entry.tokens.total || inputTokens + outputTokens || 0);
break;
} catch {
continue;
}
}
return res.json({
used: totalTokens,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Gemini sessions'
});
}
// OpenCode token totals are surfaced through provider history reads.
// This legacy endpoint only knows file-backed session formats.
if (provider === 'opencode') {
const dbPath = getOpenCodeDatabasePath();
if (!fs.existsSync(dbPath)) {
return res.status(404).json({ error: 'OpenCode database not found' });
}
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const columns = db.prepare('PRAGMA table_info(session)').all();
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return res.json({
used: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true,
message: 'Token usage tracking is not available in this OpenCode database schema'
});
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(providerNativeSessionId);
if (!row) {
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
}
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
const outputTokens = Number(row.outputTokens || 0);
const totalUsed = Number(row.inputTokens || 0)
+ outputTokens
+ Number(row.reasoningTokens || 0)
+ Number(row.cacheReadTokens || 0)
+ Number(row.cacheWriteTokens || 0);
return res.json({
used: totalUsed,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
});
} finally {
db.close();
}
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking is available in OpenCode session history, not this legacy endpoint'
});
}
// Handle Codex sessions
@@ -1325,7 +1183,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) {
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
}
}
@@ -1352,8 +1210,6 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
throw error;
}
const lines = fileContent.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI
@@ -1366,9 +1222,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) {
inputTokens = tokenInfo.total_token_usage.input_tokens || 0;
outputTokens = tokenInfo.total_token_usage.output_tokens || 0;
totalTokens = tokenInfo.total_token_usage.total_tokens || inputTokens + outputTokens;
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
}
if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window;
@@ -1383,13 +1237,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
return res.json({
used: totalTokens,
total: contextWindow,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
total: contextWindow
});
}
@@ -1409,19 +1257,12 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
// Prefer the indexed transcript path (already produced by the trusted
// session synchronizer); fall back to the conventional location
// derived from the provider-native session id.
let jsonlPath = sessionRow?.jsonl_path;
if (!jsonlPath) {
jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Constrain the constructed path to projectDir (the id is
// caller-influenced in this fallback branch).
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Read and parse the JSONL file
@@ -1439,9 +1280,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
@@ -1453,11 +1293,9 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const usage = entry.message.usage;
// Use token counts from latest assistant message only
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message
}
@@ -1467,20 +1305,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
}
}
const totalUsed = inputTokens + outputTokens;
const cacheTokens = cacheReadTokens + cacheCreationTokens;
// Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens
cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
}
});
} catch (error) {
@@ -1546,133 +1380,74 @@ function permToRwx(perm) {
return r + w + x;
}
// Directories that are almost never interesting for a project tree but can
// contain tens of thousands of files. Skipping them before recursion keeps
// traversal time bounded on large monorepos and high-latency filesystems
// (NFS / SMB).
const IGNORED_DIRS = new Set([
// JS / TS toolchains
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
// VCS
'.git', '.svn', '.hg',
// Python
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
// Rust / Go / Java / Ruby
'target', 'vendor',
// Build output / IDE
'.gradle', '.idea', 'coverage', '.nyc_output'
]);
const DEFAULT_FS_CONCURRENCY = 64;
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
? parsedFsConcurrency
: DEFAULT_FS_CONCURRENCY;
let activeFsOperations = 0;
const pendingFsOperations = [];
async function acquire() {
if (activeFsOperations < FS_CONCURRENCY) {
activeFsOperations += 1;
return;
}
await new Promise((resolve) => {
pendingFsOperations.push(resolve);
});
}
function release() {
const next = pendingFsOperations.shift();
if (next) {
next();
return;
}
activeFsOperations = Math.max(0, activeFsOperations - 1);
}
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
// Using fsPromises from import
let entries;
const items = [];
try {
await acquire();
try {
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
} finally {
release();
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// Debug: log all entries including hidden files
// Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
const stats = await fsPromises.stat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth
try {
// Check if we can access the directory before trying to read it
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
item.children = [];
}
}
items.push(item);
}
} catch (error) {
// Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error);
}
return [];
}
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
// serial stat() was the real bottleneck — issuing them concurrently lets
// the kernel pipeline the round-trips and the recursive calls overlap too.
const items = await Promise.all(filteredEntries.map(async (entry) => {
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
await acquire();
try {
const stats = await fsPromises.lstat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Mark symlinks so UI can distinguish them
if (stats.isSymbolicLink()) {
item.isSymlink = true;
}
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions =
((mode >> 6) & 7).toString() +
((mode >> 3) & 7).toString() +
(mode & 7).toString();
item.permissionsRwx =
permToRwx(ownerPerm) +
permToRwx(groupPerm) +
permToRwx(otherPerm);
} finally {
release();
}
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recurse. Let readdir's own EACCES bubble up through the catch in
// the recursive call rather than doing a separate access() probe
// (which doubled the round-trip count on SMB without adding info).
// The recursive call starts with a bounded readdir; holding a permit
// for the whole subtree can deadlock when sibling directories are
// waiting on their own children.
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
}
return item;
}));
return items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
@@ -1685,40 +1460,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 +1486,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 +1508,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,
@@ -383,25 +382,6 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
}
};
/**
* Adds the `provider_session_id` mapping column used by the session gateway.
*
* Rows that existed before this migration were always keyed directly by the
* provider-native session id, so backfilling `provider_session_id` with
* `session_id` keeps every legacy row resolvable through the new mapping.
*/
const addProviderSessionIdMapping = (db: Database): void => {
const sessionsTableInfo = getTableInfo(db, 'sessions');
const columnNames = sessionsTableInfo.map((column) => column.name);
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
db.exec(`
UPDATE sessions
SET provider_session_id = session_id
WHERE provider_session_id IS NULL
`);
};
const ensureProjectsForSessionPaths = (db: Database): void => {
if (!tableExists(db, 'sessions')) {
return;
@@ -441,9 +421,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);
@@ -451,11 +428,9 @@ export const runMigrations = (db: Database) => {
migrateLegacyWorkspaceTableIntoProjects(db);
rebuildSessionsTableWithProjectSchema(db);
migrateLegacySessionNames(db);
addProviderSessionIdMapping(db);
ensureProjectsForSessionPaths(db);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');

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,6 @@ type NotificationPreferences = {
channels: {
inApp: boolean;
webPush: boolean;
desktop: boolean;
sound: boolean;
[key: string]: boolean;
};
events: {
actionRequired: boolean;
@@ -25,8 +22,6 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: {
inApp: false,
webPush: false,
desktop: false,
sound: true,
},
events: {
actionRequired: true,
@@ -37,21 +32,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: {
actionRequired: source.events?.actionRequired !== false,
@@ -115,3 +100,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

@@ -5,7 +5,6 @@ import { normalizeProjectPath } from '@/shared/utils.js';
type SessionRow = {
session_id: string;
provider: string;
provider_session_id: string | null;
project_path: string | null;
jsonl_path: string | null;
custom_name: string | null;
@@ -14,22 +13,15 @@ type SessionRow = {
updated_at: string;
};
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}$/;
type SessionMetadataLookupRow = Pick<
SessionRow,
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
>;
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,38 +29,14 @@ 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);
}
export const sessionsDb = {
/**
* Upserts one session row discovered on disk by a provider synchronizer.
*
* The given id is the provider-native session id. Rows are keyed by
* `provider_session_id` so a session that was first created by the app
* (with an app-allocated `session_id`) is updated in place once its
* transcript shows up on disk, instead of producing a duplicate row.
*/
createSession(
providerSessionId: string,
sessionId: string,
provider: string,
projectPath: string,
customName?: string,
@@ -85,54 +53,19 @@ export const sessionsDb = {
// since it's a foreign key in the sessions table.
projectsDb.createProjectPath(normalizedProjectPath);
const existing = db
.prepare(
`SELECT session_id FROM sessions
WHERE provider_session_id = ? AND provider = ?
LIMIT 1`
)
.get(providerSessionId, provider) as { session_id: string } | undefined;
if (existing) {
db.prepare(
`UPDATE sessions SET
provider = ?,
updated_at = COALESCE(?, CURRENT_TIMESTAMP),
project_path = ?,
jsonl_path = ?,
isArchived = 0,
custom_name = COALESCE(?, custom_name)
WHERE session_id = ?`
).run(
provider,
updatedAtValue,
normalizedProjectPath,
jsonlPath ?? null,
customName ?? null,
existing.session_id
);
return existing.session_id;
}
// Sessions created outside the app (directly via the provider CLI) are
// keyed by the provider-native id for both columns. The ON CONFLICT path
// covers legacy rows that predate the provider_session_id mapping.
db.prepare(
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
ON CONFLICT(session_id) DO UPDATE SET
provider = excluded.provider,
provider_session_id = excluded.provider_session_id,
updated_at = excluded.updated_at,
project_path = excluded.project_path,
jsonl_path = excluded.jsonl_path,
isArchived = 0,
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
).run(
providerSessionId,
sessionId,
provider,
providerSessionId,
customName ?? null,
normalizedProjectPath,
jsonlPath ?? null,
@@ -140,77 +73,9 @@ export const sessionsDb = {
updatedAtValue
);
return providerSessionId;
},
/**
* Inserts one app-allocated session row before any provider run happens.
*
* The session gateway uses this when the frontend starts a brand-new chat:
* `session_id` is the stable app-facing id, while `provider_session_id`
* stays NULL until the provider runtime announces its own id and
* `assignProviderSessionId` records the mapping.
*/
createAppSession(sessionId: string, provider: string, projectPath: string): string {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
projectsDb.createProjectPath(normalizedProjectPath);
db.prepare(
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
).run(sessionId, provider, normalizedProjectPath);
return sessionId;
},
/**
* Records the provider-native session id for one app-allocated session.
*
* If the filesystem watcher indexed the provider transcript before this
* mapping was recorded (a duplicate row keyed by the provider id exists),
* the duplicate is merged into the app row: its transcript path and name
* are adopted and the duplicate row is removed. Runs in a transaction so
* the sidebar can never observe both rows at once.
*/
assignProviderSessionId(sessionId: string, providerSessionId: string): void {
const db = getConnection();
const merge = db.transaction(() => {
const duplicate = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS} FROM sessions
WHERE (session_id = ? OR provider_session_id = ?)
AND session_id <> ?
LIMIT 1`
)
.get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
if (duplicate) {
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
db.prepare(
`UPDATE sessions SET
provider_session_id = ?,
jsonl_path = COALESCE(jsonl_path, ?),
custom_name = COALESCE(custom_name, ?),
updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?`
).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
return;
}
db.prepare(
`UPDATE sessions SET
provider_session_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?`
).run(providerSessionId, sessionId);
});
merge();
},
updateSessionCustomName(sessionId: string, customName: string): void {
const db = getConnection();
db.prepare(
@@ -220,91 +85,30 @@ export const sessionsDb = {
).run(customName, sessionId);
},
getSessionById(sessionId: string): SessionRow | null {
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
const db = getConnection();
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE session_id = ?
ORDER BY updated_at DESC
LIMIT 1`
)
.get(sessionId) as SessionRow | undefined;
.get(sessionId) as SessionMetadataLookupRow | undefined;
return normalizeSessionRow(row) ?? null;
},
/**
* Resolves one session row through the provider-native id.
*
* The filesystem watcher only knows provider ids (they come from transcript
* file names), so it uses this lookup to translate disk artifacts back to
* the app-facing session row before broadcasting sidebar updates.
*/
getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
const db = getConnection();
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE provider_session_id = ?
ORDER BY updated_at DESC
LIMIT 1`
)
.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}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE isArchived = 0`
)
.all() as SessionRow[];
return normalizeSessionRows(rows);
},
/**
@@ -313,31 +117,27 @@ export const sessionsDb = {
*/
getArchivedSessions(): SessionRow[] {
const db = getConnection();
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE isArchived = 1
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}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE project_path = ?
AND isArchived = 0`
)
.all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
},
/**
@@ -347,23 +147,21 @@ export const sessionsDb = {
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
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}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE project_path = ?
AND isArchived = 0
@@ -371,8 +169,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,
@@ -100,12 +83,6 @@ export const SESSIONS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
-- The session id used by the provider CLI/SDK on disk (JSONL file name,
-- store.db folder, sqlite row id, ...). \`session_id\` is the stable
-- app-facing id that the frontend uses for the whole session lifetime;
-- \`provider_session_id\` is filled in once the provider announces its own
-- id mid-run, or equals \`session_id\` for sessions discovered on disk.
provider_session_id TEXT,
custom_name TEXT,
project_path TEXT,
jsonl_path TEXT,
@@ -161,10 +138,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,108 +0,0 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { closeConnection } from '@/modules/database/connection.js';
import { initializeDatabase } from '@/modules/database/init-db.js';
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
test('disk-discovered sessions are keyed by the provider id for both columns', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk');
const row = sessionsDb.getSessionById('provider-abc');
assert.equal(row?.session_id, 'provider-abc');
assert.equal(row?.provider_session_id, 'provider-abc');
const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc');
assert.equal(byProviderId?.session_id, 'provider-abc');
});
});
test('app sessions get the provider id assigned without creating a duplicate row', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo');
sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz');
// A later synchronizer pass that discovers the transcript on disk must
// update the app row in place instead of inserting a provider-keyed row.
const returnedId = sessionsDb.createSession(
'provider-xyz',
'claude',
'/workspace/demo',
'Synced Name',
undefined,
undefined,
'/fake/path/provider-xyz.jsonl',
);
assert.equal(returnedId, 'app-id-1');
assert.equal(sessionsDb.getAllSessions().length, 1);
const row = sessionsDb.getSessionById('app-id-1');
assert.equal(row?.provider_session_id, 'provider-xyz');
assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl');
});
});
test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo');
// Simulate the race: the filesystem watcher indexed the provider
// transcript before the runtime announced its session id to the gateway.
sessionsDb.createSession(
'provider-race',
'codex',
'/workspace/demo',
'Watcher Name',
undefined,
undefined,
'/fake/provider-race.jsonl',
);
assert.equal(sessionsDb.getAllSessions().length, 2);
sessionsDb.assignProviderSessionId('app-id-2', 'provider-race');
const rows = sessionsDb.getAllSessions();
assert.equal(rows.length, 1);
assert.equal(rows[0]?.session_id, 'app-id-2');
assert.equal(rows[0]?.provider_session_id, 'provider-race');
// Transcript path and name from the duplicate are adopted.
assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl');
assert.equal(rows[0]?.custom_name, 'Watcher Name');
});
});
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
});
});

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

@@ -67,17 +67,8 @@ function resolveRouteErrorMessage(error: unknown): string {
router.get(
'/',
asyncHandler(async (req, res) => {
const skipSynchronization =
readQueryStringValue(req.query.skipSynchronization).trim() === '1' ||
readQueryStringValue(req.query.skipSync).trim() === '1';
const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined;
const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined;
const projects = await getProjectsWithSessions({
skipSynchronization,
sessionsLimit,
sessionsOffset,
});
asyncHandler(async (_req, res) => {
const projects = await getProjectsWithSessions();
res.json(projects);
}),
);

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,17 +183,16 @@ function readProjectSessionsPageByPath(
const total = sessionsDb.countSessionsByProjectPath(projectPath);
return {
sessions: rows.map(mapSessionRowToSummary),
sessionsByProvider: bucketSessionRowsByProvider(rows),
total,
hasMore: pagination.offset + rows.length < total,
};
}
// Broadcast progress to all connected WebSocket clients.
// Uses the unified `kind` envelope like every other websocket frame.
// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress: ProgressUpdate) {
const message = JSON.stringify({
kind: 'loading_progress',
type: 'loading_progress',
...progress,
});
@@ -175,7 +204,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 +252,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 +309,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 +342,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

@@ -83,7 +83,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
- Update `server/modules/providers/provider.routes.ts`.
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
- Update the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs.
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
- Update `src/components/chat/hooks/useChatProviderState.ts` and
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
the provider should be selectable in chat.

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

@@ -16,10 +16,6 @@ type ClaudeCredentialsStatus = {
error?: string;
};
const hasErrorCode = (error: unknown, code: string): boolean => (
error instanceof Error && 'code' in error && error.code === code
);
export class ClaudeProviderAuth implements IProviderAuth {
/**
* Checks whether the Claude Code CLI is available on this host.
@@ -81,12 +77,6 @@ export class ClaudeProviderAuth implements IProviderAuth {
* Checks Claude credentials in the same priority order used by Claude Code.
*/
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
}
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
@@ -120,33 +110,15 @@ export class ClaudeProviderAuth implements IProviderAuth {
return {
authenticated: false,
email: null,
method: null,
error: 'Claude login has expired. Run claude /login again.',
email,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login',
};
}
return {
authenticated: false,
email: null,
method: null,
error: missingCredentialsError,
};
} catch (error) {
let errorMessage = 'Unable to read Claude credentials. Run claude /login again.';
if (hasErrorCode(error, 'ENOENT')) {
errorMessage = missingCredentialsError;
} else if (error instanceof SyntaxError) {
errorMessage = 'Claude credentials are unreadable. Run claude /login again.';
}
return {
authenticated: false,
email: null,
method: null,
error: errorMessage,
};
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}
}

View File

@@ -18,28 +18,18 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{
value: 'default',
label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
},
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
value: 'sonnet',
label: 'Sonnet',
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
},
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
},
{
value: 'haiku',
label: 'Haiku',

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);
@@ -133,10 +111,7 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
return null;
}
// 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(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return {

View File

@@ -5,7 +5,7 @@ import readline from 'node:readline';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
import { sessionsDb } from '@/modules/database/index.js';
const PROVIDER = 'claude';
@@ -103,13 +103,10 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
async function getSessionMessages(
sessionId: string,
providerSessionId: string,
limit: number | null,
offset: number,
): Promise<ClaudeHistoryMessagesResult> {
try {
// The DB row is keyed by the app-facing session id, while the JSONL rows
// on disk carry the provider-native id — both ids are needed here.
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
if (!jsonLPath) {
@@ -136,7 +133,7 @@ async function getSessionMessages(
try {
const entry = JSON.parse(line) as AnyRecord;
if (entry.sessionId === providerSessionId) {
if (entry.sessionId === sessionId) {
messages.push(entry);
}
} catch {
@@ -556,13 +553,12 @@ export class ClaudeSessionsProvider implements IProviderSessions {
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
const providerSessionId = options.providerSessionId ?? sessionId;
let result: ClaudeHistoryResult;
try {
// Load full history first so `total` reflects frontend-normalized messages,
// not raw JSONL records.
result = await getSessionMessages(sessionId, providerSessionId, null, 0);
result = await getSessionMessages(sessionId, null, 0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
@@ -610,6 +606,7 @@ export class ClaudeSessionsProvider implements IProviderSessions {
}
}
const totalNormalized = normalized.length;
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
@@ -618,10 +615,18 @@ export class ClaudeSessionsProvider implements IProviderSessions {
}
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
Math.max(0, totalNormalized - normalizedOffset),
);
const hasMore = normalizedLimit === null
? false
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
return {
messages: page,
messages,
total,
hasMore,
offset: normalizedOffset,

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

@@ -43,12 +43,11 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
continue;
}
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
if (existingSession) {
// If session name is untitled and we now have a name, update it
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName);
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
}
}
@@ -121,10 +120,7 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
return null;
}
// 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(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
return {

View File

@@ -4,7 +4,7 @@ import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'codex';
@@ -552,6 +552,7 @@ export class CodexSessionsProvider implements IProviderSessions {
}
}
const totalNormalized = normalized.length;
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
@@ -560,10 +561,18 @@ export class CodexSessionsProvider implements IProviderSessions {
}
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
Math.max(0, totalNormalized - normalizedOffset),
);
const hasMore = normalizedLimit === null
? false
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
return {
messages: page,
messages,
total,
hasMore,
offset: normalizedOffset,

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

@@ -9,7 +9,6 @@ import {
generateMessageId,
readObjectRecord,
sanitizeLeafDirectoryName,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'cursor';
@@ -364,32 +363,42 @@ export class CursorSessionsProvider implements IProviderSessions {
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
* the most recent page, matching every other provider.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
// The store.db folder on disk is named after the provider-native id, not
// the app-facing session id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
try {
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath);
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
const total = renderableMessages.length;
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
if (limit !== null) {
const start = offset;
const page = limit === 0
? []
: renderableMessages.slice(start, start + limit);
const hasMore = limit === 0
? start < total
: start + limit < total;
return {
messages: page,
total,
hasMore,
offset,
limit,
};
}
return {
messages: page,
messages: renderableMessages,
total,
hasMore,
offset,
limit,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

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

@@ -12,14 +12,17 @@ import {
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
],
DEFAULT: 'gemini-3-flash-preview',
DEFAULT: 'gemini-3.1-pro-preview',
};
export class GeminiProviderModels implements IProviderModels {

View File

@@ -5,7 +5,7 @@ import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini';
@@ -88,15 +88,22 @@ function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
const record = tokens as AnyRecord;
const input = Number(record.input || 0);
const output = Number(record.output || 0);
const total = Number(record.total || input + output || 0);
const cached = Number(record.cached || 0);
const thoughts = Number(record.thoughts || 0);
const tool = Number(record.tool || 0);
const totalFromFields = input + output + cached + thoughts + tool;
const total = Number(record.total || totalFromFields || 0);
return {
used: total,
inputTokens: input,
outputTokens: output,
total: total,
breakdown: {
input,
output,
cached,
thoughts,
tool,
},
};
}
@@ -518,9 +525,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
// Tail pagination via the shared contract: offset 0 returns the most
// recent page, matching every other provider.
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
const messages = pageLimit === null
? normalized.slice(start)
: normalized.slice(start, start + pageLimit);
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
@@ -529,9 +536,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
}
return {
messages: page,
messages,
total,
hasMore,
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
offset: start,
limit: pageLimit,
tokenUsage: result.tokenUsage,

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,21 +112,7 @@ 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)
?? sessionsDb.getSessionById(sessionId);
const existingSession = sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName
@@ -134,9 +120,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 +129,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,7 +12,6 @@ import {
readObjectRecord,
readJsonRecord,
readOptionalString,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
@@ -29,9 +28,9 @@ type OpenCodeHistoryRow = {
type OpenCodeTokenTotals = {
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
cacheCreationTokens: number;
reasoningTokens: number;
};
const openOpenCodeDatabase = (): Database.Database | null => {
@@ -107,13 +106,11 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
}
const inputTokens = totals.inputTokens;
const displayInputTokens = inputTokens + totals.cacheReadTokens;
const outputTokens = totals.outputTokens;
const used = inputTokens
+ outputTokens
+ totals.reasoningTokens
+ totals.cacheReadTokens
+ totals.cacheWriteTokens;
const cacheReadTokens = totals.cacheReadTokens;
const cacheCreationTokens = totals.cacheCreationTokens;
const reasoningTokens = totals.reasoningTokens;
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
if (used <= 0) {
return undefined;
@@ -121,50 +118,14 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
return {
used,
inputTokens: displayInputTokens,
total: used,
inputTokens,
outputTokens,
breakdown: {
input: displayInputTokens,
output: outputTokens,
},
cacheReadTokens,
cacheCreationTokens,
};
};
const readOpenCodeSessionColumnTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const columns = db.prepare('PRAGMA table_info(session)').all() as { name: string }[];
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return undefined;
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(sessionId) as OpenCodeTokenTotals | undefined;
if (!row) {
return undefined;
}
return buildTokenUsage({
inputTokens: Number(row.inputTokens ?? 0),
outputTokens: Number(row.outputTokens ?? 0),
reasoningTokens: Number(row.reasoningTokens ?? 0),
cacheReadTokens: Number(row.cacheReadTokens ?? 0),
cacheWriteTokens: Number(row.cacheWriteTokens ?? 0),
});
};
/**
* OpenCode stores per-message token counts on assistant `message.data` objects
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
@@ -174,18 +135,13 @@ const aggregateOpenCodeSessionTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const sessionColumnUsage = readOpenCodeSessionColumnTokenUsage(db, sessionId);
if (sessionColumnUsage) {
return sessionColumnUsage;
}
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
let inputTokens = 0;
let outputTokens = 0;
let reasoningTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let cacheCreationTokens = 0;
let reasoningTokens = 0;
for (const row of rows) {
const info = readJsonRecord(row.data);
@@ -203,15 +159,15 @@ const aggregateOpenCodeSessionTokenUsage = (
reasoningTokens += Number(tokens.reasoning ?? 0);
const cache = readObjectRecord(tokens.cache);
cacheReadTokens += Number(cache?.read ?? 0);
cacheWriteTokens += Number(cache?.write ?? 0);
cacheCreationTokens += Number(cache?.write ?? 0);
}
return buildTokenUsage({
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
cacheCreationTokens,
reasoningTokens,
});
};
@@ -326,9 +282,6 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
// OpenCode's shared sqlite database keys messages by the provider-native
// session id, not the app-facing id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
const db = openOpenCodeDatabase();
if (!db) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
@@ -353,20 +306,27 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
m.id,
COALESCE(p.time_created, 0),
p.id
`).all(providerSessionId) as OpenCodeHistoryRow[];
`).all(sessionId) as OpenCodeHistoryRow[];
const normalized = this.normalizeHistoryRows(rows, sessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const total = normalized.length;
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, total - normalizedOffset - normalizedLimit),
Math.max(0, total - normalizedOffset),
);
return {
messages: page,
messages,
total,
hasMore,
hasMore: normalizedLimit === null
? false
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
offset: normalizedOffset,
limit: normalizedLimit,
tokenUsage,

View File

@@ -1,7 +1,6 @@
import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
@@ -12,8 +11,6 @@ import type {
McpScope,
McpTransport,
ProviderChangeActiveModelInput,
ProviderSkillCreateFile,
ProviderSkillCreateInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
@@ -181,104 +178,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 +319,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',
@@ -504,51 +382,7 @@ router.post(
}),
);
router.get(
'/capabilities',
asyncHandler(async (_req: Request, res: Response) => {
res.json(createApiSuccessResponse({
providers: providerCapabilitiesService.listAllProviderCapabilities(),
}));
}),
);
router.get(
'/:provider/capabilities',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
res.json(createApiSuccessResponse(
providerCapabilitiesService.getProviderCapabilities(provider),
));
}),
);
// ----------------- Session routes -----------------
/**
* Session gateway entry point: allocates the stable app-facing session id for
* a brand-new chat. The frontend must call this before the first `chat.send`
* so the session id in the URL, the store, and the websocket all agree from
* the very first message — there is no client-visible session-id handoff.
*/
router.post(
'/sessions',
asyncHandler(async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const provider = parseProvider(body.provider);
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
const result = sessionsService.createAppSession(provider, projectPath);
res.status(201).json(createApiSuccessResponse(result));
}),
);
router.get(
'/sessions/running',
asyncHandler(async (_req: Request, res: Response) => {
const sessions = sessionsService.listRunningSessions();
res.json(createApiSuccessResponse({ sessions }));
}),
);
router.get(
'/sessions/archived',
asyncHandler(async (_req: Request, res: Response) => {
@@ -625,7 +459,7 @@ router.get(
limit,
offset,
});
res.json(createApiSuccessResponse(result));
res.json(result);
}),
);

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,91 +0,0 @@
import type { LLMProvider } from '@/shared/types.js';
/**
* Static, backend-owned description of what one provider integration supports.
*
* The frontend renders its composer UI (permission mode picker, image upload,
* abort button, ...) purely from this shape, which is what keeps the frontend
* free of per-provider conditionals. New provider features should be exposed
* here instead of branching on the provider id in React components.
*/
type ProviderCapabilities = {
provider: LLMProvider;
/** Permission modes the provider runtime understands, in cycle order. */
permissionModes: string[];
defaultPermissionMode: string;
/** Whether image attachments can be included in a chat.send. */
supportsImages: boolean;
/** Whether an in-flight run can be cancelled via chat.abort. */
supportsAbort: boolean;
/** Whether interactive tool permission prompts can reach the UI. */
supportsPermissionRequests: boolean;
/** Whether the token-usage endpoint has data for this provider. */
supportsTokenUsage: boolean;
};
/**
* The capability matrix mirrors what each runtime actually implements today:
* - permission modes match the option sets accepted by each CLI/SDK.
* - only the Claude SDK integration surfaces interactive permission requests.
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
*/
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
claude: {
provider: 'claude',
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: true,
},
cursor: {
provider: 'cursor',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: false,
},
codex: {
provider: 'codex',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
gemini: {
provider: 'gemini',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
opencode: {
provider: 'opencode',
permissionModes: ['default'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
};
/**
* Application service exposing the provider capability matrix.
*/
export const providerCapabilitiesService = {
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
return PROVIDER_CAPABILITIES[provider];
},
listAllProviderCapabilities(): ProviderCapabilities[] {
return Object.values(PROVIDER_CAPABILITIES);
},
};

View File

@@ -17,7 +17,6 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
@@ -233,42 +232,10 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
return request;
};
const loadDirectModels = (
provider: LLMProvider,
): Promise<ProviderModelsResult> => {
const request = resolveProvider(provider).models.getSupportedModels()
.then((models) => {
const currentTime = now();
return {
models,
cache: {
updatedAt: new Date(currentTime).toISOString(),
expiresAt: new Date(currentTime).toISOString(),
source: 'fresh' as const,
},
};
})
.finally(() => {
pendingRequests.delete(provider);
});
pendingRequests.set(provider, request);
return request;
};
const getProviderModels = async (
provider: LLMProvider,
options: ProviderModelsOptions = {},
): Promise<ProviderModelsResult> => {
if (UNCACHED_PROVIDERS.has(provider)) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
return loadDirectModels(provider);
}
if (options.bypassCache) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {

View File

@@ -4,11 +4,10 @@ import { promises as fsPromises } from 'node:fs';
import chokidar, { type FSWatcher } from 'chokidar';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
import type { LLMProvider } from '@/shared/types.js';
import { generateDisplayName } from '@/modules/projects/index.js';
import { getProjectsWithSessions } from '@/modules/projects/index.js';
type WatcherEventType = 'add' | 'change';
@@ -59,11 +58,6 @@ const watchers: FSWatcher[] = [];
type PendingWatcherUpdate = {
providers: Set<LLMProvider>;
changeTypes: Set<WatcherEventType>;
/**
* Provider-native session ids reported by the synchronizers. They are
* translated back to app-facing session rows at flush time, because the
* transcript file names on disk only ever contain provider ids.
*/
updatedSessionIds: Set<string>;
};
@@ -137,50 +131,6 @@ function queuePendingWatcherUpdate(
schedulePendingWatcherFlush();
}
/**
* Builds one `session_upserted` delta event for a provider-native session id.
*
* The event carries everything a sidebar needs to upsert the session in place
* (session summary plus owning-project metadata), so clients never need a full
* project-list refetch when a transcript file changes on disk. Returns `null`
* when the id cannot be resolved to an indexed session row.
*/
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
?? sessionsDb.getSessionById(updatedProviderSessionId);
if (!row || row.isArchived) {
return null;
}
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);
return JSON.stringify({
kind: 'session_upserted',
sessionId: row.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(),
});
}
async function flushPendingWatcherUpdate(): Promise<void> {
clearPendingWatcherFlushTimer();
@@ -199,29 +149,33 @@ async function flushPendingWatcherUpdate(): Promise<void> {
watcherRefreshInFlight = true;
try {
// Per-session deltas instead of full project snapshots: an upsert of one
// session can never clobber unrelated client state, so the frontend needs
// no "suppress updates while a run is active" protection logic.
const events: string[] = [];
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
const event = await buildSessionUpsertedEvent(updatedSessionId);
if (event) {
events.push(event);
}
}
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
const changeTypes = Array.from(queuedUpdate.changeTypes);
const watchProviders = Array.from(queuedUpdate.providers);
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
if (events.length > 0) {
connectedClients.forEach(client => {
if (client.readyState === WS_OPEN_STATE) {
for (const event of events) {
client.send(event);
}
}
});
}
// Backward-compatible fields stay populated with the first queued values.
const updateMessage = JSON.stringify({
type: 'projects_updated',
projects: updatedProjects,
timestamp: new Date().toISOString(),
changeType: changeTypes[0] ?? 'change',
updatedSessionId: updatedSessionIds[0] ?? undefined,
watchProvider: watchProviders[0] ?? undefined,
changeTypes,
updatedSessionIds,
watchProviders,
batched: true,
});
connectedClients.forEach(client => {
if (client.readyState === WS_OPEN_STATE) {
client.send(updateMessage);
}
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
} finally {
watcherRefreshInFlight = false;

View File

@@ -1,9 +1,7 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type {
FetchHistoryOptions,
@@ -13,12 +11,6 @@ import type {
} from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
type CreateAppSessionResult = {
sessionId: string;
provider: LLMProvider;
projectPath: string;
};
type ArchivedSessionListItem = {
sessionId: string;
provider: LLMProvider;
@@ -85,21 +77,6 @@ export const sessionsService = {
return providerRegistry.listProviders().map((provider) => provider.id);
},
/**
* Returns app-facing ids for provider runs that are currently processing.
*
* This is intentionally status-only: callers that only need sidebar activity
* indicators should not attach to chat streams or request replayed messages.
*/
listRunningSessions(): Array<{
sessionId: string;
provider: LLMProvider;
startedAt: number;
lastSeq: number;
}> {
return chatRunRegistry.listRunningRuns();
},
/**
* Normalizes one provider-native event into frontend session message events.
*/
@@ -112,43 +89,12 @@ export const sessionsService = {
},
/**
* Allocates a stable app-facing session id before any provider run happens.
*
* This is the entry point of the session gateway: the frontend calls this
* (via `POST /api/providers/sessions`) when the user starts a brand-new
* chat, navigates to the returned id immediately, and the id never changes
* for the lifetime of the conversation. The provider-native id is mapped to
* this row later, when the provider runtime announces it mid-run.
*/
createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
const normalizedProjectPath = projectPath.trim();
if (!normalizedProjectPath) {
throw new AppError('projectPath is required.', {
code: 'PROJECT_PATH_REQUIRED',
statusCode: 400,
});
}
const sessionId = randomUUID();
sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
return {
sessionId,
provider,
projectPath: normalizedProjectPath,
};
},
/**
* Fetches persisted history by app session id.
* Fetches persisted history by session id.
*
* Provider and provider-specific lookup hints are resolved from the indexed
* session metadata in the database. The provider adapter receives the
* provider-native session id (the one written into transcripts on disk),
* and every returned message is remapped back to the app session id so
* provider ids never reach the frontend.
* session metadata in the database.
*/
async fetchHistory(
fetchHistory(
sessionId: string,
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
): Promise<FetchHistoryResult> {
@@ -160,33 +106,12 @@ export const sessionsService = {
});
}
// App-created sessions that never produced a provider transcript yet
// (e.g. first message still streaming) simply have no history.
if (!session.provider_session_id) {
return {
messages: [],
total: 0,
hasMore: false,
offset: options.offset ?? 0,
limit: options.limit ?? null,
};
}
const provider = session.provider as LLMProvider;
const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
limit: options.limit ?? null,
offset: options.offset ?? 0,
projectPath: session.project_path ?? '',
providerSessionId: session.provider_session_id,
});
return {
...result,
messages: result.messages.map((message) => ({
...message,
sessionId,
})),
};
},
/**

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

@@ -85,12 +85,6 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
path TEXT,
agent TEXT,
model TEXT,
cost REAL NOT NULL DEFAULT 0,
tokens_input INTEGER NOT NULL DEFAULT 0,
tokens_output INTEGER NOT NULL DEFAULT 0,
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
);
@@ -130,10 +124,9 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
);
db.prepare(`
INSERT INTO session (
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'open-session-1',
'project-1',
@@ -144,11 +137,6 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
1_700_000_000_000,
1_700_000_004_000,
null,
10,
20,
7,
3,
2,
);
const userMessageData = JSON.stringify({
@@ -272,55 +260,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({
@@ -363,13 +302,12 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
assert.equal(history.messages[3]?.kind, 'tool_use');
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
assert.deepEqual(history.tokenUsage, {
used: 42,
inputTokens: 13,
used: 35,
total: 35,
inputTokens: 10,
outputTokens: 20,
breakdown: {
input: 13,
output: 20,
},
cacheReadTokens: 3,
cacheCreationTokens: 2,
});
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });

View File

@@ -130,37 +130,6 @@ test('provider models are cached for the three-day ttl', async () => {
}
});
test('claude provider models are always loaded directly from the provider', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
return createModels(`${provider}-${loadCount}`);
},
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const first = await service.getProviderModels('claude');
const second = await service.getProviderModels('claude');
assert.equal(loadCount, 2);
assert.equal(first.models.DEFAULT, 'claude-1');
assert.equal(second.models.DEFAULT, 'claude-2');
assert.equal(second.cache.source, 'fresh');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('provider model cache is persisted across service instances', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
const cachePath = path.join(tempRoot, 'models-cache.json');

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

@@ -33,12 +33,10 @@ Benefits:
|---|---|
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
| `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) |
| `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state |
| `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` |
| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages |
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) for non-chat writer consumers |
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) |
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
## High-Level Architecture
@@ -54,12 +52,12 @@ flowchart LR
D -->|other| H[close()]
E --> I[connectedClients Set]
E --> J[chatRunRegistry + ChatSessionWriter]
E --> J[WebSocketWriter]
F --> K[ptySessionsMap]
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
I --> M[projects.service loading_progress]
I --> N[sessions-watcher.service session_upserted]
I --> M[projects.service broadcastProgress]
I --> N[sessions-watcher.service projects_updated]
```
## Connection Handshake + Routing
@@ -107,41 +105,37 @@ sequenceDiagram
When a chat socket connects:
1. Add socket to `connectedClients`.
2. Parse each incoming message with `parseIncomingJsonObject`.
3. Dispatch by `data.type` (four message types, none provider-specific).
4. On close, remove socket from `connectedClients`.
### Session identity model
The frontend only ever knows the **app session id** (allocated by
`POST /api/providers/sessions` or discovered via the session index). The
provider-native id (JSONL file name, CLI resume id) stays inside the backend:
1. `chat.send` resolves the app id to `{ provider, provider_session_id, project_path }` from the sessions DB.
2. The provider runtime receives the provider-native id for resume.
3. The `ChatSessionWriter` remaps every outbound event back to the app id, and turns `session_created` announcements into a DB mapping update instead of forwarding them.
2. Build `WebSocketWriter` (captures `userId` from authenticated request).
3. Parse each incoming message with `parseIncomingJsonObject`.
4. Dispatch by `data.type`.
5. On close, remove socket from `connectedClients`.
### Chat Message Dispatch
```mermaid
flowchart TD
A[Incoming WS message] --> B[parseIncomingJsonObject]
B -->|invalid| C[send kind:protocol_error]
B -->|invalid| C[send {type:error}]
B -->|ok| D{data.type}
D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider]
D -->|chat.abort| F[abortFns provider + synthetic complete]
D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq]
D -->|chat.permission-response| H[resolveToolApproval]
D -->|other| I[send kind:protocol_error]
D -->|claude-command| E[queryClaudeSDK]
D -->|cursor-command| F[spawnCursor]
D -->|codex-command| G[queryCodex]
D -->|gemini-command| H[spawnGemini]
D -->|cursor-resume| I[spawnCursor resume]
D -->|abort-session| J[abort by provider]
D -->|claude-permission-response| K[resolveToolApproval]
D -->|cursor-abort| L[abortCursorSession]
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
D -->|get-active-sessions| O[getActive*Sessions]
```
### Chat Notes
1. **Unified envelope**: every server-to-client frame carries a `kind` — either a provider `NormalizedMessage` kind or a gateway kind (`chat_subscribed`, `session_upserted`, `loading_progress`, `protocol_error`). There is no second `type`-based protocol.
2. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`): `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. The chat handler emits a synthetic `complete` for runs that crash or get aborted, and the run registry drops duplicate completes.
3. **Per-run event log**: every live event gets a monotonically increasing `seq`. `chat.subscribe { sessions: [{ sessionId, lastSeq }] }` re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events with `seq > lastSeq`. If the buffer no longer covers `lastSeq`, the client refreshes over REST.
4. `chat_subscribed` includes `isProcessing` (replaces `check-session-status`) and `pendingPermissions` (replaces `get-pending-permissions`).
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
## `/shell` Terminal Flow
@@ -229,9 +223,9 @@ Only chat sockets (`/ws`) are tracked in `connectedClients`.
That shared set is consumed by:
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
Broadcasts `kind: loading_progress` while project snapshots are being built.
Broadcasts `loading_progress` while project snapshots are being built.
2. `modules/providers/services/sessions-watcher.service.ts`
Broadcasts per-session `kind: session_upserted` deltas when provider session artifacts change (no full project snapshots).
Broadcasts `projects_updated` when provider session artifacts change.
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
@@ -258,7 +252,7 @@ Current explicit close codes in this module:
Other errors:
1. Chat handler catches and emits `{ kind: "protocol_error", code, error }`.
1. Chat handler catches and emits `{ type: "error", error }`.
2. Shell handler catches and writes terminal-visible error output.
3. Unknown websocket paths are closed immediately.

View File

@@ -1,3 +1,2 @@
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
export { createWebSocketServer } from './services/websocket-server.service.js';
export { chatRunRegistry } from './services/chat-run-registry.service.js';

View File

@@ -1,327 +0,0 @@
import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { generateDisplayName } from '@/modules/projects/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,
RealtimeClientConnection,
} from '@/shared/types.js';
type ChatRunStatus = 'running' | 'completed';
/**
* One live (or recently finished) provider run for a single app session.
*
* State notes — why each mutable field is essential:
* - `providerSessionId`: the provider-native id captured mid-run. The abort
* handler needs it to address the provider runtime, and the DB mapping is
* written from it so history/resume work after the run.
* - `status`: drives `chat_subscribed.isProcessing`, prevents double sends
* into the same session, and guards the synthetic-complete fallback in the
* chat handler (only emitted when a runtime died without completing).
* - `lastSeq` / `events`: the per-run event log. Every live event gets a
* monotonically increasing `seq` and is buffered so a reconnecting client
* can replay exactly the events it missed via `chat.subscribe`.
*/
type ChatRun = {
appSessionId: string;
provider: LLMProvider;
providerSessionId: string | null;
status: ChatRunStatus;
lastSeq: number;
events: NormalizedMessage[];
writer: ChatSessionWriter;
startedAt: number;
completedAt: number | null;
};
/**
* How long a completed run stays available for replay. Covers the window
* between a run finishing and the client refreshing history over REST (for
* example when the browser tab was asleep while the run completed).
*/
const COMPLETED_RUN_RETENTION_MS = 5 * 60 * 1000;
/**
* Upper bound on buffered events per run so a very long tool-heavy run cannot
* grow memory unbounded. When exceeded, the oldest events are dropped —
* a reconnecting client whose `lastSeq` predates the buffer falls back to a
* REST history refresh, which is always the authoritative source.
*/
const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
/**
* Active and recently-completed runs keyed by app session id.
*
* This map is the single in-memory source of truth for "is something running
* for this session" — the chat websocket handler, abort path, and subscribe
* path all consult it instead of asking each provider runtime individually.
*/
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);
if (run && run.status === 'completed') {
runs.delete(appSessionId);
}
}, COMPLETED_RUN_RETENTION_MS);
// Never keep the process alive just to evict a buffered run.
timer.unref?.();
}
/**
* Decorates one outbound live event for a run and records it in the event log.
*
* Responsibilities:
* 1. Remap `sessionId` (and `actualSessionId` on `complete`) to the stable
* app session id — provider-native ids never leave the backend.
* 2. Assign the next `seq` so clients can detect/replay gaps.
* 3. Buffer the event for `chat.subscribe` replay.
* 4. Flip the run to `completed` when the terminal `complete` event passes by.
*/
function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): NormalizedMessage | null {
// Exactly-one-complete contract: when a run is aborted the chat handler
// emits the terminal `complete` immediately, but the killed runtime may
// still emit its own `complete` from its exit handler moments later.
// Whichever arrives first wins; the duplicate is dropped here.
if (message.kind === 'complete' && run.status === 'completed') {
return null;
}
run.lastSeq += 1;
const outbound: NormalizedMessage = {
...message,
sessionId: run.appSessionId,
seq: run.lastSeq,
};
if (message.kind === 'complete') {
// The provider may report its own id here; the frontend only ever knows
// the app id, so the "actual" id is by definition the app id as well.
outbound.actualSessionId = run.appSessionId;
run.status = 'completed';
run.completedAt = Date.now();
evictRunLater(run.appSessionId);
}
run.events.push(outbound);
if (run.events.length > MAX_BUFFERED_EVENTS_PER_RUN) {
run.events.splice(0, run.events.length - MAX_BUFFERED_EVENTS_PER_RUN);
}
return outbound;
}
/**
* Records the provider-native session id for a run and persists the
* app-id-to-provider-id mapping so history fetches and future resumes can
* address the provider transcript.
*
* Called from the gateway writer when the runtime either calls
* `setSessionId(...)` or emits its `session_created` event — whichever
* happens first wins; later calls with the same id are no-ops.
*/
function recordProviderSessionId(run: ChatRun, providerSessionId: string): void {
if (!providerSessionId || run.providerSessionId === providerSessionId) {
return;
}
run.providerSessionId = providerSessionId;
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', {
appSessionId: run.appSessionId,
providerSessionId,
error: message,
});
}
}
/**
* Registry of live provider runs keyed by the stable app session id.
*
* The registry is what makes the websocket protocol provider-independent:
* every run gets a `ChatSessionWriter` that remaps provider-native session
* ids to the app id, assigns `seq` numbers, and buffers events for replay —
* regardless of which provider runtime produced them.
*/
export const chatRunRegistry = {
/**
* Starts tracking a run and returns it, or `null` when a run is already in
* progress for the session (callers must reject the duplicate send).
*/
startRun(input: {
appSessionId: string;
provider: LLMProvider;
providerSessionId: string | null;
connection: RealtimeClientConnection;
userId: string | number | null;
}): ChatRun | null {
const existing = runs.get(input.appSessionId);
if (existing && existing.status === 'running') {
return null;
}
const run: ChatRun = {
appSessionId: input.appSessionId,
provider: input.provider,
providerSessionId: input.providerSessionId,
status: 'running',
lastSeq: 0,
events: [],
writer: null as unknown as ChatSessionWriter,
startedAt: Date.now(),
completedAt: null,
};
run.writer = new ChatSessionWriter({
connection: input.connection,
userId: input.userId,
provider: input.provider,
providerSessionId: input.providerSessionId,
onProviderSessionId: (providerSessionId) => {
recordProviderSessionId(run, providerSessionId);
},
decorateOutboundEvent: (message) => decorateAndRecordEvent(run, message),
});
runs.set(input.appSessionId, run);
return run;
},
getRun(appSessionId: string): ChatRun | undefined {
return runs.get(appSessionId);
},
isProcessing(appSessionId: string): boolean {
return runs.get(appSessionId)?.status === 'running';
},
listRunningRuns(): Array<{
sessionId: string;
provider: LLMProvider;
startedAt: number;
lastSeq: number;
}> {
return Array.from(runs.values())
.filter((run) => run.status === 'running')
.map((run) => ({
sessionId: run.appSessionId,
provider: run.provider,
startedAt: run.startedAt,
lastSeq: run.lastSeq,
}));
},
/**
* Re-attaches a run's outbound stream to a (new) websocket connection.
*
* This is the generic replacement for the Claude-only writer reconnect:
* after a page refresh the new socket subscribes and immediately starts
* receiving the still-running stream, for every provider.
*/
attachConnection(appSessionId: string, connection: RealtimeClientConnection): boolean {
const run = runs.get(appSessionId);
if (!run) {
return false;
}
run.writer.updateWebSocket(connection);
return true;
},
/**
* Returns buffered events with `seq` greater than `afterSeq` for replay.
*
* An empty array with `run.lastSeq > afterSeq` not covered by the buffer
* means the buffer was truncated; the client should refresh over REST.
*/
replayEvents(appSessionId: string, afterSeq: number): NormalizedMessage[] {
const run = runs.get(appSessionId);
if (!run) {
return [];
}
return run.events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
},
/**
* Emits a synthetic terminal `complete` if (and only if) the run is still
* marked running. Used when a provider runtime throws or resolves without
* having produced its own terminal event, and by the abort path.
*/
completeRun(appSessionId: string, opts: { exitCode: number; aborted?: boolean }): void {
const run = runs.get(appSessionId);
if (!run || run.status !== 'running') {
return;
}
run.writer.sendComplete(opts);
},
/**
* Test-only escape hatch: clears every tracked run.
*/
clearAll(): void {
runs.clear();
},
};

View File

@@ -1,145 +0,0 @@
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import type {
LLMProvider,
NormalizedMessage,
RealtimeClientConnection,
} from '@/shared/types.js';
import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js';
type ChatSessionWriterOptions = {
connection: RealtimeClientConnection;
userId: string | number | null;
provider: LLMProvider;
/** Provider-native id when resuming an existing session, otherwise null. */
providerSessionId: string | null;
/**
* Invoked the moment the provider runtime reveals its native session id
* (either via `setSessionId` or a `session_created` event). The registry
* persists the app-id-to-provider-id mapping from this callback.
*/
onProviderSessionId: (providerSessionId: string) => void;
/**
* Remaps/sequences/buffers one outbound live event. Implemented by the chat
* run registry; the writer never forwards a provider event untouched.
* Returns `null` when the event must be dropped (duplicate terminal
* `complete` after an abort already completed the run).
*/
decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null;
};
/**
* Gateway writer handed to provider runtimes instead of a raw websocket writer.
*
* It exposes the exact same surface as `WebSocketWriter` (`send`,
* `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`,
* `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`,
* `cursor-cli.js`, ...) need zero changes — but everything that flows through
* it is translated from the provider's world into the app's protocol:
*
* - `session_created` events are swallowed and turned into a provider-id
* mapping; the frontend never learns provider-native ids.
* - every other event gets `sessionId` remapped to the app session id and a
* per-run `seq` assigned before being forwarded.
* - `setSessionId(...)` calls (used by runtimes to label captured ids) are
* intercepted and recorded as the provider-id mapping as well.
*/
export class ChatSessionWriter {
ws: RealtimeClientConnection;
userId: string | number | null;
/**
* Some runtimes feature-detect their writer with this flag; keep it so the
* gateway writer is a drop-in replacement for `WebSocketWriter`.
*/
isWebSocketWriter = true;
private readonly options: ChatSessionWriterOptions;
/**
* The provider-native session id as the runtime knows it. Kept locally
* (besides the registry) because runtimes read it back via `getSessionId()`
* to label their own outgoing events — those labels are remapped on send
* anyway, but the runtime-visible value must stay provider-native.
*/
private providerSessionId: string | null;
constructor(options: ChatSessionWriterOptions) {
this.options = options;
this.ws = options.connection;
this.userId = options.userId;
this.providerSessionId = options.providerSessionId;
}
send(data: unknown): void {
const record = readObjectRecord(data);
if (!record || typeof record.kind !== 'string') {
// Provider runtimes only emit kind-based normalized messages. Anything
// else indicates a programming error; drop it rather than leaking an
// un-remapped payload to the client.
console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data);
return;
}
const message = record as NormalizedMessage;
if (message.kind === 'session_created') {
const announcedId =
typeof message.newSessionId === 'string' && message.newSessionId
? message.newSessionId
: message.sessionId;
if (announcedId) {
this.captureProviderSessionId(announcedId);
}
// Swallowed on purpose: the frontend already has the stable app session
// id, so there is no client-side handoff to perform anymore.
return;
}
const outbound = this.options.decorateOutboundEvent(message);
if (outbound) {
this.forward(outbound);
}
}
/**
* Emits the synthetic terminal `complete` for runs that ended without one
* (runtime crash before completing, or user abort).
*/
sendComplete(opts: { exitCode: number; aborted?: boolean }): void {
const message = createCompleteMessage({
provider: this.options.provider,
sessionId: this.providerSessionId,
exitCode: opts.exitCode,
aborted: opts.aborted,
});
const outbound = this.options.decorateOutboundEvent(message);
if (outbound) {
this.forward(outbound);
}
}
updateWebSocket(newConnection: RealtimeClientConnection): void {
this.ws = newConnection;
}
setSessionId(sessionId: string): void {
this.captureProviderSessionId(sessionId);
}
getSessionId(): string | null {
return this.providerSessionId;
}
private captureProviderSessionId(providerSessionId: string): void {
if (!providerSessionId || this.providerSessionId === providerSessionId) {
return;
}
this.providerSessionId = providerSessionId;
this.options.onProviderSessionId(providerSessionId);
}
private forward(message: NormalizedMessage): void {
if (this.ws.readyState === WS_OPEN_STATE) {
this.ws.send(JSON.stringify(message));
}
}
}

View File

@@ -1,35 +1,40 @@
import type { WebSocket } from 'ws';
import { sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
import type {
AnyRecord,
AuthenticatedWebSocketRequest,
LLMProvider,
} from '@/shared/types.js';
import { parseIncomingJsonObject } from '@/shared/utils.js';
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
/**
* One provider runtime entry point. All five runtimes share this signature,
* which lets the chat handler dispatch through a provider-keyed map instead
* of provider-specific branches.
*/
type ProviderSpawnFn = (
command: string,
options: AnyRecord,
writer: unknown
) => Promise<unknown>;
type ChatIncomingMessage = AnyRecord & {
type?: string;
command?: string;
options?: AnyRecord;
provider?: string;
sessionId?: string;
requestId?: string;
allow?: unknown;
updatedInput?: unknown;
message?: unknown;
rememberEntry?: unknown;
};
const DEFAULT_PROVIDER: LLMProvider = 'claude';
type ChatWebSocketDependencies = {
/** Provider runtimes keyed by provider id. */
spawnFns: Record<LLMProvider, ProviderSpawnFn>;
/**
* Abort functions keyed by provider id. They are addressed with the
* provider-native session id (that is how runtimes key their process maps).
* The Claude abort is async; the rest are sync — both shapes are accepted.
*/
abortFns: Record<LLMProvider, (providerSessionId: string) => boolean | Promise<boolean>>;
queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
abortCursorSession: (sessionId: string) => boolean;
abortCodexSession: (sessionId: string) => boolean;
abortGeminiSession: (sessionId: string) => boolean;
abortOpenCodeSession: (sessionId: string) => boolean;
resolveToolApproval: (
requestId: string,
payload: {
@@ -39,10 +44,31 @@ type ChatWebSocketDependencies = {
rememberEntry?: unknown;
}
) => void;
/** Claude-only today: pending tool approvals included in `chat_subscribed`. */
getPendingApprovalsForSession: (providerSessionId: string) => unknown[];
isClaudeSDKSessionActive: (sessionId: string) => boolean;
isCursorSessionActive: (sessionId: string) => boolean;
isCodexSessionActive: (sessionId: string) => boolean;
isGeminiSessionActive: (sessionId: string) => boolean;
isOpenCodeSessionActive: (sessionId: string) => boolean;
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
getPendingApprovalsForSession: (sessionId: string) => unknown[];
getActiveClaudeSDKSessions: () => unknown;
getActiveCursorSessions: () => unknown;
getActiveCodexSessions: () => unknown;
getActiveGeminiSessions: () => unknown;
getActiveOpenCodeSessions: () => unknown;
};
/**
* Normalizes potentially invalid provider names coming from websocket payloads.
*/
function readProvider(value: unknown): LLMProvider {
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
return value;
}
return DEFAULT_PROVIDER;
}
/**
* Extracts the authenticated request user id in the formats currently produced
* by platform and OSS auth code paths.
@@ -66,258 +92,8 @@ function readRequestUserId(
return null;
}
function sendJson(ws: WebSocket, payload: unknown): void {
if (ws.readyState === WS_OPEN_STATE) {
ws.send(JSON.stringify(payload));
}
}
/**
* Reports a protocol-level failure to the requesting client.
*
* Protocol errors deliberately use their own `kind` (instead of the provider
* `error` message kind) so the frontend can distinguish "your request was
* invalid" from "the model run produced an error" without inspecting text.
*/
function sendProtocolError(
ws: WebSocket,
code: string,
error: string,
sessionId?: string
): void {
sendJson(ws, {
kind: 'protocol_error',
code,
error,
sessionId: sessionId ?? null,
timestamp: new Date().toISOString(),
});
}
function readRequiredSessionId(data: AnyRecord): string | null {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : '';
return sessionId.length > 0 ? sessionId : null;
}
/**
* Handles `chat.send`: resolves the session row (provider, project path, and
* provider-native id all come from the database — never from the client),
* registers the run, and dispatches to the provider runtime.
*/
async function handleChatSend(
ws: WebSocket,
userId: string | number | null,
data: AnyRecord,
dependencies: ChatWebSocketDependencies
): Promise<void> {
const sessionId = readRequiredSessionId(data);
if (!sessionId) {
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.send requires a sessionId.');
return;
}
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
sendProtocolError(
ws,
'SESSION_NOT_FOUND',
`Session "${sessionId}" was not found. Create it via POST /api/providers/sessions first.`,
sessionId
);
return;
}
const provider = session.provider as LLMProvider;
const spawnFn = dependencies.spawnFns[provider];
if (!spawnFn) {
sendProtocolError(ws, 'UNSUPPORTED_PROVIDER', `Provider "${provider}" is not available.`, sessionId);
return;
}
const run = chatRunRegistry.startRun({
appSessionId: sessionId,
provider,
providerSessionId: session.provider_session_id,
connection: ws,
userId,
});
if (!run) {
sendProtocolError(
ws,
'RUN_IN_PROGRESS',
`Session "${sessionId}" already has a run in progress.`,
sessionId
);
return;
}
const clientOptions = (data.options ?? {}) as AnyRecord;
const command = typeof data.content === 'string' ? data.content : '';
// The provider runtimes receive the provider-native session id (that is the
// id their CLI/SDK understands for resume). Brand-new sessions have no
// provider id yet, so the runtime starts fresh and announces one, which the
// gateway writer captures and maps back to the app session id.
const runtimeOptions: AnyRecord = {
...clientOptions,
sessionId: session.provider_session_id ?? undefined,
resume: Boolean(session.provider_session_id),
cwd: clientOptions.cwd ?? session.project_path ?? undefined,
projectPath: session.project_path ?? clientOptions.projectPath,
};
try {
await spawnFn(command, runtimeOptions, run.writer);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Chat] Provider runtime "${provider}" failed`, { sessionId, error: message });
} finally {
// Safety net: a runtime that crashed (or resolved) without emitting its
// terminal `complete` would otherwise leave the session stuck in
// "processing" forever on every connected client.
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
}
}
/**
* Handles `chat.abort`: cancels the run for one app session and emits the
* terminal `complete` on its behalf (runtimes skip their own complete for
* aborted runs, and the registry drops any duplicate).
*/
async function handleChatAbort(
ws: WebSocket,
data: AnyRecord,
dependencies: ChatWebSocketDependencies
): Promise<void> {
const sessionId = readRequiredSessionId(data);
if (!sessionId) {
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.abort requires a sessionId.');
return;
}
const run = chatRunRegistry.getRun(sessionId);
if (!run || run.status !== 'running') {
sendProtocolError(ws, 'NO_ACTIVE_RUN', `Session "${sessionId}" has no active run.`, sessionId);
return;
}
const abortFn = dependencies.abortFns[run.provider];
let success = false;
if (abortFn && run.providerSessionId) {
success = Boolean(await abortFn(run.providerSessionId));
}
chatRunRegistry.completeRun(sessionId, {
exitCode: success ? 0 : 1,
aborted: true,
});
}
/**
* Handles `chat.subscribe`: for each requested session, reports whether a run
* is processing, re-attaches the live stream to this socket, replays missed
* events (seq > lastSeq), and includes pending permission requests.
*
* This single message replaces the old `check-session-status`,
* `get-pending-permissions`, and Claude-only writer reconnect flows.
*/
function handleChatSubscribe(
ws: WebSocket,
data: AnyRecord,
dependencies: ChatWebSocketDependencies
): void {
const targets = Array.isArray(data.sessions) ? data.sessions : [];
for (const target of targets) {
if (!target || typeof target !== 'object') {
continue;
}
const sessionId = typeof (target as AnyRecord).sessionId === 'string'
? ((target as AnyRecord).sessionId as string).trim()
: '';
if (!sessionId) {
continue;
}
const lastSeqRaw = (target as AnyRecord).lastSeq;
const lastSeq = typeof lastSeqRaw === 'number' && Number.isFinite(lastSeqRaw)
? Math.max(0, Math.floor(lastSeqRaw))
: 0;
const run = chatRunRegistry.getRun(sessionId);
const isProcessing = chatRunRegistry.isProcessing(sessionId);
// Future live events for this run should land on the socket that asked —
// this is what makes mid-stream page refreshes work for all providers.
if (isProcessing) {
chatRunRegistry.attachConnection(sessionId, ws);
}
// Pending approvals are tracked under the provider-native id inside the
// Claude runtime; remap their sessionId so the client only sees app ids.
const pendingPermissions = (run?.providerSessionId
? dependencies.getPendingApprovalsForSession(run.providerSessionId)
: []
).map((approval) =>
approval && typeof approval === 'object'
? { ...(approval as AnyRecord), sessionId }
: approval,
);
sendJson(ws, {
kind: 'chat_subscribed',
sessionId,
isProcessing,
lastSeq: run?.lastSeq ?? 0,
pendingPermissions,
timestamp: new Date().toISOString(),
});
// Replay only for RUNNING runs, strictly after the ack. Completed runs
// are fully persisted to the provider transcript and served over REST —
// replaying them (e.g. after a page reload where the client's lastSeq is
// 0) would duplicate messages the history fetch already returned.
if (isProcessing) {
for (const event of chatRunRegistry.replayEvents(sessionId, lastSeq)) {
sendJson(ws, event);
}
}
}
}
/**
* Handles `chat.permission-response`: forwards a tool-approval decision to the
* pending approval resolver (Claude is the only provider with interactive
* approvals today, but the message is intentionally provider-neutral).
*/
function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDependencies): void {
if (typeof data.requestId !== 'string' || data.requestId.length === 0) {
return;
}
dependencies.resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: typeof data.message === 'string' ? data.message : undefined,
rememberEntry: data.rememberEntry,
});
}
/**
* Handles authenticated chat websocket messages used by the main chat panel.
*
* Inbound protocol (client to server):
* - `chat.send` { sessionId, content, options? }
* - `chat.abort` { sessionId }
* - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] }
* - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? }
*
* Outbound protocol (server to client): every frame is `kind`-based — either
* a provider `NormalizedMessage` (with `seq`) or a gateway event
* (`chat_subscribed`, `session_upserted`, `loading_progress`,
* `protocol_error`).
*/
export function handleChatConnection(
ws: WebSocket,
@@ -327,7 +103,7 @@ export function handleChatConnection(
console.log('[INFO] Chat WebSocket connected');
connectedClients.add(ws);
const userId = readRequestUserId(request);
const writer = new WebSocketWriter(ws, readRequestUserId(request));
ws.on('message', async (rawMessage) => {
try {
@@ -336,30 +112,169 @@ export function handleChatConnection(
throw new Error('Invalid websocket payload');
}
const data = parsed as AnyRecord;
const messageType = typeof data.type === 'string' ? data.type : '';
const data = parsed as ChatIncomingMessage;
const messageType = data.type;
if (!messageType) {
throw new Error('Message type is required');
}
switch (messageType) {
case 'chat.send':
await handleChatSend(ws, userId, data, dependencies);
return;
case 'chat.abort':
await handleChatAbort(ws, data, dependencies);
return;
case 'chat.subscribe':
handleChatSubscribe(ws, data, dependencies);
return;
case 'chat.permission-response':
handlePermissionResponse(data, dependencies);
return;
default:
sendProtocolError(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${messageType}".`);
return;
if (messageType === 'claude-command') {
await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'cursor-command') {
await dependencies.spawnCursor(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'codex-command') {
await dependencies.queryCodex(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'gemini-command') {
await dependencies.spawnGemini(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'opencode-command') {
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'cursor-resume') {
await dependencies.spawnCursor(
'',
{
sessionId: data.sessionId,
resume: true,
cwd: data.options?.cwd,
},
writer
);
return;
}
if (messageType === 'abort-session') {
const provider = readProvider(data.provider);
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
let success = false;
if (provider === 'cursor') {
success = dependencies.abortCursorSession(sessionId);
} else if (provider === 'codex') {
success = dependencies.abortCodexSession(sessionId);
} else if (provider === 'gemini') {
success = dependencies.abortGeminiSession(sessionId);
} else if (provider === 'opencode') {
success = dependencies.abortOpenCodeSession(sessionId);
} else {
success = await dependencies.abortClaudeSDKSession(sessionId);
}
writer.send(
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider,
})
);
return;
}
if (messageType === 'claude-permission-response') {
if (typeof data.requestId === 'string' && data.requestId.length > 0) {
dependencies.resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: typeof data.message === 'string' ? data.message : undefined,
rememberEntry: data.rememberEntry,
});
}
return;
}
if (messageType === 'cursor-abort') {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
const success = dependencies.abortCursorSession(sessionId);
writer.send(
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider: 'cursor',
})
);
return;
}
if (messageType === 'check-session-status') {
const provider = readProvider(data.provider);
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
let isActive = false;
if (provider === 'cursor') {
isActive = dependencies.isCursorSessionActive(sessionId);
} else if (provider === 'codex') {
isActive = dependencies.isCodexSessionActive(sessionId);
} else if (provider === 'gemini') {
isActive = dependencies.isGeminiSessionActive(sessionId);
} else if (provider === 'opencode') {
isActive = dependencies.isOpenCodeSessionActive(sessionId);
} else {
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
if (isActive) {
dependencies.reconnectSessionWriter(sessionId, ws);
}
}
writer.send({
type: 'session-status',
sessionId,
provider,
isProcessing: isActive,
});
return;
}
if (messageType === 'get-pending-permissions') {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) {
const pending = dependencies.getPendingApprovalsForSession(sessionId);
writer.send({
type: 'pending-permissions-response',
sessionId,
data: pending,
});
}
return;
}
if (messageType === 'get-active-sessions') {
writer.send({
type: 'active-sessions',
sessions: {
claude: dependencies.getActiveClaudeSDKSessions(),
cursor: dependencies.getActiveCursorSessions(),
codex: dependencies.getActiveCodexSessions(),
gemini: dependencies.getActiveGeminiSessions(),
opencode: dependencies.getActiveOpenCodeSessions(),
},
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[ERROR] Chat WebSocket error:', message);
sendProtocolError(ws, 'INTERNAL_ERROR', message);
writer.send({
type: 'error',
error: message,
});
}
});

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