feat(frontend): improve runtime connection and team setup ux

This commit is contained in:
777genius 2026-04-11 19:55:25 +03:00
parent 96739c41fc
commit 01e9e8350e
67 changed files with 6395 additions and 975 deletions

View file

@ -5,9 +5,12 @@ on:
branches: [main, dev]
paths:
- 'src/**'
- 'scripts/**'
- 'agent-teams-controller/**'
- 'mcp-server/**'
- 'packages/**'
- 'resources/runtime/**'
- 'runtime.lock.json'
- 'test/**'
- '.github/workflows/**'
- 'pnpm-workspace.yaml'
@ -21,9 +24,12 @@ on:
pull_request:
paths:
- 'src/**'
- 'scripts/**'
- 'agent-teams-controller/**'
- 'mcp-server/**'
- 'packages/**'
- 'resources/runtime/**'
- 'runtime.lock.json'
- 'test/**'
- '.github/workflows/**'
- 'pnpm-workspace.yaml'
@ -41,15 +47,15 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
@ -76,15 +82,15 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies

View file

@ -19,11 +19,11 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22
- name: Install dependencies
working-directory: landing

View file

@ -15,15 +15,15 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
@ -57,7 +57,7 @@ jobs:
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
- name: Upload dist artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: dist
path: |
@ -65,8 +65,94 @@ jobs:
dist-electron
retention-days: 1
prepare-runtime:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$TAG" \
--generate-notes \
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
- name: Check runtime assets
id: runtime-assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
missing=0
while IFS= read -r asset; do
[ -n "$asset" ] || continue
if ! grep -Fqx "$asset" <<<"$existing"; then
echo "Missing runtime asset: $asset"
missing=1
fi
done < <(node ./scripts/runtime-lock.mjs asset-list)
echo "missing=$missing" >> "$GITHUB_OUTPUT"
- name: Dispatch private runtime build
if: steps.runtime-assets.outputs.missing == '1'
env:
GH_TOKEN: ${{ secrets.RUNTIME_BUILD_DISPATCH_TOKEN }}
run: |
set -euo pipefail
TARGET_TAG="${GITHUB_REF#refs/tags/}"
SOURCE_REPO="$(node ./scripts/runtime-lock.mjs source-repository)"
SOURCE_REF="$(node ./scripts/runtime-lock.mjs source-ref)"
RUNTIME_VERSION="$(node ./scripts/runtime-lock.mjs version)"
gh api \
--method POST \
"repos/${SOURCE_REPO}/actions/workflows/release-runtime.yml/dispatches" \
-f ref=main \
-f inputs[source_ref]="$SOURCE_REF" \
-f inputs[runtime_version]="$RUNTIME_VERSION" \
-f inputs[target_release_repo]="$GITHUB_REPOSITORY" \
-f inputs[target_release_tag]="$TARGET_TAG"
- name: Wait for runtime assets
if: steps.runtime-assets.outputs.missing == '1'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
for attempt in $(seq 1 60); do
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
all_found=1
while IFS= read -r asset; do
[ -n "$asset" ] || continue
if ! grep -Fqx "$asset" <<<"$existing"; then
all_found=0
break
fi
done < <(node ./scripts/runtime-lock.mjs asset-list)
if [ "$all_found" -eq 1 ]; then
echo "Runtime assets are ready"
exit 0
fi
echo "Waiting for runtime assets - attempt $attempt/60"
sleep 10
done
echo "Timed out waiting for runtime assets in release $TAG" >&2
exit 1
release-mac:
needs: build
needs: [build, prepare-runtime]
strategy:
fail-fast: false
matrix:
@ -81,10 +167,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: dist
@ -92,9 +178,9 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Setup Python for node-gyp
@ -111,6 +197,33 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION"
- name: Resolve runtime asset name (macOS ${{ matrix.arch }})
if: startsWith(github.ref, 'refs/tags/v')
id: runtime-asset
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.arch }}" == "arm64" ]]; then
platform="darwin-arm64"
else
platform="darwin-x64"
fi
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name "$platform")" >> "$GITHUB_OUTPUT"
- name: Stage bundled runtime (macOS ${{ matrix.arch }})
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
rm -rf .runtime-download resources/runtime
mkdir -p .runtime-download resources/runtime
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download
cp -R .runtime-download/runtime/. resources/runtime/
- name: Build app (macOS ${{ matrix.arch }})
env:
NODE_OPTIONS: '--max-old-space-size=8192'
@ -126,6 +239,9 @@ jobs:
test -f dist-electron/preload/index.js
test -f out/renderer/index.html
test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (macOS ${{ matrix.arch }})
env:
@ -137,6 +253,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: ${{ matrix.dist_command }} --publish never
- name: Validate packaged bundle (macOS ${{ matrix.arch }})
run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Claude Agent Teams UI.app" darwin ${{ matrix.arch }}
- name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v')
env:
@ -152,15 +271,15 @@ jobs:
done
release-win:
needs: build
needs: [build, prepare-runtime]
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: dist
@ -168,9 +287,9 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Setup Python for node-gyp
@ -188,6 +307,29 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION"
- name: Resolve runtime asset name (Windows)
if: startsWith(github.ref, 'refs/tags/v')
id: runtime-asset
shell: bash
run: |
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name win32-x64)" >> "$GITHUB_OUTPUT"
- name: Stage bundled runtime (Windows)
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$tag = $env:GITHUB_REF.Replace('refs/tags/', '')
Remove-Item .runtime-download -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item resources/runtime/* -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path .runtime-download | Out-Null
New-Item -ItemType Directory -Force -Path resources/runtime | Out-Null
gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
Expand-Archive -Path ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -DestinationPath .runtime-download/unpacked -Force
Copy-Item .runtime-download/unpacked/runtime/* resources/runtime -Recurse -Force
- name: Build app (Windows)
env:
NODE_OPTIONS: '--max-old-space-size=8192'
@ -204,12 +346,19 @@ jobs:
test -f dist-electron/preload/index.js
test -f out/renderer/index.html
test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (Windows)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm dist:win --publish never
- name: Validate packaged bundle (Windows)
shell: bash
run: node ./scripts/electron-builder/verifyBundle.cjs "release/win-unpacked" win32 x64
- name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
@ -226,15 +375,15 @@ jobs:
done
release-linux:
needs: build
needs: [build, prepare-runtime]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: dist
@ -242,9 +391,9 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Setup Python for node-gyp
@ -266,6 +415,27 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION"
- name: Resolve runtime asset name (Linux)
if: startsWith(github.ref, 'refs/tags/v')
id: runtime-asset
shell: bash
run: |
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name linux-x64)" >> "$GITHUB_OUTPUT"
- name: Stage bundled runtime (Linux)
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
rm -rf .runtime-download resources/runtime
mkdir -p .runtime-download resources/runtime
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download
cp -R .runtime-download/runtime/. resources/runtime/
- name: Build app (Linux)
env:
NODE_OPTIONS: '--max-old-space-size=8192'
@ -281,12 +451,18 @@ jobs:
test -f dist-electron/preload/index.js
test -f out/renderer/index.html
test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (Linux)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm dist:linux --publish never
- name: Validate packaged bundle (Linux)
run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64
- name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v')
env:
@ -313,9 +489,12 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF#refs/tags/v}"
REPO="${GITHUB_REPOSITORY}"
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
declare -A FILES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
@ -327,18 +506,13 @@ jobs:
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman"
)
# Remove old stable assets (ignore errors if they don't exist)
for STABLE_NAME in "${!FILES[@]}"; do
gh release delete-asset "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --yes 2>/dev/null || true
done
# Download versioned files and re-upload with stable names
for STABLE_NAME in "${!FILES[@]}"; do
VERSIONED_NAME="${FILES[$STABLE_NAME]}"
echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}"
curl -fSL -o "$STABLE_NAME" "${DOWNLOAD_BASE}/${VERSIONED_NAME}" && \
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber
rm -f "$STABLE_NAME"
curl -fSL -o "${TMP_DIR}/${VERSIONED_NAME}" "${DOWNLOAD_BASE}/${VERSIONED_NAME}"
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}"
gh release upload "v${VERSION}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber
done
- name: Publish canonical updater metadata
@ -397,26 +571,25 @@ jobs:
EOF
# Canonical macOS feed.
# electron-updater consumes only one latest-mac.yml asset on GitHub releases.
# We publish the x64 feed here because it works on Intel Macs and remains
# installable on Apple Silicon via Rosetta until we add arch-specific feed
# selection or universal packaging.
download_asset "Claude.Agent.Teams.UI-${VERSION}-mac.zip"
download_asset "Claude.Agent.Teams.UI-${VERSION}.dmg"
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}.dmg)"
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}.dmg)"
# electron-updater on GitHub still consumes a single latest-mac.yml, so we
# publish the Apple Silicon feed here and suppress Intel auto-update in-app
# until we switch to universal packaging or an arch-aware provider.
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip"
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
cat > latest-mac.yml <<EOF
version: ${VERSION}
files:
- url: Claude.Agent.Teams.UI-${VERSION}-mac.zip
- url: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA}
size: ${MAC_ZIP_SIZE}
- url: Claude.Agent.Teams.UI-${VERSION}.dmg
- url: Claude.Agent.Teams.UI-${VERSION}-arm64.dmg
sha512: ${MAC_DMG_SHA}
size: ${MAC_DMG_SIZE}
path: Claude.Agent.Teams.UI-${VERSION}-mac.zip
path: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA}
releaseDate: '${RELEASE_DATE}'
EOF

View file

@ -235,7 +235,12 @@ Without these secrets, macOS builds will be unsigned (users need to bypass Gatek
## Auto-Update
electron-builder generates `latest-mac.yml`, `latest.yml`, `latest-linux.yml` alongside release artifacts. These files enable the built-in auto-updater — users get notified when a new version is available.
The release workflow publishes canonical updater metadata after all platform assets are uploaded:
- `latest.yml` for Windows
- `latest-linux.yml` for Linux
- `latest-mac.yml` for macOS
⚠️ `latest-mac.yml` is currently Apple Silicon first because `electron-updater` on GitHub releases still uses a single macOS metadata file. Intel Mac users keep manual download support, while automatic macOS updates stay aligned with the native arm64 build until we move to universal packaging or an arch-aware provider.
## Quick Reference

View file

@ -57,11 +57,11 @@ Main failure modes to avoid:
Relevant code:
- [src/main/services/team/TeamDataService.ts#L501](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L501)
- [src/main/ipc/teams.ts#L540](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/ipc/teams.ts#L540)
- [src/renderer/store/index.ts#L938](/Users/belief/dev/projects/claude/claude_team_freecode/src/renderer/store/index.ts#L938)
- [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamInboxReader.ts#L182)
- [agent-teams-controller/src/internal/processStore.js#L29](/Users/belief/dev/projects/claude/claude_team_freecode/agent-teams-controller/src/internal/processStore.js#L29)
- [src/main/services/team/TeamDataService.ts#L501](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L501)
- [src/main/ipc/teams.ts#L540](/Users/belief/dev/projects/claude/claude_team/src/main/ipc/teams.ts#L540)
- [src/renderer/store/index.ts#L938](/Users/belief/dev/projects/claude/claude_team/src/renderer/store/index.ts#L938)
- [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts#L182)
- [agent-teams-controller/src/internal/processStore.js#L29](/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/processStore.js#L29)
## Hard Non-Goals
@ -174,8 +174,8 @@ Why this section exists:
Relevant code:
- [src/main/services/team/TeamDataService.ts#L874](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L874)
- [agent-teams-controller/src/internal/processStore.js#L29](/Users/belief/dev/projects/claude/claude_team_freecode/agent-teams-controller/src/internal/processStore.js#L29)
- [src/main/services/team/TeamDataService.ts#L874](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L874)
- [agent-teams-controller/src/internal/processStore.js#L29](/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/processStore.js#L29)
Rule:
@ -187,8 +187,8 @@ Rule:
Relevant code:
- [src/main/services/team/TeamInboxReader.ts#L14](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamInboxReader.ts#L14)
- [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamInboxReader.ts#L182)
- [src/main/services/team/TeamInboxReader.ts#L14](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts#L14)
- [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts#L182)
Implication:
@ -201,8 +201,8 @@ Implication:
Relevant code:
- [src/main/services/team/TeamDataService.ts#L549](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L549)
- [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamInboxReader.ts#L182)
- [src/main/services/team/TeamDataService.ts#L549](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L549)
- [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts#L182)
Implication:
@ -259,7 +259,7 @@ Rule:
Relevant code:
- [src/main/services/team/TeamDataService.ts#L740](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L740)
- [src/main/services/team/TeamDataService.ts#L740](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L740)
Rule:
@ -501,7 +501,7 @@ Why:
## Diagnostics And Marks
Current `mark(...)` timestamps feed the slow log in [TeamDataService.ts#L804](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L804).
Current `mark(...)` timestamps feed the slow log in [TeamDataService.ts#L804](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L804).
Do not lose that.
@ -956,7 +956,7 @@ Why this matters:
## Testing Plan
Add focused tests in [TeamDataService.test.ts](/Users/belief/dev/projects/claude/claude_team_freecode/test/main/services/team/TeamDataService.test.ts).
Add focused tests in [TeamDataService.test.ts](/Users/belief/dev/projects/claude/claude_team/test/main/services/team/TeamDataService.test.ts).
### Preferred test technique

View file

@ -31,11 +31,11 @@ Today, `getTeamData()` repeatedly pays for lead-session history assembly:
Relevant code:
- [src/main/services/team/TeamDataService.ts#L494](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L494)
- [src/main/services/team/TeamDataService.ts#L2150](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L2150)
- [src/main/services/team/TeamDataService.ts#L2324](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L2324)
- [src/main/services/team/leadSessionMessageExtractor.ts#L96](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/leadSessionMessageExtractor.ts#L96)
- [src/main/utils/pathDecoder.ts#L5](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/utils/pathDecoder.ts#L5)
- [src/main/services/team/TeamDataService.ts#L494](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L494)
- [src/main/services/team/TeamDataService.ts#L2150](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L2150)
- [src/main/services/team/TeamDataService.ts#L2324](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L2324)
- [src/main/services/team/leadSessionMessageExtractor.ts#L96](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/leadSessionMessageExtractor.ts#L96)
- [src/main/utils/pathDecoder.ts#L5](/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathDecoder.ts#L5)
This is safer than introducing a new lightweight IPC path because it does not change:
@ -94,8 +94,8 @@ Why:
Relevant code:
- [src/main/services/team/TeamDataService.ts#L2135](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L2135)
- [src/main/utils/pathDecoder.ts#L27](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/utils/pathDecoder.ts#L27)
- [src/main/services/team/TeamDataService.ts#L2135](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L2135)
- [src/main/utils/pathDecoder.ts#L27](/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathDecoder.ts#L27)
Rule:
@ -107,7 +107,7 @@ This cache must not use time-based correctness semantics.
There is a TTL-based advisory cache elsewhere:
- [src/main/services/team/TeamMemberRuntimeAdvisoryService.ts](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts)
- [src/main/services/team/TeamMemberRuntimeAdvisoryService.ts](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts)
That pattern is not appropriate here because lead-session rows are part of user-visible truth.
@ -274,7 +274,7 @@ Do not:
Relevant renderer equality code:
- [src/renderer/utils/messageRenderEquality.ts](/Users/belief/dev/projects/claude/claude_team_freecode/src/renderer/utils/messageRenderEquality.ts)
- [src/renderer/utils/messageRenderEquality.ts](/Users/belief/dev/projects/claude/claude_team/src/renderer/utils/messageRenderEquality.ts)
### 7. Bounded storage
@ -504,7 +504,7 @@ Decision:
## Testing Plan
In [test/main/services/team/TeamDataService.test.ts](/Users/belief/dev/projects/claude/claude_team_freecode/test/main/services/team/TeamDataService.test.ts) or a dedicated cache test:
In [test/main/services/team/TeamDataService.test.ts](/Users/belief/dev/projects/claude/claude_team/test/main/services/team/TeamDataService.test.ts) or a dedicated cache test:
1. repeated extraction of the same unchanged JSONL uses cached results
2. append invalidates cache and returns fresh results
@ -519,7 +519,7 @@ In [test/main/services/team/TeamDataService.test.ts](/Users/belief/dev/projects/
11. if the file changes during parse, result is returned but not stored in fulfilled cache
12. fresh `TeamDataService` instances do not share hidden cache state
In [test/main/services/team/leadSessionMessageExtractor.test.ts](/Users/belief/dev/projects/claude/claude_team_freecode/test/main/services/team/leadSessionMessageExtractor.test.ts):
In [test/main/services/team/leadSessionMessageExtractor.test.ts](/Users/belief/dev/projects/claude/claude_team/test/main/services/team/leadSessionMessageExtractor.test.ts):
13. existing extraction semantics remain unchanged

View file

@ -231,6 +231,10 @@
"from": "resources/pricing.json",
"to": "pricing.json"
},
{
"from": "resources/runtime",
"to": "runtime"
},
{
"from": "mcp-server/dist/index.js",
"to": "mcp-server/index.js"
@ -241,6 +245,7 @@
}
],
"npmRebuild": false,
"afterPack": "scripts/electron-builder/afterPack.cjs",
"extraMetadata": {
"main": "dist-electron/main/index.cjs"
},

View file

@ -1059,12 +1059,14 @@
"cache_read_input_token_cost": 3e-8,
"cache_creation_input_token_cost": 3.75e-7
},
"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": {
"bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
"cache_read_input_token_cost": 3.3e-7,
"input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 4096,
"max_tokens": 4096,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.0000165,
"supports_assistant_prefill": true,
@ -1076,8 +1078,28 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
"cache_read_input_token_cost": 3.3e-7,
"cache_creation_input_token_cost": 0.000004125
"input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.0000165,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": {
"cache_creation_input_token_cost": 0.0000045,
@ -1131,12 +1153,14 @@
"cache_read_input_token_cost": 3e-8,
"cache_creation_input_token_cost": 3.75e-7
},
"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": {
"bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
"cache_read_input_token_cost": 3.3e-7,
"input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 4096,
"max_tokens": 4096,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.0000165,
"supports_assistant_prefill": true,
@ -1148,8 +1172,28 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
"cache_read_input_token_cost": 3.3e-7,
"cache_creation_input_token_cost": 0.000004125
"input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.0000165,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-west-2/anthropic.claude-instant-v1": {
"input_cost_per_token": 8e-7,
@ -2193,7 +2237,8 @@
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gemini-2.5-flash-lite": {
"cache_read_input_token_cost": 1e-8,
@ -2238,7 +2283,8 @@
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gemini-2.5-pro": {
"cache_read_input_token_cost": 1.25e-7,
@ -2283,7 +2329,8 @@
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gmi/anthropic/claude-opus-4.5": {
"input_cost_per_token": 0.000005,
@ -2459,13 +2506,11 @@
"cache_read_input_token_cost_above_272k_tokens": 5e-7,
"cache_read_input_token_cost_flex": 1.3e-7,
"cache_read_input_token_cost_priority": 5e-7,
"cache_read_input_token_cost_above_272k_tokens_priority": 0.000001,
"input_cost_per_token": 0.0000025,
"input_cost_per_token_above_272k_tokens": 0.000005,
"input_cost_per_token_flex": 0.00000125,
"input_cost_per_token_batches": 0.00000125,
"input_cost_per_token_priority": 0.000005,
"input_cost_per_token_above_272k_tokens_priority": 0.00001,
"litellm_provider": "openai",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
@ -2475,8 +2520,7 @@
"output_cost_per_token_above_272k_tokens": 0.0000225,
"output_cost_per_token_flex": 0.0000075,
"output_cost_per_token_batches": 0.0000075,
"output_cost_per_token_priority": 0.0000225,
"output_cost_per_token_above_272k_tokens_priority": 0.00003375,
"output_cost_per_token_priority": 0.00003,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@ -2506,11 +2550,13 @@
},
"gpt-5.4-mini": {
"cache_read_input_token_cost": 7.5e-8,
"cache_read_input_token_cost_flex": 1e-8,
"cache_read_input_token_cost_batches": 3.8e-8,
"cache_read_input_token_cost_flex": 3.75e-8,
"cache_read_input_token_cost_batches": 3.75e-8,
"cache_read_input_token_cost_priority": 1.5e-7,
"input_cost_per_token": 7.5e-7,
"input_cost_per_token_flex": 3.75e-7,
"input_cost_per_token_batches": 3.75e-7,
"input_cost_per_token_priority": 0.0000015,
"litellm_provider": "openai",
"max_input_tokens": 272000,
"max_output_tokens": 128000,
@ -2519,6 +2565,7 @@
"output_cost_per_token": 0.0000045,
"output_cost_per_token_flex": 0.00000225,
"output_cost_per_token_batches": 0.00000225,
"output_cost_per_token_priority": 0.000009,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@ -3292,6 +3339,32 @@
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
},
"us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
"cache_read_input_token_cost": 3.3e-7,
"input_cost_per_token": 0.0000033,
"input_cost_per_token_above_200k_tokens": 0.0000066,
"output_cost_per_token_above_200k_tokens": 0.00002475,
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.0000165,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
},
"au.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.000001375,
"cache_read_input_token_cost": 1.1e-7,
@ -3767,6 +3840,27 @@
"supports_pdf_input": true,
"supports_tool_choice": true
},
"vertex_ai/claude-haiku-4-5": {
"cache_creation_input_token_cost": 0.00000125,
"cache_read_input_token_cost": 1e-7,
"input_cost_per_token": 0.000001,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.000005,
"source": "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/haiku-4-5",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_native_streaming": true,
"supports_vision": true
},
"vertex_ai/claude-haiku-4-5@20251001": {
"cache_creation_input_token_cost": 0.00000125,
"cache_read_input_token_cost": 1e-7,
@ -4273,6 +4367,52 @@
"search_context_size_medium": 0.01
}
},
"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.0000015,
"cache_read_input_token_cost": 1.2e-7,
"input_cost_per_token": 0.0000012,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.000006,
"source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock",
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_pdf_input": true
},
"bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.0000015,
"cache_read_input_token_cost": 1.2e-7,
"input_cost_per_token": 0.0000012,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 0.000006,
"source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock",
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_pdf_input": true
},
"gpt-5.3-codex-spark": {
"cache_read_input_token_cost": 1.75e-7,
"cache_read_input_token_cost_priority": 3.5e-7,

View file

@ -0,0 +1 @@

28
runtime.lock.json Normal file
View file

@ -0,0 +1,28 @@
{
"version": "0.0.1",
"sourceRef": "v0.0.1",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.1.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.1.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.1.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.1.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}
}
}

View file

@ -75,10 +75,11 @@ runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot })
const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev');
const uiEnv = {
...process.env,
// Dev-only free-code runtime override. Keep it separate from the generic
// CLAUDE_CLI_PATH override so switching the app into Claude CLI mode still
// resolves the real official binary instead of this local cli-dev shim.
CLAUDE_FREE_CODE_CLI_PATH: runtimeCliPath,
// Dev-only agent_teams_orchestrator runtime override. Keep it separate from
// the generic CLAUDE_CLI_PATH override so switching the app into Claude CLI
// mode still resolves the real official binary instead of this local
// cli-dev shim.
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: runtimeCliPath,
};
// If the parent shell exported a stale generic override, do not let it leak
// into the Electron main process. Claude mode must resolve the real binary.

View file

@ -0,0 +1,392 @@
const fs = require('node:fs');
const path = require('node:path');
const ARCH_LABELS = {
0: 'ia32',
1: 'x64',
2: 'armv7l',
3: 'arm64',
4: 'universal',
};
const TARGET_BINARY_FORMAT = {
darwin: 'mach-o',
linux: 'elf',
win32: 'pe',
};
function getArchLabel(arch) {
return ARCH_LABELS[arch] ?? String(arch);
}
async function walkFiles(rootDir) {
const files = [];
const queue = [rootDir];
while (queue.length > 0) {
const currentDir = queue.pop();
if (!currentDir) {
continue;
}
let entries;
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const absolutePath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
queue.push(absolutePath);
continue;
}
if (entry.isFile()) {
files.push(absolutePath);
}
}
}
return files;
}
async function findNodePtyRoots(appOutDir) {
const roots = [];
const queue = [appOutDir];
while (queue.length > 0) {
const currentDir = queue.pop();
if (!currentDir) {
continue;
}
const baseName = path.basename(currentDir);
if (baseName === 'node-pty') {
roots.push(currentDir);
continue;
}
let entries;
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
queue.push(path.join(currentDir, entry.name));
}
}
return roots;
}
function shouldKeepNodePtyPrebuild(entryName, platform, archLabel) {
if (!entryName.startsWith(`${platform}-`)) {
return false;
}
if (platform === 'darwin' && archLabel === 'universal') {
return (
entryName === 'darwin-universal' ||
entryName === 'darwin-arm64' ||
entryName === 'darwin-x64'
);
}
return (
entryName === `${platform}-${archLabel}` ||
(platform === 'darwin' && entryName === 'darwin-universal')
);
}
function shouldKeepNodePtyBin(entryName, platform, archLabel) {
if (!entryName.startsWith(`${platform}-`)) {
return false;
}
if (platform === 'darwin' && archLabel === 'universal') {
return (
entryName.startsWith('darwin-universal-') ||
entryName.startsWith('darwin-arm64-') ||
entryName.startsWith('darwin-x64-')
);
}
return (
entryName.startsWith(`${platform}-${archLabel}-`) ||
(platform === 'darwin' && entryName.startsWith('darwin-universal-'))
);
}
async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) {
const removedPaths = [];
const nodePtyRoots = await findNodePtyRoots(appOutDir);
for (const nodePtyRoot of nodePtyRoots) {
for (const [subdirName, shouldKeep] of [
['prebuilds', shouldKeepNodePtyPrebuild],
['bin', shouldKeepNodePtyBin],
]) {
const subdir = path.join(nodePtyRoot, subdirName);
let entries;
try {
entries = await fs.promises.readdir(subdir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (shouldKeep(entry.name, platform, archLabel)) {
continue;
}
const absolutePath = path.join(subdir, entry.name);
await fs.promises.rm(absolutePath, { recursive: true, force: true });
removedPaths.push(absolutePath);
}
}
}
return removedPaths;
}
function mapMachOCpuType(cpuType) {
switch (cpuType >>> 0) {
case 0x00000007:
return 'ia32';
case 0x01000007:
return 'x64';
case 0x0000000c:
return 'armv7l';
case 0x0100000c:
return 'arm64';
default:
return null;
}
}
function parseMachO(buffer) {
if (buffer.length < 8) {
return null;
}
const magicHex = buffer.subarray(0, 4).toString('hex');
const archs = new Set();
if (magicHex === 'cafebabe' || magicHex === 'cafebabf') {
if (buffer.length < 8) {
return null;
}
const archCount = buffer.readUInt32BE(4);
const stride = magicHex === 'cafebabf' ? 32 : 20;
let offset = 8;
for (let index = 0; index < archCount; index += 1) {
if (buffer.length < offset + stride) {
break;
}
const arch = mapMachOCpuType(buffer.readUInt32BE(offset));
if (arch) {
archs.add(arch);
}
offset += stride;
}
return archs.size > 0 ? { format: 'mach-o', archs } : null;
}
if (magicHex === 'bebafeca' || magicHex === 'bfbafeca') {
if (buffer.length < 8) {
return null;
}
const archCount = buffer.readUInt32LE(4);
const stride = magicHex === 'bfbafeca' ? 32 : 20;
let offset = 8;
for (let index = 0; index < archCount; index += 1) {
if (buffer.length < offset + stride) {
break;
}
const arch = mapMachOCpuType(buffer.readUInt32LE(offset));
if (arch) {
archs.add(arch);
}
offset += stride;
}
return archs.size > 0 ? { format: 'mach-o', archs } : null;
}
if (magicHex === 'feedfacf' || magicHex === 'feedface') {
const arch = mapMachOCpuType(buffer.readUInt32BE(4));
return arch ? { format: 'mach-o', archs: new Set([arch]) } : null;
}
if (magicHex === 'cffaedfe' || magicHex === 'cefaedfe') {
const arch = mapMachOCpuType(buffer.readUInt32LE(4));
return arch ? { format: 'mach-o', archs: new Set([arch]) } : null;
}
return null;
}
function parseElf(buffer) {
if (buffer.length < 20) {
return null;
}
if (
buffer[0] !== 0x7f ||
buffer[1] !== 0x45 ||
buffer[2] !== 0x4c ||
buffer[3] !== 0x46
) {
return null;
}
const littleEndian = buffer[5] !== 2;
const machine = littleEndian ? buffer.readUInt16LE(18) : buffer.readUInt16BE(18);
const arch =
machine === 0x03
? 'ia32'
: machine === 0x3e
? 'x64'
: machine === 0x28
? 'armv7l'
: machine === 0xb7
? 'arm64'
: null;
return arch ? { format: 'elf', archs: new Set([arch]) } : null;
}
function parsePortableExecutable(buffer) {
if (buffer.length < 0x40) {
return null;
}
if (buffer[0] !== 0x4d || buffer[1] !== 0x5a) {
return null;
}
const peOffset = buffer.readUInt32LE(0x3c);
if (buffer.length < peOffset + 6) {
return null;
}
if (
buffer[peOffset] !== 0x50 ||
buffer[peOffset + 1] !== 0x45 ||
buffer[peOffset + 2] !== 0x00 ||
buffer[peOffset + 3] !== 0x00
) {
return null;
}
const machine = buffer.readUInt16LE(peOffset + 4);
const arch =
machine === 0x014c
? 'ia32'
: machine === 0x8664
? 'x64'
: machine === 0xaa64
? 'arm64'
: machine === 0x01c4
? 'armv7l'
: null;
return arch ? { format: 'pe', archs: new Set([arch]) } : null;
}
async function detectBinaryMetadata(filePath) {
const handle = await fs.promises.open(filePath, 'r');
try {
const buffer = Buffer.alloc(4096);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const slice = buffer.subarray(0, bytesRead);
return parseMachO(slice) ?? parseElf(slice) ?? parsePortableExecutable(slice);
} finally {
await handle.close();
}
}
function isBinaryCompatible(format, archs, targetPlatform, targetArch) {
if (format !== TARGET_BINARY_FORMAT[targetPlatform]) {
return false;
}
if (targetPlatform === 'darwin' && targetArch === 'universal') {
return archs.has('arm64') || archs.has('x64');
}
return archs.has(targetArch);
}
async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
const mismatches = [];
const files = await walkFiles(appOutDir);
for (const filePath of files) {
const metadata = await detectBinaryMetadata(filePath);
if (!metadata) {
continue;
}
if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) {
continue;
}
mismatches.push({
path: path.relative(appOutDir, filePath),
format: metadata.format,
archs: [...metadata.archs].sort(),
});
}
return mismatches;
}
async function afterPack(context) {
const targetPlatform = context.electronPlatformName;
const targetArch = getArchLabel(context.arch);
const removedPaths = await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch);
const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch);
if (mismatches.length > 0) {
const details = mismatches
.slice(0, 20)
.map((mismatch) => `- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`)
.join('\n');
throw new Error(
`Found incompatible native binaries in ${targetPlatform}-${targetArch} bundle after pruning.\n${details}`
);
}
if (removedPaths.length > 0) {
console.log(
`[afterPack] pruned ${removedPaths.length} incompatible native artifact(s) for ${targetPlatform}-${targetArch}`
);
}
}
module.exports = afterPack;
module.exports._internal = {
detectBinaryMetadata,
getArchLabel,
isBinaryCompatible,
parseElf,
parseMachO,
parsePortableExecutable,
pruneNodePtyArtifacts,
validateNativeBinaries,
walkFiles,
};

View file

@ -0,0 +1,35 @@
const path = require('node:path');
const afterPackModule = require('./afterPack.cjs');
const { validateNativeBinaries } = afterPackModule._internal;
async function main() {
const [bundlePathArg, platform, arch] = process.argv.slice(2);
if (!bundlePathArg || !platform || !arch) {
console.error('Usage: node ./scripts/electron-builder/verifyBundle.cjs <bundlePath> <platform> <arch>');
process.exit(1);
}
const bundlePath = path.resolve(bundlePathArg);
const mismatches = await validateNativeBinaries(bundlePath, platform, arch);
if (mismatches.length === 0) {
console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}`);
return;
}
console.error(
`[verifyBundle] Found ${mismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}`
);
for (const mismatch of mismatches.slice(0, 50)) {
console.error(`- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`);
}
process.exit(1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

61
scripts/runtime-lock.mjs Normal file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const runtimeLockPath = path.join(repoRoot, 'runtime.lock.json');
function readRuntimeLock() {
return JSON.parse(fs.readFileSync(runtimeLockPath, 'utf8'));
}
function fail(message) {
console.error(message);
process.exit(1);
}
const [command, arg] = process.argv.slice(2);
const runtimeLock = readRuntimeLock();
switch (command) {
case 'version':
process.stdout.write(`${runtimeLock.version}\n`);
break;
case 'source-ref':
process.stdout.write(`${runtimeLock.sourceRef}\n`);
break;
case 'source-repository':
process.stdout.write(`${runtimeLock.sourceRepository}\n`);
break;
case 'release-repository':
process.stdout.write(`${runtimeLock.releaseRepository}\n`);
break;
case 'asset-name': {
const asset = runtimeLock.assets[arg];
if (!asset) {
fail(`Unknown runtime asset platform: ${arg ?? '<missing>'}`);
}
process.stdout.write(`${asset.file}\n`);
break;
}
case 'binary-name': {
const asset = runtimeLock.assets[arg];
if (!asset) {
fail(`Unknown runtime asset platform: ${arg ?? '<missing>'}`);
}
process.stdout.write(`${asset.binaryName}\n`);
break;
}
case 'asset-list':
for (const asset of Object.values(runtimeLock.assets)) {
process.stdout.write(`${asset.file}\n`);
}
break;
default:
fail(
'Usage: node scripts/runtime-lock.mjs <version|source-ref|source-repository|release-repository|asset-name <platform>|binary-name <platform>|asset-list>'
);
}

View file

@ -12,6 +12,7 @@ import type {
HttpServerConfig,
NotificationConfig,
NotificationTrigger,
ProviderConnectionsConfig,
RuntimeConfig,
SshPersistConfig,
} from '../services';
@ -32,6 +33,7 @@ interface ValidationFailure {
export type ConfigUpdateValidationResult =
| ValidationSuccess<'notifications'>
| ValidationSuccess<'general'>
| ValidationSuccess<'providerConnections'>
| ValidationSuccess<'runtime'>
| ValidationSuccess<'display'>
| ValidationSuccess<'httpServer'>
@ -41,6 +43,7 @@ export type ConfigUpdateValidationResult =
const VALID_SECTIONS = new Set<ConfigSection>([
'notifications',
'general',
'providerConnections',
'runtime',
'display',
'httpServer',
@ -455,6 +458,94 @@ function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | V
};
}
function validateProviderConnectionsSection(
data: unknown
): ValidationSuccess<'providerConnections'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'providerConnections update must be an object' };
}
const result: Partial<ProviderConnectionsConfig> = {};
for (const [key, value] of Object.entries(data)) {
if (key !== 'anthropic' && key !== 'codex') {
return { valid: false, error: `providerConnections.${key} is not a valid setting` };
}
if (!isPlainObject(value)) {
return { valid: false, error: `providerConnections.${key} must be an object` };
}
if (key === 'anthropic') {
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
for (const [connectionKey, connectionValue] of Object.entries(value)) {
if (connectionKey !== 'authMode') {
return {
valid: false,
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
};
}
if (
connectionValue !== 'auto' &&
connectionValue !== 'oauth' &&
connectionValue !== 'api_key'
) {
return {
valid: false,
error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key',
};
}
anthropicUpdate.authMode = connectionValue;
}
result.anthropic = anthropicUpdate as ProviderConnectionsConfig['anthropic'];
continue;
}
const codexUpdate: Partial<ProviderConnectionsConfig['codex']> = {};
for (const [connectionKey, connectionValue] of Object.entries(value)) {
if (connectionKey === 'apiKeyBetaEnabled') {
if (typeof connectionValue !== 'boolean') {
return {
valid: false,
error: 'providerConnections.codex.apiKeyBetaEnabled must be a boolean',
};
}
codexUpdate.apiKeyBetaEnabled = connectionValue;
continue;
}
if (connectionKey === 'authMode') {
if (connectionValue !== 'oauth' && connectionValue !== 'api_key') {
return {
valid: false,
error: 'providerConnections.codex.authMode must be one of: oauth, api_key',
};
}
codexUpdate.authMode = connectionValue;
continue;
}
return {
valid: false,
error: `providerConnections.codex.${connectionKey} is not a valid setting`,
};
}
result.codex = codexUpdate as ProviderConnectionsConfig['codex'];
}
return {
valid: true,
section: 'providerConnections',
data: result,
};
}
function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'display update must be an object' };
@ -601,7 +692,8 @@ export function validateConfigUpdatePayload(
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
return {
valid: false,
error: 'Section must be one of: notifications, general, runtime, display, httpServer, ssh',
error:
'Section must be one of: notifications, general, providerConnections, runtime, display, httpServer, ssh',
};
}
@ -610,6 +702,8 @@ export function validateConfigUpdatePayload(
return validateNotificationsSection(data);
case 'general':
return validateGeneralSection(data);
case 'providerConnections':
return validateProviderConnectionsSection(data);
case 'runtime':
return validateRuntimeSection(data);
case 'display':

View file

@ -147,6 +147,24 @@ export class ApiKeyService {
}));
}
async lookupPreferred(envVarName: string): Promise<ApiKeyLookupResult | null> {
const keys = await this.readStore();
const matching = keys.filter((key) => key.envVarName === envVarName);
const preferred =
matching.find((key) => key.scope === 'user') ??
matching.find((key) => key.scope === 'project') ??
null;
if (!preferred) {
return null;
}
return {
envVarName: preferred.envVarName,
value: this.decrypt(preferred),
};
}
async getStorageStatus(): Promise<ApiKeyStorageStatus> {
const secure = this.isSecureBackend();
const backend = this.getBackendName();
@ -171,15 +189,12 @@ export class ApiKeyService {
return;
}
const lookups = await this.lookup([...envVarNames]);
const valueByEnv = new Map(lookups.map((entry) => [entry.envVarName, entry.value]));
for (const envVarName of envVarNames) {
if (!this.originalProcessEnv.has(envVarName)) {
this.originalProcessEnv.set(envVarName, process.env[envVarName]);
}
const nextValue = valueByEnv.get(envVarName);
const nextValue = (await this.lookupPreferred(envVarName))?.value;
if (nextValue && nextValue.trim().length > 0) {
process.env[envVarName] = nextValue;
continue;

View file

@ -17,6 +17,8 @@ const logger = createLogger('Extensions:SkillParser');
const ALLOWED_FRONTMATTER_KEYS = new Set([
'name',
'description',
// Third-party skills often include a semantic version in frontmatter.
'version',
'license',
'compatibility',
'metadata',

View file

@ -39,7 +39,7 @@ import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor';
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor';
import type {
CliInstallationStatus,
@ -134,6 +134,7 @@ const DIAG_AUTH_STDOUT_TAIL = 160;
function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallationStatus {
return {
...status,
launchError: status.launchError ?? null,
providers: status.providers.map((provider) => ({
...provider,
capabilities: { ...provider.capabilities },
@ -371,6 +372,7 @@ export class CliInstallerService {
installed: r.installed,
binaryPath: r.binaryPath ? clipHeadForDiag(r.binaryPath, DIAG_PATH_HEAD) : null,
installedVersion: r.installedVersion,
launchError: r.launchError ?? null,
authLoggedIn: r.authLoggedIn,
authMethod: r.authMethod,
latestVersion: r.latestVersion,
@ -400,7 +402,7 @@ export class CliInstallerService {
const flavor = getConfiguredCliFlavor();
const ui = getCliFlavorUiOptions(flavor);
const providers =
flavor === 'free-code'
flavor === 'agent_teams_orchestrator'
? (
[
{
@ -441,6 +443,7 @@ export class CliInstallerService {
installed: false,
installedVersion: null,
binaryPath: null,
launchError: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
@ -499,11 +502,16 @@ export class CliInstallerService {
}
const flavor = getConfiguredCliFlavor();
if (flavor !== 'free-code') {
if (flavor !== 'agent_teams_orchestrator') {
const fullStatus = await this.getStatus();
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
}
const versionProbe = await this.probeCliVersion(binaryPath);
if (!versionProbe.ok) {
return null;
}
return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
}
@ -524,35 +532,44 @@ export class CliInstallerService {
const r = ref.current;
const binaryPath = await ClaudeBinaryResolver.resolve();
if (binaryPath) {
r.installed = true;
r.binaryPath = binaryPath;
r.authStatusChecking = true;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
const versionProbe = await this.probeCliVersion(binaryPath);
if (versionProbe.ok) {
r.installed = true;
r.installedVersion = versionProbe.version;
r.launchError = null;
r.authStatusChecking = true;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
env: this.envForCli(binaryPath),
});
r.installedVersion = normalizeVersion(stdout);
logger.info(
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"`
// Auth and GCS version check are independent — run in parallel.
// Both mutate `r` directly so partial results survive the outer timeout.
await Promise.all([
this.checkAuthStatus(binaryPath, r, diag),
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
]);
} else {
diag.versionError = versionProbe.error;
r.installed = false;
r.installedVersion = null;
r.launchError = versionProbe.error;
r.authStatusChecking = false;
this.markProvidersUnavailable(
r,
r.binaryPath ? 'Runtime found, but startup health check failed.' : 'Runtime unavailable.'
);
} catch (err) {
diag.versionError = getErrorMessage(err);
logger.warn('Failed to get CLI version:', diag.versionError);
if (diag.versionError) {
logger.warn('Failed to get CLI version:', diag.versionError);
}
if (r.supportsSelfUpdate) {
await this.fetchLatestVersion(r);
}
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
}
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
// Auth and GCS version check are independent — run in parallel.
// Both mutate `r` directly so partial results survive the outer timeout.
await Promise.all([
this.checkAuthStatus(binaryPath, r, diag),
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
]);
} else {
// No binary — still check latest version for "install" prompt
r.authStatusChecking = false;
r.launchError = null;
this.markProvidersUnavailable(r, 'Runtime not found.');
if (r.supportsSelfUpdate) {
await this.fetchLatestVersion(r);
}
@ -560,6 +577,44 @@ export class CliInstallerService {
}
}
private async probeCliVersion(
binaryPath: string
): Promise<{ ok: true; version: string } | { ok: false; error: string }> {
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
env: this.envForCli(binaryPath),
});
const version = normalizeVersion(stdout);
if (!version) {
return { ok: false, error: 'CLI returned an empty version string.' };
}
logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`);
return { ok: true, version };
} catch (err) {
return { ok: false, error: getErrorMessage(err) };
}
}
private markProvidersUnavailable(result: CliInstallationStatus, message: string): void {
if (result.flavor !== 'agent_teams_orchestrator') {
return;
}
result.providers = result.providers.map((provider) => ({
...provider,
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: message,
models: [],
canLoginFromUi: false,
backend: null,
}));
result.authLoggedIn = false;
result.authMethod = null;
}
/**
* Check auth status with retry covers stale lock files after Ctrl+C interruption.
* Wrapped in its own timeout to prevent slow auth from blocking the overall status.
@ -571,7 +626,7 @@ export class CliInstallerService {
result: CliInstallationStatus,
diag: CliInstallerStatusRunDiag
): Promise<void> {
if (result.flavor === 'free-code') {
if (result.flavor === 'agent_teams_orchestrator') {
result.authStatusChecking = true;
try {
const providers = await this.multimodelBridgeService.getProviderStatuses(
@ -690,7 +745,7 @@ export class CliInstallerService {
async install(): Promise<void> {
if (!getCliFlavorUiOptions(getConfiguredCliFlavor()).supportsSelfUpdate) {
const error = 'Updates are disabled for the configured free-code runtime.';
const error = 'Updates are disabled for the configured agent_teams_orchestrator runtime.';
logger.warn(error);
this.sendProgress({ type: 'error', error });
return;

View file

@ -224,6 +224,19 @@ export interface RuntimeConfig {
};
}
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
export type CodexProviderConnectionAuthMode = Exclude<ProviderConnectionAuthMode, 'auto'>;
export interface ProviderConnectionsConfig {
anthropic: {
authMode: ProviderConnectionAuthMode;
};
codex: {
apiKeyBetaEnabled: boolean;
authMode: CodexProviderConnectionAuthMode;
};
}
export interface DisplayConfig {
showTimestamps: boolean;
compactMode: boolean;
@ -256,6 +269,7 @@ export interface HttpServerConfig {
export interface AppConfig {
notifications: NotificationConfig;
general: GeneralConfig;
providerConnections: ProviderConnectionsConfig;
runtime: RuntimeConfig;
display: DisplayConfig;
sessions: SessionsConfig;
@ -309,6 +323,15 @@ const DEFAULT_CONFIG: AppConfig = {
customProjectPaths: [],
telemetryEnabled: true,
},
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: false,
authMode: 'oauth',
},
},
runtime: {
providerBackends: {
gemini: 'auto',
@ -484,6 +507,16 @@ export class ConfigManager {
triggers: mergedTriggers,
},
general: mergedGeneral,
providerConnections: {
anthropic: {
...DEFAULT_CONFIG.providerConnections.anthropic,
...(loaded.providerConnections?.anthropic ?? {}),
},
codex: {
...DEFAULT_CONFIG.providerConnections.codex,
...(loaded.providerConnections?.codex ?? {}),
},
},
runtime: {
providerBackends: {
...DEFAULT_CONFIG.runtime.providerBackends,
@ -562,7 +595,7 @@ export class ConfigManager {
section: K,
data: Partial<AppConfig[K]>
): Partial<AppConfig[K]> {
if (section !== 'general' && section !== 'runtime') {
if (section !== 'general' && section !== 'runtime' && section !== 'providerConnections') {
return data;
}
@ -577,6 +610,21 @@ export class ConfigManager {
} as unknown as Partial<AppConfig[K]>;
}
if (section === 'providerConnections') {
const connectionUpdate = data as Partial<ProviderConnectionsConfig>;
return {
...connectionUpdate,
anthropic: {
...this.config.providerConnections.anthropic,
...(connectionUpdate.anthropic ?? {}),
},
codex: {
...this.config.providerConnections.codex,
...(connectionUpdate.codex ?? {}),
},
} as unknown as Partial<AppConfig[K]>;
}
if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {
return data;
}

View file

@ -22,33 +22,14 @@ import { app, net } from 'electron';
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
import {
getExpectedReleaseAssetUrl,
getLatestMacMetadataUrl,
isLatestMacMetadataCompatible,
} from './updaterReleaseMetadata';
const logger = createLogger('UpdaterService');
const REPO_OWNER = '777genius';
const REPO_NAME = 'claude_agent_teams_ui';
/**
* Build the expected download URL for the platform-specific installer asset.
* Returns null if the current platform is unrecognized.
*/
function getExpectedAssetUrl(version: string): string | null {
const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`;
switch (process.platform) {
case 'darwin':
return process.arch === 'arm64'
? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg`
: `${base}/Claude.Agent.Teams.UI-${version}.dmg`;
case 'win32':
return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`;
case 'linux':
return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`;
default:
return null;
}
}
/**
* Check if a remote URL exists using a HEAD request.
* Follows redirects (GitHub releases use 302 S3).
@ -62,6 +43,18 @@ async function assetExists(url: string): Promise<boolean> {
}
}
async function fetchText(url: string): Promise<string | null> {
try {
const response = await net.fetch(url, { method: 'GET' });
if (!response.ok) {
return null;
}
return await response.text();
} catch {
return null;
}
}
export class UpdaterService {
private mainWindow: BrowserWindow | null = null;
private periodicTimer: ReturnType<typeof setInterval> | null = null;
@ -154,6 +147,24 @@ export class UpdaterService {
return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion));
}
private async hasCompatibleMacFeed(version: string): Promise<boolean> {
if (process.platform !== 'darwin') {
return true;
}
if (process.arch !== 'arm64' && process.arch !== 'x64') {
return false;
}
const metadataUrl = getLatestMacMetadataUrl(version);
const metadataText = await fetchText(metadataUrl);
if (!metadataText) {
logger.warn(`latest-mac.yml is not available for ${version} (${metadataUrl})`);
return false;
}
return isLatestMacMetadataCompatible(metadataText, version, process.arch);
}
/**
* Verify that the platform-specific asset exists before notifying the renderer.
* If CI hasn't finished uploading the artifact for this OS yet, suppress the
@ -170,7 +181,7 @@ export class UpdaterService {
return;
}
const url = getExpectedAssetUrl(info.version);
const url = getExpectedReleaseAssetUrl(info.version, process.platform, process.arch);
if (url) {
const exists = await assetExists(url);
if (!exists) {
@ -181,6 +192,13 @@ export class UpdaterService {
}
}
if (!(await this.hasCompatibleMacFeed(info.version))) {
logger.warn(
`latest-mac.yml does not match ${process.platform}/${process.arch}, suppressing update notification`
);
return;
}
this.sendStatus({
type: 'available',
version: info.version,

View file

@ -0,0 +1,79 @@
const REPO_OWNER = '777genius';
const REPO_NAME = 'claude_agent_teams_ui';
export function buildReleaseAssetBase(version: string): string {
return `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`;
}
export function getExpectedReleaseAssetUrl(
version: string,
platform: NodeJS.Platform,
arch: NodeJS.Architecture
): string | null {
const base = buildReleaseAssetBase(version);
switch (platform) {
case 'darwin':
return arch === 'arm64'
? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg`
: `${base}/Claude.Agent.Teams.UI-${version}.dmg`;
case 'win32':
return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`;
case 'linux':
return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`;
default:
return null;
}
}
export function getLatestMacMetadataUrl(version: string): string {
return `${buildReleaseAssetBase(version)}/latest-mac.yml`;
}
export function getExpectedLatestMacArtifacts(
version: string,
arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'>
): readonly string[] {
return arch === 'arm64'
? [
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`,
`Claude.Agent.Teams.UI-${version}-arm64.dmg`,
]
: [`Claude.Agent.Teams.UI-${version}-mac.zip`, `Claude.Agent.Teams.UI-${version}.dmg`];
}
function stripYamlScalar(rawValue: string): string {
const trimmed = rawValue.trim();
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
export function parseReleaseMetadataAssetNames(metadataText: string): Set<string> {
const assets = new Set<string>();
for (const rawLine of metadataText.split(/\r?\n/u)) {
const line = rawLine.trim();
const match = line.match(/^(?:-\s+)?(url|path):\s+(.+)$/u);
if (!match) {
continue;
}
assets.add(stripYamlScalar(match[2]));
}
return assets;
}
export function isLatestMacMetadataCompatible(
metadataText: string,
version: string,
arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'>
): boolean {
const assets = parseReleaseMetadataAssetNames(metadataText);
return getExpectedLatestMacArtifacts(version, arch).every((asset) => assets.has(asset));
}

View file

@ -7,11 +7,14 @@ import {
} from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
import { configManager } from '../infrastructure/ConfigManager';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { providerConnectionService } from './ProviderConnectionService';
import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
@ -48,7 +51,7 @@ interface ProviderModelsCommandResponse {
providers?: Record<
string,
{
models?: Array<string | { id?: string; label?: string; description?: string }>;
models?: (string | { id?: string; label?: string; description?: string })[];
}
>;
}
@ -67,7 +70,7 @@ interface UnifiedRuntimeStatusResponse {
detailMessage?: string | null;
selectedBackendId?: string | null;
resolvedBackendId?: string | null;
availableBackends?: Array<{
availableBackends?: {
id?: string;
label?: string;
description?: string;
@ -76,15 +79,15 @@ interface UnifiedRuntimeStatusResponse {
available?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}>;
externalRuntimeDiagnostics?: Array<{
}[];
externalRuntimeDiagnostics?: {
id?: string;
label?: string;
detected?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}>;
models?: Array<string | { id?: string; label?: string; description?: string }>;
}[];
models?: (string | { id?: string; label?: string; description?: string })[];
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
@ -137,11 +140,12 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
};
}
function extractModelIds(
models: Array<string | { id?: string; label?: string; description?: string }> | undefined
models: (string | { id?: string; label?: string; description?: string })[] | undefined
): string[] {
if (!models) {
return [];
@ -159,7 +163,7 @@ function extractModelIds(
}
export class ClaudeMultimodelBridgeService {
private buildCliEnv(binaryPath: string): NodeJS.ProcessEnv {
private async buildCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
const shellEnv = getCachedShellEnv() ?? {};
const home =
getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE;
@ -170,11 +174,15 @@ export class ClaudeMultimodelBridgeService {
if (home) {
env.HOME = home;
}
return applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
return providerConnectionService.applyAllConfiguredConnectionEnv(env);
}
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
return applyProviderRuntimeEnv({ ...this.buildCliEnv(binaryPath) }, providerId);
private async buildProviderCliEnv(
binaryPath: string,
providerId: CliProviderId
): Promise<NodeJS.ProcessEnv> {
return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId);
}
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
@ -249,7 +257,7 @@ export class ClaudeMultimodelBridgeService {
providerId: CliProviderId
): Promise<CliProviderStatus> {
await resolveInteractiveShellEnv();
const env = this.buildCliEnv(binaryPath);
const env = await this.buildCliEnv(binaryPath);
try {
const { stdout } = await execCli(
@ -261,7 +269,9 @@ export class ClaudeMultimodelBridgeService {
}
);
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
return this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]);
return providerConnectionService.enrichProviderStatus(
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
);
} catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) {
logger.warn(
@ -281,7 +291,7 @@ export class ClaudeMultimodelBridgeService {
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
const provider = createDefaultProviderStatus('gemini');
const env = this.buildProviderCliEnv(binaryPath, 'gemini');
const env = await this.buildProviderCliEnv(binaryPath, 'gemini');
try {
const { stdout } = await execCli(
@ -340,7 +350,7 @@ export class ClaudeMultimodelBridgeService {
onUpdate?: (providers: CliProviderStatus[]) => void
): Promise<CliProviderStatus[]> {
await resolveInteractiveShellEnv();
const env = this.buildCliEnv(binaryPath);
const env = await this.buildCliEnv(binaryPath);
try {
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
@ -348,8 +358,10 @@ export class ClaudeMultimodelBridgeService {
env,
});
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
const providers = ORDERED_PROVIDER_IDS.map((providerId) =>
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
const providers = await providerConnectionService.enrichProviderStatuses(
ORDERED_PROVIDER_IDS.map((providerId) =>
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
)
);
onUpdate?.(providers);
return providers;
@ -457,6 +469,11 @@ export class ClaudeMultimodelBridgeService {
providers.set('gemini', await this.buildGeminiStatus(binaryPath));
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
return ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!);
const enrichedProviders = await providerConnectionService.enrichProviderStatuses(
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!)
);
onUpdate?.(enrichedProviders);
return enrichedProviders;
}
}

View file

@ -0,0 +1,250 @@
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
import { ConfigManager } from '../infrastructure/ConfigManager';
import type {
CliProviderAuthMode,
CliProviderConnectionInfo,
CliProviderId,
CliProviderStatus,
} from '@shared/types';
type ExternalCredential = {
label: string;
value: string;
} | null;
const PROVIDER_CAPABILITIES: Record<
CliProviderId,
Pick<CliProviderConnectionInfo, 'supportsOAuth' | 'supportsApiKey' | 'configurableAuthModes'>
> = {
anthropic: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['auto', 'oauth', 'api_key'],
},
codex: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: [],
},
gemini: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: [],
},
};
const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
anthropic: 'ANTHROPIC_API_KEY',
codex: 'OPENAI_API_KEY',
gemini: 'GEMINI_API_KEY',
};
const CODEX_API_KEY_BETA_ENV_VAR = 'CLAUDE_CODE_CODEX_API_KEY_BETA';
export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null;
constructor(
private readonly apiKeyService = new ApiKeyService(),
private readonly configManager = ConfigManager.getInstance()
) {}
static getInstance(): ProviderConnectionService {
ProviderConnectionService.instance ??= new ProviderConnectionService();
return ProviderConnectionService.instance;
}
getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null {
if (providerId === 'anthropic') {
return this.configManager.getConfig().providerConnections.anthropic.authMode;
}
if (providerId === 'codex') {
const codexConnection = this.configManager.getConfig().providerConnections.codex;
return codexConnection.apiKeyBetaEnabled ? codexConnection.authMode : null;
}
return null;
}
async applyConfiguredConnectionEnv(
env: NodeJS.ProcessEnv,
providerId: CliProviderId
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
const authMode = this.getConfiguredAuthMode(providerId);
if (authMode === 'oauth') {
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_AUTH_TOKEN;
return env;
}
if (authMode !== 'api_key') {
return env;
}
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
if (storedKey?.value.trim()) {
env.ANTHROPIC_API_KEY = storedKey.value;
delete env.ANTHROPIC_AUTH_TOKEN;
return env;
}
delete env.ANTHROPIC_AUTH_TOKEN;
if (typeof env.ANTHROPIC_API_KEY !== 'string' || !env.ANTHROPIC_API_KEY.trim()) {
delete env.ANTHROPIC_API_KEY;
}
return env;
}
if (providerId !== 'codex') {
return env;
}
const codexConnection = this.configManager.getConfig().providerConnections.codex;
if (!codexConnection.apiKeyBetaEnabled) {
delete env[CODEX_API_KEY_BETA_ENV_VAR];
delete env.OPENAI_API_KEY;
return env;
}
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
if (codexConnection.authMode === 'oauth') {
env.CLAUDE_CODE_CODEX_BACKEND = 'adapter';
delete env.OPENAI_API_KEY;
return env;
}
env.CLAUDE_CODE_CODEX_BACKEND = 'api';
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
if (storedKey?.value.trim()) {
env.OPENAI_API_KEY = storedKey.value;
return env;
}
if (typeof env.OPENAI_API_KEY !== 'string' || !env.OPENAI_API_KEY.trim()) {
delete env.OPENAI_API_KEY;
}
return env;
}
async applyAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
let nextEnv = env;
for (const providerId of ['anthropic', 'codex', 'gemini'] as const) {
nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId);
}
return nextEnv;
}
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
return {
...provider,
connection: await this.getConnectionInfo(provider.providerId),
};
}
async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {
return Promise.all(providers.map((provider) => this.enrichProviderStatus(provider)));
}
async getConnectionInfo(providerId: CliProviderId): Promise<CliProviderConnectionInfo> {
const capabilities = PROVIDER_CAPABILITIES[providerId];
const storedApiKey = await this.getStoredApiKey(providerId);
const externalCredential = this.getExternalCredential(providerId);
const codexBetaEnabled =
providerId === 'codex'
? this.configManager.getConfig().providerConnections.codex.apiKeyBetaEnabled
: undefined;
const configurableAuthModes =
providerId === 'codex' && codexBetaEnabled
? (['oauth', 'api_key'] as CliProviderAuthMode[])
: capabilities.configurableAuthModes;
const configuredAuthMode =
providerId === 'codex' && !codexBetaEnabled ? null : this.getConfiguredAuthMode(providerId);
return {
...capabilities,
configurableAuthModes,
configuredAuthMode,
apiKeyBetaAvailable: providerId === 'codex' ? true : undefined,
apiKeyBetaEnabled: codexBetaEnabled,
apiKeyConfigured: Boolean(storedApiKey?.value.trim() || externalCredential?.value.trim()),
apiKeySource: storedApiKey?.value.trim()
? 'stored'
: externalCredential?.value.trim()
? 'environment'
: null,
apiKeySourceLabel: storedApiKey?.value.trim()
? 'Stored in app'
: (externalCredential?.label ?? null),
};
}
private async getStoredApiKey(
providerId: CliProviderId
): Promise<{ envVarName: string; value: string } | null> {
const envVarName = PROVIDER_API_KEY_ENV_VARS[providerId];
if (!envVarName) {
return null;
}
return this.apiKeyService.lookupPreferred(envVarName);
}
private getExternalCredential(providerId: CliProviderId): ExternalCredential {
const shellEnv = getCachedShellEnv() ?? {};
const sources = [shellEnv, process.env];
const findEnvValue = (envVarName: string): string | null => {
for (const source of sources) {
const value = source[envVarName];
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return null;
};
if (providerId === 'anthropic') {
const apiKey = findEnvValue('ANTHROPIC_API_KEY');
if (apiKey) {
return {
label: 'Detected from ANTHROPIC_API_KEY',
value: apiKey,
};
}
}
if (providerId === 'gemini') {
const apiKey = findEnvValue('GEMINI_API_KEY');
if (apiKey) {
return {
label: 'Detected from GEMINI_API_KEY',
value: apiKey,
};
}
}
if (providerId === 'codex') {
const apiKey = findEnvValue('OPENAI_API_KEY');
if (apiKey) {
return {
label: 'Detected from OPENAI_API_KEY',
value: apiKey,
};
}
}
return null;
}
}
export const providerConnectionService = ProviderConnectionService.getInstance();

View file

@ -13,6 +13,7 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import { providerConnectionService } from '../runtime/ProviderConnectionService';
import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
@ -105,13 +106,18 @@ export class ScheduledTaskExecutor {
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
const env = applyProviderRuntimeEnv(
applyConfiguredRuntimeBackendsEnv({
...buildEnrichedEnv(binaryPath),
...shellEnv,
CLAUDECODE: undefined,
}),
request.config.providerId
const env = await providerConnectionService.applyConfiguredConnectionEnv(
applyProviderRuntimeEnv(
applyConfiguredRuntimeBackendsEnv({
...buildEnrichedEnv(binaryPath),
...shellEnv,
CLAUDECODE: undefined,
}),
request.config.providerId
),
request.config.providerId === 'codex' || request.config.providerId === 'gemini'
? request.config.providerId
: 'anthropic'
);
const child = spawnCli(binaryPath, args, {

View file

@ -1,8 +1,10 @@
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import * as fs from 'fs';
import * as path from 'path';
import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
import { getConfiguredCliFlavor } from './cliFlavor';
async function isExecutable(filePath: string): Promise<boolean> {
@ -176,6 +178,31 @@ async function resolveFromCandidateList(candidates: string[]): Promise<string |
return null;
}
async function resolveFromDoctorFallback(commandName: string): Promise<string | null> {
const candidates = await getDoctorInvokedCandidates(commandName);
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const candidate = candidates[index];
if (!candidate) {
continue;
}
const resolved = await resolveFromExplicitPath(candidate);
if (resolved) {
return resolved;
}
}
return null;
}
async function resolveBundledOrchestratorBinary(): Promise<string | null> {
const resourcesPath = process.resourcesPath?.trim();
if (!resourcesPath) {
return null;
}
const binaryName = process.platform === 'win32' ? 'claude-multimodel.exe' : 'claude-multimodel';
return resolveFromCandidateList([path.join(resourcesPath, 'runtime', binaryName)]);
}
let cachedPath: string | null | undefined;
/** Timestamp of last successful cache verification (ms). */
@ -227,8 +254,9 @@ export class ClaudeBinaryResolver {
const flavor = getConfiguredCliFlavor();
const overrideRaw =
flavor === 'free-code'
? (process.env.CLAUDE_FREE_CODE_CLI_PATH?.trim() ?? process.env.CLAUDE_CLI_PATH?.trim())
flavor === 'agent_teams_orchestrator'
? (process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ??
process.env.CLAUDE_CLI_PATH?.trim())
: process.env.CLAUDE_CLI_PATH?.trim();
if (overrideRaw) {
const looksLikePath =
@ -244,20 +272,36 @@ export class ClaudeBinaryResolver {
}
}
if (flavor === 'free-code') {
// Keep free-code resolution generic. Dev flows should inject an explicit
// CLAUDE_CLI_PATH, while non-dev setups can expose claude-multimodel on
// PATH without making this resolver guess a sibling repo name or folder.
const freeCodeBinaryName = 'claude-multimodel';
const fromPath = await resolveFromPathEnv(freeCodeBinaryName, enrichedPath);
if (flavor === 'agent_teams_orchestrator') {
const bundledBinary = await resolveBundledOrchestratorBinary();
if (bundledBinary) {
cachedPath = bundledBinary;
cacheVerifiedAt = Date.now();
return cachedPath;
}
// Keep agent_teams_orchestrator resolution generic. Dev flows should
// inject an explicit CLI path, while non-dev setups can expose
// claude-multimodel on PATH without making this resolver guess a sibling
// repo name or folder.
const orchestratorBinaryName = 'claude-multimodel';
const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath);
if (fromPath) {
cachedPath = fromPath;
cacheVerifiedAt = Date.now();
return cachedPath;
}
// Free-code mode is explicit. If the configured local runtime is missing,
// fail closed instead of silently falling back to a different CLI.
const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
if (fromDoctor) {
cachedPath = fromDoctor;
cacheVerifiedAt = Date.now();
return cachedPath;
}
// agent_teams_orchestrator mode is explicit. If the configured local
// runtime is missing, fail closed instead of silently falling back to a
// different CLI.
return null;
}
@ -273,9 +317,12 @@ export class ClaudeBinaryResolver {
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
const home = getShellPreferredHome();
const vendorBinDir = path.join(getClaudeBasePath(), 'local', 'node_modules', '.bin');
const candidateDirs: string[] =
process.platform === 'win32'
? [
// Windows: Claude npm-local vendor install
vendorBinDir,
// Windows: npm global install
path.join(home, 'AppData', 'Roaming', 'npm'),
// Windows: scoop, chocolatey, and other package managers
@ -288,6 +335,8 @@ export class ClaudeBinaryResolver {
...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []),
]
: [
// Unix: Claude npm-local vendor install
vendorBinDir,
// Unix: native binary installation path (claude install)
path.join(home, '.local', 'bin'),
path.join(home, '.npm-global', 'bin'),
@ -318,6 +367,13 @@ export class ClaudeBinaryResolver {
return cachedPath;
}
const fromDoctor = await resolveFromDoctorFallback(baseBinaryName);
if (fromDoctor) {
cachedPath = fromDoctor;
cacheVerifiedAt = Date.now();
return cachedPath;
}
// Don't cache null — CLI may be installed later without app restart
return null;
}

View file

@ -0,0 +1,183 @@
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import type { IPty } from 'node-pty';
import type * as NodePty from 'node-pty';
const logger = createLogger('ClaudeDoctorProbe');
const DOCTOR_TIMEOUT_MS = 12_000;
const DOCTOR_COLS = 240;
const DOCTOR_ROWS = 40;
const DOCTOR_MAX_OUTPUT_CHARS = 128_000;
const DOCTOR_CONTINUE_PROMPT_RE = /Press (?:Enter|any key) to continue/i;
const DOCTOR_FIELD_RE = /^\s*[│├└L|]?\s*[A-Za-z][A-Za-z0-9 /()_-]*:\s*/;
const DOCTOR_SECTION_RE =
/^\s*(?:Diagnostics|Updates|Version Locks|Plugin Errors|Context Usage Warnings)\s*$/i;
const DOCTOR_SEPARATOR_RE = /^\s*[\u2500\u2501-]{6,}\s*$/;
type NodePtyModule = typeof NodePty;
let nodePty: NodePtyModule | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon
nodePty = require('node-pty') as NodePtyModule;
} catch {
logger.warn('node-pty not available - doctor fallback disabled');
}
function stripAnsiSequences(value: string): string {
return value
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, '')
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
.replace(/\u009B[0-?]*[ -/]*[@-~]/g, '');
}
function normalizeDoctorOutput(output: string): string {
return stripAnsiSequences(output)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
}
function isDoctorStopLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) {
return true;
}
return (
DOCTOR_CONTINUE_PROMPT_RE.test(trimmed) ||
DOCTOR_SECTION_RE.test(trimmed) ||
DOCTOR_SEPARATOR_RE.test(trimmed) ||
DOCTOR_FIELD_RE.test(line)
);
}
export function extractDoctorInvokedCandidates(output: string): string[] {
const normalized = normalizeDoctorOutput(output);
const lines = normalized.split('\n');
const candidates: string[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index] ?? '';
const markerIndex = line.indexOf('Invoked:');
if (markerIndex < 0) {
index += 1;
continue;
}
const parts = [line.slice(markerIndex + 'Invoked:'.length).trimStart()];
let cursor = index + 1;
while (cursor < lines.length) {
const nextLine = lines[cursor] ?? '';
if (isDoctorStopLine(nextLine)) {
break;
}
parts.push(nextLine.trimStart());
cursor += 1;
}
const candidate = parts.join('').trim();
if (candidate.length > 0) {
candidates.push(candidate);
}
index = Math.max(index + 1, cursor);
}
return candidates;
}
async function captureDoctorOutput(commandName: string): Promise<string | null> {
if (!nodePty) {
return null;
}
const env = {
...buildEnrichedEnv(),
COLUMNS: String(DOCTOR_COLS),
LINES: String(DOCTOR_ROWS),
};
const cwd = getShellPreferredHome();
return new Promise((resolve) => {
let transcript = '';
let settled = false;
let continueSent = false;
let pty: IPty | null = null;
let timeoutHandle: NodeJS.Timeout | null = null;
const finalize = (output: string | null): void => {
if (settled) {
return;
}
settled = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
try {
pty?.kill();
} catch {
/* already closed */
}
resolve(output);
};
try {
if (process.platform === 'win32') {
const shell = process.env.COMSPEC ?? 'cmd.exe';
pty = nodePty.spawn(shell, ['/d', '/c', commandName, 'doctor'], {
name: 'xterm-256color',
cols: DOCTOR_COLS,
rows: DOCTOR_ROWS,
cwd,
env: env as Record<string, string>,
});
} else {
pty = nodePty.spawn(commandName, ['doctor'], {
name: 'xterm-256color',
cols: DOCTOR_COLS,
rows: DOCTOR_ROWS,
cwd,
env: env as Record<string, string>,
});
}
} catch (error) {
logger.warn(`Failed to start doctor fallback for ${commandName}: ${String(error)}`);
finalize(null);
return;
}
timeoutHandle = setTimeout(() => {
logger.warn(`Doctor fallback timed out after ${DOCTOR_TIMEOUT_MS}ms for ${commandName}`);
finalize(transcript);
}, DOCTOR_TIMEOUT_MS);
pty.onData((chunk) => {
transcript = (transcript + chunk).slice(-DOCTOR_MAX_OUTPUT_CHARS);
if (!continueSent && DOCTOR_CONTINUE_PROMPT_RE.test(normalizeDoctorOutput(transcript))) {
continueSent = true;
try {
pty?.write('\r');
} catch {
/* PTY already exited */
}
}
});
pty.onExit(() => finalize(transcript));
});
}
export async function getDoctorInvokedCandidates(commandName: string): Promise<string[]> {
await resolveInteractiveShellEnv();
const output = await captureDoctorOutput(commandName);
if (!output) {
return [];
}
return extractDoctorInvokedCandidates(output);
}

