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

View file

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

View file

@ -15,15 +15,15 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: 20 node-version: 22
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies
@ -57,7 +57,7 @@ jobs:
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation" --draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
- name: Upload dist artifact - name: Upload dist artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: dist name: dist
path: | path: |
@ -65,8 +65,94 @@ jobs:
dist-electron dist-electron
retention-days: 1 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: release-mac:
needs: build needs: [build, prepare-runtime]
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -81,10 +167,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Download dist artifact - name: Download dist artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: dist name: dist
@ -92,9 +178,9 @@ jobs:
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: 20 node-version: 22
cache: pnpm cache: pnpm
- name: Setup Python for node-gyp - name: Setup Python for node-gyp
@ -111,6 +197,33 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}" VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION" 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 }}) - name: Build app (macOS ${{ matrix.arch }})
env: env:
NODE_OPTIONS: '--max-old-space-size=8192' NODE_OPTIONS: '--max-old-space-size=8192'
@ -126,6 +239,9 @@ jobs:
test -f dist-electron/preload/index.js test -f dist-electron/preload/index.js
test -f out/renderer/index.html test -f out/renderer/index.html
test -f mcp-server/dist/index.js 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 }}) - name: Package (macOS ${{ matrix.arch }})
env: env:
@ -137,6 +253,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: ${{ matrix.dist_command }} --publish never 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 - name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
env: env:
@ -152,15 +271,15 @@ jobs:
done done
release-win: release-win:
needs: build needs: [build, prepare-runtime]
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Download dist artifact - name: Download dist artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: dist name: dist
@ -168,9 +287,9 @@ jobs:
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: 20 node-version: 22
cache: pnpm cache: pnpm
- name: Setup Python for node-gyp - name: Setup Python for node-gyp
@ -188,6 +307,29 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}" VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION" 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) - name: Build app (Windows)
env: env:
NODE_OPTIONS: '--max-old-space-size=8192' NODE_OPTIONS: '--max-old-space-size=8192'
@ -204,12 +346,19 @@ jobs:
test -f dist-electron/preload/index.js test -f dist-electron/preload/index.js
test -f out/renderer/index.html test -f out/renderer/index.html
test -f mcp-server/dist/index.js test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (Windows) - name: Package (Windows)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm dist:win --publish never 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 - name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
@ -226,15 +375,15 @@ jobs:
done done
release-linux: release-linux:
needs: build needs: [build, prepare-runtime]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Download dist artifact - name: Download dist artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: dist name: dist
@ -242,9 +391,9 @@ jobs:
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: 20 node-version: 22
cache: pnpm cache: pnpm
- name: Setup Python for node-gyp - name: Setup Python for node-gyp
@ -266,6 +415,27 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}" VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION" 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) - name: Build app (Linux)
env: env:
NODE_OPTIONS: '--max-old-space-size=8192' NODE_OPTIONS: '--max-old-space-size=8192'
@ -281,12 +451,18 @@ jobs:
test -f dist-electron/preload/index.js test -f dist-electron/preload/index.js
test -f out/renderer/index.html test -f out/renderer/index.html
test -f mcp-server/dist/index.js test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (Linux) - name: Package (Linux)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm dist:linux --publish never 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 - name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
env: env:
@ -313,9 +489,12 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail
VERSION="${GITHUB_REF#refs/tags/v}" VERSION="${GITHUB_REF#refs/tags/v}"
REPO="${GITHUB_REPOSITORY}" REPO="${GITHUB_REPOSITORY}"
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}" DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
declare -A FILES=( declare -A FILES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg" ["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" ["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 # Download versioned files and re-upload with stable names
for STABLE_NAME in "${!FILES[@]}"; do for STABLE_NAME in "${!FILES[@]}"; do
VERSIONED_NAME="${FILES[$STABLE_NAME]}" VERSIONED_NAME="${FILES[$STABLE_NAME]}"
echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}" echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}"
curl -fSL -o "$STABLE_NAME" "${DOWNLOAD_BASE}/${VERSIONED_NAME}" && \ curl -fSL -o "${TMP_DIR}/${VERSIONED_NAME}" "${DOWNLOAD_BASE}/${VERSIONED_NAME}"
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}"
rm -f "$STABLE_NAME" gh release upload "v${VERSION}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber
done done
- name: Publish canonical updater metadata - name: Publish canonical updater metadata
@ -397,26 +571,25 @@ jobs:
EOF EOF
# Canonical macOS feed. # Canonical macOS feed.
# electron-updater consumes only one latest-mac.yml asset on GitHub releases. # electron-updater on GitHub still consumes a single latest-mac.yml, so we
# We publish the x64 feed here because it works on Intel Macs and remains # publish the Apple Silicon feed here and suppress Intel auto-update in-app
# installable on Apple Silicon via Rosetta until we add arch-specific feed # until we switch to universal packaging or an arch-aware provider.
# selection or universal packaging. download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip"
download_asset "Claude.Agent.Teams.UI-${VERSION}-mac.zip" download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
download_asset "Claude.Agent.Teams.UI-${VERSION}.dmg" MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-mac.zip)" MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-mac.zip)" MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}.dmg)" MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}.dmg)"
cat > latest-mac.yml <<EOF cat > latest-mac.yml <<EOF
version: ${VERSION} version: ${VERSION}
files: files:
- url: Claude.Agent.Teams.UI-${VERSION}-mac.zip - url: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA} sha512: ${MAC_ZIP_SHA}
size: ${MAC_ZIP_SIZE} size: ${MAC_ZIP_SIZE}
- url: Claude.Agent.Teams.UI-${VERSION}.dmg - url: Claude.Agent.Teams.UI-${VERSION}-arm64.dmg
sha512: ${MAC_DMG_SHA} sha512: ${MAC_DMG_SHA}
size: ${MAC_DMG_SIZE} 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} sha512: ${MAC_ZIP_SHA}
releaseDate: '${RELEASE_DATE}' releaseDate: '${RELEASE_DATE}'
EOF EOF

View file