View file

@ -3,7 +3,6 @@ import { NotificationManager } from '@main/services/infrastructure/NotificationM
import { getAppIconPath } from '@main/utils/appIcon';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { killProcessByPid } from '@main/utils/processKill';
import {
encodePath,
extractBaseDir,
@ -14,6 +13,7 @@ import {
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import {
@ -50,31 +50,39 @@ import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/ut
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import {
extractToolPreview,
parseAgentToolResultStatus,
extractToolResultPreview,
formatToolSummaryFromCalls,
parseAgentToolResultStatus,
} from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { execFileSync, type ChildProcess, type spawn } from 'child_process';
import { type ChildProcess, execFileSync, type spawn } from 'child_process';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
type GeminiRuntimeAuthState,
resolveGeminiRuntimeAuth,
} from '../runtime/geminiRuntimeAuth';
import { providerConnectionService } from '../runtime/ProviderConnectionService';
import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
resolveTeamProviderId,
} from '../runtime/providerRuntimeEnv';
import { buildActionModeProtocol } from './actionModeInstructions';
import { atomicWriteAsync } from './atomicWrite';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { withFileLock } from './fileLock';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import {
type ClassifiedMainProcessIdle,
classifyIdleNotificationForMainProcess,
} from './idleNotificationMainProcessSemantics';
import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode';
import {
choosePreferredLaunchSnapshot,
clearBootstrapState,
@ -82,25 +90,19 @@ import {
readBootstrapRealTaskSubmissionState,
readBootstrapRuntimeState,
} from './TeamBootstrapStateReader';
import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import {
createPersistedLaunchSnapshot,
snapshotFromRuntimeMemberStatuses,
snapshotToMemberSpawnStatuses,
} from './TeamLaunchStateEvaluator';
import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
resolveTeamProviderId,
} from '../runtime/providerRuntimeEnv';
import {
resolveGeminiRuntimeAuth,
type GeminiRuntimeAuthState,
} from '../runtime/geminiRuntimeAuth';
import {
classifyIdleNotificationForMainProcess,
type ClassifiedMainProcessIdle,
} from './idleNotificationMainProcessSemantics';
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
/**
* Kill a team CLI process using SIGKILL (uncatchable).
@ -136,20 +138,21 @@ interface PersistedRuntimeMemberLike {
type RelayInboxMessage = InboxMessage & { messageId: string };
type RelayInboxMessageView = {
interface RelayInboxMessageView {
message: RelayInboxMessage;
idle: ClassifiedMainProcessIdle | null;
isCoarseNoise: boolean;
};
}
import type {
ActiveToolCall,
CrossTeamSendResult,
EffortLevel,
InboxMessage,
LeadContextUsage,
MemberLaunchState,
MemberSpawnStatus,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry,
PersistedTeamLaunchPhase,
PersistedTeamLaunchSummary,
@ -159,19 +162,18 @@ import type {
TeamLaunchAggregateState,
TeamLaunchRequest,
TeamLaunchResponse,
TeamProviderId,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
TeamRuntimeState,
TeamTask,
EffortLevel,
ToolActivityEventPayload,
ToolApprovalAutoResolved,
ToolApprovalEvent,
ToolApprovalRequest,
ToolApprovalSettings,
ToolCallMeta,
TeamProviderId,
} from '@shared/types';
const logger = createLogger('Service:TeamProvisioning');
@ -321,11 +323,11 @@ function getTeamProviderLabel(providerId: TeamProviderId): string {
}
}
type CanonicalSendMessageExample = {
interface CanonicalSendMessageExample {
to: string;
summary: string;
message: string;
};
}
// TODO(refactor): If more prompt-bound tool contracts appear here, move these
// canonical examples/rules into a small dedicated module (for example
@ -1082,7 +1084,8 @@ If member_briefing fails, SendMessage "${leadName}" one short natural-language s
${getCanonicalSendMessageFieldRule()}
Correct example:
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })}
After member_briefing succeeds, stay silent until you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads.`;
After member_briefing succeeds, stay silent until you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads.
- Review flow rule: review happens on the SAME work task. If task #X needs review and a reviewer exists or has been named, the owner completes #X and sends #X through review_request, and the reviewer handles review_start then review_approve/review_request_changes on #X. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".`;
}
function buildGeminiReconnectMemberSpawnPrompt(
@ -1110,7 +1113,17 @@ If member_briefing fails, SendMessage "${leadName}" one short natural-language s
${getCanonicalSendMessageFieldRule()}
Correct example:
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })}
After member_briefing succeeds, stay silent unless you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads.`;
After member_briefing succeeds, stay silent unless you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads.
- Review flow rule: review happens on the SAME work task. If task #X needs review and a reviewer exists or has been named, the owner completes #X and sends #X through review_request, and the reviewer handles review_start then review_approve/review_request_changes on #X. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".`;
}
function buildMemberReviewFlowReminder(): string {
return [
'- Review flow rule: review is a state transition on the SAME work task, not a separate task.',
'- If your task #X needs review and a reviewer exists or has been named, finish the work on #X, call task_complete on #X, then use review_request on #X for that reviewer. If no reviewer exists, leave #X completed. Do NOT create a separate "review task".',
'- If you are the reviewer for task #X, call review_start on #X first, then review_approve or review_request_changes on #X itself.',
'- If review requests changes, resume/fix the SAME task #X, then task_complete #X and send #X back through review_request when ready.',
].join('\n');
}
function buildMemberSpawnPrompt(
@ -1158,6 +1171,8 @@ After member_briefing succeeds:
- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.
- CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response you will need it in the next step. The task comment is the primary delivery channel the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.
- After task_complete, notify your team lead via SendMessage. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: "#abcd1234 done. Found 3 competitors, two lack kanban. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678."
- Review discipline:
${indentMultiline(buildMemberReviewFlowReminder(), ' ')}
- Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.
- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent never output meta-commentary about skipped or already-delivered messages.
${buildTeammateAgentBlockReminder()}
@ -1230,6 +1245,8 @@ ${actionModeProtocol}
- If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle.
- CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment BEFORE calling task_complete. The task comment is the primary delivery channel the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.
- After task_complete, notify your team lead via SendMessage. The task_add_comment response contains comment.id (UUID) take its first 8 characters as the short commentId. Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: "#abcd1234 done. Found 3 competitors, two lack kanban. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678."
- Review discipline:
${indentMultiline(buildMemberReviewFlowReminder(), ' ')}
- Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.
- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent never output meta-commentary about skipped or already-delivered messages.
- If you have no tasks, wait for new assignments.`;
@ -1280,7 +1297,7 @@ export function buildAddMemberSpawnMessage(
);
}
type RuntimeBootstrapMemberSpec = {
interface RuntimeBootstrapMemberSpec {
name: string;
prompt?: string;
cwd?: string;
@ -1291,15 +1308,15 @@ type RuntimeBootstrapMemberSpec = {
description?: string;
useSplitPane?: boolean;
planModeRequired?: boolean;
};
}
type RuntimeBootstrapSpec = {
interface RuntimeBootstrapSpec {
version: 1;
runId: string;
mode: 'create' | 'launch';
initiator: {
kind: 'app';
source: 'claude_team_freecode';
source: 'claude_team_agent_teams_orchestrator';
};
team: {
name: string;
@ -1320,7 +1337,7 @@ type RuntimeBootstrapSpec = {
ui?: {
emitStructuredEvents?: boolean;
};
};
}
function buildDeterministicCreateBootstrapSpec(
runId: string,
@ -1333,7 +1350,7 @@ function buildDeterministicCreateBootstrapSpec(
mode: 'create',
initiator: {
kind: 'app',
source: 'claude_team_freecode',
source: 'claude_team_agent_teams_orchestrator',
},
team: {
name: request.teamName,
@ -1385,7 +1402,7 @@ function buildDeterministicLaunchBootstrapSpec(
mode: 'launch',
initiator: {
kind: 'app',
source: 'claude_team_freecode',
source: 'claude_team_agent_teams_orchestrator',
},
team: {
name: request.teamName,
@ -1509,6 +1526,8 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`- Approve review: review_approve { teamName: "${teamName}", taskId: "<id>", note?: "<note>", notifyOwner: true }`,
` Call review_approve EXACTLY ONCE per review. Include your review feedback in the "note" field of that single call. Do NOT call it twice (once to approve, once with a note). The tool auto-creates a comment from the note.`,
`- Request changes: review_request_changes { teamName: "${teamName}", taskId: "<id>", comment: "<what to fix>" }`,
`CRITICAL: Review is a state transition on the EXISTING work task. When implementation for task #X needs review, move #X through the review flow with review_request/review_start/review_approve/review_request_changes. Do NOT create a new separate task just to represent that review.`,
`CRITICAL: Only send task #X into review when a concrete reviewer exists for #X. If no reviewer exists yet, keep #X completed until you assign/decide the reviewer. Do NOT use review_request just to park the task in REVIEW without an actual reviewer.`,
`CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`,
``,
`Background service operations — use MCP tools directly (dev servers, watchers, databases, etc.; NOT teammate-agent liveness):`,
@ -1522,8 +1541,10 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`- Use blockedBy when a task cannot start until another is done.`,
`- If you set blockedBy, create the task in pending (for example with startImmediately: false). Do NOT put blocked tasks into in_progress.`,
`- Use related to link related work (e.g. frontend + backend) without blocking.`,
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via review_start/review_approve/review_request_changes on #X.`,
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with related (and optionally blockedBy #X if it truly cannot start yet).`,
`- Review tasks: By default, NEVER create a separate "review task". Reviews belong to the existing work task (#X) and must use the dedicated review flow on #X.`,
` - Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.`,
` - Only move #X into REVIEW when a real reviewer exists for #X. If nobody is reviewing it yet, keep #X completed until the reviewer is decided.`,
` - The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.`,
` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
``,
@ -3538,7 +3559,7 @@ export class TeamProvisioningService {
ready: false,
message:
blockingMessages.length === 1
? blockingMessages[0]!
? blockingMessages[0]
: 'Some provider runtimes are not ready',
warnings: blockingMessages.length > 1 ? blockingMessages : undefined,
};
@ -3708,7 +3729,7 @@ export class TeamProvisioningService {
return sanitized;
}
const jsonMatch = sanitized.match(/^\d{3}\s+(\{[\s\S]*\})$/);
const jsonMatch = /^\d{3}\s+(\{[\s\S]*\})$/.exec(sanitized);
const jsonCandidate = jsonMatch?.[1] ?? (sanitized.startsWith('{') ? sanitized : null);
if (jsonCandidate) {
try {
@ -4150,7 +4171,7 @@ export class TeamProvisioningService {
ctx.claudePath,
ctx.cwd,
ctx.env,
ctx.args[mcpFlagIdx + 1]!
ctx.args[mcpFlagIdx + 1]
);
}
child = spawnCli(ctx.claudePath, ctx.args, {
@ -6439,7 +6460,7 @@ export class TeamProvisioningService {
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed.includes(teamMarker)) continue;
const match = trimmed.match(/--agent-id\s+([^\s@]+)@/);
const match = /--agent-id\s+([^\s@]+)@/.exec(trimmed);
if (!match) continue;
const agentName = match[1]?.trim();
if (agentName) {
@ -7192,7 +7213,7 @@ export class TeamProvisioningService {
if (!trimmed || !trimmed.includes(marker) || !trimmed.includes('--agent-id')) {
continue;
}
const match = trimmed.match(/^(\d+)\s+(.*)$/);
const match = /^(\d+)\s+(.*)$/.exec(trimmed);
if (!match) continue;
const pid = Number.parseInt(match[1], 10);
if (!Number.isFinite(pid) || pid <= 0) continue;
@ -8496,7 +8517,7 @@ export class TeamProvisioningService {
return 'Question: User input is required';
}
const firstQuestion = questions[0]!;
const firstQuestion = questions[0];
const truncatedQuestion =
firstQuestion.length > 140 ? `${firstQuestion.slice(0, 137)}...` : firstQuestion;
@ -10247,6 +10268,10 @@ export class TeamProvisioningService {
};
applyConfiguredRuntimeBackendsEnv(env);
applyProviderRuntimeEnv(env, providerId);
await providerConnectionService.applyConfiguredConnectionEnv(
env,
resolveTeamProviderId(providerId)
);
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
if (controlApiBaseUrl) {

View file

@ -1,12 +1,12 @@
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
import { configManager } from '../infrastructure/ConfigManager';
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'free-code';
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
function parseFlavorOverride(raw: string | undefined): CliFlavor | null {
const trimmed = raw?.trim();
if (trimmed === 'claude' || trimmed === 'free-code') {
if (trimmed === 'claude' || trimmed === 'agent_teams_orchestrator') {
return trimmed;
}
return null;
@ -19,14 +19,14 @@ export function getConfiguredCliFlavor(): CliFlavor {
}
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
return multimodelEnabled ? 'free-code' : 'claude';
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
}
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
switch (flavor) {
case 'free-code':
case 'agent_teams_orchestrator':
return {
displayName: 'free-code-gemini-research',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,

View file

@ -3,6 +3,7 @@
* Packaged macOS apps get a minimal PATH; login-shell cache fixes that once warm.
*/
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
import { realpathSync } from 'fs';
import { join as pathJoin, posix as pathPosix, win32 as pathWin32 } from 'path';
@ -15,11 +16,12 @@ import { join as pathJoin, posix as pathPosix, win32 as pathWin32 } from 'path';
export function buildMergedCliPath(binaryPath?: string | null): string {
const home = getShellPreferredHome();
const sep = process.platform === 'win32' ? pathWin32.delimiter : pathPosix.delimiter;
const pathForBin = process.platform === 'win32' ? pathWin32 : pathPosix;
const currentPath = process.env.PATH || '';
const extraDirs: string[] = [];
const vendorBinDir = pathForBin.join(getClaudeBasePath(), 'local', 'node_modules', '.bin');
if (binaryPath) {
const pathForBin = process.platform === 'win32' ? pathWin32 : pathPosix;
const binDir = pathForBin.dirname(binaryPath);
extraDirs.push(binDir);
try {
@ -35,8 +37,13 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
const cachedEnv = getCachedShellEnv();
if (cachedEnv?.PATH) {
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
extraDirs.push(vendorBinDir);
} else if (process.platform === 'win32') {
extraDirs.push(pathJoin(home, 'AppData', 'Roaming', 'npm'), pathJoin(home, 'scoop', 'shims'));
extraDirs.push(
vendorBinDir,
pathJoin(home, 'AppData', 'Roaming', 'npm'),
pathJoin(home, 'scoop', 'shims')
);
if (process.env.LOCALAPPDATA) {
extraDirs.push(pathJoin(process.env.LOCALAPPDATA, 'Programs', 'claude'));
}
@ -45,6 +52,7 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
}
} else {
extraDirs.push(
vendorBinDir,
pathPosix.join(home, '.local', 'bin'),
pathPosix.join(home, '.npm-global', 'bin'),
pathPosix.join(home, '.npm', 'bin'),

View file

@ -1074,6 +1074,7 @@ export class HttpAPIClient implements ElectronAPI {
installed: false,
installedVersion: null,
binaryPath: null,
launchError: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,

View file

@ -8,8 +8,8 @@
import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
const cliStatus = useStore(useShallow((s) => s.cliStatus));
@ -39,7 +39,9 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => {
>
<AlertTriangle className="size-3.5 shrink-0" />
<span className="text-xs">
Claude Code is not installed. Install it from the Dashboard to enable all features.
{cliStatus.binaryPath && cliStatus.launchError
? 'Claude Code was found but failed to start. Open the Dashboard to repair or reinstall it.'
: 'Claude Code is not installed. Install it from the Dashboard to enable all features.'}
</span>
<button
onClick={openDashboard}

View file

@ -12,6 +12,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import {
formatProviderStatusText,
getProviderConnectionModeSummary,
getProviderConnectLabel,
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
getProviderDisconnectAction,
isConnectionManagedRuntimeProvider,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
@ -42,7 +52,7 @@ import {
Terminal,
} from 'lucide-react';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
// =============================================================================
// Border color by state
@ -256,24 +266,6 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
};
}
function formatProviderStatus(provider: CliProviderStatus): string {
if (!provider.supported) {
return provider.statusMessage ?? 'Unavailable in current runtime';
}
if (provider.authenticated) {
return provider.authMethod ? `Authenticated via ${provider.authMethod}` : 'Authenticated';
}
if (provider.verificationState === 'offline') {
return provider.statusMessage ?? 'Unable to verify';
}
return provider.statusMessage ?? 'Not connected';
}
function formatProviderModels(provider: CliProviderStatus): string | null {
const visibleModels = getVisibleTeamProviderModels(provider.providerId, provider.models);
return visibleModels.length > 0 ? visibleModels.join(', ') : null;
}
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
return getTeamModelBadgeLabel(providerId, model) ?? model;
}
@ -342,7 +334,7 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo
function formatRuntimeLabel(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
): string | null {
if (cliStatus.flavor === 'free-code') {
if (cliStatus.flavor === 'agent_teams_orchestrator') {
return null;
}
@ -355,7 +347,7 @@ function formatRuntimeAuthSummary(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
visibleProviders: readonly CliProviderStatus[]
): string | null {
if (cliStatus.flavor === 'free-code' && visibleProviders.length > 0) {
if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) {
if (
visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
@ -385,7 +377,7 @@ function isCheckingMultimodelStatus(
visibleProviders: readonly CliProviderStatus[]
): boolean {
return (
cliStatus.flavor === 'free-code' &&
cliStatus.flavor === 'agent_teams_orchestrator' &&
visibleProviders.length > 0 &&
visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
@ -524,16 +516,28 @@ const InstalledBanner = ({
style={{ borderColor: 'var(--color-border-subtle)' }}
>
{visibleProviders.map((provider) => {
const statusText = formatProviderStatus(provider);
const statusText = formatProviderStatusText(provider);
const actionDisabled = isBusy || !cliStatus.binaryPath;
const runtimeSummary = getProviderRuntimeBackendSummary(provider);
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
? getProviderCurrentRuntimeSummary(provider)
: getProviderRuntimeBackendSummary(provider);
const connectionModeSummary = getProviderConnectionModeSummary(provider);
const credentialSummary = getProviderCredentialSummary(provider);
const disconnectAction = getProviderDisconnectAction(provider);
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = isProviderCardLoading(provider, providerLoading);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
connectionModeSummary ||
credentialSummary ||
provider.models.length === 0
);
return (
<div
key={provider.providerId}
className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md p-2"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md p-2"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
>
<div className="col-span-2 flex items-start justify-between gap-3">
@ -562,25 +566,28 @@ const InstalledBanner = ({
</div>
{showSkeleton ? (
<ProviderDetailSkeleton />
) : (
) : hasDetailContent ? (
<div
className="mt-1 flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (
<span>
Backend: {provider.backend.label}
{provider.backend.endpointLabel
? ` (${provider.backend.endpointLabel})`
: ''}
</span>
{provider.backend?.label && !runtimeSummary && (
<span>Backend: {provider.backend.label}</span>
)}
{runtimeSummary ? <span>Runtime: {runtimeSummary}</span> : null}
{runtimeSummary ? (
<span>
{isConnectionManagedRuntimeProvider(provider)
? runtimeSummary
: `Runtime: ${runtimeSummary}`}
</span>
) : null}
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
{credentialSummary ? <span>{credentialSummary}</span> : null}
{provider.models.length === 0 && (
<span>Models unavailable for this runtime build</span>
)}
</div>
)}
) : null}
</div>
<div className="flex shrink-0 items-start gap-2">
<button
@ -595,7 +602,7 @@ const InstalledBanner = ({
<SlidersHorizontal className="size-3" />
Manage
</button>
{provider.authenticated && provider.canLoginFromUi ? (
{disconnectAction ? (
<button
onClick={() => onProviderLogout(provider.providerId)}
disabled={actionDisabled}
@ -606,9 +613,9 @@ const InstalledBanner = ({
}}
>
<LogOut className="size-3" />
Logout
{disconnectAction.label}
</button>
) : provider.canLoginFromUi ? (
) : shouldShowProviderConnectAction(provider) ? (
<button
onClick={() => onProviderLogin(provider.providerId)}
disabled={actionDisabled}
@ -619,7 +626,7 @@ const InstalledBanner = ({
}}
>
<LogIn className="size-3" />
Login
{getProviderConnectLabel(provider)}
</button>
) : null}
<button
@ -736,6 +743,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const handleMultimodelToggle = useCallback(
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
let nextMultimodelEnabled = multimodelEnabled;
try {
useStore.setState({
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
@ -743,17 +751,24 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError: null,
});
await updateConfig('general', { multimodelEnabled: enabled });
nextMultimodelEnabled = enabled;
await invalidateCliStatus();
if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} catch {
if (nextMultimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally {
setIsSwitchingFlavor(false);
}
},
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
);
const recheckAuthState = useCallback(() => {
@ -772,23 +787,33 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
setProviderTerminal({ providerId, action: 'login' });
}, []);
const handleProviderLogout = useCallback((providerId: CliProviderId) => {
void (async () => {
const confirmed = await confirm({
title: `Logout from ${getProviderLabel(providerId)}?`,
message: 'This will remove the current provider session from the local Claude CLI runtime.',
confirmLabel: 'Logout',
cancelLabel: 'Cancel',
variant: 'danger',
});
const handleProviderLogout = useCallback(
(providerId: CliProviderId) => {
void (async () => {
const provider =
cliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
if (!disconnectAction) {
return;
}
if (!confirmed) {
return;
}
const confirmed = await confirm({
title: disconnectAction.title,
message: disconnectAction.message,
confirmLabel: disconnectAction.confirmLabel,
cancelLabel: 'Cancel',
variant: 'danger',
});
setProviderTerminal({ providerId, action: 'logout' });
})();
}, []);
if (!confirmed) {
return;
}
setProviderTerminal({ providerId, action: 'logout' });
})();
},
[cliStatus?.providers]
);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
setManageProviderId(providerId);
@ -819,7 +844,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
[providerId]: backendId,
},
});
await fetchCliProviderStatus(providerId);
try {
await fetchCliProviderStatus(providerId);
} catch {
throw new Error('Runtime updated, but failed to refresh provider status.');
}
},
[appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
);
@ -867,10 +897,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}
providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath}
onSelectBackend={(providerId, backendId) => {
void handleProviderBackendChange(providerId, backendId);
}}
onSelectBackend={handleProviderBackendChange}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
/>
{providerTerminal && cliStatus.binaryPath && (
<TerminalModal
@ -1065,7 +1094,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}
// ── Completed ──────────────────────────────────────────────────────────
if (installerState === 'completed' && !cliStatus?.installed) {
if (
installerState === 'completed' &&
!cliStatus?.installed &&
!(cliStatus?.binaryPath && cliStatus?.launchError)
) {
return <InstallCompletedNotice version={completedVersion} />;
}
@ -1083,6 +1116,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// ── Idle state with status ─────────────────────────────────────────────
if (!cliStatus) return null;
const cliLaunchIssue =
!cliStatus.installed && Boolean(cliStatus.binaryPath && cliStatus.launchError);
// Not installed — red error banner
if (!cliStatus.installed) {
@ -1096,29 +1131,64 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<AlertTriangle className="mt-0.5 size-5 shrink-0" style={{ color: '#ef4444' }} />
<div>
<p className="text-sm font-medium" style={{ color: '#f87171' }}>
Claude CLI is required
{cliLaunchIssue
? 'Claude CLI was found but failed to start'
: 'Claude CLI is required'}
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Claude CLI is required for team provisioning and session management. Install it to
get started.
{cliLaunchIssue
? 'The app found a Claude CLI binary, but its startup health check failed. Repair or reinstall it, then retry.'
: 'Claude CLI is required for team provisioning and session management. Install it to get started.'}
</p>
{cliStatus.showBinaryPath && cliStatus.binaryPath && (
<p
className="mt-2 break-all font-mono text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{cliStatus.binaryPath}
</p>
)}
{cliLaunchIssue && cliStatus.launchError && (
<div
className="mt-2 rounded border px-2 py-1.5 font-mono text-[11px]"
style={{
borderColor: 'rgba(239, 68, 68, 0.2)',
backgroundColor: 'rgba(239, 68, 68, 0.04)',
color: 'var(--color-text-muted)',
}}
>
{cliStatus.launchError}
</div>
)}
</div>
</div>
{cliStatus.supportsSelfUpdate ? (
<div className="flex shrink-0 flex-col gap-2">
<button
onClick={handleInstall}
disabled={isBusy}
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
onClick={handleRefresh}
className="flex items-center justify-center gap-1.5 rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<Download className="size-4" />
Install Claude CLI
<RefreshCw className="size-4" />
Re-check
</button>
) : (
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
The configured free-code runtime was not found.
</p>
)}
{cliStatus.supportsSelfUpdate ? (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center justify-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-4" />
{cliLaunchIssue ? 'Reinstall Claude CLI' : 'Install Claude CLI'}
</button>
) : (
<p className="max-w-40 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{cliLaunchIssue
? `The configured ${cliStatus.displayName} runtime failed its startup health check.`
: `The configured ${cliStatus.displayName} runtime was not found.`}
</p>
)}
</div>
</div>
</div>
);
@ -1127,7 +1197,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed but not logged in — yellow warning banner
if (
cliStatus.installed &&
cliStatus.flavor !== 'free-code' &&
cliStatus.flavor !== 'agent_teams_orchestrator' &&
(cliStatus.authStatusChecking || isVerifyingAuth)
) {
if (cliStatus.authStatusChecking || isVerifyingAuth) {
@ -1158,7 +1228,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (
cliStatus.installed &&
cliStatus.flavor !== 'free-code' &&
cliStatus.flavor !== 'agent_teams_orchestrator' &&
!cliStatus.authStatusChecking &&
!cliStatus.authLoggedIn
) {

View file

@ -5,8 +5,6 @@
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
@ -20,6 +18,8 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
@ -165,15 +165,26 @@ export const ExtensionStoreView = (): React.JSX.Element => {
}
if (!cliStatus.installed) {
const cliLaunchIssue = Boolean(cliStatus.binaryPath && cliStatus.launchError);
return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-300">Claude CLI is not available</p>
<p className="mt-0.5 text-xs text-text-muted">
Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to
install it and retry.
<p className="text-sm font-medium text-amber-300">
{cliLaunchIssue
? 'Claude CLI was found but failed to start'
: 'Claude CLI is not available'}
</p>
<p className="mt-0.5 text-xs text-text-muted">
{cliLaunchIssue
? 'Plugin installs are disabled until Claude CLI passes its startup health check. Open the Dashboard to repair or reinstall it.'
: 'Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to install it and retry.'}
</p>
{cliLaunchIssue && cliStatus.launchError && (
<p className="mt-2 break-all font-mono text-[11px] text-text-muted">
{cliStatus.launchError}
</p>
)}
</div>
<Button size="sm" variant="outline" onClick={openDashboard}>
Open Dashboard
@ -231,8 +242,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
return (
<TooltipProvider>
<div className="flex flex-1 flex-col overflow-hidden">
{cliStatusBanner}
<div className="flex-1 overflow-y-auto">
{cliStatusBanner}
{/* Header */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">

View file

@ -27,19 +27,19 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleCopyEnvVar = async () => {
const handleCopyEnvVar = async (): Promise<void> => {
await navigator.clipboard.writeText(apiKey.envVarName);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const handleDelete = () => {
const handleDelete = (): void => {
if (!confirmDelete) {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
return;
}
void deleteApiKey(apiKey.id);
void deleteApiKey(apiKey.id).catch(() => undefined);
setConfirmDelete(false);
};

View file

@ -0,0 +1,255 @@
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
const CODEX_SUBSCRIPTION_LABEL = 'Codex subscription';
const CODEX_API_KEY_LABEL = 'OpenAI API key';
const ANTHROPIC_SUBSCRIPTION_LABEL = 'Anthropic subscription';
const AUTH_MODE_LABELS: Record<CliProviderAuthMode, string> = {
auto: 'Auto',
oauth: 'Subscription / OAuth',
api_key: 'API key',
};
export function formatProviderAuthModeLabel(authMode: CliProviderAuthMode | null): string | null {
return authMode ? AUTH_MODE_LABELS[authMode] : null;
}
export function formatProviderAuthModeLabelForProvider(
providerId: CliProviderStatus['providerId'],
authMode: CliProviderAuthMode | null
): string | null {
if (!authMode) {
return null;
}
if (providerId === 'codex' && authMode === 'oauth') {
return CODEX_SUBSCRIPTION_LABEL;
}
if (providerId === 'anthropic' && authMode === 'oauth') {
return ANTHROPIC_SUBSCRIPTION_LABEL;
}
return formatProviderAuthModeLabel(authMode);
}
export function formatProviderAuthMethodLabel(authMethod: string | null): string {
switch (authMethod) {
case 'api_key':
return 'API key';
case 'api_key_helper':
return 'API key helper';
case 'oauth_token':
return 'OAuth';
case 'claude.ai':
return 'Claude subscription';
case 'cli_oauth_personal':
return 'Gemini CLI';
case 'gemini_adc_authorized_user':
return 'Google account';
case 'gemini_adc_service_account':
return 'service account';
default:
return authMethod ? authMethod.replaceAll('_', ' ') : 'Not connected';
}
}
export function formatProviderAuthMethodLabelForProvider(
providerId: CliProviderStatus['providerId'],
authMethod: string | null
): string {
if (providerId === 'codex' && authMethod === 'oauth_token') {
return CODEX_SUBSCRIPTION_LABEL;
}
if (providerId === 'anthropic' && (authMethod === 'oauth_token' || authMethod === 'claude.ai')) {
return ANTHROPIC_SUBSCRIPTION_LABEL;
}
return formatProviderAuthMethodLabel(authMethod);
}
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
return provider.providerId === 'codex';
}
function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string {
if (provider.authenticated) {
return provider.authMethod === 'api_key' ? CODEX_API_KEY_LABEL : CODEX_SUBSCRIPTION_LABEL;
}
if (provider.connection?.configuredAuthMode === 'api_key') {
return CODEX_API_KEY_LABEL;
}
return CODEX_SUBSCRIPTION_LABEL;
}
export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null {
if (provider.providerId !== 'codex') {
return null;
}
const prefix = provider.authenticated ? 'Current runtime' : 'Selected runtime';
return `${prefix}: ${getCodexCurrentRuntimeLabel(provider)}`;
}
export function formatProviderStatusText(provider: CliProviderStatus): string {
if (!provider.supported) {
return provider.statusMessage ?? 'Unavailable in current runtime';
}
if (provider.authenticated) {
return `Connected via ${formatProviderAuthMethodLabelForProvider(
provider.providerId,
provider.authMethod
)}`;
}
if (provider.verificationState === 'offline') {
return provider.statusMessage ?? 'Unable to verify';
}
return provider.statusMessage ?? 'Not connected';
}
export function getProviderConnectionModeSummary(provider: CliProviderStatus): string | null {
if (provider.providerId !== 'anthropic' && provider.providerId !== 'codex') {
return null;
}
if (provider.providerId === 'codex') {
return null;
}
if (provider.providerId === 'anthropic' && provider.authenticated) {
return null;
}
if (provider.providerId === 'anthropic' && provider.connection?.configuredAuthMode === 'auto') {
return null;
}
const authModeLabel = formatProviderAuthModeLabelForProvider(
provider.providerId,
provider.connection?.configuredAuthMode ?? null
);
return authModeLabel ? `Preferred auth: ${authModeLabel}` : null;
}
export function getProviderCredentialSummary(provider: CliProviderStatus): string | null {
if (!provider.connection?.apiKeyConfigured) {
return null;
}
if (
provider.providerId === 'anthropic' &&
provider.connection.apiKeySource === 'stored' &&
provider.connection.configuredAuthMode === 'auto'
) {
return 'Saved API key available in Manage';
}
if (provider.authMethod !== 'api_key' && provider.providerId === 'anthropic') {
return provider.connection.apiKeySource === 'stored'
? 'API key also configured in Manage'
: (provider.connection.apiKeySourceLabel ?? 'API key is configured');
}
if (provider.authMethod !== 'api_key' && provider.providerId === 'gemini') {
return provider.connection.apiKeySource === 'stored'
? 'API key is configured in Manage'
: (provider.connection.apiKeySourceLabel ?? 'API key is configured');
}
if (provider.providerId === 'codex' && provider.connection?.apiKeyBetaEnabled !== true) {
return provider.connection.apiKeySource === 'stored'
? 'OpenAI API key is saved in Manage. Enable API key mode to use it.'
: 'OpenAI API key detected. Enable API key mode in Manage to use it.';
}
if (provider.authMethod !== 'api_key' && provider.providerId === 'codex') {
return provider.connection.apiKeySource === 'stored'
? 'OpenAI API key is also configured in Manage'
: (provider.connection.apiKeySourceLabel ?? 'OpenAI API key is configured');
}
return provider.connection.apiKeySourceLabel ?? null;
}
export function getProviderDisconnectAction(provider: CliProviderStatus): {
label: string;
confirmLabel: string;
title: string;
message: string;
} | null {
if (!provider.authenticated) {
return null;
}
if (provider.providerId === 'anthropic') {
if (provider.authMethod !== 'oauth_token' && provider.authMethod !== 'claude.ai') {
return null;
}
return {
label: 'Disconnect',
confirmLabel: 'Disconnect',
title: 'Disconnect Anthropic subscription?',
message: provider.connection?.apiKeyConfigured
? 'This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.'
: 'This removes the local Anthropic subscription session from the Claude CLI runtime.',
};
}
if (provider.providerId === 'codex' && provider.authMethod === 'oauth_token') {
return {
label: 'Disconnect',
confirmLabel: 'Disconnect',
title: 'Disconnect Codex subscription?',
message: provider.connection?.apiKeyConfigured
? 'This removes the local Codex subscription session from the Claude CLI runtime. Saved OPENAI_API_KEY credentials in Manage stay available.'
: 'This removes the local Codex subscription session from the Claude CLI runtime.',
};
}
if (provider.providerId === 'gemini' && provider.authMethod === 'cli_oauth_personal') {
return {
label: 'Disconnect',
confirmLabel: 'Disconnect',
title: 'Disconnect Gemini CLI?',
message:
'This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed.',
};
}
return null;
}
export function getProviderConnectLabel(provider: CliProviderStatus): string {
if (provider.providerId === 'anthropic') {
return 'Connect Anthropic';
}
if (provider.providerId === 'codex') {
return 'Connect Codex';
}
if (provider.providerId === 'gemini') {
return 'Open Login';
}
return 'Connect';
}
export function shouldShowProviderConnectAction(provider: CliProviderStatus): boolean {
if (!provider.canLoginFromUi || provider.authenticated) {
return false;
}
if (provider.connection?.configuredAuthMode === 'api_key') {
return false;
}
return true;
}

View file

@ -67,49 +67,55 @@ export function useSettingsHandlers({
// Use ref for config to avoid recreating callbacks when config changes
const configRef = useRef(config);
configRef.current = config;
const fireAndForgetConfigUpdate = useCallback(
(section: keyof AppConfig, data: Partial<AppConfig[keyof AppConfig]>) => {
void updateConfig(section, data).catch(() => undefined);
},
[updateConfig]
);
// General handlers
const handleGeneralToggle = useCallback(
(key: keyof AppConfig['general'], value: boolean) => {
void updateConfig('general', { [key]: value });
fireAndForgetConfigUpdate('general', { [key]: value });
},
[updateConfig]
[fireAndForgetConfigUpdate]
);
const handleThemeChange = useCallback(
(value: 'dark' | 'light' | 'system') => {
void updateConfig('general', { theme: value });
fireAndForgetConfigUpdate('general', { theme: value });
},
[updateConfig]
[fireAndForgetConfigUpdate]
);
const handleLanguageChange = useCallback(
(value: string) => {
void updateConfig('general', { agentLanguage: value });
fireAndForgetConfigUpdate('general', { agentLanguage: value });
},
[updateConfig]
[fireAndForgetConfigUpdate]
);
const handleDefaultTabChange = useCallback(
(value: 'dashboard' | 'last-session') => {
void updateConfig('general', { defaultTab: value });
fireAndForgetConfigUpdate('general', { defaultTab: value });
},
[updateConfig]
[fireAndForgetConfigUpdate]
);
// Notification handlers
const handleNotificationToggle = useCallback(
(key: keyof AppConfig['notifications'], value: boolean) => {
void updateConfig('notifications', { [key]: value });
fireAndForgetConfigUpdate('notifications', { [key]: value });
},
[updateConfig]
[fireAndForgetConfigUpdate]
);
const handleStatusChangeStatusesUpdate = useCallback(
(statuses: string[]) => {
void updateConfig('notifications', { statusChangeStatuses: statuses });
fireAndForgetConfigUpdate('notifications', { statusChangeStatuses: statuses });
},
[updateConfig]
[fireAndForgetConfigUpdate]
);
const handleSnooze = useCallback(
@ -250,9 +256,9 @@ export function useSettingsHandlers({
// Display handlers
const handleDisplayToggle = useCallback(
(key: keyof AppConfig['display'], value: boolean) => {
void updateConfig('display', { [key]: value });
fireAndForgetConfigUpdate('display', { [key]: value });
},
[updateConfig]
[fireAndForgetConfigUpdate]
);
// Advanced handlers
@ -321,6 +327,15 @@ export function useSettingsHandlers({
useNativeTitleBar: false,
telemetryEnabled: true,
},
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: false,
authMode: 'oauth',
},
},
runtime: {
providerBackends: {
gemini: 'auto',
@ -340,6 +355,7 @@ export function useSettingsHandlers({
await api.config.update('notifications', defaultConfig.notifications);
await api.config.update('general', defaultConfig.general);
await api.config.update('providerConnections', defaultConfig.providerConnections);
await api.config.update('runtime', defaultConfig.runtime);
const updatedConfig = await api.config.update('display', defaultConfig.display);
setConfig(updatedConfig);

View file

@ -10,6 +10,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import {
formatProviderStatusText,
getProviderConnectionModeSummary,
getProviderConnectLabel,
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
getProviderDisconnectAction,
isConnectionManagedRuntimeProvider,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
@ -188,9 +198,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
? createLoadingMultimodelCliStatus()
: cliStatus;
const showInstalledControls =
effectiveCliStatus !== null &&
(installerState === 'idle' ||
(installerState === 'completed' && effectiveCliStatus.installed === true));
effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
useEffect(() => {
if (isElectron) {
@ -212,24 +220,34 @@ export const CliStatusSection = (): React.JSX.Element | null => {
void fetchCliStatus();
}, [fetchCliStatus]);
const handleProviderLogout = useCallback(async (providerId: CliProviderId) => {
const confirmed = await confirm({
title: `Logout from ${getProviderLabel(providerId)}?`,
message: 'This will remove the current provider session from the local Claude CLI runtime.',
confirmLabel: 'Logout',
cancelLabel: 'Cancel',
variant: 'danger',
});
const handleProviderLogout = useCallback(
async (providerId: CliProviderId) => {
const provider =
effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
if (!disconnectAction) {
return;
}
if (!confirmed) {
return;
}
const confirmed = await confirm({
title: disconnectAction.title,
message: disconnectAction.message,
confirmLabel: disconnectAction.confirmLabel,
cancelLabel: 'Cancel',
variant: 'danger',
});
setProviderTerminal({
providerId,
action: 'logout',
});
}, []);
if (!confirmed) {
return;
}
setProviderTerminal({
providerId,
action: 'logout',
});
},
[effectiveCliStatus?.providers]
);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
setManageProviderId(providerId);
@ -246,6 +264,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const handleMultimodelToggle = useCallback(
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
let nextMultimodelEnabled = multimodelEnabled;
try {
useStore.setState({
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
@ -253,42 +272,26 @@ export const CliStatusSection = (): React.JSX.Element | null => {
cliStatusError: null,
});
await updateConfig('general', { multimodelEnabled: enabled });
nextMultimodelEnabled = enabled;
await invalidateCliStatus();
if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} catch {
if (nextMultimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally {
setIsSwitchingFlavor(false);
}
},
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
);
if (!isElectron) return null;
const runtimeLabel =
effectiveCliStatus?.flavor === 'free-code'
? null
: effectiveCliStatus &&
effectiveCliStatus.showVersionDetails &&
effectiveCliStatus.installedVersion
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
const activeTerminalProvider = providerTerminal
? (effectiveCliStatus?.providers.find(
(provider) => provider.providerId === providerTerminal.providerId
) ?? null)
: null;
const providerTerminalCommand =
providerTerminal && activeTerminalProvider
? providerTerminal.action === 'login'
? getProviderTerminalCommand(activeTerminalProvider)
: getProviderTerminalLogoutCommand(activeTerminalProvider)
: null;
const handleRuntimeBackendChange = useCallback(
async (providerId: CliProviderId, backendId: string) => {
const currentBackends = appConfig?.runtime?.providerBackends ?? {
@ -306,11 +309,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
[providerId]: backendId,
},
});
await fetchCliProviderStatus(providerId);
try {
await fetchCliProviderStatus(providerId);
} catch {
throw new Error('Runtime updated, but failed to refresh provider status.');
}
},
[appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
);
if (!isElectron) return null;
const runtimeLabel =
effectiveCliStatus?.flavor === 'agent_teams_orchestrator'
? null
: effectiveCliStatus &&
effectiveCliStatus.showVersionDetails &&
effectiveCliStatus.installedVersion
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
const activeTerminalProvider = providerTerminal
? (effectiveCliStatus?.providers.find(
(provider) => provider.providerId === providerTerminal.providerId
) ?? null)
: null;
const providerTerminalCommand =
providerTerminal && activeTerminalProvider
? providerTerminal.action === 'login'
? getProviderTerminalCommand(activeTerminalProvider)
: getProviderTerminalLogoutCommand(activeTerminalProvider)
: null;
return (
<div className="mb-2">
<SettingsSectionHeader title="CLI Runtime" />
@ -438,7 +469,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{effectiveCliStatus.providers.map((provider) => (
<div
key={provider.providerId}
className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
@ -448,7 +479,20 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const providerLoading =
cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = isProviderCardLoading(provider, providerLoading);
const runtimeSummary = getProviderRuntimeBackendSummary(provider);
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
? getProviderCurrentRuntimeSummary(provider)
: getProviderRuntimeBackendSummary(provider);
const statusText = formatProviderStatusText(provider);
const connectionModeSummary = getProviderConnectionModeSummary(provider);
const credentialSummary = getProviderCredentialSummary(provider);
const disconnectAction = getProviderDisconnectAction(provider);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
connectionModeSummary ||
credentialSummary ||
provider.models.length === 0
);
return (
<>
@ -474,31 +518,35 @@ export const CliStatusSection = (): React.JSX.Element | null => {
: 'var(--color-text-muted)',
}}
>
{provider.authenticated
? provider.authMethod
? `Authenticated via ${provider.authMethod}`
: 'Authenticated'
: provider.statusMessage || 'Not connected'}
{statusText}
</span>
</div>
{showSkeleton ? (
<ProviderDetailSkeleton />
) : (
) : hasDetailContent ? (
<div
className="mt-1 flex min-h-11 flex-wrap gap-x-3 gap-y-1 text-[11px]"
className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (
{provider.backend?.label && !runtimeSummary && (
<span>Backend: {provider.backend.label}</span>
)}
{runtimeSummary ? (
<span>Runtime: {runtimeSummary}</span>
<span>
{isConnectionManagedRuntimeProvider(provider)
? runtimeSummary
: `Runtime: ${runtimeSummary}`}
</span>
) : null}
{connectionModeSummary ? (
<span>{connectionModeSummary}</span>
) : null}
{credentialSummary ? <span>{credentialSummary}</span> : null}
{provider.models.length === 0 && (
<span>Models unavailable for this runtime build</span>
)}
</div>
)}
) : null}
</div>
<div className="flex shrink-0 items-start gap-2">
<button
@ -514,7 +562,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<SlidersHorizontal className="size-3" />
Manage
</button>
{provider.authenticated && provider.canLoginFromUi ? (
{disconnectAction ? (
<button
type="button"
onClick={() => void handleProviderLogout(provider.providerId)}
@ -526,9 +574,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
<LogOut className="size-3" />
Logout
{disconnectAction.label}
</button>
) : provider.canLoginFromUi ? (
) : shouldShowProviderConnectAction(provider) ? (
<button
type="button"
onClick={() =>
@ -537,9 +585,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
action: 'login',
})
}
disabled={
!effectiveCliStatus.binaryPath || !provider.canLoginFromUi
}
disabled={!effectiveCliStatus.binaryPath}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
@ -547,7 +593,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
<LogIn className="size-3" />
Login
{getProviderConnectLabel(provider)}
</button>
) : null}
</div>
@ -574,37 +620,73 @@ export const CliStatusSection = (): React.JSX.Element | null => {
initialProviderId={manageProviderId}
providerStatusLoading={cliProviderStatusLoading}
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
onSelectBackend={(providerId, backendId) => {
void handleRuntimeBackendChange(providerId, backendId);
}}
onSelectBackend={handleRuntimeBackendChange}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
onRequestLogin={(providerId) =>
setProviderTerminal({ providerId, action: 'login' })
}
/>
</div>
) : (
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
Claude CLI not installed
<div className="space-y-2 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
? 'Claude CLI was found but failed to start'
: 'Claude CLI not installed'}
</div>
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
<div className="break-all font-mono text-xs text-text-muted">
{effectiveCliStatus.binaryPath}
</div>
)}
{effectiveCliStatus.launchError && (
<div
className="rounded border px-2 py-1.5 font-mono text-xs"
style={{
borderColor: 'rgba(245, 158, 11, 0.25)',
backgroundColor: 'rgba(245, 158, 11, 0.06)',
color: 'var(--color-text-muted)',
}}
>
{effectiveCliStatus.launchError}
</div>
)}
</div>
)}
{/* Install button (CLI not installed) */}
{!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Install Claude CLI
</button>
<div className="flex flex-wrap gap-2">
<button
onClick={handleRefresh}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<RefreshCw className="size-3.5" />
Re-check
</button>
<button
onClick={handleInstall}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
? 'Reinstall Claude CLI'
: 'Install Claude CLI'}
</button>
</div>
)}
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
The configured free-code runtime was not found.
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
? `The configured ${effectiveCliStatus.displayName} runtime failed its startup health check.`
: `The configured ${effectiveCliStatus.displayName} runtime was not found.`}
</p>
)}
</div>

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo } from 'react';
import { Label } from '@renderer/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import {
Tooltip,
TooltipContent,
@ -17,13 +18,13 @@ import {
import {
doesTeamModelCarryProviderBrand,
getTeamModelLabel as getCatalogTeamModelLabel,
getTeamModelUiDisabledReason,
getTeamProviderLabel as getCatalogTeamProviderLabel,
getTeamProviderModelOptions,
getTeamModelUiDisabledReason,
normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from '@renderer/utils/teamModelCatalog';
import { Check, ChevronDown, Info } from 'lucide-react';
import { Info } from 'lucide-react';
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
@ -153,6 +154,17 @@ export function getTeamModelLabel(model: string): string {
return getCatalogTeamModelLabel(model) ?? model;
}
export function getProviderScopedTeamModelLabel(
providerId: 'anthropic' | 'codex' | 'gemini',
model: string
): string {
const baseLabel = getTeamModelLabel(model);
if (providerId !== 'codex') {
return baseLabel;
}
return baseLabel.replace(/^GPT-/i, '');
}
export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
@ -169,10 +181,15 @@ export function formatTeamModelSummary(
effort?: string
): string {
const providerLabel = getTeamProviderLabel(providerId);
const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
const rawModelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
const modelLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
: 'Default';
const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(providerId, modelLabel);
const modelAlreadyCarriesProviderBrand =
doesTeamModelCarryProviderBrand(providerId, rawModelLabel) ||
(providerId === 'codex' && model.trim().toLowerCase().startsWith('gpt-'));
const providerActsAsBackendOnly =
providerId !== 'anthropic' && modelLabel !== 'Default' && !modelAlreadyCarriesProviderBrand;
@ -222,29 +239,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}) => {
const cliStatus = useStore((s) => s.cliStatus);
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'free-code';
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown on click outside
useEffect(() => {
if (!dropdownOpen) return;
const handleClickOutside = (event: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [dropdownOpen]);
const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator';
const effectiveProviderId =
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
const activeProvider =
PROVIDERS.find((provider) => provider.id === effectiveProviderId) ?? PROVIDERS[0];
const ProviderIcon = activeProvider.icon;
const defaultModelTooltip = useMemo(() => {
if (effectiveProviderId === 'anthropic') {
return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.';
@ -266,9 +264,39 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
!isProviderTemporarilyDisabled(candidateProviderId) &&
(multimodelAvailable || candidateProviderId === 'anthropic');
const activeProviderSelectable = isProviderSelectable(effectiveProviderId);
const runtimeModels =
cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId)?.models ??
[];
const getProviderStatusBadge = (candidateProviderId: string): string | null => {
if (candidateProviderId === 'opencode') {
return 'In development';
}
const providerDisabledReason = getProviderDisabledReason(candidateProviderId);
if (providerDisabledReason) {
return GEMINI_UI_DISABLED_BADGE_LABEL;
}
if (!isProviderSelectable(candidateProviderId)) {
return 'Multimodel off';
}
return null;
};
const getProviderStatusBadgeLabel = (statusBadge: string | null): string | null => {
if (statusBadge === 'In development') {
return 'Dev';
}
if (statusBadge === 'Multimodel off') {
return 'Off';
}
return statusBadge;
};
const runtimeModels = useMemo(
() =>
cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId)
?.models ?? [],
[cliStatus?.providers, effectiveProviderId]
);
const normalizedValue = normalizeTeamModelForUi(effectiveProviderId, value);
useEffect(() => {
@ -280,11 +308,17 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const modelOptions = useMemo(() => {
const fallback = getTeamProviderModelOptions(effectiveProviderId);
if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) {
return [...fallback];
return fallback.map((option) => ({
...option,
label:
option.value === ''
? option.label
: getProviderScopedTeamModelLabel(effectiveProviderId, option.value),
}));
}
const dynamicOptions = runtimeModels.map((model) => ({
value: model,
label: getTeamModelLabel(model),
label: getProviderScopedTeamModelLabel(effectiveProviderId, model),
}));
return [{ value: '', label: 'Default' }, ...dynamicOptions];
}, [effectiveProviderId, runtimeModels]);
@ -294,207 +328,173 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
<Label htmlFor={id} className="label-optional mb-1.5 block">
Model (optional)
</Label>
<div ref={containerRef} className="relative space-y-2">
<div className="relative inline-flex">
<button
type="button"
className={cn(
'flex min-w-[170px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-xs font-medium transition-colors',
dropdownOpen
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
)}
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}
onClick={() => setDropdownOpen(!dropdownOpen)}
>
<span className="flex items-center gap-2">
<ProviderIcon className="size-3.5" />
<span>{activeProvider.label}</span>
</span>
<ChevronDown
className={cn(
'size-3 transition-transform duration-200',
dropdownOpen && 'rotate-180'
)}
/>
</button>
{/* Provider dropdown */}
{dropdownOpen && (
<div
className="absolute left-0 top-full z-50 mt-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border-subtle)',
}}
>
{PROVIDERS.map((provider, index) => {
<Tabs
value={effectiveProviderId}
onValueChange={(nextValue) => {
if (
(nextValue === 'anthropic' || nextValue === 'codex' || nextValue === 'gemini') &&
isProviderSelectable(nextValue)
) {
onProviderChange(nextValue);
}
}}
>
<div className="space-y-0">
<div className="-mb-px border-b border-[var(--color-border-subtle)]">
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 rounded-none bg-transparent p-0">
{PROVIDERS.map((provider) => {
const Icon = provider.icon;
const isActive = provider.id === activeProvider.id;
const isFirst = index === 0;
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
const providerDisabledReason = getProviderDisabledReason(provider.id);
const providerSelectable = isProviderSelectable(provider.id);
const statusBadge = getProviderStatusBadge(provider.id);
const statusBadgeLabel = getProviderStatusBadgeLabel(statusBadge);
return (
<React.Fragment key={provider.id}>
{prevWasActive && !isFirst && (
<div
className="mx-2 my-1 border-t"
style={{ borderColor: 'var(--color-border-subtle)' }}
/>
<TabsTrigger
key={provider.id}
value={provider.id}
disabled={provider.comingSoon || !providerSelectable}
title={
providerDisabledReason ??
(statusBadge === 'Multimodel off'
? 'Enable Multimodel mode to use this provider.'
: (statusBadge ?? undefined))
}
className={cn(
"relative h-12 min-w-[128px] items-center justify-start gap-2 rounded-b-none border border-b-0 border-transparent px-3 py-2 text-left text-xs text-[var(--color-text-secondary)] data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:border-[var(--color-border)] data-[state=active]:bg-[var(--color-surface)] data-[state=active]:text-[var(--color-text)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:-bottom-px data-[state=active]:after:h-px data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']",
!providerSelectable && 'opacity-50'
)}
<button
type="button"
disabled={provider.comingSoon || !isProviderSelectable(provider.id)}
onClick={() => {
if (!provider.comingSoon && isProviderSelectable(provider.id)) {
onProviderChange(provider.id as 'anthropic' | 'codex' | 'gemini');
setDropdownOpen(false);
}
}}
>
<Icon className="size-3.5 shrink-0" />
<span
className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
isActive && 'bg-indigo-500/10 text-indigo-400',
(provider.comingSoon || !isProviderSelectable(provider.id)) &&
'cursor-not-allowed opacity-40',
!isActive &&
!provider.comingSoon &&
isProviderSelectable(provider.id) &&
'hover:bg-white/5'
'min-w-0 truncate text-sm font-medium',
statusBadgeLabel && 'pr-9'
)}
style={
!isActive && !provider.comingSoon && isProviderSelectable(provider.id)
? { color: 'var(--color-text-secondary)' }
: undefined
}
>
<Icon className="size-3.5 shrink-0" />
<span className="flex-1">{provider.label}</span>
{provider.comingSoon && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Coming Soon
</span>
)}
{!provider.comingSoon && providerDisabledReason && (
<span
className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={providerDisabledReason}
>
{GEMINI_UI_DISABLED_BADGE_LABEL}
</span>
)}
{!provider.comingSoon &&
!providerDisabledReason &&
!isProviderSelectable(provider.id) && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Multimodel off
</span>
)}
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
</React.Fragment>
{provider.label}
</span>
{statusBadgeLabel ? (
<span
className="absolute right-2 top-1.5 rounded px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.08em]"
style={{
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
}}
aria-label={statusBadge ?? undefined}
title={statusBadge ?? undefined}
>
{statusBadgeLabel}
</span>
) : null}
</TabsTrigger>
);
})}
</div>
)}
</div>
{!multimodelAvailable && (
<p className="text-[11px] text-[var(--color-text-muted)]">
Codex and Gemini require Multimodel mode.
</p>
)}
<div
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{modelOptions.map((opt) =>
(() => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
opt.value
);
const modelSelectable = activeProviderSelectable && !modelDisabledReason;
</TabsList>
</div>
return (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === normalizedValue ? id : undefined}
aria-disabled={!modelSelectable}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors',
normalizedValue === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
!modelSelectable && 'cursor-not-allowed opacity-45',
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
)}
style={{
borderColor:
normalizedValue === opt.value
? 'var(--color-border-emphasis)'
: 'transparent',
}}
onClick={() => {
if (!modelSelectable) return;
onValueChange(opt.value);
}}
>
<span className="flex flex-col items-center justify-center gap-0.5">
<span className="leading-tight">{opt.label}</span>
{opt.value === '' && (
<span className="flex items-center justify-center gap-1">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{defaultModelTooltip.split('\n').map((line, index) => (
<React.Fragment key={line}>
{index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{modelDisabledReason && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason}
<div className="rounded-b-md border border-t-0 border-[var(--color-border)] bg-[var(--color-surface)]">
{!multimodelAvailable ? (
<div className="border-b border-[var(--color-border-subtle)] px-3 py-2">
<p className="text-[11px] text-[var(--color-text-muted)]">
Codex and Gemini require Multimodel mode.
</p>
</div>
) : null}
<div className="p-3">
<div
className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{modelOptions.map((opt) =>
(() => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
opt.value
);
const modelSelectable = activeProviderSelectable && !modelDisabledReason;
return (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === normalizedValue ? id : undefined}
aria-disabled={!modelSelectable}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors',
normalizedValue === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
!modelSelectable && 'cursor-not-allowed opacity-45',
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
)}
style={{
borderColor:
normalizedValue === opt.value
? 'var(--color-border-emphasis)'
: 'var(--color-border-subtle)',
}}
onClick={() => {
if (!modelSelectable) return;
onValueChange(opt.value);
}}
>
<span>{TEAM_MODEL_UI_DISABLED_BADGE_LABEL}</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
<span className="flex flex-col items-center justify-center gap-0.5">
<span className="leading-tight">{opt.label}</span>
{opt.value === '' && (
<span className="flex items-center justify-center gap-1">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{defaultModelTooltip.split('\n').map((line, index) => (
<React.Fragment key={line}>
{index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{modelDisabledReason && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelDisabledReason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
</span>
</button>
);
})()
)}
<span>{TEAM_MODEL_UI_DISABLED_BADGE_LABEL}</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelDisabledReason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
</span>
</button>
);
})()
)}
</div>
</div>
</div>
</div>
</div>
</Tabs>
</div>
);
};

View file

@ -1,13 +1,13 @@
import React, { useState } from 'react';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
import {
getTeamModelLabel,
getProviderScopedTeamModelLabel,
TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { getMemberColorByName } from '@shared/constants/memberColors';
@ -49,7 +49,9 @@ export const LeadModelRow = ({
const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false);
const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
const modelButtonLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
: 'Default';
return (
<div
@ -90,11 +92,11 @@ export const LeadModelRow = ({
</div>
</div>
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<div className="w-full min-w-0 space-y-1 sm:w-[150px] sm:min-w-[150px]">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[220px] shrink-0 justify-start gap-1 overflow-hidden text-left"
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (

View file

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import {
getTeamModelLabel,
getProviderScopedTeamModelLabel,
TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
@ -164,7 +164,7 @@ export const MemberDraftRow = ({
? inheritedEffort
: (member.effort ?? inheritedEffort);
const modelButtonLabelBase = effectiveModel?.trim()
? getTeamModelLabel(effectiveModel.trim())
? getProviderScopedTeamModelLabel(effectiveProviderId, effectiveModel.trim())
: 'Default';
const modelButtonLabel = forceInheritedModelSettings
? `${modelButtonLabelBase} (lead)`
@ -175,7 +175,7 @@ export const MemberDraftRow = ({
return (
<div
className={`relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto] ${isRemoved ? 'opacity-55' : ''}`}
className={`relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[minmax(0,1fr)_156px_auto] ${isRemoved ? 'opacity-55' : ''}`}
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -238,14 +238,14 @@ export const MemberDraftRow = ({
) : null}
</Button>
) : null}
<div className="min-w-0 space-y-1">
<div className="w-full min-w-0 space-y-1 sm:w-[150px] sm:min-w-[150px]">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex max-w-[190px]">
<span className="inline-flex w-full">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
disabled={lockProviderModel || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)}
>

View file

@ -39,14 +39,15 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
}));
return {
flavor: 'free-code',
displayName: 'free-code-gemini-research',
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: null,
binaryPath: null,
launchError: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
@ -143,7 +144,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
try {
const metadata = await api.cliInstaller.getStatus();
if (metadata.flavor !== 'free-code') {
if (metadata.flavor !== 'agent_teams_orchestrator') {
set((state) => {
if (epoch !== cliStatusEpoch) {
return {};
@ -175,14 +176,28 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
installed: metadata.installed,
installedVersion: metadata.installedVersion,
binaryPath: metadata.binaryPath,
launchError: metadata.launchError ?? null,
latestVersion: metadata.latestVersion,
updateAvailable: metadata.updateAvailable,
authStatusChecking: state.cliStatus.providers.some(
(provider) => provider.statusMessage === 'Checking...'
),
authStatusChecking:
metadata.installed &&
state.cliStatus.providers.some(
(provider) => provider.statusMessage === 'Checking...'
),
providers: metadata.installed ? state.cliStatus.providers : metadata.providers,
},
};
});
if (!metadata.installed) {
if (epoch === cliStatusEpoch) {
set({
cliStatusLoading: false,
cliProviderStatusLoading: {},
});
}
return;
}
} catch (error) {
logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error);
}
@ -216,11 +231,13 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return;
}
set({ cliStatus: status, cliProviderStatusLoading: {} });
for (const provider of status.providers) {
void get().fetchCliProviderStatus(provider.providerId, {
silent: true,
epoch,
});
if (status.installed) {
for (const provider of status.providers) {
void get().fetchCliProviderStatus(provider.providerId, {
silent: true,
epoch,
});
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to check CLI status';
@ -237,6 +254,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
fetchCliProviderStatus: async (providerId, options) => {
if (!api.cliInstaller) return;
if (get().cliStatus && !get().cliStatus?.installed) {
return;
}
const inFlight = cliProviderStatusInFlight.get(providerId);
if (inFlight) return inFlight;

View file

@ -62,6 +62,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
// Update a section of the app configuration
updateConfig: async (section: string, data: Record<string, unknown>) => {
set({ configError: null });
try {
await api.config.update(section, data);
// Refresh config after update
@ -74,6 +75,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
set({
configError: error instanceof Error ? error.message : 'Failed to update config',
});
throw error;
}
},

View file

@ -146,6 +146,8 @@ function getSkillsCatalogKey(projectPath?: string): string {
const SUCCESS_DISPLAY_MS = 2_000;
const CLI_AUTH_REQUIRED_MESSAGE =
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
const CLI_HEALTHCHECK_FAILED_MESSAGE =
'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.';
const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
@ -571,7 +573,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? CLI_NOT_FOUND_MESSAGE
? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
@ -878,6 +882,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
set({
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',
});
throw err;
}
},

View file

@ -21,9 +21,22 @@ export type CliPlatform =
| 'win32-x64'
| 'win32-arm64';
export type CliFlavor = 'claude' | 'free-code';
export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
export type CliProviderAuthMode = 'auto' | 'oauth' | 'api_key';
export interface CliProviderConnectionInfo {
supportsOAuth: boolean;
supportsApiKey: boolean;
configurableAuthModes: CliProviderAuthMode[];
configuredAuthMode: CliProviderAuthMode | null;
apiKeyBetaAvailable?: boolean;
apiKeyBetaEnabled?: boolean;
apiKeyConfigured: boolean;
apiKeySource: 'stored' | 'environment' | null;
apiKeySourceLabel?: string | null;
}
export interface CliProviderBackendOption {
id: string;
@ -69,6 +82,7 @@ export interface CliProviderStatus {
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
connection?: CliProviderConnectionInfo | null;
}
export interface CliFlavorUiOptions {
@ -96,12 +110,14 @@ export interface CliInstallationStatus {
showVersionDetails: boolean;
/** Whether binary path should be shown in the UI */
showBinaryPath: boolean;
/** Whether CLI binary is found on the system */
/** Whether the CLI was found and passed the startup health check (`--version`) */
installed: boolean;
/** Installed version string (e.g. "2.1.59"), null if not installed */
installedVersion: string | null;
/** Absolute path to the resolved binary, null if not found */
/** Absolute path to the resolved binary candidate, null if not found */
binaryPath: string | null;
/** Probe failure when a binary was found but could not be started */
launchError?: string | null;
/** Latest available version from GCS, null if check failed */
latestVersion: string | null;
/** True when installed version < latest version */

View file

@ -321,7 +321,17 @@ export interface AppConfig {
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
telemetryEnabled: boolean;
};
/** Runtime backend preferences for app-launched free-code sessions */
/** Provider connection preferences for app-launched multimodel sessions */
providerConnections: {
anthropic: {
authMode: 'auto' | 'oauth' | 'api_key';
};
codex: {
apiKeyBetaEnabled: boolean;
authMode: 'oauth' | 'api_key';
};
};
/** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */
runtime: {
providerBackends: {
gemini: 'auto' | 'api' | 'cli-sdk';

View file

@ -233,7 +233,7 @@ export interface TeamTask {
blockedBy?: string[];
/**
* Explicit task links (non-blocking). Used for navigation between related tasks,
* e.g. "review task" "work task".
* e.g. "frontend task" "backend task" or a rare meta reminder the main work task.
*/
related?: string[];
createdAt?: string;

View file

@ -0,0 +1,227 @@
// @vitest-environment node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
const afterPackModule = require('../../../scripts/electron-builder/afterPack.cjs');
const {
detectBinaryMetadata,
parseElf,
parseMachO,
parsePortableExecutable,
pruneNodePtyArtifacts,
validateNativeBinaries,
} = afterPackModule._internal;
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'after-pack-test-'));
}
function writeFile(filePath: string, content: Buffer): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function createMachOBuffer(arch: 'arm64' | 'x64'): Buffer {
const cpuType = arch === 'arm64' ? 0x0100000c : 0x01000007;
const buffer = Buffer.alloc(8);
buffer.writeUInt32LE(0xfeedfacf, 0);
buffer.writeUInt32LE(cpuType, 4);
return buffer;
}
function createElfBuffer(arch: 'arm64' | 'x64'): Buffer {
const machine = arch === 'arm64' ? 0x00b7 : 0x003e;
const buffer = Buffer.alloc(64);
buffer[0] = 0x7f;
buffer[1] = 0x45;
buffer[2] = 0x4c;
buffer[3] = 0x46;
buffer[5] = 1;
buffer.writeUInt16LE(machine, 18);
return buffer;
}
function createPortableExecutableBuffer(arch: 'arm64' | 'x64'): Buffer {
const machine = arch === 'arm64' ? 0xaa64 : 0x8664;
const buffer = Buffer.alloc(256);
buffer[0] = 0x4d;
buffer[1] = 0x5a;
buffer.writeUInt32LE(0x80, 0x3c);
buffer[0x80] = 0x50;
buffer[0x81] = 0x45;
buffer[0x82] = 0x00;
buffer[0x83] = 0x00;
buffer.writeUInt16LE(machine, 0x84);
return buffer;
}
describe('electron-builder afterPack', () => {
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
it('parses native binary headers for all supported bundle formats', async () => {
const tempDir = createTempDir();
tempDirs.push(tempDir);
const machoPath = path.join(tempDir, 'arm64.node');
const elfPath = path.join(tempDir, 'linux.node');
const pePath = path.join(tempDir, 'win.node');
writeFile(machoPath, createMachOBuffer('arm64'));
writeFile(elfPath, createElfBuffer('x64'));
writeFile(pePath, createPortableExecutableBuffer('arm64'));
await expect(detectBinaryMetadata(machoPath)).resolves.toEqual({
format: 'mach-o',
archs: new Set(['arm64']),
});
await expect(detectBinaryMetadata(elfPath)).resolves.toEqual({
format: 'elf',
archs: new Set(['x64']),
});
await expect(detectBinaryMetadata(pePath)).resolves.toEqual({
format: 'pe',
archs: new Set(['arm64']),
});
expect(parseMachO(createMachOBuffer('x64'))).toEqual({
format: 'mach-o',
archs: new Set(['x64']),
});
expect(parseElf(createElfBuffer('arm64'))).toEqual({
format: 'elf',
archs: new Set(['arm64']),
});
expect(parsePortableExecutable(createPortableExecutableBuffer('x64'))).toEqual({
format: 'pe',
archs: new Set(['x64']),
});
});
it('prunes node-pty prebuilds that do not match the target platform and arch', async () => {
const tempDir = createTempDir();
tempDirs.push(tempDir);
const prebuildsDir = path.join(tempDir, 'node_modules', 'node-pty', 'prebuilds');
const binDir = path.join(tempDir, 'node_modules', 'node-pty', 'bin');
writeFile(path.join(prebuildsDir, 'darwin-arm64', 'pty.node'), createMachOBuffer('arm64'));
writeFile(path.join(prebuildsDir, 'darwin-x64', 'pty.node'), createMachOBuffer('x64'));
writeFile(path.join(prebuildsDir, 'win32-x64', 'pty.node'), createPortableExecutableBuffer('x64'));
writeFile(path.join(binDir, 'darwin-arm64-143', 'node-pty.node'), createMachOBuffer('arm64'));
writeFile(path.join(binDir, 'darwin-x64-143', 'node-pty.node'), createMachOBuffer('x64'));
const removed = await pruneNodePtyArtifacts(tempDir, 'darwin', 'arm64');
expect(removed).toEqual(
expect.arrayContaining([
path.join(prebuildsDir, 'darwin-x64'),
path.join(prebuildsDir, 'win32-x64'),
path.join(binDir, 'darwin-x64-143'),
])
);
expect(fs.existsSync(path.join(prebuildsDir, 'darwin-arm64'))).toBe(true);
expect(fs.existsSync(path.join(binDir, 'darwin-arm64-143'))).toBe(true);
expect(fs.existsSync(path.join(prebuildsDir, 'darwin-x64'))).toBe(false);
});
it('fails validation when a foreign-arch native binary remains in the bundle', async () => {
const tempDir = createTempDir();
tempDirs.push(tempDir);
writeFile(
path.join(tempDir, 'Contents', 'Resources', 'app.asar.unpacked', 'bad.node'),
createMachOBuffer('x64')
);
await expect(validateNativeBinaries(tempDir, 'darwin', 'arm64')).resolves.toEqual([
{
path: path.join('Contents', 'Resources', 'app.asar.unpacked', 'bad.node'),
format: 'mach-o',
archs: ['x64'],
},
]);
});
it('accepts a clean arm64 mac bundle after pruning', async () => {
const tempDir = createTempDir();
tempDirs.push(tempDir);
writeFile(
path.join(tempDir, 'Contents', 'MacOS', 'Claude Agent Teams UI'),
createMachOBuffer('arm64')
);
writeFile(
path.join(
tempDir,
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules',
'node-pty',
'build',
'Release',
'pty.node'
),
createMachOBuffer('arm64')
);
writeFile(
path.join(
tempDir,
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules',
'node-pty',
'prebuilds',
'darwin-arm64',
'pty.node'
),
createMachOBuffer('arm64')
);
writeFile(
path.join(
tempDir,
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules',
'node-pty',
'prebuilds',
'darwin-x64',
'pty.node'
),
createMachOBuffer('x64')
);
await afterPackModule({
appOutDir: tempDir,
electronPlatformName: 'darwin',
arch: 3,
});
expect(
fs.existsSync(
path.join(
tempDir,
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules',
'node-pty',
'prebuilds',
'darwin-x64'
)
)
).toBe(false);
});
});

View file

@ -205,4 +205,36 @@ describe('configValidation', () => {
});
}
});
it('accepts Codex provider connection beta updates', () => {
const result = validateConfigUpdatePayload('providerConnections', {
codex: {
apiKeyBetaEnabled: true,
authMode: 'api_key',
},
});
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.data).toEqual({
codex: {
apiKeyBetaEnabled: true,
authMode: 'api_key',
},
});
}
});
it('rejects invalid Codex auth modes in providerConnections', () => {
const result = validateConfigUpdatePayload('providerConnections', {
codex: {
authMode: 'auto',
},
});
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain('providerConnections.codex.authMode');
}
});
});

View file

@ -19,6 +19,7 @@ describe('SkillMetadataParser', () => {
rawContent: `---
name: demo-skill
description: Test skill
version: 1.2.3
allowed-tools:
- Read
compatibility: Requires network and API key
@ -41,6 +42,12 @@ unknown-key: true
'compatibility-advisory',
])
);
expect(item.issues).not.toContainEqual(
expect.objectContaining({
code: 'unknown-frontmatter-keys',
message: expect.stringContaining('version'),
})
);
});
it('marks missing frontmatter as invalid', () => {

View file

@ -0,0 +1,113 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
const execCliMock = vi.fn();
const resolveBinaryMock = vi.fn();
vi.mock('@main/utils/childProcess', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
return {
...actual,
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
};
});
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: {
resolve: () => resolveBinaryMock(),
clearCache: vi.fn(),
},
}));
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: vi.fn(() => Promise.resolve({})),
getCachedShellEnv: vi.fn(() => null),
getShellPreferredHome: vi.fn(() => '/Users/tester'),
}));
vi.mock('@main/utils/cliPathMerge', () => ({
buildMergedCliPath: vi.fn(() => '/usr/local/bin:/usr/bin'),
}));
vi.mock('@main/utils/cliAuthDiagLog', () => ({
appendCliAuthDiag: vi.fn(() => Promise.resolve(undefined)),
}));
vi.mock('https', () => ({
default: {
get: vi.fn(() => {
const req = {
setTimeout: vi.fn(),
on: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === 'error') {
queueMicrotask(() => handler(new Error('offline')));
}
return req;
}),
destroy: vi.fn(),
};
return req;
}),
},
}));
vi.mock('http', () => ({
default: {
get: vi.fn(() => {
const req = {
setTimeout: vi.fn(),
on: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === 'error') {
queueMicrotask(() => handler(new Error('offline')));
}
return req;
}),
destroy: vi.fn(),
};
return req;
}),
},
}));
import { CliInstallerService } from '@main/services/infrastructure/CliInstallerService';
describe('CliInstallerService health check', () => {
let service: CliInstallerService;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
service = new CliInstallerService();
});
it('does not treat a found binary as installed until --version succeeds', async () => {
resolveBinaryMock.mockResolvedValue('/usr/local/bin/claude');
execCliMock.mockRejectedValueOnce(new Error('spawn EACCES'));
const status = await service.getStatus();
expect(status.installed).toBe(false);
expect(status.binaryPath).toBe('/usr/local/bin/claude');
expect(status.installedVersion).toBeNull();
expect(status.launchError).toContain('spawn EACCES');
expect(status.authStatusChecking).toBe(false);
});
it('marks the CLI installed after a successful version probe', async () => {
resolveBinaryMock.mockResolvedValue('/usr/local/bin/claude');
execCliMock
.mockResolvedValueOnce({ stdout: '2.1.100 (Claude Code)', stderr: '' })
.mockResolvedValueOnce({
stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
stderr: '',
});
const status = await service.getStatus();
expect(status.installed).toBe(true);
expect(status.binaryPath).toBe('/usr/local/bin/claude');
expect(status.installedVersion).toBe('2.1.100');
expect(status.launchError).toBeNull();
});
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
getExpectedLatestMacArtifacts,
getExpectedReleaseAssetUrl,
getLatestMacMetadataUrl,
isLatestMacMetadataCompatible,
parseReleaseMetadataAssetNames,
} from '../../../../src/main/services/infrastructure/updaterReleaseMetadata';
describe('updaterReleaseMetadata', () => {
it('builds platform-specific asset URLs', () => {
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'arm64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg'
);
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'x64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3.dmg'
);
expect(getExpectedReleaseAssetUrl('1.2.3', 'win32', 'x64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI.Setup.1.2.3.exe'
);
expect(getExpectedReleaseAssetUrl('1.2.3', 'linux', 'x64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3.AppImage'
);
});
it('extracts updater asset names from latest-mac.yml text', () => {
const metadata = `
version: 1.2.3
files:
- url: "Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip"
sha512: abc
size: 123
- url: 'Claude.Agent.Teams.UI-1.2.3-arm64.dmg'
sha512: def
size: 456
path: Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip
`;
expect(parseReleaseMetadataAssetNames(metadata)).toEqual(
new Set([
'Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip',
'Claude.Agent.Teams.UI-1.2.3-arm64.dmg',
])
);
});
it('validates arch compatibility for latest-mac.yml', () => {
const version = '1.2.3';
const arm64Metadata = `
version: ${version}
files:
- url: Claude.Agent.Teams.UI-${version}-arm64-mac.zip
sha512: abc
size: 123
- url: Claude.Agent.Teams.UI-${version}-arm64.dmg
sha512: def
size: 456
path: Claude.Agent.Teams.UI-${version}-arm64-mac.zip
`;
expect(getExpectedLatestMacArtifacts(version, 'arm64')).toEqual([
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`,
`Claude.Agent.Teams.UI-${version}-arm64.dmg`,
]);
expect(getLatestMacMetadataUrl(version)).toBe(
`https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`
);
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'arm64')).toBe(true);
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'x64')).toBe(false);
});
});

View file

@ -8,6 +8,8 @@ const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
const getShellPreferredHomeMock = vi.fn<() => string>();
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
const enrichProviderStatusMock = vi.fn((provider) => Promise.resolve(provider));
const enrichProviderStatusesMock = vi.fn((providers) => Promise.resolve(providers));
vi.mock('@main/utils/childProcess', () => ({
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
@ -25,15 +27,31 @@ vi.mock('@main/utils/shellEnv', () => ({
vi.mock('fs', () => ({
default: {
readFileSync: () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
},
promises: {
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
},
},
readFileSync: () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
},
promises: {
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
},
}));
vi.mock('@main/services/runtime/ProviderConnectionService', () => ({
providerConnectionService: {
enrichProviderStatus: (...args: Parameters<typeof enrichProviderStatusMock>) =>
enrichProviderStatusMock(...args),
enrichProviderStatuses: (...args: Parameters<typeof enrichProviderStatusesMock>) =>
enrichProviderStatusesMock(...args),
applyAllConfiguredConnectionEnv: vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env)),
},
}));
describe('ClaudeMultimodelBridgeService', () => {
beforeEach(() => {
vi.resetModules();
@ -42,25 +60,27 @@ describe('ClaudeMultimodelBridgeService', () => {
getCachedShellEnvMock.mockReturnValue({});
getShellPreferredHomeMock.mockReturnValue('/Users/tester');
resolveInteractiveShellEnvMock.mockResolvedValue({});
readFileMock.mockImplementation(async (filePath) => {
readFileMock.mockImplementation((filePath) => {
if (String(filePath) === '/Users/tester/.claude.json') {
return JSON.stringify({
geminiResolvedBackend: 'cli',
geminiLastAuthMethod: 'cli_oauth_personal',
geminiProjectId: 'demo-project',
});
return Promise.resolve(
JSON.stringify({
geminiResolvedBackend: 'cli',
geminiLastAuthMethod: 'cli_oauth_personal',
geminiProjectId: 'demo-project',
})
);
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
});
it('parses object-based model lists and exposes Gemini runtime status', async () => {
execCliMock.mockImplementation(async (_binaryPath, args, options) => {
execCliMock.mockImplementation((_binaryPath, args, options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const env = options?.env ?? {};
if (normalizedArgs === 'auth status --json --provider all') {
return {
return Promise.resolve({
stdout: JSON.stringify({
providers: {
anthropic: {
@ -85,11 +105,14 @@ describe('ClaudeMultimodelBridgeService', () => {
}),
stderr: '',
exitCode: 0,
};
});
}
if (normalizedArgs === 'model list --json --provider all' && env.CLAUDE_CODE_USE_GEMINI === '1') {
return {
if (
normalizedArgs === 'model list --json --provider all' &&
env.CLAUDE_CODE_ENTRY_PROVIDER === 'gemini'
) {
return Promise.resolve({
stdout: JSON.stringify({
providers: {
gemini: {
@ -99,11 +122,11 @@ describe('ClaudeMultimodelBridgeService', () => {
}),
stderr: '',
exitCode: 0,
};
});
}
if (normalizedArgs === 'model list --json --provider all') {
return {
return Promise.resolve({
stdout: JSON.stringify({
providers: {
anthropic: {
@ -116,18 +139,17 @@ describe('ClaudeMultimodelBridgeService', () => {
}),
stderr: '',
exitCode: 0,
};
});
}
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } = await import(
'@main/services/runtime/ClaudeMultimodelBridgeService'
);
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const providers = await service.getProviderStatuses('/mock/free-code');
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
expect(providers).toHaveLength(3);
expect(providers[0]).toMatchObject({

View file

@ -0,0 +1,280 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => getCachedShellEnvMock(),
}));
describe('ProviderConnectionService', () => {
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
function createConfig(authMode: 'auto' | 'oauth' | 'api_key' = 'auto') {
return {
providerConnections: {
anthropic: {
authMode,
},
codex: {
apiKeyBetaEnabled: false,
authMode: 'oauth' as const,
},
},
};
}
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
getCachedShellEnvMock.mockReturnValue({});
delete process.env.OPENAI_API_KEY;
});
afterEach(() => {
if (originalOpenAiApiKey === undefined) {
delete process.env.OPENAI_API_KEY;
return;
}
process.env.OPENAI_API_KEY = originalOpenAiApiKey;
});
it('removes Anthropic environment credentials when OAuth mode is selected', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('oauth'),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_API_KEY: 'direct-key',
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
},
'anthropic'
);
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});
it('injects the stored Anthropic API key when api_key mode is selected', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
value: 'stored-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () => createConfig('api_key'),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_API_KEY: undefined,
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
},
'anthropic'
);
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_API_KEY');
expect(result.ANTHROPIC_API_KEY).toBe('stored-key');
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});
it('does not treat ANTHROPIC_AUTH_TOKEN as an API key in api_key mode', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('api_key'),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
ANTHROPIC_AUTH_TOKEN: 'oauth-token',
},
'anthropic'
);
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});
it('prefers stored API key status over environment detection', async () => {
getCachedShellEnvMock.mockReturnValue({
ANTHROPIC_API_KEY: 'shell-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
value: 'stored-key',
}),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const info = await service.getConnectionInfo('anthropic');
expect(info).toMatchObject({
supportsOAuth: true,
supportsApiKey: true,
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
});
});
it('does not report ANTHROPIC_AUTH_TOKEN as an API key credential source', async () => {
getCachedShellEnvMock.mockReturnValue({
ANTHROPIC_AUTH_TOKEN: 'oauth-token',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const info = await service.getConnectionInfo('anthropic');
expect(info.apiKeyConfigured).toBe(false);
expect(info.apiKeySource).toBeNull();
expect(info.apiKeySourceLabel).toBeNull();
});
it('keeps Codex API key beta opt-in disabled by default', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const info = await service.getConnectionInfo('codex');
expect(info).toMatchObject({
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: [],
configuredAuthMode: null,
apiKeyBetaAvailable: true,
apiKeyBetaEnabled: false,
apiKeyConfigured: false,
});
});
it('injects OPENAI_API_KEY and selects the API backend when Codex API key mode is enabled', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'OPENAI_API_KEY',
value: 'openai-stored-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () => ({
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: true,
authMode: 'api_key',
},
},
}),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
OPENAI_API_KEY: undefined,
CLAUDE_CODE_CODEX_BACKEND: 'auto',
},
'codex'
);
expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
expect(result.OPENAI_API_KEY).toBe('openai-stored-key');
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('api');
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
});
it('forces the Codex adapter and strips OPENAI_API_KEY in OAuth mode', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => ({
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: true,
authMode: 'oauth',
},
},
}),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
OPENAI_API_KEY: 'shell-openai-key',
CLAUDE_CODE_CODEX_BACKEND: 'auto',
},
'codex'
);
expect(result.OPENAI_API_KEY).toBeUndefined();
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
});
});

View file

@ -4,8 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
const mockGetShellPreferredHome = vi.fn<() => string>();
const mockGetClaudeBasePath = vi.fn<() => string>();
const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
const statMock = vi.fn<(filePath: PathLike) => Promise<{ isFile: () => boolean }>>();
@ -19,10 +21,18 @@ vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(),
}));
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => mockGetClaudeBasePath(),
}));
vi.mock('@main/services/team/cliFlavor', () => ({
getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(),
}));
vi.mock('@main/services/team/ClaudeDoctorProbe', () => ({
getDoctorInvokedCandidates: (commandName: string) => mockGetDoctorInvokedCandidates(commandName),
}));
vi.mock('fs', () => ({
default: {
constants: { X_OK: 1 },
@ -41,23 +51,31 @@ vi.mock('fs', () => ({
describe('ClaudeBinaryResolver', () => {
const originalPlatform = process.platform;
const originalCwd = process.cwd;
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_freecode';
const originalResourcesPath = process.resourcesPath;
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_runtime';
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin');
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
mockGetClaudeBasePath.mockReturnValue('/Users/tester/.claude');
mockResolveInteractiveShellEnv.mockResolvedValue({});
mockGetConfiguredCliFlavor.mockReturnValue('free-code');
mockGetConfiguredCliFlavor.mockReturnValue('agent_teams_orchestrator');
mockGetDoctorInvokedCandidates.mockResolvedValue([]);
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
writable: true,
});
process.cwd = vi.fn(() => workspaceRoot);
Object.defineProperty(process, 'resourcesPath', {
value: '/Applications/Claude Agent Teams UI.app/Contents/Resources',
configurable: true,
writable: true,
});
delete process.env.CLAUDE_CLI_PATH;
delete process.env.CLAUDE_FREE_CODE_CLI_PATH;
delete process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
});
afterEach(() => {
@ -67,18 +85,23 @@ describe('ClaudeBinaryResolver', () => {
writable: true,
});
process.cwd = originalCwd;
Object.defineProperty(process, 'resourcesPath', {
value: originalResourcesPath,
configurable: true,
writable: true,
});
vi.unstubAllEnvs();
});
it('resolves free-code runtime from an explicit CLAUDE_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev';
it('resolves agent_teams_orchestrator runtime from an explicit CLAUDE_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_CLI_PATH = expectedBinary;
accessMock.mockImplementation(async (filePath) => {
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return;
return Promise.resolve();
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -88,15 +111,15 @@ describe('ClaudeBinaryResolver', () => {
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('prefers the dedicated CLAUDE_FREE_CODE_CLI_PATH override in free-code mode', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev';
process.env.CLAUDE_FREE_CODE_CLI_PATH = expectedBinary;
it('prefers the dedicated CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = expectedBinary;
accessMock.mockImplementation(async (filePath) => {
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return;
return Promise.resolve();
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -106,17 +129,17 @@ describe('ClaudeBinaryResolver', () => {
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('ignores CLAUDE_FREE_CODE_CLI_PATH when Claude flavor is selected', async () => {
process.env.CLAUDE_FREE_CODE_CLI_PATH =
'/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev';
it('ignores the dedicated orchestrator overrides when Claude flavor is selected', async () => {
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
mockGetConfiguredCliFlavor.mockReturnValue('claude');
const expectedBinary = '/usr/local/bin/claude';
accessMock.mockImplementation(async (filePath) => {
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return;
return Promise.resolve();
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -126,14 +149,14 @@ describe('ClaudeBinaryResolver', () => {
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('falls back to claude-multimodel on PATH for free-code runtime', async () => {
it('falls back to claude-multimodel on PATH for agent_teams_orchestrator runtime', async () => {
const expectedBinary = '/usr/local/bin/claude-multimodel';
accessMock.mockImplementation(async (filePath) => {
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return;
return Promise.resolve();
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -142,4 +165,62 @@ describe('ClaudeBinaryResolver', () => {
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('prefers the bundled runtime binary for packaged agent_teams_orchestrator builds', async () => {
const expectedBinary =
'/Applications/Claude Agent Teams UI.app/Contents/Resources/runtime/claude-multimodel';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('finds npm-local Claude install in the vendor bin directory', async () => {
mockGetConfiguredCliFlavor.mockReturnValue('claude');
const expectedBinary = '/Users/tester/.claude/local/node_modules/.bin/claude';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('falls back to the doctor Invoked path when normal resolution misses the CLI', async () => {
mockGetConfiguredCliFlavor.mockReturnValue('claude');
mockGetDoctorInvokedCandidates.mockResolvedValue([
'/Users/tester/.local/share/claude/versions/2.1.101',
]);
const expectedBinary = '/Users/tester/.local/share/claude/versions/2.1.101';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(mockGetDoctorInvokedCandidates).toHaveBeenCalledWith('claude');
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
});

View file

@ -0,0 +1,74 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { extractDoctorInvokedCandidates } from '@main/services/team/ClaudeDoctorProbe';
describe('ClaudeDoctorProbe', () => {
it('extracts a single invoked path from doctor output', () => {
const output = `
Diagnostics
Currently running: native (2.1.101)
Path: /Users/belief/.local/share/claude/versions/2.1.101
Invoked: /Users/belief/.local/share/claude/versions/2.1.101
Config install method: native
Press Enter to continue
`;
expect(extractDoctorInvokedCandidates(output)).toEqual([
'/Users/belief/.local/share/claude/versions/2.1.101',
]);
});
it('reconstructs wrapped invoked paths without corrupting spaces', () => {
const output = `
\u001B[2J
Diagnostics
Invoked: /Applications/Claude Agent
Teams UI.app/Contents/Resources/runtime/clau
de-multimodel
Config install method: native
Press Enter to continue
`;
expect(extractDoctorInvokedCandidates(output)).toEqual([
'/Applications/Claude Agent Teams UI.app/Contents/Resources/runtime/claude-multimodel',
]);
});
it('keeps all invoked candidates across repeated redraw frames', () => {
const output = `
Diagnostics
Invoked: /Users/belief/.local/sh
are/claude/versions/2.1.100
Config install method: native
Press Enter to continue
Diagnostics
Invoked: /Users/belief/.local/sh
are/claude/versions/2.1.101
Config install method: native
Press Enter to continue
`;
expect(extractDoctorInvokedCandidates(output)).toEqual([
'/Users/belief/.local/share/claude/versions/2.1.100',
'/Users/belief/.local/share/claude/versions/2.1.101',
]);
});
it('accepts ASCII bullet variants from degraded terminal captures', () => {
const output = `
Diagnostics
L Path: /Users/vladislavkonovalov/.nvm/versions/node/v22.22.1/bin/node
L Invoked: /Users/vladislavkonovalov/.claude/local/node_modules/.bin/claude
L Config install method: local
`;
expect(extractDoctorInvokedCandidates(output)).toEqual([
'/Users/vladislavkonovalov/.claude/local/node_modules/.bin/claude',
]);
});
});

View file

@ -7,6 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
const hoisted = vi.hoisted(() => ({
paths: {
claudeRoot: '',
teamsBase: '',
tasksBase: '',
},
}));
let tempClaudeRoot = '';
let tempTeamsBase = '';
let tempTasksBase = '';
@ -24,14 +32,15 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return {
...actual,
getAutoDetectedClaudeBasePath: () => tempClaudeRoot,
getClaudeBasePath: () => tempClaudeRoot,
getTeamsBasePath: () => tempTeamsBase,
getTasksBasePath: () => tempTasksBase,
getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot,
getClaudeBasePath: () => hoisted.paths.claudeRoot,
getTeamsBasePath: () => hoisted.paths.teamsBase,
getTasksBasePath: () => hoisted.paths.tasksBase,
};
});
import {
buildAddMemberSpawnMessage,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
@ -90,6 +99,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prompts-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
hoisted.paths.claudeRoot = tempClaudeRoot;
hoisted.paths.teamsBase = tempTeamsBase;
hoisted.paths.tasksBase = tempTasksBase;
setAppDataBasePath(tempClaudeRoot);
fs.mkdirSync(tempTeamsBase, { recursive: true });
fs.mkdirSync(tempTasksBase, { recursive: true });
@ -206,6 +218,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
);
expect(prompt).toContain(
'Review is a state transition on the EXISTING work task.'
);
expect(prompt).toContain(
'The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.'
);
expect(prompt).toContain('task_create_from_message');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
@ -258,6 +276,22 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
it('add-member spawn prompt tells teammates to keep review on the same task', () => {
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
name: 'alice',
role: 'developer',
});
expect(prompt).toContain('Review flow rule: review is a state transition on the SAME work task');
expect(prompt).toContain('Do NOT create a separate "review task"');
expect(prompt).toContain(
'If no reviewer exists, leave #X completed.'
);
expect(prompt).toContain(
'If you are the reviewer for task #X, call review_start on #X first, then review_approve or review_request_changes on #X itself.'
);
});
it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => {
const teamName = 'forward-live-team';
const teamDir = path.join(tempTeamsBase, teamName);
@ -380,6 +414,18 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(
'review_request already notifies the reviewer'
);
expect(prompt).toContain(
'By default, NEVER create a separate "review task".'
);
expect(prompt).toContain(
'Only move #X into REVIEW when a real reviewer exists for #X.'
);
expect(prompt).not.toContain(
'Only create a separate review reminder/assignment task'
);
expect(prompt).toContain(
'Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.'
);
await svc.cancelProvisioning(runId);
});

View file

@ -25,7 +25,7 @@ describe('cliFlavor', () => {
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
expect(getConfiguredCliFlavor()).toBe('free-code');
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
});
it('uses claude runtime when multimodel is disabled in config', async () => {

View file

@ -2,12 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetCachedShellEnv = vi.fn<() => Record<string, string> | null>();
const mockGetShellPreferredHome = vi.fn<() => string>();
const mockGetClaudeBasePath = vi.fn<() => string>();
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => mockGetCachedShellEnv(),
getShellPreferredHome: () => mockGetShellPreferredHome(),
}));
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => mockGetClaudeBasePath(),
}));
describe('buildMergedCliPath', () => {
let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath;
const originalPlatform = process.platform;
@ -16,6 +21,7 @@ describe('buildMergedCliPath', () => {
vi.resetModules();
mockGetShellPreferredHome.mockReturnValue('/home/testuser');
mockGetCachedShellEnv.mockReturnValue(null);
mockGetClaudeBasePath.mockReturnValue('/home/testuser/.claude');
process.env.PATH = '/usr/bin';
({ buildMergedCliPath } = await import('@main/utils/cliPathMerge'));
});
@ -29,6 +35,7 @@ describe('buildMergedCliPath', () => {
const p = buildMergedCliPath(null);
expect(p.split(':')).toEqual(
expect.arrayContaining([
'/home/testuser/.claude/local/node_modules/.bin',
'/home/testuser/.local/bin',
'/home/testuser/.npm-global/bin',
'/home/testuser/.npm/bin',
@ -37,7 +44,7 @@ describe('buildMergedCliPath', () => {
'/usr/bin',
])
);
expect(p.startsWith('/home/testuser/.local/bin')).toBe(true);
expect(p.startsWith('/home/testuser/.claude/local/node_modules/.bin')).toBe(true);
});
it('on win32 with cold shell cache uses semicolon and npm-style dirs', () => {
@ -58,6 +65,7 @@ describe('buildMergedCliPath', () => {
const p = buildMergedCliPath(null);
expect(p.startsWith('/opt/custom/bin')).toBe(true);
expect(p).toContain('/bin');
expect(p).toContain('/home/testuser/.claude/local/node_modules/.bin');
expect(p).toContain('/usr/bin');
expect(p).not.toContain('/home/testuser/.local/bin');
});

View file

@ -2,7 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type StoreState = {
interface StoreState {
cliStatus: Record<string, unknown> | null;
cliStatusLoading: boolean;
cliProviderStatusLoading: Record<string, boolean>;
@ -37,9 +37,12 @@ type StoreState = {
};
updateConfig: ReturnType<typeof vi.fn>;
openExtensionsTab: ReturnType<typeof vi.fn>;
};
}
const storeState = {} as StoreState;
let providerRuntimeSettingsDialogProps: {
onSelectBackend?: (providerId: string, backendId: string) => Promise<void> | void;
} | null = null;
vi.mock('@renderer/api', () => ({
api: {
@ -49,11 +52,16 @@ vi.mock('@renderer/api', () => ({
}));
vi.mock('@renderer/components/common/ConfirmDialog', () => ({
confirm: vi.fn(async () => true),
confirm: vi.fn(() => Promise.resolve(true)),
}));
vi.mock('@renderer/components/runtime/ProviderRuntimeSettingsDialog', () => ({
ProviderRuntimeSettingsDialog: () => null,
ProviderRuntimeSettingsDialog: (props: {
onSelectBackend?: (providerId: string, backendId: string) => Promise<void> | void;
}) => {
providerRuntimeSettingsDialogProps = props;
return null;
},
}));
vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({
@ -133,6 +141,7 @@ describe('CLI status visibility during completed install state', () => {
});
beforeEach(() => {
providerRuntimeSettingsDialogProps = null;
storeState.cliStatus = createInstalledCliStatus();
storeState.cliStatusLoading = false;
storeState.cliProviderStatusLoading = {};
@ -145,10 +154,10 @@ describe('CLI status visibility during completed install state', () => {
storeState.cliInstallerDetail = null;
storeState.cliInstallerRawChunks = [];
storeState.cliCompletedVersion = '2.1.100';
storeState.bootstrapCliStatus = vi.fn(async () => undefined);
storeState.fetchCliStatus = vi.fn(async () => undefined);
storeState.fetchCliProviderStatus = vi.fn(async () => undefined);
storeState.invalidateCliStatus = vi.fn(async () => undefined);
storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchCliProviderStatus = vi.fn().mockResolvedValue(undefined);
storeState.invalidateCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.installCli = vi.fn();
storeState.appConfig = {
general: {
@ -158,7 +167,7 @@ describe('CLI status visibility during completed install state', () => {
providerBackends: {},
},
};
storeState.updateConfig = vi.fn(async () => undefined);
storeState.updateConfig = vi.fn().mockResolvedValue(undefined);
storeState.openExtensionsTab = vi.fn();
});
@ -209,6 +218,41 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.fetchCliProviderStatus = vi.fn(() =>
Promise.reject(new Error('refresh failed'))
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
const onSelectBackend = providerRuntimeSettingsDialogProps?.onSelectBackend;
expect(onSelectBackend).toBeTypeOf('function');
await expect(onSelectBackend?.('codex', 'api')).rejects.toThrow(
'Runtime updated, but failed to refresh provider status.'
);
expect(storeState.updateConfig).toHaveBeenCalledWith('runtime', {
providerBackends: {
codex: 'api',
},
});
expect(storeState.fetchCliProviderStatus).toHaveBeenCalledWith('codex');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps auth verification inside the main installed banner instead of rendering a second banner', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
@ -235,6 +279,34 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('shows a degraded runtime warning when a binary is found but the health check fails', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
installed: false,
installedVersion: null,
binaryPath: '/Users/tester/.claude/local/node_modules/.bin/claude',
launchError: 'spawn EACCES',
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('failed to start');
expect(host.textContent).toContain('Reinstall Claude CLI');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps installed controls visible in settings and wires the Extensions button correctly', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = createInstalledCliStatus({
@ -272,6 +344,41 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('preserves settings runtime backend refresh errors for the manage dialog', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.fetchCliProviderStatus = vi.fn(() =>
Promise.reject(new Error('refresh failed'))
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusSection));
await Promise.resolve();
});
const onSelectBackend = providerRuntimeSettingsDialogProps?.onSelectBackend;
expect(onSelectBackend).toBeTypeOf('function');
await expect(onSelectBackend?.('codex', 'api')).rejects.toThrow(
'Runtime updated, but failed to refresh provider status.'
);
expect(storeState.updateConfig).toHaveBeenCalledWith('runtime', {
providerBackends: {
codex: 'api',
},
});
expect(storeState.fetchCliProviderStatus).toHaveBeenCalledWith('codex');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('hides the settings Extensions button when the runtime is not authenticated yet', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = createInstalledCliStatus({

View file

@ -0,0 +1,896 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliProviderStatus } from '@shared/types';
interface StoreState {
appConfig: {
providerConnections: {
anthropic: {
authMode: 'auto' | 'oauth' | 'api_key';
};
codex: {
apiKeyBetaEnabled: boolean;
authMode: 'oauth' | 'api_key';
};
};
};
apiKeys: {
id: string;
envVarName: string;
scope: 'user' | 'project';
name: string;
maskedValue?: string;
createdAt?: number;
}[];
apiKeysLoading: boolean;
apiKeysError: string | null;
apiKeySaving: boolean;
apiKeyStorageStatus: { available: boolean; backend: string; detail?: string | null } | null;
fetchApiKeys: ReturnType<typeof vi.fn>;
fetchApiKeyStorageStatus: ReturnType<typeof vi.fn>;
saveApiKey: ReturnType<typeof vi.fn>;
deleteApiKey: ReturnType<typeof vi.fn>;
updateConfig: ReturnType<typeof vi.fn>;
}
const storeState = {} as StoreState;
vi.mock('@renderer/store', () => {
const useStore = (selector: (state: StoreState) => unknown) => selector(storeState);
Object.assign(useStore, {
setState: vi.fn(),
});
return { useStore };
});
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
type = 'button',
}: React.PropsWithChildren<{
onClick?: () => void;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) =>
React.createElement('div', { 'data-testid': 'dialog-content' }, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
SelectTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement('button', { type: 'button' }, children),
SelectValue: () => React.createElement('span', null, 'select-value'),
SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) =>
React.createElement('button', { type: 'button' }, children),
}));
vi.mock('@renderer/components/ui/tabs', () => ({
Tabs: ({
children,
value,
onValueChange,
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
React.createElement('div', { 'data-value': value, 'data-on-change': Boolean(onValueChange) }, children),
TabsList: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
TabsTrigger: ({
children,
value,
onClick,
}: React.PropsWithChildren<{ value: string; onClick?: () => void }>) =>
React.createElement(
'button',
{
type: 'button',
'data-value': value,
onClick,
},
children
),
}));
vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({
ProviderRuntimeBackendSelector: ({
provider,
onSelect,
}: {
provider: { providerId: string };
onSelect: (providerId: string, backendId: string) => void;
}) =>
React.createElement(
'button',
{
type: 'button',
onClick: () => onSelect(provider.providerId, 'api'),
},
'Select runtime backend'
),
getProviderRuntimeBackendSummary: () => null,
}));
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
function createCodexProvider(
overrides?: Partial<CliProviderStatus['connection']> & {
authenticated?: boolean;
authMethod?: string | null;
}
): CliProviderStatus {
return {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: overrides?.authenticated ?? true,
authMethod: overrides?.authMethod ?? 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected',
models: ['gpt-5-codex'],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
selectedBackendId: 'auto',
resolvedBackendId: 'adapter',
availableBackends: [],
backend: {
kind: 'adapter',
label: 'Codex subscription',
},
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: overrides?.apiKeyBetaEnabled ? ['oauth', 'api_key'] : [],
configuredAuthMode: overrides?.configuredAuthMode ?? null,
apiKeyBetaAvailable: true,
apiKeyBetaEnabled: overrides?.apiKeyBetaEnabled ?? false,
apiKeyConfigured: overrides?.apiKeyConfigured ?? false,
apiKeySource: overrides?.apiKeySource ?? null,
apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null,
},
};
}
function createAnthropicProvider(
overrides?: Partial<CliProviderStatus['connection']> & {
authenticated?: boolean;
authMethod?: string | null;
}
): CliProviderStatus {
return {
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: overrides?.authenticated ?? true,
authMethod: overrides?.authMethod ?? 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected',
models: ['claude-sonnet-4-6'],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
selectedBackendId: null,
resolvedBackendId: null,
availableBackends: [],
backend: null,
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['auto', 'oauth', 'api_key'],
configuredAuthMode: overrides?.configuredAuthMode ?? 'auto',
apiKeyConfigured: overrides?.apiKeyConfigured ?? false,
apiKeySource: overrides?.apiKeySource ?? null,
apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null,
},
};
}
function createGeminiProvider(): CliProviderStatus {
return {
providerId: 'gemini',
displayName: 'Gemini',
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
statusMessage: 'Connected',
models: ['gemini-2.5-pro'],
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: true,
},
selectedBackendId: 'auto',
resolvedBackendId: 'api',
availableBackends: [
{
id: 'auto',
label: 'Auto',
description: 'Automatically choose the best backend.',
selectable: true,
recommended: true,
available: true,
},
{
id: 'api',
label: 'Gemini API',
description: 'Use GEMINI_API_KEY and Google AI Studio billing.',
selectable: true,
recommended: false,
available: true,
},
],
backend: {
kind: 'api',
label: 'Gemini API',
},
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: [],
configuredAuthMode: null,
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
},
};
}
function findButtonByText(container: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes(text)
);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Button with text "${text}" not found`);
}
return button;
}
describe('ProviderRuntimeSettingsDialog Codex connection flows', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.appConfig = {
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: false,
authMode: 'oauth',
},
},
};
storeState.apiKeys = [];
storeState.apiKeysLoading = false;
storeState.apiKeysError = null;
storeState.apiKeySaving = false;
storeState.apiKeyStorageStatus = { available: true, backend: 'keytar', detail: null };
storeState.fetchApiKeys = vi.fn(() => Promise.resolve(undefined));
storeState.fetchApiKeyStorageStatus = vi.fn(() => Promise.resolve(undefined));
storeState.saveApiKey = vi.fn(() => Promise.resolve(undefined));
storeState.deleteApiKey = vi.fn(() => Promise.resolve(undefined));
storeState.updateConfig = vi.fn((section: string, data: Record<string, unknown>) => {
if (section === 'providerConnections') {
const nextProviderConnections = data as Partial<StoreState['appConfig']['providerConnections']>;
storeState.appConfig = {
...storeState.appConfig,
providerConnections: {
anthropic: {
...storeState.appConfig.providerConnections.anthropic,
...(nextProviderConnections.anthropic ?? {}),
},
codex: {
...storeState.appConfig.providerConnections.codex,
...(nextProviderConnections.codex ?? {}),
},
},
};
}
return Promise.resolve(undefined);
});
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('switches Codex into api_key mode when enabling API key mode', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createCodexProvider({
apiKeyBetaEnabled: false,
configuredAuthMode: null,
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
}),
],
initialProviderId: 'codex',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Enable API key mode').click();
await Promise.resolve();
});
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
codex: {
apiKeyBetaEnabled: true,
authMode: 'api_key',
},
});
expect(onRefreshProvider).toHaveBeenCalledWith('codex');
});
it('shows a loading message while switching Codex to OpenAI API key', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
let resolveUpdate: (() => void) | null = null;
storeState.appConfig.providerConnections.codex = {
apiKeyBetaEnabled: true,
authMode: 'oauth',
};
storeState.updateConfig = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUpdate = resolve;
})
);
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createCodexProvider({
apiKeyBetaEnabled: true,
configuredAuthMode: 'oauth',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
}),
],
initialProviderId: 'codex',
onSelectBackend: vi.fn(),
onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'OpenAI API key').click();
await Promise.resolve();
});
expect(host.textContent).toContain('Switching to OpenAI API key...');
expect(host.textContent).toContain('Switching...');
await act(async () => {
resolveUpdate?.();
await Promise.resolve();
});
});
it('renders Anthropics connection methods as cards and hides the empty runtime section', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from ANTHROPIC_API_KEY',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Connection method');
expect(host.textContent).toContain('Auto');
expect(host.textContent).toContain('Anthropic subscription');
expect(host.textContent).toContain('API key');
expect(host.textContent).not.toContain('Authentication method');
expect(host.textContent).not.toContain('Runtime backend is not configurable');
});
it('switches Anthropic to API key mode from the connection cards', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'API key').click();
await Promise.resolve();
});
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
anthropic: {
authMode: 'api_key',
},
});
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
});
it('does not show Connect Anthropic when Auto is already authenticated via API key', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
authenticated: true,
authMethod: 'api_key',
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from ANTHROPIC_API_KEY',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
onRequestLogin: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('Connect Anthropic');
expect(host.textContent).not.toContain('Reconnect Anthropic');
});
it('keeps the API key form open and shows an error when delete fails', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
storeState.apiKeys = [
{
id: 'key-1',
envVarName: 'ANTHROPIC_API_KEY',
scope: 'user',
name: 'Anthropic API Key',
maskedValue: 'sk-ant-...1234',
createdAt: Date.now(),
},
];
storeState.deleteApiKey = vi.fn(() => Promise.reject(new Error('Delete failed')));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
configuredAuthMode: 'api_key',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Replace key').click();
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Delete').click();
await Promise.resolve();
});
expect(host.textContent).toContain('Delete failed');
expect(host.textContent).toContain('Update key');
expect(onRefreshProvider).not.toHaveBeenCalled();
});
it('shows a deleted stored key as removed even if provider refresh fails afterwards', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.reject(new Error('refresh failed')));
storeState.apiKeys = [
{
id: 'key-1',
envVarName: 'ANTHROPIC_API_KEY',
scope: 'user',
name: 'Anthropic API Key',
maskedValue: 'sk-ant-...1234',
createdAt: Date.now(),
},
];
storeState.deleteApiKey = vi.fn((id: string) => {
storeState.apiKeys = storeState.apiKeys.filter((entry) => entry.id !== id);
return Promise.resolve(undefined);
});
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
configuredAuthMode: 'api_key',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Replace key').click();
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Delete').click();
await Promise.resolve();
});
expect(host.textContent).toContain('API key deleted, but failed to refresh provider status.');
expect(host.textContent).toContain('Not configured');
expect(host.textContent).not.toContain('sk-ant-...1234');
});
it('shows a connection error and skips refresh when auth mode update fails', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
storeState.updateConfig = vi.fn(() => Promise.reject(new Error('Config update failed')));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'API key').click();
await Promise.resolve();
});
expect(host.textContent).toContain('Config update failed');
expect(host.textContent).not.toContain('Switching to API key...');
expect(host.textContent).not.toContain('Switching...');
expect(onRefreshProvider).not.toHaveBeenCalled();
});
it('clears Codex beta loading state when enabling API key mode fails early', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
storeState.updateConfig = vi.fn(() => Promise.reject(new Error('Config update failed')));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createCodexProvider({
apiKeyBetaEnabled: false,
configuredAuthMode: null,
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'codex',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Enable API key mode').click();
await Promise.resolve();
});
expect(host.textContent).toContain('Config update failed');
expect(host.textContent).not.toContain('Enabling API key mode...');
expect(onRefreshProvider).not.toHaveBeenCalled();
});
it('reports refresh failures separately after a successful auth mode update', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.reject(new Error('refresh failed')));
storeState.updateConfig = vi.fn((section: string, data: Record<string, unknown>) => {
if (section === 'providerConnections') {
const nextProviderConnections = data as Partial<StoreState['appConfig']['providerConnections']>;
storeState.appConfig = {
...storeState.appConfig,
providerConnections: {
anthropic: {
...storeState.appConfig.providerConnections.anthropic,
...(nextProviderConnections.anthropic ?? {}),
},
codex: {
...storeState.appConfig.providerConnections.codex,
...(nextProviderConnections.codex ?? {}),
},
},
};
}
return Promise.resolve(undefined);
});
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'API key').click();
await Promise.resolve();
});
expect(storeState.updateConfig).toHaveBeenCalled();
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
expect(host.textContent).toContain('Mode: API key');
expect(host.textContent).toContain('Connection updated, but failed to refresh provider status.');
expect(host.textContent).not.toContain('Failed to update connection');
});
it('shows subscription recovery actions when OAuth mode is selected but stale status still says API key', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.reject(new Error('refresh failed')));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createAnthropicProvider({
authenticated: true,
authMethod: 'api_key',
configuredAuthMode: 'auto',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'anthropic',
onSelectBackend: vi.fn(),
onRefreshProvider,
onRequestLogin: vi.fn(),
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Anthropic subscription').click();
await Promise.resolve();
});
expect(host.textContent).toContain('Mode: Anthropic subscription');
expect(host.textContent).toContain('Connect Anthropic');
expect(host.textContent).toContain(
'Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.'
);
expect(host.textContent).toContain('Connection updated, but failed to refresh provider status.');
});
it('keeps the Codex API key mode UI in sync with config when refresh fails after enabling beta', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRefreshProvider = vi.fn(() => Promise.reject(new Error('refresh failed')));
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [
createCodexProvider({
apiKeyBetaEnabled: false,
configuredAuthMode: null,
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
}),
],
initialProviderId: 'codex',
onSelectBackend: vi.fn(),
onRefreshProvider,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Enable API key mode').click();
await Promise.resolve();
});
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
codex: {
apiKeyBetaEnabled: true,
authMode: 'api_key',
},
});
expect(host.textContent).toContain('Mode: API key');
expect(host.textContent).toContain('Disable API key mode');
expect(host.textContent).toContain('Connection updated, but failed to refresh provider status.');
});
it('shows a runtime error when backend selection refresh fails after a successful update', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onSelectBackend = vi.fn(() =>
Promise.reject(new Error('Runtime updated, but failed to refresh provider status.'))
);
await act(async () => {
root.render(
React.createElement(ProviderRuntimeSettingsDialog, {
open: true,
onOpenChange: vi.fn(),
providers: [createGeminiProvider()],
initialProviderId: 'gemini',
onSelectBackend,
})
);
await Promise.resolve();
});
await act(async () => {
findButtonByText(host, 'Select runtime backend').click();
await Promise.resolve();
});
expect(onSelectBackend).toHaveBeenCalledWith('gemini', 'api');
expect(host.textContent).toContain('Runtime updated, but failed to refresh provider status.');
});
});

View file

@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import {
getProviderConnectionModeSummary,
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
} from '@renderer/components/runtime/providerConnectionUi';
import type { CliProviderStatus } from '@shared/types';
function createAnthropicProvider(
overrides?: Partial<CliProviderStatus['connection']> & {
authenticated?: boolean;
authMethod?: string | null;
}
): CliProviderStatus {
return {
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: overrides?.authenticated ?? true,
authMethod: overrides?.authMethod ?? 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected',
models: ['claude-sonnet-4-6'],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
selectedBackendId: null,
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['auto', 'oauth', 'api_key'],
configuredAuthMode: overrides?.configuredAuthMode ?? 'auto',
apiKeyConfigured: overrides?.apiKeyConfigured ?? false,
apiKeySource: overrides?.apiKeySource ?? null,
apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null,
},
};
}
function createCodexProvider(
overrides?: Partial<CliProviderStatus['connection']> & {
authenticated?: boolean;
authMethod?: string | null;
}
): CliProviderStatus {
return {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: overrides?.authenticated ?? true,
authMethod: overrides?.authMethod ?? 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected',
models: ['gpt-5-codex'],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
selectedBackendId: 'auto',
resolvedBackendId: 'adapter',
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: {
kind: 'adapter',
label: 'Codex subscription',
},
connection: {
supportsOAuth: true,
supportsApiKey: true,
configurableAuthModes: ['oauth', 'api_key'],
configuredAuthMode: overrides?.configuredAuthMode ?? 'oauth',
apiKeyBetaAvailable: true,
apiKeyBetaEnabled: overrides?.apiKeyBetaEnabled ?? true,
apiKeyConfigured: overrides?.apiKeyConfigured ?? false,
apiKeySource: overrides?.apiKeySource ?? null,
apiKeySourceLabel: overrides?.apiKeySourceLabel ?? null,
},
};
}
describe('providerConnectionUi', () => {
it('hides Anthropic preferred auth summary once the provider is already authenticated', () => {
const provider = createAnthropicProvider({
authenticated: true,
authMethod: 'api_key',
configuredAuthMode: 'api_key',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
});
expect(getProviderConnectionModeSummary(provider)).toBeNull();
});
it('shows Anthropic preferred auth summary when a pinned mode is selected but not connected', () => {
const provider = createAnthropicProvider({
authenticated: false,
authMethod: null,
configuredAuthMode: 'oauth',
});
expect(getProviderConnectionModeSummary(provider)).toBe(
'Preferred auth: Anthropic subscription'
);
});
it('prefers the actual Codex runtime once the provider is already authenticated', () => {
const provider = createCodexProvider({
authenticated: true,
authMethod: 'oauth_token',
configuredAuthMode: 'api_key',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
});
expect(getProviderCurrentRuntimeSummary(provider)).toBe(
'Current runtime: Codex subscription'
);
});
it('shows the selected Codex runtime when the provider is not authenticated yet', () => {
const provider = createCodexProvider({
authenticated: false,
authMethod: null,
configuredAuthMode: 'api_key',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
});
expect(getProviderCurrentRuntimeSummary(provider)).toBe('Selected runtime: OpenAI API key');
});
it('reports an environment Anthropic API key without claiming it is stored in Manage', () => {
const provider = createAnthropicProvider({
authenticated: true,
authMethod: 'oauth_token',
configuredAuthMode: 'oauth',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from ANTHROPIC_API_KEY',
});
expect(getProviderCredentialSummary(provider)).toBe('Detected from ANTHROPIC_API_KEY');
});
it('reports an environment Codex API key without claiming it is stored in Manage', () => {
const provider = createCodexProvider({
authenticated: true,
authMethod: 'oauth_token',
configuredAuthMode: 'oauth',
apiKeyConfigured: true,
apiKeySource: 'environment',
apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
});
expect(getProviderCredentialSummary(provider)).toBe('Detected from OPENAI_API_KEY');
});
it('tells the user when a stored Codex key exists but API key mode is still disabled', () => {
const provider = createCodexProvider({
authenticated: true,
authMethod: 'oauth_token',
configuredAuthMode: 'oauth',
apiKeyBetaEnabled: false,
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
});
expect(getProviderCredentialSummary(provider)).toBe(
'OpenAI API key is saved in Manage. Enable API key mode to use it.'
);
});
});

View file

@ -16,10 +16,10 @@ describe('formatTeamModelSummary', () => {
});
it('keeps native Codex-family models branded normally', () => {
expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('GPT-5.4 · Medium');
expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('5.4 · Medium');
});
it('marks GPT-5.1 Codex Mini as disabled only for Codex team selection', () => {
it('marks 5.1 Codex Mini as disabled only for Codex team selection', () => {
expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe(
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON
);

View file

@ -13,6 +13,54 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tabs', () => {
let currentValue = '';
let currentOnValueChange: ((value: string) => void) | null = null;
return {
Tabs: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value: string;
onValueChange?: (value: string) => void;
}) => {
currentValue = value;
currentOnValueChange = onValueChange ?? null;
return React.createElement('div', { 'data-tabs-value': value }, children);
},
TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
TabsTrigger: ({
children,
value,
disabled,
title,
}: {
children: React.ReactNode;
value: string;
disabled?: boolean;
title?: string;
}) =>
React.createElement(
'button',
{
type: 'button',
disabled,
title,
'data-state': currentValue === value ? 'active' : 'inactive',
onClick: () => {
if (!disabled) {
currentOnValueChange?.(value);
}
},
},
children
),
};
});
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: unknown) => unknown) =>
selector({
@ -32,7 +80,7 @@ describe('TeamModelSelector disabled Codex models', () => {
document.body.innerHTML = '';
});
it('renders GPT-5.1 Codex Mini as disabled with an explanation tooltip', async () => {
it('renders 5.1 Codex Mini as disabled with an explanation tooltip', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@ -50,7 +98,7 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('GPT-5.1 Codex Mini');
expect(host.textContent).toContain('5.1 Codex Mini');
expect(host.textContent).toContain('Disabled');
expect(host.textContent).toContain(GPT_5_1_CODEX_MINI_UI_DISABLED_REASON);
@ -60,7 +108,7 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('renders GPT-5.3 Codex Spark as disabled with an explanation tooltip', async () => {
it('renders 5.3 Codex Spark as disabled with an explanation tooltip', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@ -78,7 +126,7 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('GPT-5.3 Codex Spark');
expect(host.textContent).toContain('5.3 Codex Spark');
expect(host.textContent).toContain('Disabled');
expect(host.textContent).toContain(GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON);
@ -115,7 +163,7 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('normalizes a stale GPT-5.3 Codex Spark selection back to default', async () => {
it('normalizes a stale 5.3 Codex Spark selection back to default', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@ -162,22 +210,14 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve();
});
const trigger = host.querySelector('button');
expect(trigger).not.toBeNull();
await act(async () => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode');
expect(host.textContent).not.toContain('Gemini in development');
expect(host.textContent?.match(/In development/g)?.length ?? 0).toBeGreaterThanOrEqual(1);
const buttons = Array.from(host.querySelectorAll('button'));
const openCodeButton = buttons.find((button) => button.textContent?.includes('OpenCode'));
expect(openCodeButton).not.toBeNull();
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
expect(openCodeButton?.getAttribute('title')).toContain('OpenCode in development');
await act(async () => {
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
@ -191,4 +231,42 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve();
});
});
it('switches providers through tabs instead of a dropdown', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onProviderChange = vi.fn();
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const buttons = Array.from(host.querySelectorAll('button'));
const codexTab = buttons.find((button) => button.textContent?.trim() === 'Codex');
expect(codexTab).not.toBeNull();
expect(host.textContent).toContain('Anthropic');
expect(host.textContent).toContain('Codex');
await act(async () => {
codexTab?.click();
await Promise.resolve();
});
expect(onProviderChange).toHaveBeenCalledWith('codex');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -167,6 +167,48 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliStatusLoading).toBe(false);
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
});
it('does not fetch provider status when the multimodel runtime fails its health check', async () => {
const mockStatus: CliInstallationStatus = {
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: true,
installed: false,
installedVersion: null,
binaryPath: '/Users/tester/.claude/local/node_modules/.bin/claude',
launchError: 'spawn EACCES',
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: false,
authMethod: null,
providers: [
{
providerId: 'anthropic',
displayName: 'Anthropic',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: 'Runtime found, but startup health check failed.',
models: [],
canLoginFromUi: false,
capabilities: { teamLaunch: false, oneShot: false },
backend: null,
},
],
};
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });
expect(useStore.getState().cliStatus).toEqual(mockStatus);
expect(useStore.getState().cliStatusLoading).toBe(false);
expect(useStore.getState().cliProviderStatusLoading).toEqual({});
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
});
});
describe('installCli', () => {

View file

@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestStore, type TestStore } from './storeTestUtils';
vi.mock('../../../src/renderer/api', () => ({
api: {
config: {
get: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock('../../../src/renderer/sentry', () => ({
syncRendererTelemetry: vi.fn(),
}));
import { api } from '../../../src/renderer/api';
describe('configSlice', () => {
let store: TestStore;
beforeEach(() => {
store = createTestStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('rethrows updateConfig failures after storing configError', async () => {
const configApi = api.config;
if (!configApi) {
throw new Error('config api mock is missing');
}
vi.mocked(configApi.update).mockRejectedValue(new Error('update failed'));
await expect(store.getState().updateConfig('general', { theme: 'light' })).rejects.toThrow(
'update failed'
);
expect(store.getState().configError).toBe('update failed');
});
});

View file

@ -34,4 +34,29 @@ describe('reviewState utils', () => {
})
).toBe('review');
});
it('resets derived review state after work resumes following requested changes', () => {
expect(
getReviewStateFromTask({
historyEvents: [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'review_changes_requested',
from: 'review',
to: 'needsFix',
actor: 'reviewer',
},
{
id: '2',
timestamp: '2026-01-01T00:01:00Z',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
actor: 'owner',
},
],
})
).toBe('none');
});
});