@ -235,7 +235,12 @@ Without these secrets, macOS builds will be unsigned (users need to bypass Gatek
## Auto-Update ## 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 ## Quick Reference

View file

@ -57,11 +57,11 @@ Main failure modes to avoid:
Relevant code: 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/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_freecode/src/main/ipc/teams.ts#L540) - [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_freecode/src/renderer/store/index.ts#L938) - [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_freecode/src/main/services/team/TeamInboxReader.ts#L182) - [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_freecode/agent-teams-controller/src/internal/processStore.js#L29) - [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 ## Hard Non-Goals
@ -174,8 +174,8 @@ Why this section exists:
Relevant code: Relevant code:
- [src/main/services/team/TeamDataService.ts#L874](/Users/belief/dev/projects/claude/claude_team_freecode/src/main/services/team/TeamDataService.ts#L874) - [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_freecode/agent-teams-controller/src/internal/processStore.js#L29) - [agent-teams-controller/src/internal/processStore.js#L29](/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/processStore.js#L29)
Rule: Rule:
@ -187,8 +187,8 @@ Rule:
Relevant code: 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#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_freecode/src/main/services/team/TeamInboxReader.ts#L182) - [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts#L182)
Implication: Implication:
@ -201,8 +201,8 @@ Implication:
Relevant code: 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/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_freecode/src/main/services/team/TeamInboxReader.ts#L182) - [src/main/services/team/TeamInboxReader.ts#L182](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts#L182)
Implication: Implication:
@ -259,7 +259,7 @@ Rule:
Relevant code: 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: Rule:
@ -501,7 +501,7 @@ Why:
## Diagnostics And Marks ## 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. Do not lose that.
@ -956,7 +956,7 @@ Why this matters:
## Testing Plan ## 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 ### Preferred test technique

View file

@ -31,11 +31,11 @@ Today, `getTeamData()` repeatedly pays for lead-session history assembly:
Relevant code: 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#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_freecode/src/main/services/team/TeamDataService.ts#L2150) - [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_freecode/src/main/services/team/TeamDataService.ts#L2324) - [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_freecode/src/main/services/team/leadSessionMessageExtractor.ts#L96) - [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_freecode/src/main/utils/pathDecoder.ts#L5) - [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: This is safer than introducing a new lightweight IPC path because it does not change:
@ -94,8 +94,8 @@ Why:
Relevant code: 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/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_freecode/src/main/utils/pathDecoder.ts#L27) - [src/main/utils/pathDecoder.ts#L27](/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathDecoder.ts#L27)
Rule: Rule:
@ -107,7 +107,7 @@ This cache must not use time-based correctness semantics.
There is a TTL-based advisory cache elsewhere: 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. 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: 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 ### 7. Bounded storage
@ -504,7 +504,7 @@ Decision:
## Testing Plan ## 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 1. repeated extraction of the same unchanged JSONL uses cached results
2. append invalidates cache and returns fresh 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 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 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 13. existing extraction semantics remain unchanged

View file

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

View file

@ -1059,12 +1059,14 @@
"cache_read_input_token_cost": 3e-8, "cache_read_input_token_cost": 3e-8,
"cache_creation_input_token_cost": 3.75e-7 "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, "input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock", "litellm_provider": "bedrock",
"max_input_tokens": 200000, "max_input_tokens": 200000,
"max_output_tokens": 4096, "max_output_tokens": 8192,
"max_tokens": 4096, "max_tokens": 8192,
"mode": "chat", "mode": "chat",
"output_cost_per_token": 0.0000165, "output_cost_per_token": 0.0000165,
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
@ -1076,8 +1078,28 @@
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": 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_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": { "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": {
"cache_creation_input_token_cost": 0.0000045, "cache_creation_input_token_cost": 0.0000045,
@ -1131,12 +1153,14 @@
"cache_read_input_token_cost": 3e-8, "cache_read_input_token_cost": 3e-8,
"cache_creation_input_token_cost": 3.75e-7 "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, "input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock", "litellm_provider": "bedrock",
"max_input_tokens": 200000, "max_input_tokens": 200000,
"max_output_tokens": 4096, "max_output_tokens": 8192,
"max_tokens": 4096, "max_tokens": 8192,
"mode": "chat", "mode": "chat",
"output_cost_per_token": 0.0000165, "output_cost_per_token": 0.0000165,
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
@ -1148,8 +1172,28 @@
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": 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_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": { "bedrock/us-west-2/anthropic.claude-instant-v1": {
"input_cost_per_token": 8e-7, "input_cost_per_token": 8e-7,
@ -2193,7 +2237,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_url_context": true, "supports_url_context": true,
"supports_vision": true, "supports_vision": true,
"supports_web_search": true "supports_web_search": true,
"supports_service_tier": true
}, },
"gemini-2.5-flash-lite": { "gemini-2.5-flash-lite": {
"cache_read_input_token_cost": 1e-8, "cache_read_input_token_cost": 1e-8,
@ -2238,7 +2283,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_url_context": true, "supports_url_context": true,
"supports_vision": true, "supports_vision": true,
"supports_web_search": true "supports_web_search": true,
"supports_service_tier": true
}, },
"gemini-2.5-pro": { "gemini-2.5-pro": {
"cache_read_input_token_cost": 1.25e-7, "cache_read_input_token_cost": 1.25e-7,
@ -2283,7 +2329,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_video_input": true, "supports_video_input": true,
"supports_vision": true, "supports_vision": true,
"supports_web_search": true "supports_web_search": true,
"supports_service_tier": true
}, },
"gmi/anthropic/claude-opus-4.5": { "gmi/anthropic/claude-opus-4.5": {
"input_cost_per_token": 0.000005, "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_above_272k_tokens": 5e-7,
"cache_read_input_token_cost_flex": 1.3e-7, "cache_read_input_token_cost_flex": 1.3e-7,
"cache_read_input_token_cost_priority": 5e-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": 0.0000025,
"input_cost_per_token_above_272k_tokens": 0.000005, "input_cost_per_token_above_272k_tokens": 0.000005,
"input_cost_per_token_flex": 0.00000125, "input_cost_per_token_flex": 0.00000125,
"input_cost_per_token_batches": 0.00000125, "input_cost_per_token_batches": 0.00000125,
"input_cost_per_token_priority": 0.000005, "input_cost_per_token_priority": 0.000005,
"input_cost_per_token_above_272k_tokens_priority": 0.00001,
"litellm_provider": "openai", "litellm_provider": "openai",
"max_input_tokens": 1050000, "max_input_tokens": 1050000,
"max_output_tokens": 128000, "max_output_tokens": 128000,
@ -2475,8 +2520,7 @@
"output_cost_per_token_above_272k_tokens": 0.0000225, "output_cost_per_token_above_272k_tokens": 0.0000225,
"output_cost_per_token_flex": 0.0000075, "output_cost_per_token_flex": 0.0000075,
"output_cost_per_token_batches": 0.0000075, "output_cost_per_token_batches": 0.0000075,
"output_cost_per_token_priority": 0.0000225, "output_cost_per_token_priority": 0.00003,
"output_cost_per_token_above_272k_tokens_priority": 0.00003375,
"supported_endpoints": [ "supported_endpoints": [
"/v1/chat/completions", "/v1/chat/completions",
"/v1/batch", "/v1/batch",
@ -2506,11 +2550,13 @@
}, },
"gpt-5.4-mini": { "gpt-5.4-mini": {
"cache_read_input_token_cost": 7.5e-8, "cache_read_input_token_cost": 7.5e-8,
"cache_read_input_token_cost_flex": 1e-8, "cache_read_input_token_cost_flex": 3.75e-8,
"cache_read_input_token_cost_batches": 3.8e-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": 7.5e-7,
"input_cost_per_token_flex": 3.75e-7, "input_cost_per_token_flex": 3.75e-7,
"input_cost_per_token_batches": 3.75e-7, "input_cost_per_token_batches": 3.75e-7,
"input_cost_per_token_priority": 0.0000015,
"litellm_provider": "openai", "litellm_provider": "openai",
"max_input_tokens": 272000, "max_input_tokens": 272000,
"max_output_tokens": 128000, "max_output_tokens": 128000,
@ -2519,6 +2565,7 @@
"output_cost_per_token": 0.0000045, "output_cost_per_token": 0.0000045,
"output_cost_per_token_flex": 0.00000225, "output_cost_per_token_flex": 0.00000225,
"output_cost_per_token_batches": 0.00000225, "output_cost_per_token_batches": 0.00000225,
"output_cost_per_token_priority": 0.000009,
"supported_endpoints": [ "supported_endpoints": [
"/v1/chat/completions", "/v1/chat/completions",
"/v1/batch", "/v1/batch",
@ -3292,6 +3339,32 @@
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "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": { "au.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.000001375, "cache_creation_input_token_cost": 0.000001375,
"cache_read_input_token_cost": 1.1e-7, "cache_read_input_token_cost": 1.1e-7,
@ -3767,6 +3840,27 @@
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_tool_choice": 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": { "vertex_ai/claude-haiku-4-5@20251001": {
"cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost": 0.00000125,
"cache_read_input_token_cost": 1e-7, "cache_read_input_token_cost": 1e-7,
@ -4273,6 +4367,52 @@
"search_context_size_medium": 0.01 "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": { "gpt-5.3-codex-spark": {
"cache_read_input_token_cost": 1.75e-7, "cache_read_input_token_cost": 1.75e-7,
"cache_read_input_token_cost_priority": 3.5e-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 runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev');
const uiEnv = { const uiEnv = {
...process.env, ...process.env,
// Dev-only free-code runtime override. Keep it separate from the generic // Dev-only agent_teams_orchestrator runtime override. Keep it separate from
// CLAUDE_CLI_PATH override so switching the app into Claude CLI mode still // the generic CLAUDE_CLI_PATH override so switching the app into Claude CLI
// resolves the real official binary instead of this local cli-dev shim. // mode still resolves the real official binary instead of this local
CLAUDE_FREE_CODE_CLI_PATH: runtimeCliPath, // cli-dev shim.
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: runtimeCliPath,
}; };
// If the parent shell exported a stale generic override, do not let it leak // 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. // 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, HttpServerConfig,
NotificationConfig, NotificationConfig,
NotificationTrigger, NotificationTrigger,
ProviderConnectionsConfig,
RuntimeConfig, RuntimeConfig,
SshPersistConfig, SshPersistConfig,
} from '../services'; } from '../services';
@ -32,6 +33,7 @@ interface ValidationFailure {
export type ConfigUpdateValidationResult = export type ConfigUpdateValidationResult =
| ValidationSuccess<'notifications'> | ValidationSuccess<'notifications'>
| ValidationSuccess<'general'> | ValidationSuccess<'general'>
| ValidationSuccess<'providerConnections'>
| ValidationSuccess<'runtime'> | ValidationSuccess<'runtime'>
| ValidationSuccess<'display'> | ValidationSuccess<'display'>
| ValidationSuccess<'httpServer'> | ValidationSuccess<'httpServer'>
@ -41,6 +43,7 @@ export type ConfigUpdateValidationResult =
const VALID_SECTIONS = new Set<ConfigSection>([ const VALID_SECTIONS = new Set<ConfigSection>([
'notifications', 'notifications',
'general', 'general',
'providerConnections',
'runtime', 'runtime',
'display', 'display',
'httpServer', '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 { function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure {
if (!isPlainObject(data)) { if (!isPlainObject(data)) {
return { valid: false, error: 'display update must be an object' }; 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)) { if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
return { return {
valid: false, 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); return validateNotificationsSection(data);
case 'general': case 'general':
return validateGeneralSection(data); return validateGeneralSection(data);
case 'providerConnections':
return validateProviderConnectionsSection(data);
case 'runtime': case 'runtime':
return validateRuntimeSection(data); return validateRuntimeSection(data);
case 'display': 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> { async getStorageStatus(): Promise<ApiKeyStorageStatus> {
const secure = this.isSecureBackend(); const secure = this.isSecureBackend();
const backend = this.getBackendName(); const backend = this.getBackendName();
@ -171,15 +189,12 @@ export class ApiKeyService {
return; return;
} }
const lookups = await this.lookup([...envVarNames]);
const valueByEnv = new Map(lookups.map((entry) => [entry.envVarName, entry.value]));
for (const envVarName of envVarNames) { for (const envVarName of envVarNames) {
if (!this.originalProcessEnv.has(envVarName)) { if (!this.originalProcessEnv.has(envVarName)) {
this.originalProcessEnv.set(envVarName, process.env[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) { if (nextValue && nextValue.trim().length > 0) {
process.env[envVarName] = nextValue; process.env[envVarName] = nextValue;
continue; continue;

View file

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

View file

@ -39,7 +39,7 @@ import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService'; import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor'; import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor';
import type { import type {
CliInstallationStatus, CliInstallationStatus,
@ -134,6 +134,7 @@ const DIAG_AUTH_STDOUT_TAIL = 160;
function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallationStatus { function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallationStatus {
return { return {
...status, ...status,
launchError: status.launchError ?? null,
providers: status.providers.map((provider) => ({ providers: status.providers.map((provider) => ({
...provider, ...provider,
capabilities: { ...provider.capabilities }, capabilities: { ...provider.capabilities },
@ -371,6 +372,7 @@ export class CliInstallerService {
installed: r.installed, installed: r.installed,
binaryPath: r.binaryPath ? clipHeadForDiag(r.binaryPath, DIAG_PATH_HEAD) : null, binaryPath: r.binaryPath ? clipHeadForDiag(r.binaryPath, DIAG_PATH_HEAD) : null,
installedVersion: r.installedVersion, installedVersion: r.installedVersion,
launchError: r.launchError ?? null,
authLoggedIn: r.authLoggedIn, authLoggedIn: r.authLoggedIn,
authMethod: r.authMethod, authMethod: r.authMethod,
latestVersion: r.latestVersion, latestVersion: r.latestVersion,
@ -400,7 +402,7 @@ export class CliInstallerService {
const flavor = getConfiguredCliFlavor(); const flavor = getConfiguredCliFlavor();
const ui = getCliFlavorUiOptions(flavor); const ui = getCliFlavorUiOptions(flavor);
const providers = const providers =
flavor === 'free-code' flavor === 'agent_teams_orchestrator'
? ( ? (
[ [
{ {
@ -441,6 +443,7 @@ export class CliInstallerService {
installed: false, installed: false,
installedVersion: null, installedVersion: null,
binaryPath: null, binaryPath: null,
launchError: null,
latestVersion: null, latestVersion: null,
updateAvailable: false, updateAvailable: false,
authLoggedIn: false, authLoggedIn: false,
@ -499,11 +502,16 @@ export class CliInstallerService {
} }
const flavor = getConfiguredCliFlavor(); const flavor = getConfiguredCliFlavor();
if (flavor !== 'free-code') { if (flavor !== 'agent_teams_orchestrator') {
const fullStatus = await this.getStatus(); const fullStatus = await this.getStatus();
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null; 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); return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
} }
@ -524,35 +532,44 @@ export class CliInstallerService {
const r = ref.current; const r = ref.current;
const binaryPath = await ClaudeBinaryResolver.resolve(); const binaryPath = await ClaudeBinaryResolver.resolve();
if (binaryPath) { if (binaryPath) {
r.installed = true;
r.binaryPath = binaryPath; r.binaryPath = binaryPath;
r.authStatusChecking = true; const versionProbe = await this.probeCliVersion(binaryPath);
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); if (versionProbe.ok) {
r.installed = true;
r.installedVersion = versionProbe.version;
r.launchError = null;
r.authStatusChecking = true;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
try { // Auth and GCS version check are independent — run in parallel.
const { stdout } = await execCli(binaryPath, ['--version'], { // Both mutate `r` directly so partial results survive the outer timeout.
timeout: VERSION_TIMEOUT_MS, await Promise.all([
env: this.envForCli(binaryPath), this.checkAuthStatus(binaryPath, r, diag),
}); r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
r.installedVersion = normalizeVersion(stdout); ]);
logger.info( } else {
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"` 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) { if (diag.versionError) {
diag.versionError = getErrorMessage(err); logger.warn('Failed to get CLI version:', 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 { } else {
// No binary — still check latest version for "install" prompt // No binary — still check latest version for "install" prompt
r.authStatusChecking = false; r.authStatusChecking = false;
r.launchError = null;
this.markProvidersUnavailable(r, 'Runtime not found.');
if (r.supportsSelfUpdate) { if (r.supportsSelfUpdate) {
await this.fetchLatestVersion(r); 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. * 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. * Wrapped in its own timeout to prevent slow auth from blocking the overall status.
@ -571,7 +626,7 @@ export class CliInstallerService {
result: CliInstallationStatus, result: CliInstallationStatus,
diag: CliInstallerStatusRunDiag diag: CliInstallerStatusRunDiag
): Promise<void> { ): Promise<void> {
if (result.flavor === 'free-code') { if (result.flavor === 'agent_teams_orchestrator') {
result.authStatusChecking = true; result.authStatusChecking = true;
try { try {
const providers = await this.multimodelBridgeService.getProviderStatuses( const providers = await this.multimodelBridgeService.getProviderStatuses(
@ -690,7 +745,7 @@ export class CliInstallerService {
async install(): Promise<void> { async install(): Promise<void> {
if (!getCliFlavorUiOptions(getConfiguredCliFlavor()).supportsSelfUpdate) { 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); logger.warn(error);
this.sendProgress({ type: 'error', error }); this.sendProgress({ type: 'error', error });
return; 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 { export interface DisplayConfig {
showTimestamps: boolean; showTimestamps: boolean;
compactMode: boolean; compactMode: boolean;
@ -256,6 +269,7 @@ export interface HttpServerConfig {
export interface AppConfig { export interface AppConfig {
notifications: NotificationConfig; notifications: NotificationConfig;
general: GeneralConfig; general: GeneralConfig;
providerConnections: ProviderConnectionsConfig;
runtime: RuntimeConfig; runtime: RuntimeConfig;
display: DisplayConfig; display: DisplayConfig;
sessions: SessionsConfig; sessions: SessionsConfig;
@ -309,6 +323,15 @@ const DEFAULT_CONFIG: AppConfig = {
customProjectPaths: [], customProjectPaths: [],
telemetryEnabled: true, telemetryEnabled: true,
}, },
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: false,
authMode: 'oauth',
},
},
runtime: { runtime: {
providerBackends: { providerBackends: {
gemini: 'auto', gemini: 'auto',
@ -484,6 +507,16 @@ export class ConfigManager {
triggers: mergedTriggers, triggers: mergedTriggers,
}, },
general: mergedGeneral, general: mergedGeneral,
providerConnections: {
anthropic: {
...DEFAULT_CONFIG.providerConnections.anthropic,
...(loaded.providerConnections?.anthropic ?? {}),
},
codex: {
...DEFAULT_CONFIG.providerConnections.codex,
...(loaded.providerConnections?.codex ?? {}),
},
},
runtime: { runtime: {
providerBackends: { providerBackends: {
...DEFAULT_CONFIG.runtime.providerBackends, ...DEFAULT_CONFIG.runtime.providerBackends,
@ -562,7 +595,7 @@ export class ConfigManager {
section: K, section: K,
data: Partial<AppConfig[K]> data: Partial<AppConfig[K]>
): Partial<AppConfig[K]> { ): Partial<AppConfig[K]> {
if (section !== 'general' && section !== 'runtime') { if (section !== 'general' && section !== 'runtime' && section !== 'providerConnections') {
return data; return data;
} }
@ -577,6 +610,21 @@ export class ConfigManager {
} as unknown as Partial<AppConfig[K]>; } 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')) { if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {
return data; return data;
} }

View file

@ -22,33 +22,14 @@ import { app, net } from 'electron';
import type { UpdaterStatus } from '@shared/types'; import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import {
getExpectedReleaseAssetUrl,
getLatestMacMetadataUrl,
isLatestMacMetadataCompatible,
} from './updaterReleaseMetadata';
const logger = createLogger('UpdaterService'); 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. * Check if a remote URL exists using a HEAD request.
* Follows redirects (GitHub releases use 302 S3). * 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 { export class UpdaterService {
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
private periodicTimer: ReturnType<typeof setInterval> | null = null; private periodicTimer: ReturnType<typeof setInterval> | null = null;
@ -154,6 +147,24 @@ export class UpdaterService {
return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion)); 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. * 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 * If CI hasn't finished uploading the artifact for this OS yet, suppress the
@ -170,7 +181,7 @@ export class UpdaterService {
return; return;
} }
const url = getExpectedAssetUrl(info.version); const url = getExpectedReleaseAssetUrl(info.version, process.platform, process.arch);
if (url) { if (url) {
const exists = await assetExists(url); const exists = await assetExists(url);
if (!exists) { 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({ this.sendStatus({
type: 'available', type: 'available',
version: info.version, 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'; } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
import { configManager } from '../infrastructure/ConfigManager'; import { configManager } from '../infrastructure/ConfigManager';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { providerConnectionService } from './ProviderConnectionService';
import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv'; import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
const logger = createLogger('ClaudeMultimodelBridgeService'); const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000; const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
@ -48,7 +51,7 @@ interface ProviderModelsCommandResponse {
providers?: Record< providers?: Record<
string, 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; detailMessage?: string | null;
selectedBackendId?: string | null; selectedBackendId?: string | null;
resolvedBackendId?: string | null; resolvedBackendId?: string | null;
availableBackends?: Array<{ availableBackends?: {
id?: string; id?: string;
label?: string; label?: string;
description?: string; description?: string;
@ -76,15 +79,15 @@ interface UnifiedRuntimeStatusResponse {
available?: boolean; available?: boolean;
statusMessage?: string | null; statusMessage?: string | null;
detailMessage?: string | null; detailMessage?: string | null;
}>; }[];
externalRuntimeDiagnostics?: Array<{ externalRuntimeDiagnostics?: {
id?: string; id?: string;
label?: string; label?: string;
detected?: boolean; detected?: boolean;
statusMessage?: string | null; statusMessage?: string | null;
detailMessage?: string | null; detailMessage?: string | null;
}>; }[];
models?: Array<string | { id?: string; label?: string; description?: string }>; models?: (string | { id?: string; label?: string; description?: string })[];
capabilities?: { capabilities?: {
teamLaunch?: boolean; teamLaunch?: boolean;
oneShot?: boolean; oneShot?: boolean;
@ -137,11 +140,12 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
availableBackends: [], availableBackends: [],
externalRuntimeDiagnostics: [], externalRuntimeDiagnostics: [],
backend: null, backend: null,
connection: null,
}; };
} }
function extractModelIds( function extractModelIds(
models: Array<string | { id?: string; label?: string; description?: string }> | undefined models: (string | { id?: string; label?: string; description?: string })[] | undefined
): string[] { ): string[] {
if (!models) { if (!models) {
return []; return [];
@ -159,7 +163,7 @@ function extractModelIds(
} }
export class ClaudeMultimodelBridgeService { export class ClaudeMultimodelBridgeService {
private buildCliEnv(binaryPath: string): NodeJS.ProcessEnv { private async buildCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
const shellEnv = getCachedShellEnv() ?? {}; const shellEnv = getCachedShellEnv() ?? {};
const home = const home =
getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE; getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE;
@ -170,11 +174,15 @@ export class ClaudeMultimodelBridgeService {
if (home) { if (home) {
env.HOME = 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 { private async buildProviderCliEnv(
return applyProviderRuntimeEnv({ ...this.buildCliEnv(binaryPath) }, providerId); binaryPath: string,
providerId: CliProviderId
): Promise<NodeJS.ProcessEnv> {
return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId);
} }
private isUnifiedRuntimeUnsupported(error: unknown): boolean { private isUnifiedRuntimeUnsupported(error: unknown): boolean {
@ -249,7 +257,7 @@ export class ClaudeMultimodelBridgeService {
providerId: CliProviderId providerId: CliProviderId
): Promise<CliProviderStatus> { ): Promise<CliProviderStatus> {
await resolveInteractiveShellEnv(); await resolveInteractiveShellEnv();
const env = this.buildCliEnv(binaryPath); const env = await this.buildCliEnv(binaryPath);
try { try {
const { stdout } = await execCli( const { stdout } = await execCli(
@ -261,7 +269,9 @@ export class ClaudeMultimodelBridgeService {
} }
); );
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout); const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
return this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]); return providerConnectionService.enrichProviderStatus(
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
);
} catch (error) { } catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) { if (!this.isUnifiedRuntimeUnsupported(error)) {
logger.warn( logger.warn(
@ -281,7 +291,7 @@ export class ClaudeMultimodelBridgeService {
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> { private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
const provider = createDefaultProviderStatus('gemini'); const provider = createDefaultProviderStatus('gemini');
const env = this.buildProviderCliEnv(binaryPath, 'gemini'); const env = await this.buildProviderCliEnv(binaryPath, 'gemini');
try { try {
const { stdout } = await execCli( const { stdout } = await execCli(
@ -340,7 +350,7 @@ export class ClaudeMultimodelBridgeService {
onUpdate?: (providers: CliProviderStatus[]) => void onUpdate?: (providers: CliProviderStatus[]) => void
): Promise<CliProviderStatus[]> { ): Promise<CliProviderStatus[]> {
await resolveInteractiveShellEnv(); await resolveInteractiveShellEnv();
const env = this.buildCliEnv(binaryPath); const env = await this.buildCliEnv(binaryPath);
try { try {
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], { const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
@ -348,8 +358,10 @@ export class ClaudeMultimodelBridgeService {
env, env,
}); });
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout); const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
const providers = ORDERED_PROVIDER_IDS.map((providerId) => const providers = await providerConnectionService.enrichProviderStatuses(
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) ORDERED_PROVIDER_IDS.map((providerId) =>
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
)
); );
onUpdate?.(providers); onUpdate?.(providers);
return providers; return providers;
@ -457,6 +469,11 @@ export class ClaudeMultimodelBridgeService {
providers.set('gemini', await this.buildGeminiStatus(binaryPath)); providers.set('gemini', await this.buildGeminiStatus(binaryPath));
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!)); 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 { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { providerConnectionService } from '../runtime/ProviderConnectionService';
import { import {
applyConfiguredRuntimeBackendsEnv, applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv, applyProviderRuntimeEnv,
@ -105,13 +106,18 @@ export class ScheduledTaskExecutor {
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
const env = applyProviderRuntimeEnv( const env = await providerConnectionService.applyConfiguredConnectionEnv(
applyConfiguredRuntimeBackendsEnv({ applyProviderRuntimeEnv(
...buildEnrichedEnv(binaryPath), applyConfiguredRuntimeBackendsEnv({
...shellEnv, ...buildEnrichedEnv(binaryPath),
CLAUDECODE: undefined, ...shellEnv,
}), CLAUDECODE: undefined,
request.config.providerId }),
request.config.providerId
),
request.config.providerId === 'codex' || request.config.providerId === 'gemini'
? request.config.providerId
: 'anthropic'
); );
const child = spawnCli(binaryPath, args, { const child = spawnCli(binaryPath, args, {

View file

@ -1,8 +1,10 @@
import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
import { getConfiguredCliFlavor } from './cliFlavor'; import { getConfiguredCliFlavor } from './cliFlavor';
async function isExecutable(filePath: string): Promise<boolean> { async function isExecutable(filePath: string): Promise<boolean> {
@ -176,6 +178,31 @@ async function resolveFromCandidateList(candidates: string[]): Promise<string |
return null; 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; let cachedPath: string | null | undefined;
/** Timestamp of last successful cache verification (ms). */ /** Timestamp of last successful cache verification (ms). */
@ -227,8 +254,9 @@ export class ClaudeBinaryResolver {
const flavor = getConfiguredCliFlavor(); const flavor = getConfiguredCliFlavor();
const overrideRaw = const overrideRaw =
flavor === 'free-code' flavor === 'agent_teams_orchestrator'
? (process.env.CLAUDE_FREE_CODE_CLI_PATH?.trim() ?? process.env.CLAUDE_CLI_PATH?.trim()) ? (process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ??
process.env.CLAUDE_CLI_PATH?.trim())
: process.env.CLAUDE_CLI_PATH?.trim(); : process.env.CLAUDE_CLI_PATH?.trim();
if (overrideRaw) { if (overrideRaw) {
const looksLikePath = const looksLikePath =
@ -244,20 +272,36 @@ export class ClaudeBinaryResolver {
} }
} }
if (flavor === 'free-code') { if (flavor === 'agent_teams_orchestrator') {
// Keep free-code resolution generic. Dev flows should inject an explicit const bundledBinary = await resolveBundledOrchestratorBinary();
// CLAUDE_CLI_PATH, while non-dev setups can expose claude-multimodel on if (bundledBinary) {
// PATH without making this resolver guess a sibling repo name or folder. cachedPath = bundledBinary;
const freeCodeBinaryName = 'claude-multimodel'; cacheVerifiedAt = Date.now();
const fromPath = await resolveFromPathEnv(freeCodeBinaryName, enrichedPath); 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) { if (fromPath) {
cachedPath = fromPath; cachedPath = fromPath;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
return cachedPath; return cachedPath;
} }
// Free-code mode is explicit. If the configured local runtime is missing, const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
// fail closed instead of silently falling back to a different CLI. 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; return null;
} }
@ -273,9 +317,12 @@ export class ClaudeBinaryResolver {
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName]; process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
const home = getShellPreferredHome(); const home = getShellPreferredHome();
const vendorBinDir = path.join(getClaudeBasePath(), 'local', 'node_modules', '.bin');
const candidateDirs: string[] = const candidateDirs: string[] =
process.platform === 'win32' process.platform === 'win32'
? [ ? [
// Windows: Claude npm-local vendor install
vendorBinDir,
// Windows: npm global install // Windows: npm global install
path.join(home, 'AppData', 'Roaming', 'npm'), path.join(home, 'AppData', 'Roaming', 'npm'),
// Windows: scoop, chocolatey, and other package managers // Windows: scoop, chocolatey, and other package managers
@ -288,6 +335,8 @@ export class ClaudeBinaryResolver {
...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []), ...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []),
] ]
: [ : [
// Unix: Claude npm-local vendor install
vendorBinDir,
// Unix: native binary installation path (claude install) // Unix: native binary installation path (claude install)
path.join(home, '.local', 'bin'), path.join(home, '.local', 'bin'),
path.join(home, '.npm-global', 'bin'), path.join(home, '.npm-global', 'bin'),
@ -318,6 +367,13 @@ export class ClaudeBinaryResolver {
return cachedPath; 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 // Don't cache null — CLI may be installed later without app restart
return null; 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 { getAppIconPath } from '@main/utils/appIcon';
import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { killProcessByPid } from '@main/utils/processKill';
import { import {
encodePath, encodePath,
extractBaseDir, extractBaseDir,
@ -14,6 +13,7 @@ import {
getTasksBasePath, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import { import {
@ -50,31 +50,39 @@ import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/ut
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { import {
extractToolPreview, extractToolPreview,
parseAgentToolResultStatus,
extractToolResultPreview, extractToolResultPreview,
formatToolSummaryFromCalls, formatToolSummaryFromCalls,
parseAgentToolResultStatus,
} from '@shared/utils/toolSummary'; } from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller'; 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 { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; 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 { buildActionModeProtocol } from './actionModeInstructions';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { withFileLock } from './fileLock'; import { withFileLock } from './fileLock';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; import {
type ClassifiedMainProcessIdle,
classifyIdleNotificationForMainProcess,
} from './idleNotificationMainProcessSemantics';
import { withInboxLock } from './inboxLock'; import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import { TeamInboxReader } from './TeamInboxReader'; import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode';
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 { import {
choosePreferredLaunchSnapshot, choosePreferredLaunchSnapshot,
clearBootstrapState, clearBootstrapState,
@ -82,25 +90,19 @@ import {
readBootstrapRealTaskSubmissionState, readBootstrapRealTaskSubmissionState,
readBootstrapRuntimeState, readBootstrapRuntimeState,
} from './TeamBootstrapStateReader'; } from './TeamBootstrapStateReader';
import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode'; import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { import {
createPersistedLaunchSnapshot, createPersistedLaunchSnapshot,
snapshotFromRuntimeMemberStatuses, snapshotFromRuntimeMemberStatuses,
snapshotToMemberSpawnStatuses, snapshotToMemberSpawnStatuses,
} from './TeamLaunchStateEvaluator'; } from './TeamLaunchStateEvaluator';
import { import { TeamLaunchStateStore } from './TeamLaunchStateStore';
applyConfiguredRuntimeBackendsEnv, import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
applyProviderRuntimeEnv, import { TeamMembersMetaStore } from './TeamMembersMetaStore';
resolveTeamProviderId, import { TeamMetaStore } from './TeamMetaStore';
} from '../runtime/providerRuntimeEnv'; import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { import { TeamTaskReader } from './TeamTaskReader';
resolveGeminiRuntimeAuth,
type GeminiRuntimeAuthState,
} from '../runtime/geminiRuntimeAuth';
import {
classifyIdleNotificationForMainProcess,
type ClassifiedMainProcessIdle,
} from './idleNotificationMainProcessSemantics';
/** /**
* Kill a team CLI process using SIGKILL (uncatchable). * Kill a team CLI process using SIGKILL (uncatchable).
@ -136,20 +138,21 @@ interface PersistedRuntimeMemberLike {
type RelayInboxMessage = InboxMessage & { messageId: string }; type RelayInboxMessage = InboxMessage & { messageId: string };
type RelayInboxMessageView = { interface RelayInboxMessageView {
message: RelayInboxMessage; message: RelayInboxMessage;
idle: ClassifiedMainProcessIdle | null; idle: ClassifiedMainProcessIdle | null;
isCoarseNoise: boolean; isCoarseNoise: boolean;
}; }
import type { import type {
ActiveToolCall, ActiveToolCall,
CrossTeamSendResult, CrossTeamSendResult,
EffortLevel,
InboxMessage, InboxMessage,
LeadContextUsage, LeadContextUsage,
MemberLaunchState, MemberLaunchState,
MemberSpawnStatus,
MemberSpawnLivenessSource, MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
PersistedTeamLaunchPhase, PersistedTeamLaunchPhase,
PersistedTeamLaunchSummary, PersistedTeamLaunchSummary,
@ -159,19 +162,18 @@ import type {
TeamLaunchAggregateState, TeamLaunchAggregateState,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamProviderId,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
TeamProvisioningProgress, TeamProvisioningProgress,
TeamProvisioningState, TeamProvisioningState,
TeamRuntimeState, TeamRuntimeState,
TeamTask, TeamTask,
EffortLevel,
ToolActivityEventPayload, ToolActivityEventPayload,
ToolApprovalAutoResolved, ToolApprovalAutoResolved,
ToolApprovalEvent, ToolApprovalEvent,
ToolApprovalRequest, ToolApprovalRequest,
ToolApprovalSettings, ToolApprovalSettings,
ToolCallMeta, ToolCallMeta,
TeamProviderId,
} from '@shared/types'; } from '@shared/types';
const logger = createLogger('Service:TeamProvisioning'); const logger = createLogger('Service:TeamProvisioning');
@ -321,11 +323,11 @@ function getTeamProviderLabel(providerId: TeamProviderId): string {
} }
} }
type CanonicalSendMessageExample = { interface CanonicalSendMessageExample {
to: string; to: string;
summary: string; summary: string;
message: string; message: string;
}; }
// TODO(refactor): If more prompt-bound tool contracts appear here, move these // TODO(refactor): If more prompt-bound tool contracts appear here, move these
// canonical examples/rules into a small dedicated module (for example // 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()} ${getCanonicalSendMessageFieldRule()}
Correct example: Correct example:
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} ${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( function buildGeminiReconnectMemberSpawnPrompt(
@ -1110,7 +1113,17 @@ If member_briefing fails, SendMessage "${leadName}" one short natural-language s
${getCanonicalSendMessageFieldRule()} ${getCanonicalSendMessageFieldRule()}
Correct example: Correct example:
${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} ${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( 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: 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. - 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." - 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. - 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 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()} ${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. - 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. - 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." - 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. - 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 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.`; - If you have no tasks, wait for new assignments.`;
@ -1280,7 +1297,7 @@ export function buildAddMemberSpawnMessage(
); );
} }
type RuntimeBootstrapMemberSpec = { interface RuntimeBootstrapMemberSpec {
name: string; name: string;
prompt?: string; prompt?: string;
cwd?: string; cwd?: string;
@ -1291,15 +1308,15 @@ type RuntimeBootstrapMemberSpec = {
description?: string; description?: string;
useSplitPane?: boolean; useSplitPane?: boolean;
planModeRequired?: boolean; planModeRequired?: boolean;
}; }
type RuntimeBootstrapSpec = { interface RuntimeBootstrapSpec {
version: 1; version: 1;
runId: string; runId: string;
mode: 'create' | 'launch'; mode: 'create' | 'launch';
initiator: { initiator: {
kind: 'app'; kind: 'app';
source: 'claude_team_freecode'; source: 'claude_team_agent_teams_orchestrator';
}; };
team: { team: {
name: string; name: string;
@ -1320,7 +1337,7 @@ type RuntimeBootstrapSpec = {
ui?: { ui?: {
emitStructuredEvents?: boolean; emitStructuredEvents?: boolean;
}; };
}; }
function buildDeterministicCreateBootstrapSpec( function buildDeterministicCreateBootstrapSpec(
runId: string, runId: string,
@ -1333,7 +1350,7 @@ function buildDeterministicCreateBootstrapSpec(
mode: 'create', mode: 'create',
initiator: { initiator: {
kind: 'app', kind: 'app',
source: 'claude_team_freecode', source: 'claude_team_agent_teams_orchestrator',
}, },
team: { team: {
name: request.teamName, name: request.teamName,
@ -1385,7 +1402,7 @@ function buildDeterministicLaunchBootstrapSpec(
mode: 'launch', mode: 'launch',
initiator: { initiator: {
kind: 'app', kind: 'app',
source: 'claude_team_freecode', source: 'claude_team_agent_teams_orchestrator',
}, },
team: { team: {
name: request.teamName, 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 }`, `- 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.`, ` 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>" }`, `- 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.`, `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):`, `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.`, `- 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.`, `- 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.`, `- 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.`, `- 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.`,
` - 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).`, ` - 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.`, ` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
`- Avoid over-specifying. Only add dependencies when execution order matters.`, `- Avoid over-specifying. Only add dependencies when execution order matters.`,
``, ``,
@ -3538,7 +3559,7 @@ export class TeamProvisioningService {
ready: false, ready: false,
message: message:
blockingMessages.length === 1 blockingMessages.length === 1
? blockingMessages[0]! ? blockingMessages[0]
: 'Some provider runtimes are not ready', : 'Some provider runtimes are not ready',
warnings: blockingMessages.length > 1 ? blockingMessages : undefined, warnings: blockingMessages.length > 1 ? blockingMessages : undefined,
}; };
@ -3708,7 +3729,7 @@ export class TeamProvisioningService {
return sanitized; 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); const jsonCandidate = jsonMatch?.[1] ?? (sanitized.startsWith('{') ? sanitized : null);
if (jsonCandidate) { if (jsonCandidate) {
try { try {
@ -4150,7 +4171,7 @@ export class TeamProvisioningService {
ctx.claudePath, ctx.claudePath,
ctx.cwd, ctx.cwd,
ctx.env, ctx.env,
ctx.args[mcpFlagIdx + 1]! ctx.args[mcpFlagIdx + 1]
); );
} }
child = spawnCli(ctx.claudePath, ctx.args, { child = spawnCli(ctx.claudePath, ctx.args, {
@ -6439,7 +6460,7 @@ export class TeamProvisioningService {
for (const line of output.split('\n')) { for (const line of output.split('\n')) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed.includes(teamMarker)) continue; if (!trimmed.includes(teamMarker)) continue;
const match = trimmed.match(/--agent-id\s+([^\s@]+)@/); const match = /--agent-id\s+([^\s@]+)@/.exec(trimmed);
if (!match) continue; if (!match) continue;
const agentName = match[1]?.trim(); const agentName = match[1]?.trim();
if (agentName) { if (agentName) {
@ -7192,7 +7213,7 @@ export class TeamProvisioningService {
if (!trimmed || !trimmed.includes(marker) || !trimmed.includes('--agent-id')) { if (!trimmed || !trimmed.includes(marker) || !trimmed.includes('--agent-id')) {
continue; continue;
} }
const match = trimmed.match(/^(\d+)\s+(.*)$/); const match = /^(\d+)\s+(.*)$/.exec(trimmed);
if (!match) continue; if (!match) continue;
const pid = Number.parseInt(match[1], 10); const pid = Number.parseInt(match[1], 10);
if (!Number.isFinite(pid) || pid <= 0) continue; if (!Number.isFinite(pid) || pid <= 0) continue;
@ -8496,7 +8517,7 @@ export class TeamProvisioningService {
return 'Question: User input is required'; return 'Question: User input is required';
} }
const firstQuestion = questions[0]!; const firstQuestion = questions[0];
const truncatedQuestion = const truncatedQuestion =
firstQuestion.length > 140 ? `${firstQuestion.slice(0, 137)}...` : firstQuestion; firstQuestion.length > 140 ? `${firstQuestion.slice(0, 137)}...` : firstQuestion;
@ -10247,6 +10268,10 @@ export class TeamProvisioningService {
}; };
applyConfiguredRuntimeBackendsEnv(env); applyConfiguredRuntimeBackendsEnv(env);
applyProviderRuntimeEnv(env, providerId); applyProviderRuntimeEnv(env, providerId);
await providerConnectionService.applyConfiguredConnectionEnv(
env,
resolveTeamProviderId(providerId)
);
const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
if (controlApiBaseUrl) { if (controlApiBaseUrl) {

View file

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

View file

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

View file

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

View file

@ -8,8 +8,8 @@
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
export const CliInstallWarningBanner = (): React.JSX.Element | null => { export const CliInstallWarningBanner = (): React.JSX.Element | null => {
const cliStatus = useStore(useShallow((s) => s.cliStatus)); 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" /> <AlertTriangle className="size-3.5 shrink-0" />
<span className="text-xs"> <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> </span>
<button <button
onClick={openDashboard} onClick={openDashboard}

View file

@ -12,6 +12,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; 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 { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components'; import { SettingsToggle } from '@renderer/components/settings/components';
@ -42,7 +52,7 @@ import {
Terminal, Terminal,
} from 'lucide-react'; } from 'lucide-react';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types'; import type { CliProviderId, CliProviderStatus } from '@shared/types';
// ============================================================================= // =============================================================================
// Border color by state // 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 { function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
return getTeamModelBadgeLabel(providerId, model) ?? model; return getTeamModelBadgeLabel(providerId, model) ?? model;
} }
@ -342,7 +334,7 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo
function formatRuntimeLabel( function formatRuntimeLabel(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']> cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
): string | null { ): string | null {
if (cliStatus.flavor === 'free-code') { if (cliStatus.flavor === 'agent_teams_orchestrator') {
return null; return null;
} }
@ -355,7 +347,7 @@ function formatRuntimeAuthSummary(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>, cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
visibleProviders: readonly CliProviderStatus[] visibleProviders: readonly CliProviderStatus[]
): string | null { ): string | null {
if (cliStatus.flavor === 'free-code' && visibleProviders.length > 0) { if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) {
if ( if (
visibleProviders.every( visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
@ -385,7 +377,7 @@ function isCheckingMultimodelStatus(
visibleProviders: readonly CliProviderStatus[] visibleProviders: readonly CliProviderStatus[]
): boolean { ): boolean {
return ( return (
cliStatus.flavor === 'free-code' && cliStatus.flavor === 'agent_teams_orchestrator' &&
visibleProviders.length > 0 && visibleProviders.length > 0 &&
visibleProviders.every( visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
@ -524,16 +516,28 @@ const InstalledBanner = ({
style={{ borderColor: 'var(--color-border-subtle)' }} style={{ borderColor: 'var(--color-border-subtle)' }}
> >
{visibleProviders.map((provider) => { {visibleProviders.map((provider) => {
const statusText = formatProviderStatus(provider); const statusText = formatProviderStatusText(provider);
const actionDisabled = isBusy || !cliStatus.binaryPath; 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 providerLoading = cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = isProviderCardLoading(provider, providerLoading); const showSkeleton = isProviderCardLoading(provider, providerLoading);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
connectionModeSummary ||
credentialSummary ||
provider.models.length === 0
);
return ( return (
<div <div
key={provider.providerId} 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)' }} style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
> >
<div className="col-span-2 flex items-start justify-between gap-3"> <div className="col-span-2 flex items-start justify-between gap-3">
@ -562,25 +566,28 @@ const InstalledBanner = ({
</div> </div>
{showSkeleton ? ( {showSkeleton ? (
<ProviderDetailSkeleton /> <ProviderDetailSkeleton />
) : ( ) : hasDetailContent ? (
<div <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)' }} style={{ color: 'var(--color-text-muted)' }}
> >
{provider.backend?.label && ( {provider.backend?.label && !runtimeSummary && (
<span> <span>Backend: {provider.backend.label}</span>
Backend: {provider.backend.label}
{provider.backend.endpointLabel
? ` (${provider.backend.endpointLabel})`
: ''}
</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 && ( {provider.models.length === 0 && (
<span>Models unavailable for this runtime build</span> <span>Models unavailable for this runtime build</span>
)} )}
</div> </div>
)} ) : null}
</div> </div>
<div className="flex shrink-0 items-start gap-2"> <div className="flex shrink-0 items-start gap-2">
<button <button
@ -595,7 +602,7 @@ const InstalledBanner = ({
<SlidersHorizontal className="size-3" /> <SlidersHorizontal className="size-3" />
Manage Manage
</button> </button>
{provider.authenticated && provider.canLoginFromUi ? ( {disconnectAction ? (
<button <button
onClick={() => onProviderLogout(provider.providerId)} onClick={() => onProviderLogout(provider.providerId)}
disabled={actionDisabled} disabled={actionDisabled}
@ -606,9 +613,9 @@ const InstalledBanner = ({
}} }}
> >
<LogOut className="size-3" /> <LogOut className="size-3" />
Logout {disconnectAction.label}
</button> </button>
) : provider.canLoginFromUi ? ( ) : shouldShowProviderConnectAction(provider) ? (
<button <button
onClick={() => onProviderLogin(provider.providerId)} onClick={() => onProviderLogin(provider.providerId)}
disabled={actionDisabled} disabled={actionDisabled}
@ -619,7 +626,7 @@ const InstalledBanner = ({
}} }}
> >
<LogIn className="size-3" /> <LogIn className="size-3" />
Login {getProviderConnectLabel(provider)}
</button> </button>
) : null} ) : null}
<button <button
@ -736,6 +743,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const handleMultimodelToggle = useCallback( const handleMultimodelToggle = useCallback(
async (enabled: boolean) => { async (enabled: boolean) => {
setIsSwitchingFlavor(true); setIsSwitchingFlavor(true);
let nextMultimodelEnabled = multimodelEnabled;
try { try {
useStore.setState({ useStore.setState({
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null, cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
@ -743,17 +751,24 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError: null, cliStatusError: null,
}); });
await updateConfig('general', { multimodelEnabled: enabled }); await updateConfig('general', { multimodelEnabled: enabled });
nextMultimodelEnabled = enabled;
await invalidateCliStatus(); await invalidateCliStatus();
if (enabled) { if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true }); await bootstrapCliStatus({ multimodelEnabled: true });
} else { } else {
await fetchCliStatus(); await fetchCliStatus();
} }
} catch {
if (nextMultimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally { } finally {
setIsSwitchingFlavor(false); setIsSwitchingFlavor(false);
} }
}, },
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig] [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
); );
const recheckAuthState = useCallback(() => { const recheckAuthState = useCallback(() => {
@ -772,23 +787,33 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
setProviderTerminal({ providerId, action: 'login' }); setProviderTerminal({ providerId, action: 'login' });
}, []); }, []);
const handleProviderLogout = useCallback((providerId: CliProviderId) => { const handleProviderLogout = useCallback(
void (async () => { (providerId: CliProviderId) => {
const confirmed = await confirm({ void (async () => {
title: `Logout from ${getProviderLabel(providerId)}?`, const provider =
message: 'This will remove the current provider session from the local Claude CLI runtime.', cliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
confirmLabel: 'Logout', const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
cancelLabel: 'Cancel', if (!disconnectAction) {
variant: 'danger', return;
}); }
if (!confirmed) { const confirmed = await confirm({
return; 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) => { const handleProviderManage = useCallback((providerId: CliProviderId) => {
setManageProviderId(providerId); setManageProviderId(providerId);
@ -819,7 +844,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
[providerId]: backendId, [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] [appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
); );
@ -867,10 +897,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
} }
providerStatusLoading={cliProviderStatusLoading} providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath} disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath}
onSelectBackend={(providerId, backendId) => { onSelectBackend={handleProviderBackendChange}
void handleProviderBackendChange(providerId, backendId);
}}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
/> />
{providerTerminal && cliStatus.binaryPath && ( {providerTerminal && cliStatus.binaryPath && (
<TerminalModal <TerminalModal
@ -1065,7 +1094,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
} }
// ── Completed ────────────────────────────────────────────────────────── // ── Completed ──────────────────────────────────────────────────────────
if (installerState === 'completed' && !cliStatus?.installed) { if (
installerState === 'completed' &&
!cliStatus?.installed &&
!(cliStatus?.binaryPath && cliStatus?.launchError)
) {
return <InstallCompletedNotice version={completedVersion} />; return <InstallCompletedNotice version={completedVersion} />;
} }
@ -1083,6 +1116,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// ── Idle state with status ───────────────────────────────────────────── // ── Idle state with status ─────────────────────────────────────────────
if (!cliStatus) return null; if (!cliStatus) return null;
const cliLaunchIssue =
!cliStatus.installed && Boolean(cliStatus.binaryPath && cliStatus.launchError);
// Not installed — red error banner // Not installed — red error banner
if (!cliStatus.installed) { 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' }} /> <AlertTriangle className="mt-0.5 size-5 shrink-0" style={{ color: '#ef4444' }} />
<div> <div>
<p className="text-sm font-medium" style={{ color: '#f87171' }}> <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>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}> <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 {cliLaunchIssue
get started. ? '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> </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>
</div> </div>
{cliStatus.supportsSelfUpdate ? ( <div className="flex shrink-0 flex-col gap-2">
<button <button
onClick={handleInstall} onClick={handleRefresh}
disabled={isBusy} 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"
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={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
style={{ backgroundColor: '#3b82f6' }}
> >
<Download className="size-4" /> <RefreshCw className="size-4" />
Install Claude CLI Re-check
</button> </button>
) : ( {cliStatus.supportsSelfUpdate ? (
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}> <button
The configured free-code runtime was not found. onClick={handleInstall}
</p> 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>
</div> </div>
); );
@ -1127,7 +1197,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed but not logged in — yellow warning banner // Installed but not logged in — yellow warning banner
if ( if (
cliStatus.installed && cliStatus.installed &&
cliStatus.flavor !== 'free-code' && cliStatus.flavor !== 'agent_teams_orchestrator' &&
(cliStatus.authStatusChecking || isVerifyingAuth) (cliStatus.authStatusChecking || isVerifyingAuth)
) { ) {
if (cliStatus.authStatusChecking || isVerifyingAuth) { if (cliStatus.authStatusChecking || isVerifyingAuth) {
@ -1158,7 +1228,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if ( if (
cliStatus.installed && cliStatus.installed &&
cliStatus.flavor !== 'free-code' && cliStatus.flavor !== 'agent_teams_orchestrator' &&
!cliStatus.authStatusChecking && !cliStatus.authStatusChecking &&
!cliStatus.authLoggedIn !cliStatus.authLoggedIn
) { ) {

View file

@ -5,8 +5,6 @@
*/ */
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -20,6 +18,8 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store'; 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 { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog'; import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
@ -165,15 +165,26 @@ export const ExtensionStoreView = (): React.JSX.Element => {
} }
if (!cliStatus.installed) { if (!cliStatus.installed) {
const cliLaunchIssue = Boolean(cliStatus.binaryPath && cliStatus.launchError);
return ( 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"> <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" /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-300">Claude CLI is not available</p> <p className="text-sm font-medium text-amber-300">
<p className="mt-0.5 text-xs text-text-muted"> {cliLaunchIssue
Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to ? 'Claude CLI was found but failed to start'
install it and retry. : 'Claude CLI is not available'}
</p> </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> </div>
<Button size="sm" variant="outline" onClick={openDashboard}> <Button size="sm" variant="outline" onClick={openDashboard}>
Open Dashboard Open Dashboard
@ -231,8 +242,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{cliStatusBanner}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{cliStatusBanner}
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3"> <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 [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const handleCopyEnvVar = async () => { const handleCopyEnvVar = async (): Promise<void> => {
await navigator.clipboard.writeText(apiKey.envVarName); await navigator.clipboard.writeText(apiKey.envVarName);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
}; };
const handleDelete = () => { const handleDelete = (): void => {
if (!confirmDelete) { if (!confirmDelete) {
setConfirmDelete(true); setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000); setTimeout(() => setConfirmDelete(false), 3000);
return; return;
} }
void deleteApiKey(apiKey.id); void deleteApiKey(apiKey.id).catch(() => undefined);
setConfirmDelete(false); 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 // Use ref for config to avoid recreating callbacks when config changes
const configRef = useRef(config); const configRef = useRef(config);
configRef.current = config; configRef.current = config;
const fireAndForgetConfigUpdate = useCallback(
(section: keyof AppConfig, data: Partial<AppConfig[keyof AppConfig]>) => {
void updateConfig(section, data).catch(() => undefined);
},
[updateConfig]
);
// General handlers // General handlers
const handleGeneralToggle = useCallback( const handleGeneralToggle = useCallback(
(key: keyof AppConfig['general'], value: boolean) => { (key: keyof AppConfig['general'], value: boolean) => {
void updateConfig('general', { [key]: value }); fireAndForgetConfigUpdate('general', { [key]: value });
}, },
[updateConfig] [fireAndForgetConfigUpdate]
); );
const handleThemeChange = useCallback( const handleThemeChange = useCallback(
(value: 'dark' | 'light' | 'system') => { (value: 'dark' | 'light' | 'system') => {
void updateConfig('general', { theme: value }); fireAndForgetConfigUpdate('general', { theme: value });
}, },
[updateConfig] [fireAndForgetConfigUpdate]
); );
const handleLanguageChange = useCallback( const handleLanguageChange = useCallback(
(value: string) => { (value: string) => {
void updateConfig('general', { agentLanguage: value }); fireAndForgetConfigUpdate('general', { agentLanguage: value });
}, },
[updateConfig] [fireAndForgetConfigUpdate]
); );
const handleDefaultTabChange = useCallback( const handleDefaultTabChange = useCallback(
(value: 'dashboard' | 'last-session') => { (value: 'dashboard' | 'last-session') => {
void updateConfig('general', { defaultTab: value }); fireAndForgetConfigUpdate('general', { defaultTab: value });
}, },
[updateConfig] [fireAndForgetConfigUpdate]
); );
// Notification handlers // Notification handlers
const handleNotificationToggle = useCallback( const handleNotificationToggle = useCallback(
(key: keyof AppConfig['notifications'], value: boolean) => { (key: keyof AppConfig['notifications'], value: boolean) => {
void updateConfig('notifications', { [key]: value }); fireAndForgetConfigUpdate('notifications', { [key]: value });
}, },
[updateConfig] [fireAndForgetConfigUpdate]
); );
const handleStatusChangeStatusesUpdate = useCallback( const handleStatusChangeStatusesUpdate = useCallback(
(statuses: string[]) => { (statuses: string[]) => {
void updateConfig('notifications', { statusChangeStatuses: statuses }); fireAndForgetConfigUpdate('notifications', { statusChangeStatuses: statuses });
}, },
[updateConfig] [fireAndForgetConfigUpdate]
); );
const handleSnooze = useCallback( const handleSnooze = useCallback(
@ -250,9 +256,9 @@ export function useSettingsHandlers({
// Display handlers // Display handlers
const handleDisplayToggle = useCallback( const handleDisplayToggle = useCallback(
(key: keyof AppConfig['display'], value: boolean) => { (key: keyof AppConfig['display'], value: boolean) => {
void updateConfig('display', { [key]: value }); fireAndForgetConfigUpdate('display', { [key]: value });
}, },
[updateConfig] [fireAndForgetConfigUpdate]
); );
// Advanced handlers // Advanced handlers
@ -321,6 +327,15 @@ export function useSettingsHandlers({
useNativeTitleBar: false, useNativeTitleBar: false,
telemetryEnabled: true, telemetryEnabled: true,
}, },
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: false,
authMode: 'oauth',
},
},
runtime: { runtime: {
providerBackends: { providerBackends: {
gemini: 'auto', gemini: 'auto',
@ -340,6 +355,7 @@ export function useSettingsHandlers({
await api.config.update('notifications', defaultConfig.notifications); await api.config.update('notifications', defaultConfig.notifications);
await api.config.update('general', defaultConfig.general); await api.config.update('general', defaultConfig.general);
await api.config.update('providerConnections', defaultConfig.providerConnections);
await api.config.update('runtime', defaultConfig.runtime); await api.config.update('runtime', defaultConfig.runtime);
const updatedConfig = await api.config.update('display', defaultConfig.display); const updatedConfig = await api.config.update('display', defaultConfig.display);
setConfig(updatedConfig); setConfig(updatedConfig);

View file

@ -10,6 +10,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; 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 { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components'; import { SettingsToggle } from '@renderer/components/settings/components';
@ -188,9 +198,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
? createLoadingMultimodelCliStatus() ? createLoadingMultimodelCliStatus()
: cliStatus; : cliStatus;
const showInstalledControls = const showInstalledControls =
effectiveCliStatus !== null && effectiveCliStatus !== null && (installerState === 'idle' || installerState === 'completed');
(installerState === 'idle' ||
(installerState === 'completed' && effectiveCliStatus.installed === true));
useEffect(() => { useEffect(() => {
if (isElectron) { if (isElectron) {
@ -212,24 +220,34 @@ export const CliStatusSection = (): React.JSX.Element | null => {
void fetchCliStatus(); void fetchCliStatus();
}, [fetchCliStatus]); }, [fetchCliStatus]);
const handleProviderLogout = useCallback(async (providerId: CliProviderId) => { const handleProviderLogout = useCallback(
const confirmed = await confirm({ async (providerId: CliProviderId) => {
title: `Logout from ${getProviderLabel(providerId)}?`, const provider =
message: 'This will remove the current provider session from the local Claude CLI runtime.', effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
confirmLabel: 'Logout', const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
cancelLabel: 'Cancel', if (!disconnectAction) {
variant: 'danger', return;
}); }
if (!confirmed) { const confirmed = await confirm({
return; title: disconnectAction.title,
} message: disconnectAction.message,
confirmLabel: disconnectAction.confirmLabel,
cancelLabel: 'Cancel',
variant: 'danger',
});
setProviderTerminal({ if (!confirmed) {
providerId, return;
action: 'logout', }
});
}, []); setProviderTerminal({
providerId,
action: 'logout',
});
},
[effectiveCliStatus?.providers]
);
const handleProviderManage = useCallback((providerId: CliProviderId) => { const handleProviderManage = useCallback((providerId: CliProviderId) => {
setManageProviderId(providerId); setManageProviderId(providerId);
@ -246,6 +264,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const handleMultimodelToggle = useCallback( const handleMultimodelToggle = useCallback(
async (enabled: boolean) => { async (enabled: boolean) => {
setIsSwitchingFlavor(true); setIsSwitchingFlavor(true);
let nextMultimodelEnabled = multimodelEnabled;
try { try {
useStore.setState({ useStore.setState({
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null, cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
@ -253,42 +272,26 @@ export const CliStatusSection = (): React.JSX.Element | null => {
cliStatusError: null, cliStatusError: null,
}); });
await updateConfig('general', { multimodelEnabled: enabled }); await updateConfig('general', { multimodelEnabled: enabled });
nextMultimodelEnabled = enabled;
await invalidateCliStatus(); await invalidateCliStatus();
if (enabled) { if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true }); await bootstrapCliStatus({ multimodelEnabled: true });
} else { } else {
await fetchCliStatus(); await fetchCliStatus();
} }
} catch {
if (nextMultimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally { } finally {
setIsSwitchingFlavor(false); 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( const handleRuntimeBackendChange = useCallback(
async (providerId: CliProviderId, backendId: string) => { async (providerId: CliProviderId, backendId: string) => {
const currentBackends = appConfig?.runtime?.providerBackends ?? { const currentBackends = appConfig?.runtime?.providerBackends ?? {
@ -306,11 +309,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
[providerId]: backendId, [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] [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 ( return (
<div className="mb-2"> <div className="mb-2">
<SettingsSectionHeader title="CLI Runtime" /> <SettingsSectionHeader title="CLI Runtime" />
@ -438,7 +469,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{effectiveCliStatus.providers.map((provider) => ( {effectiveCliStatus.providers.map((provider) => (
<div <div
key={provider.providerId} 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={{ style={{
borderColor: 'var(--color-border-subtle)', borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.02)', backgroundColor: 'rgba(255, 255, 255, 0.02)',
@ -448,7 +479,20 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const providerLoading = const providerLoading =
cliProviderStatusLoading[provider.providerId] === true; cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = isProviderCardLoading(provider, providerLoading); 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 ( return (
<> <>
@ -474,31 +518,35 @@ export const CliStatusSection = (): React.JSX.Element | null => {
: 'var(--color-text-muted)', : 'var(--color-text-muted)',
}} }}
> >
{provider.authenticated {statusText}
? provider.authMethod
? `Authenticated via ${provider.authMethod}`
: 'Authenticated'
: provider.statusMessage || 'Not connected'}
</span> </span>
</div> </div>
{showSkeleton ? ( {showSkeleton ? (
<ProviderDetailSkeleton /> <ProviderDetailSkeleton />
) : ( ) : hasDetailContent ? (
<div <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)' }} style={{ color: 'var(--color-text-muted)' }}
> >
{provider.backend?.label && ( {provider.backend?.label && !runtimeSummary && (
<span>Backend: {provider.backend.label}</span> <span>Backend: {provider.backend.label}</span>
)} )}
{runtimeSummary ? ( {runtimeSummary ? (
<span>Runtime: {runtimeSummary}</span> <span>
{isConnectionManagedRuntimeProvider(provider)
? runtimeSummary
: `Runtime: ${runtimeSummary}`}
</span>
) : null} ) : null}
{connectionModeSummary ? (
<span>{connectionModeSummary}</span>
) : null}
{credentialSummary ? <span>{credentialSummary}</span> : null}
{provider.models.length === 0 && ( {provider.models.length === 0 && (
<span>Models unavailable for this runtime build</span> <span>Models unavailable for this runtime build</span>
)} )}
</div> </div>
)} ) : null}
</div> </div>
<div className="flex shrink-0 items-start gap-2"> <div className="flex shrink-0 items-start gap-2">
<button <button
@ -514,7 +562,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<SlidersHorizontal className="size-3" /> <SlidersHorizontal className="size-3" />
Manage Manage
</button> </button>
{provider.authenticated && provider.canLoginFromUi ? ( {disconnectAction ? (
<button <button
type="button" type="button"
onClick={() => void handleProviderLogout(provider.providerId)} onClick={() => void handleProviderLogout(provider.providerId)}
@ -526,9 +574,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}} }}
> >
<LogOut className="size-3" /> <LogOut className="size-3" />
Logout {disconnectAction.label}
</button> </button>
) : provider.canLoginFromUi ? ( ) : shouldShowProviderConnectAction(provider) ? (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@ -537,9 +585,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
action: 'login', action: 'login',
}) })
} }
disabled={ disabled={!effectiveCliStatus.binaryPath}
!effectiveCliStatus.binaryPath || !provider.canLoginFromUi
}
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" 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={{ style={{
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
@ -547,7 +593,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}} }}
> >
<LogIn className="size-3" /> <LogIn className="size-3" />
Login {getProviderConnectLabel(provider)}
</button> </button>
) : null} ) : null}
</div> </div>
@ -574,37 +620,73 @@ export const CliStatusSection = (): React.JSX.Element | null => {
initialProviderId={manageProviderId} initialProviderId={manageProviderId}
providerStatusLoading={cliProviderStatusLoading} providerStatusLoading={cliProviderStatusLoading}
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading} disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
onSelectBackend={(providerId, backendId) => { onSelectBackend={handleRuntimeBackendChange}
void handleRuntimeBackendChange(providerId, backendId);
}}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
onRequestLogin={(providerId) =>
setProviderTerminal({ providerId, action: 'login' })
}
/> />
</div> </div>
) : ( ) : (
<div <div className="space-y-2 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
className="flex items-center gap-2 text-sm" <div className="flex items-center gap-2">
style={{ color: 'var(--color-text-secondary)' }} <AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
> {effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} /> ? 'Claude CLI was found but failed to start'
Claude CLI not installed : '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> </div>
)} )}
{/* Install button (CLI not installed) */} {/* Install button (CLI not installed) */}
{!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && ( {!effectiveCliStatus.installed && effectiveCliStatus.supportsSelfUpdate && (
<button <div className="flex flex-wrap gap-2">
onClick={handleInstall} <button
disabled={isBusy} onClick={handleRefresh}
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" 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={{ backgroundColor: '#3b82f6' }} style={{
> borderColor: 'var(--color-border)',
<Download className="size-3.5" /> color: 'var(--color-text-secondary)',
Install Claude CLI }}
</button> >
<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 && ( {!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}> <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> </p>
)} )}
</div> </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 { Label } from '@renderer/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -17,13 +18,13 @@ import {
import { import {
doesTeamModelCarryProviderBrand, doesTeamModelCarryProviderBrand,
getTeamModelLabel as getCatalogTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel,
getTeamModelUiDisabledReason,
getTeamProviderLabel as getCatalogTeamProviderLabel, getTeamProviderLabel as getCatalogTeamProviderLabel,
getTeamProviderModelOptions, getTeamProviderModelOptions,
getTeamModelUiDisabledReason,
normalizeTeamModelForUi, normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL, TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from '@renderer/utils/teamModelCatalog'; } 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) --- // --- 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; 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 { export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
} }
@ -169,10 +181,15 @@ export function formatTeamModelSummary(
effort?: string effort?: string
): string { ): string {
const providerLabel = getTeamProviderLabel(providerId); 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 effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(providerId, modelLabel); const modelAlreadyCarriesProviderBrand =
doesTeamModelCarryProviderBrand(providerId, rawModelLabel) ||
(providerId === 'codex' && model.trim().toLowerCase().startsWith('gpt-'));
const providerActsAsBackendOnly = const providerActsAsBackendOnly =
providerId !== 'anthropic' && modelLabel !== 'Default' && !modelAlreadyCarriesProviderBrand; providerId !== 'anthropic' && modelLabel !== 'Default' && !modelAlreadyCarriesProviderBrand;
@ -222,29 +239,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}) => { }) => {
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'free-code'; const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'agent_teams_orchestrator';
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 effectiveProviderId = const effectiveProviderId =
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
const activeProvider =
PROVIDERS.find((provider) => provider.id === effectiveProviderId) ?? PROVIDERS[0];
const ProviderIcon = activeProvider.icon;
const defaultModelTooltip = useMemo(() => { const defaultModelTooltip = useMemo(() => {
if (effectiveProviderId === 'anthropic') { if (effectiveProviderId === 'anthropic') {
return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.'; 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) && !isProviderTemporarilyDisabled(candidateProviderId) &&
(multimodelAvailable || candidateProviderId === 'anthropic'); (multimodelAvailable || candidateProviderId === 'anthropic');
const activeProviderSelectable = isProviderSelectable(effectiveProviderId); const activeProviderSelectable = isProviderSelectable(effectiveProviderId);
const runtimeModels = const getProviderStatusBadge = (candidateProviderId: string): string | null => {
cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId)?.models ?? 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); const normalizedValue = normalizeTeamModelForUi(effectiveProviderId, value);
useEffect(() => { useEffect(() => {
@ -280,11 +308,17 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const modelOptions = useMemo(() => { const modelOptions = useMemo(() => {
const fallback = getTeamProviderModelOptions(effectiveProviderId); const fallback = getTeamProviderModelOptions(effectiveProviderId);
if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) { 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) => ({ const dynamicOptions = runtimeModels.map((model) => ({
value: model, value: model,
label: getTeamModelLabel(model), label: getProviderScopedTeamModelLabel(effectiveProviderId, model),
})); }));
return [{ value: '', label: 'Default' }, ...dynamicOptions]; return [{ value: '', label: 'Default' }, ...dynamicOptions];
}, [effectiveProviderId, runtimeModels]); }, [effectiveProviderId, runtimeModels]);
@ -294,207 +328,173 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
<Label htmlFor={id} className="label-optional mb-1.5 block"> <Label htmlFor={id} className="label-optional mb-1.5 block">
Model (optional) Model (optional)
</Label> </Label>
<div ref={containerRef} className="relative space-y-2"> <Tabs
<div className="relative inline-flex"> value={effectiveProviderId}
<button onValueChange={(nextValue) => {
type="button" if (
className={cn( (nextValue === 'anthropic' || nextValue === 'codex' || nextValue === 'gemini') &&
'flex min-w-[170px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-xs font-medium transition-colors', isProviderSelectable(nextValue)
dropdownOpen ) {
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' onProviderChange(nextValue);
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]' }
)} }}
style={{ >
borderColor: 'var(--color-border)', <div className="space-y-0">
backgroundColor: 'var(--color-surface)', <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">
onClick={() => setDropdownOpen(!dropdownOpen)} {PROVIDERS.map((provider) => {
>
<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) => {
const Icon = provider.icon; 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 providerDisabledReason = getProviderDisabledReason(provider.id);
const providerSelectable = isProviderSelectable(provider.id);
const statusBadge = getProviderStatusBadge(provider.id);
const statusBadgeLabel = getProviderStatusBadgeLabel(statusBadge);
return ( return (
<React.Fragment key={provider.id}> <TabsTrigger
{prevWasActive && !isFirst && ( key={provider.id}
<div value={provider.id}
className="mx-2 my-1 border-t" disabled={provider.comingSoon || !providerSelectable}
style={{ borderColor: 'var(--color-border-subtle)' }} 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" <Icon className="size-3.5 shrink-0" />
disabled={provider.comingSoon || !isProviderSelectable(provider.id)} <span
onClick={() => {
if (!provider.comingSoon && isProviderSelectable(provider.id)) {
onProviderChange(provider.id as 'anthropic' | 'codex' | 'gemini');
setDropdownOpen(false);
}
}}
className={cn( className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100', 'min-w-0 truncate text-sm font-medium',
isActive && 'bg-indigo-500/10 text-indigo-400', statusBadgeLabel && 'pr-9'
(provider.comingSoon || !isProviderSelectable(provider.id)) &&
'cursor-not-allowed opacity-40',
!isActive &&
!provider.comingSoon &&
isProviderSelectable(provider.id) &&
'hover:bg-white/5'
)} )}
style={
!isActive && !provider.comingSoon && isProviderSelectable(provider.id)
? { color: 'var(--color-text-secondary)' }
: undefined
}
> >
<Icon className="size-3.5 shrink-0" /> {provider.label}
<span className="flex-1">{provider.label}</span> </span>
{provider.comingSoon && ( {statusBadgeLabel ? (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"> <span
Coming Soon className="absolute right-2 top-1.5 rounded px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.08em]"
</span> style={{
)} color: 'var(--color-text-muted)',
{!provider.comingSoon && providerDisabledReason && ( backgroundColor: 'rgba(255, 255, 255, 0.05)',
<span }}
className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]" aria-label={statusBadge ?? undefined}
title={providerDisabledReason} title={statusBadge ?? undefined}
> >
{GEMINI_UI_DISABLED_BADGE_LABEL} {statusBadgeLabel}
</span> </span>
)} ) : null}
{!provider.comingSoon && </TabsTrigger>
!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>
); );
})} })}
</div> </TabsList>
)} </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;
return ( <div className="rounded-b-md border border-t-0 border-[var(--color-border)] bg-[var(--color-surface)]">
<button {!multimodelAvailable ? (
key={opt.value || '__default__'} <div className="border-b border-[var(--color-border-subtle)] px-3 py-2">
type="button" <p className="text-[11px] text-[var(--color-text-muted)]">
id={opt.value === normalizedValue ? id : undefined} Codex and Gemini require Multimodel mode.
aria-disabled={!modelSelectable} </p>
className={cn( </div>
'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', ) : null}
normalizedValue === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' <div className="p-3">
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]', <div
!modelSelectable && 'cursor-not-allowed opacity-45', className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none' style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
)} >
style={{ {modelOptions.map((opt) =>
borderColor: (() => {
normalizedValue === opt.value const modelDisabledReason = getTeamModelUiDisabledReason(
? 'var(--color-border-emphasis)' effectiveProviderId,
: 'transparent', opt.value
}} );
onClick={() => { const modelSelectable = activeProviderSelectable && !modelDisabledReason;
if (!modelSelectable) return;
onValueChange(opt.value); return (
}} <button
> key={opt.value || '__default__'}
<span className="flex flex-col items-center justify-center gap-0.5"> type="button"
<span className="leading-tight">{opt.label}</span> id={opt.value === normalizedValue ? id : undefined}
{opt.value === '' && ( aria-disabled={!modelSelectable}
<span className="flex items-center justify-center gap-1"> className={cn(
<TooltipProvider delayDuration={200}> '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',
<Tooltip> normalizedValue === opt.value
<TooltipTrigger ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
asChild : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
onClick={(e: React.MouseEvent) => e.stopPropagation()} !modelSelectable && 'cursor-not-allowed opacity-45',
> !modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" /> )}
</TooltipTrigger> style={{
<TooltipContent side="top" className="max-w-[240px] text-xs"> borderColor:
{defaultModelTooltip.split('\n').map((line, index) => ( normalizedValue === opt.value
<React.Fragment key={line}> ? 'var(--color-border-emphasis)'
{index > 0 ? <br /> : null} : 'var(--color-border-subtle)',
{line} }}
</React.Fragment> onClick={() => {
))} if (!modelSelectable) return;
</TooltipContent> onValueChange(opt.value);
</Tooltip> }}
</TooltipProvider>
</span>
)}
{modelDisabledReason && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason}
> >
<span>{TEAM_MODEL_UI_DISABLED_BADGE_LABEL}</span> <span className="flex flex-col items-center justify-center gap-0.5">
<TooltipProvider delayDuration={200}> <span className="leading-tight">{opt.label}</span>
<Tooltip> {opt.value === '' && (
<TooltipTrigger <span className="flex items-center justify-center gap-1">
asChild <TooltipProvider delayDuration={200}>
onClick={(e: React.MouseEvent) => e.stopPropagation()} <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" /> <span>{TEAM_MODEL_UI_DISABLED_BADGE_LABEL}</span>
</TooltipTrigger> <TooltipProvider delayDuration={200}>
<TooltipContent side="top" className="max-w-[240px] text-xs"> <Tooltip>
{modelDisabledReason} <TooltipTrigger
</TooltipContent> asChild
</Tooltip> onClick={(e: React.MouseEvent) => e.stopPropagation()}
</TooltipProvider> >
</span> <Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
)} </TooltipTrigger>
</span> <TooltipContent side="top" className="max-w-[240px] text-xs">
</button> {modelDisabledReason}
); </TooltipContent>
})() </Tooltip>
)} </TooltipProvider>
</span>
)}
</span>
</button>
);
})()
)}
</div>
</div>
</div>
</div> </div>
</div> </Tabs>
</div> </div>
); );
}; };

View file

@ -1,13 +1,13 @@
import React, { useState } from 'react'; 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 { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
import { import {
getTeamModelLabel, getProviderScopedTeamModelLabel,
TeamModelSelector, TeamModelSelector,
} from '@renderer/components/team/dialogs/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 { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { getMemberColorByName } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
@ -49,7 +49,9 @@ export const LeadModelRow = ({
const { isLight } = useTheme(); const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false); const [modelExpanded, setModelExpanded] = useState(false);
const leadColorSet = getTeamColorSet(getMemberColorByName('lead')); const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
const modelButtonLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
: 'Default';
return ( return (
<div <div
@ -90,11 +92,11 @@ export const LeadModelRow = ({
</div> </div>
</div> </div>
<div className="space-y-1"> <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 <Button
variant="outline" variant="outline"
size="sm" 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)} onClick={() => setModelExpanded((prev) => !prev)}
> >
{modelExpanded ? ( {modelExpanded ? (

View file

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { import {
getTeamModelLabel, getProviderScopedTeamModelLabel,
TeamModelSelector, TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector'; } from '@renderer/components/team/dialogs/TeamModelSelector';
import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { RoleSelect } from '@renderer/components/team/RoleSelect';
@ -164,7 +164,7 @@ export const MemberDraftRow = ({
? inheritedEffort ? inheritedEffort
: (member.effort ?? inheritedEffort); : (member.effort ?? inheritedEffort);
const modelButtonLabelBase = effectiveModel?.trim() const modelButtonLabelBase = effectiveModel?.trim()
? getTeamModelLabel(effectiveModel.trim()) ? getProviderScopedTeamModelLabel(effectiveProviderId, effectiveModel.trim())
: 'Default'; : 'Default';
const modelButtonLabel = forceInheritedModelSettings const modelButtonLabel = forceInheritedModelSettings
? `${modelButtonLabelBase} (lead)` ? `${modelButtonLabelBase} (lead)`
@ -175,7 +175,7 @@ export const MemberDraftRow = ({
return ( return (
<div <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={{ style={{
backgroundColor: isLight backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)' ? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -238,14 +238,14 @@ export const MemberDraftRow = ({
) : null} ) : null}
</Button> </Button>
) : null} ) : 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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex max-w-[190px]"> <span className="inline-flex w-full">
<Button <Button
variant="outline" variant="outline"
size="sm" 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} disabled={lockProviderModel || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)} onClick={() => setModelExpanded((prev) => !prev)}
> >

View file

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

View file

@ -62,6 +62,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
// Update a section of the app configuration // Update a section of the app configuration
updateConfig: async (section: string, data: Record<string, unknown>) => { updateConfig: async (section: string, data: Record<string, unknown>) => {
set({ configError: null });
try { try {
await api.config.update(section, data); await api.config.update(section, data);
// Refresh config after update // Refresh config after update
@ -74,6 +75,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
set({ set({
configError: error instanceof Error ? error.message : 'Failed to update config', 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 SUCCESS_DISPLAY_MS = 2_000;
const CLI_AUTH_REQUIRED_MESSAGE = const CLI_AUTH_REQUIRED_MESSAGE =
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.'; '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 = const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; '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 cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE ? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed : !cliStatus.installed
? CLI_NOT_FOUND_MESSAGE ? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn : !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE ? CLI_AUTH_REQUIRED_MESSAGE
: null; : null;
@ -878,6 +882,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
set({ set({
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key', 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-x64'
| 'win32-arm64'; | 'win32-arm64';
export type CliFlavor = 'claude' | 'free-code'; export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
export type CliProviderId = 'anthropic' | 'codex' | 'gemini'; 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 { export interface CliProviderBackendOption {
id: string; id: string;
@ -69,6 +82,7 @@ export interface CliProviderStatus {
projectId?: string | null; projectId?: string | null;
authMethodDetail?: string | null; authMethodDetail?: string | null;
} | null; } | null;
connection?: CliProviderConnectionInfo | null;
} }
export interface CliFlavorUiOptions { export interface CliFlavorUiOptions {
@ -96,12 +110,14 @@ export interface CliInstallationStatus {
showVersionDetails: boolean; showVersionDetails: boolean;
/** Whether binary path should be shown in the UI */ /** Whether binary path should be shown in the UI */
showBinaryPath: boolean; 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: boolean;
/** Installed version string (e.g. "2.1.59"), null if not installed */ /** Installed version string (e.g. "2.1.59"), null if not installed */
installedVersion: string | null; 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; 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 */ /** Latest available version from GCS, null if check failed */
latestVersion: string | null; latestVersion: string | null;
/** True when installed version < latest version */ /** 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) */ /** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
telemetryEnabled: boolean; 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: { runtime: {
providerBackends: { providerBackends: {
gemini: 'auto' | 'api' | 'cli-sdk'; gemini: 'auto' | 'api' | 'cli-sdk';

View file

@ -233,7 +233,7 @@ export interface TeamTask {
blockedBy?: string[]; blockedBy?: string[];
/** /**
* Explicit task links (non-blocking). Used for navigation between related tasks, * 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[]; related?: string[];
createdAt?: 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: `--- rawContent: `---
name: demo-skill name: demo-skill
description: Test skill description: Test skill
version: 1.2.3
allowed-tools: allowed-tools:
- Read - Read
compatibility: Requires network and API key compatibility: Requires network and API key
@ -41,6 +42,12 @@ unknown-key: true
'compatibility-advisory', 'compatibility-advisory',
]) ])
); );
expect(item.issues).not.toContainEqual(
expect.objectContaining({
code: 'unknown-frontmatter-keys',
message: expect.stringContaining('version'),
})
);
}); });
it('marks missing frontmatter as invalid', () => { 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 getShellPreferredHomeMock = vi.fn<() => string>();
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>(); const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>(); 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', () => ({ vi.mock('@main/utils/childProcess', () => ({
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args), execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
@ -25,15 +27,31 @@ vi.mock('@main/utils/shellEnv', () => ({
vi.mock('fs', () => ({ vi.mock('fs', () => ({
default: { default: {
readFileSync: () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
},
promises: { promises: {
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding), readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding),
}, },
}, },
readFileSync: () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
},
promises: { promises: {
readFile: (filePath: PathLike, encoding: BufferEncoding) => readFileMock(filePath, encoding), 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', () => { describe('ClaudeMultimodelBridgeService', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
@ -42,25 +60,27 @@ describe('ClaudeMultimodelBridgeService', () => {
getCachedShellEnvMock.mockReturnValue({}); getCachedShellEnvMock.mockReturnValue({});
getShellPreferredHomeMock.mockReturnValue('/Users/tester'); getShellPreferredHomeMock.mockReturnValue('/Users/tester');
resolveInteractiveShellEnvMock.mockResolvedValue({}); resolveInteractiveShellEnvMock.mockResolvedValue({});
readFileMock.mockImplementation(async (filePath) => { readFileMock.mockImplementation((filePath) => {
if (String(filePath) === '/Users/tester/.claude.json') { if (String(filePath) === '/Users/tester/.claude.json') {
return JSON.stringify({ return Promise.resolve(
geminiResolvedBackend: 'cli', JSON.stringify({
geminiLastAuthMethod: 'cli_oauth_personal', geminiResolvedBackend: 'cli',
geminiProjectId: 'demo-project', 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 () => { 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 normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const env = options?.env ?? {}; const env = options?.env ?? {};
if (normalizedArgs === 'auth status --json --provider all') { if (normalizedArgs === 'auth status --json --provider all') {
return { return Promise.resolve({
stdout: JSON.stringify({ stdout: JSON.stringify({
providers: { providers: {
anthropic: { anthropic: {
@ -85,11 +105,14 @@ describe('ClaudeMultimodelBridgeService', () => {
}), }),
stderr: '', stderr: '',
exitCode: 0, exitCode: 0,
}; });
} }
if (normalizedArgs === 'model list --json --provider all' && env.CLAUDE_CODE_USE_GEMINI === '1') { if (
return { normalizedArgs === 'model list --json --provider all' &&
env.CLAUDE_CODE_ENTRY_PROVIDER === 'gemini'
) {
return Promise.resolve({
stdout: JSON.stringify({ stdout: JSON.stringify({
providers: { providers: {
gemini: { gemini: {
@ -99,11 +122,11 @@ describe('ClaudeMultimodelBridgeService', () => {
}), }),
stderr: '', stderr: '',
exitCode: 0, exitCode: 0,
}; });
} }
if (normalizedArgs === 'model list --json --provider all') { if (normalizedArgs === 'model list --json --provider all') {
return { return Promise.resolve({
stdout: JSON.stringify({ stdout: JSON.stringify({
providers: { providers: {
anthropic: { anthropic: {
@ -116,18 +139,17 @@ describe('ClaudeMultimodelBridgeService', () => {
}), }),
stderr: '', stderr: '',
exitCode: 0, exitCode: 0,
}; });
} }
throw new Error(`Unexpected execCli call: ${normalizedArgs}`); return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
}); });
const { ClaudeMultimodelBridgeService } = await import( const { ClaudeMultimodelBridgeService } =
'@main/services/runtime/ClaudeMultimodelBridgeService' await import('@main/services/runtime/ClaudeMultimodelBridgeService');
);
const service = new 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).toHaveLength(3);
expect(providers[0]).toMatchObject({ 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 mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
const mockGetShellPreferredHome = vi.fn<() => string>(); const mockGetShellPreferredHome = vi.fn<() => string>();
const mockGetClaudeBasePath = vi.fn<() => string>();
const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>(); 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 accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
const statMock = vi.fn<(filePath: PathLike) => Promise<{ isFile: () => boolean }>>(); const statMock = vi.fn<(filePath: PathLike) => Promise<{ isFile: () => boolean }>>();
@ -19,10 +21,18 @@ vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(), resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(),
})); }));
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => mockGetClaudeBasePath(),
}));
vi.mock('@main/services/team/cliFlavor', () => ({ vi.mock('@main/services/team/cliFlavor', () => ({
getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(), getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(),
})); }));
vi.mock('@main/services/team/ClaudeDoctorProbe', () => ({
getDoctorInvokedCandidates: (commandName: string) => mockGetDoctorInvokedCandidates(commandName),
}));
vi.mock('fs', () => ({ vi.mock('fs', () => ({
default: { default: {
constants: { X_OK: 1 }, constants: { X_OK: 1 },
@ -41,23 +51,31 @@ vi.mock('fs', () => ({
describe('ClaudeBinaryResolver', () => { describe('ClaudeBinaryResolver', () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
const originalCwd = process.cwd; 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(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks(); vi.clearAllMocks();
mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin'); mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin');
mockGetShellPreferredHome.mockReturnValue('/Users/tester'); mockGetShellPreferredHome.mockReturnValue('/Users/tester');
mockGetClaudeBasePath.mockReturnValue('/Users/tester/.claude');
mockResolveInteractiveShellEnv.mockResolvedValue({}); mockResolveInteractiveShellEnv.mockResolvedValue({});
mockGetConfiguredCliFlavor.mockReturnValue('free-code'); mockGetConfiguredCliFlavor.mockReturnValue('agent_teams_orchestrator');
mockGetDoctorInvokedCandidates.mockResolvedValue([]);
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
value: 'darwin', value: 'darwin',
configurable: true, configurable: true,
writable: true, writable: true,
}); });
process.cwd = vi.fn(() => workspaceRoot); 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_CLI_PATH;
delete process.env.CLAUDE_FREE_CODE_CLI_PATH; delete process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
}); });
afterEach(() => { afterEach(() => {
@ -67,18 +85,23 @@ describe('ClaudeBinaryResolver', () => {
writable: true, writable: true,
}); });
process.cwd = originalCwd; process.cwd = originalCwd;
Object.defineProperty(process, 'resourcesPath', {
value: originalResourcesPath,
configurable: true,
writable: true,
});
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it('resolves free-code runtime from an explicit CLAUDE_CLI_PATH override', async () => { it('resolves agent_teams_orchestrator runtime from an explicit CLAUDE_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev'; const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_CLI_PATH = expectedBinary; process.env.CLAUDE_CLI_PATH = expectedBinary;
accessMock.mockImplementation(async (filePath) => { accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) { 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'); const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -88,15 +111,15 @@ describe('ClaudeBinaryResolver', () => {
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
}); });
it('prefers the dedicated CLAUDE_FREE_CODE_CLI_PATH override in free-code mode', async () => { it('prefers the dedicated CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev'; const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_FREE_CODE_CLI_PATH = expectedBinary; process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = expectedBinary;
accessMock.mockImplementation(async (filePath) => { accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) { 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'); const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -106,17 +129,17 @@ describe('ClaudeBinaryResolver', () => {
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
}); });
it('ignores CLAUDE_FREE_CODE_CLI_PATH when Claude flavor is selected', async () => { it('ignores the dedicated orchestrator overrides when Claude flavor is selected', async () => {
process.env.CLAUDE_FREE_CODE_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
'/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev'; '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
mockGetConfiguredCliFlavor.mockReturnValue('claude'); mockGetConfiguredCliFlavor.mockReturnValue('claude');
const expectedBinary = '/usr/local/bin/claude'; const expectedBinary = '/usr/local/bin/claude';
accessMock.mockImplementation(async (filePath) => { accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) { 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'); const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -126,14 +149,14 @@ describe('ClaudeBinaryResolver', () => {
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); 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'; const expectedBinary = '/usr/local/bin/claude-multimodel';
accessMock.mockImplementation(async (filePath) => { accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) { 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'); const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
@ -142,4 +165,62 @@ describe('ClaudeBinaryResolver', () => {
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); 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'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
const hoisted = vi.hoisted(() => ({
paths: {
claudeRoot: '',
teamsBase: '',
tasksBase: '',
},
}));
let tempClaudeRoot = ''; let tempClaudeRoot = '';
let tempTeamsBase = ''; let tempTeamsBase = '';
let tempTasksBase = ''; let tempTasksBase = '';
@ -24,14 +32,15 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>(); const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return { return {
...actual, ...actual,
getAutoDetectedClaudeBasePath: () => tempClaudeRoot, getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot,
getClaudeBasePath: () => tempClaudeRoot, getClaudeBasePath: () => hoisted.paths.claudeRoot,
getTeamsBasePath: () => tempTeamsBase, getTeamsBasePath: () => hoisted.paths.teamsBase,
getTasksBasePath: () => tempTasksBase, getTasksBasePath: () => hoisted.paths.tasksBase,
}; };
}); });
import { import {
buildAddMemberSpawnMessage,
TeamProvisioningService, TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService'; } from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; 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-')); tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prompts-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams'); tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks'); tempTasksBase = path.join(tempClaudeRoot, 'tasks');
hoisted.paths.claudeRoot = tempClaudeRoot;
hoisted.paths.teamsBase = tempTeamsBase;
hoisted.paths.tasksBase = tempTasksBase;
setAppDataBasePath(tempClaudeRoot); setAppDataBasePath(tempClaudeRoot);
fs.mkdirSync(tempTeamsBase, { recursive: true }); fs.mkdirSync(tempTeamsBase, { recursive: true });
fs.mkdirSync(tempTasksBase, { recursive: true }); fs.mkdirSync(tempTasksBase, { recursive: true });
@ -206,6 +218,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain( expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request' '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('task_create_from_message');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); 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); 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 () => { it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => {
const teamName = 'forward-live-team'; const teamName = 'forward-live-team';
const teamDir = path.join(tempTeamsBase, teamName); const teamDir = path.join(tempTeamsBase, teamName);
@ -380,6 +414,18 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain( expect(prompt).toContain(
'review_request already notifies the reviewer' '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); await svc.cancelProvisioning(runId);
}); });

View file

@ -25,7 +25,7 @@ describe('cliFlavor', () => {
const { getConfiguredCliFlavor } = await import('@main/services/team/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 () => { 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 mockGetCachedShellEnv = vi.fn<() => Record<string, string> | null>();
const mockGetShellPreferredHome = vi.fn<() => string>(); const mockGetShellPreferredHome = vi.fn<() => string>();
const mockGetClaudeBasePath = vi.fn<() => string>();
vi.mock('@main/utils/shellEnv', () => ({ vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => mockGetCachedShellEnv(), getCachedShellEnv: () => mockGetCachedShellEnv(),
getShellPreferredHome: () => mockGetShellPreferredHome(), getShellPreferredHome: () => mockGetShellPreferredHome(),
})); }));
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => mockGetClaudeBasePath(),
}));
describe('buildMergedCliPath', () => { describe('buildMergedCliPath', () => {
let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath; let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath;
const originalPlatform = process.platform; const originalPlatform = process.platform;
@ -16,6 +21,7 @@ describe('buildMergedCliPath', () => {
vi.resetModules(); vi.resetModules();
mockGetShellPreferredHome.mockReturnValue('/home/testuser'); mockGetShellPreferredHome.mockReturnValue('/home/testuser');
mockGetCachedShellEnv.mockReturnValue(null); mockGetCachedShellEnv.mockReturnValue(null);
mockGetClaudeBasePath.mockReturnValue('/home/testuser/.claude');
process.env.PATH = '/usr/bin'; process.env.PATH = '/usr/bin';
({ buildMergedCliPath } = await import('@main/utils/cliPathMerge')); ({ buildMergedCliPath } = await import('@main/utils/cliPathMerge'));
}); });
@ -29,6 +35,7 @@ describe('buildMergedCliPath', () => {
const p = buildMergedCliPath(null); const p = buildMergedCliPath(null);
expect(p.split(':')).toEqual( expect(p.split(':')).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'/home/testuser/.claude/local/node_modules/.bin',
'/home/testuser/.local/bin', '/home/testuser/.local/bin',
'/home/testuser/.npm-global/bin', '/home/testuser/.npm-global/bin',
'/home/testuser/.npm/bin', '/home/testuser/.npm/bin',
@ -37,7 +44,7 @@ describe('buildMergedCliPath', () => {
'/usr/bin', '/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', () => { it('on win32 with cold shell cache uses semicolon and npm-style dirs', () => {
@ -58,6 +65,7 @@ describe('buildMergedCliPath', () => {
const p = buildMergedCliPath(null); const p = buildMergedCliPath(null);
expect(p.startsWith('/opt/custom/bin')).toBe(true); expect(p.startsWith('/opt/custom/bin')).toBe(true);
expect(p).toContain('/bin'); expect(p).toContain('/bin');
expect(p).toContain('/home/testuser/.claude/local/node_modules/.bin');
expect(p).toContain('/usr/bin'); expect(p).toContain('/usr/bin');
expect(p).not.toContain('/home/testuser/.local/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 { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type StoreState = { interface StoreState {
cliStatus: Record<string, unknown> | null; cliStatus: Record<string, unknown> | null;
cliStatusLoading: boolean; cliStatusLoading: boolean;
cliProviderStatusLoading: Record<string, boolean>; cliProviderStatusLoading: Record<string, boolean>;
@ -37,9 +37,12 @@ type StoreState = {
}; };
updateConfig: ReturnType<typeof vi.fn>; updateConfig: ReturnType<typeof vi.fn>;
openExtensionsTab: ReturnType<typeof vi.fn>; openExtensionsTab: ReturnType<typeof vi.fn>;
}; }
const storeState = {} as StoreState; const storeState = {} as StoreState;
let providerRuntimeSettingsDialogProps: {
onSelectBackend?: (providerId: string, backendId: string) => Promise<void> | void;
} | null = null;
vi.mock('@renderer/api', () => ({ vi.mock('@renderer/api', () => ({
api: { api: {
@ -49,11 +52,16 @@ vi.mock('@renderer/api', () => ({
})); }));
vi.mock('@renderer/components/common/ConfirmDialog', () => ({ vi.mock('@renderer/components/common/ConfirmDialog', () => ({
confirm: vi.fn(async () => true), confirm: vi.fn(() => Promise.resolve(true)),
})); }));
vi.mock('@renderer/components/runtime/ProviderRuntimeSettingsDialog', () => ({ 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', () => ({ vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({
@ -133,6 +141,7 @@ describe('CLI status visibility during completed install state', () => {
}); });
beforeEach(() => { beforeEach(() => {
providerRuntimeSettingsDialogProps = null;
storeState.cliStatus = createInstalledCliStatus(); storeState.cliStatus = createInstalledCliStatus();
storeState.cliStatusLoading = false; storeState.cliStatusLoading = false;
storeState.cliProviderStatusLoading = {}; storeState.cliProviderStatusLoading = {};
@ -145,10 +154,10 @@ describe('CLI status visibility during completed install state', () => {
storeState.cliInstallerDetail = null; storeState.cliInstallerDetail = null;
storeState.cliInstallerRawChunks = []; storeState.cliInstallerRawChunks = [];
storeState.cliCompletedVersion = '2.1.100'; storeState.cliCompletedVersion = '2.1.100';
storeState.bootstrapCliStatus = vi.fn(async () => undefined); storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchCliStatus = vi.fn(async () => undefined); storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchCliProviderStatus = vi.fn(async () => undefined); storeState.fetchCliProviderStatus = vi.fn().mockResolvedValue(undefined);
storeState.invalidateCliStatus = vi.fn(async () => undefined); storeState.invalidateCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.installCli = vi.fn(); storeState.installCli = vi.fn();
storeState.appConfig = { storeState.appConfig = {
general: { general: {
@ -158,7 +167,7 @@ describe('CLI status visibility during completed install state', () => {
providerBackends: {}, providerBackends: {},
}, },
}; };
storeState.updateConfig = vi.fn(async () => undefined); storeState.updateConfig = vi.fn().mockResolvedValue(undefined);
storeState.openExtensionsTab = vi.fn(); 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 () => { it('keeps auth verification inside the main installed banner instead of rendering a second banner', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle'; 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 () => { it('keeps installed controls visible in settings and wires the Extensions button correctly', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = createInstalledCliStatus({ 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 () => { it('hides the settings Extensions button when the runtime is not authenticated yet', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = createInstalledCliStatus({ 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', () => { 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( expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe(
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON 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), 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', () => ({ vi.mock('@renderer/store', () => ({
useStore: (selector: (state: unknown) => unknown) => useStore: (selector: (state: unknown) => unknown) =>
selector({ selector({
@ -32,7 +80,7 @@ describe('TeamModelSelector disabled Codex models', () => {
document.body.innerHTML = ''; 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -50,7 +98,7 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve(); 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('Disabled');
expect(host.textContent).toContain(GPT_5_1_CODEX_MINI_UI_DISABLED_REASON); 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -78,7 +126,7 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve(); 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('Disabled');
expect(host.textContent).toContain(GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON); 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -162,22 +210,14 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve(); 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).toContain('OpenCode');
expect(host.textContent).not.toContain('Gemini in development'); 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 buttons = Array.from(host.querySelectorAll('button'));
const openCodeButton = buttons.find((button) => button.textContent?.includes('OpenCode')); const openCodeButton = buttons.find((button) => button.textContent?.includes('OpenCode'));
expect(openCodeButton).not.toBeNull(); expect(openCodeButton).not.toBeNull();
expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
expect(openCodeButton?.getAttribute('title')).toContain('OpenCode in development');
await act(async () => { await act(async () => {
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
@ -191,4 +231,42 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve(); 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(useStore.getState().cliStatusLoading).toBe(false);
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); 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', () => { 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'); ).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');
});
}); });