dev -> main: multiprovider support (including codex), perf optimization
Merge pull request #43 from 777genius/dev
This commit is contained in:
commit
1af1c3b46c
265 changed files with 44422 additions and 3803 deletions
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -5,9 +5,12 @@ on:
|
|||
branches: [main, dev]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'scripts/**'
|
||||
- 'agent-teams-controller/**'
|
||||
- 'mcp-server/**'
|
||||
- 'packages/**'
|
||||
- 'resources/runtime/**'
|
||||
- 'runtime.lock.json'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
|
@ -21,9 +24,12 @@ on:
|
|||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'scripts/**'
|
||||
- 'agent-teams-controller/**'
|
||||
- 'mcp-server/**'
|
||||
- 'packages/**'
|
||||
- 'resources/runtime/**'
|
||||
- 'runtime.lock.json'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
|
@ -41,15 +47,15 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
@ -76,15 +82,15 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
6
.github/workflows/landing.yml
vendored
6
.github/workflows/landing.yml
vendored
|
|
@ -19,11 +19,11 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: landing
|
||||
|
|
|
|||
253
.github/workflows/release.yml
vendored
253
.github/workflows/release.yml
vendored
|
|
@ -15,15 +15,15 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist
|
||||
path: |
|
||||
|
|
@ -65,8 +65,94 @@ jobs:
|
|||
dist-electron
|
||||
retention-days: 1
|
||||
|
||||
prepare-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "$TAG" \
|
||||
--generate-notes \
|
||||
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
|
||||
|
||||
- name: Check runtime assets
|
||||
id: runtime-assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
missing=0
|
||||
while IFS= read -r asset; do
|
||||
[ -n "$asset" ] || continue
|
||||
if ! grep -Fqx "$asset" <<<"$existing"; then
|
||||
echo "Missing runtime asset: $asset"
|
||||
missing=1
|
||||
fi
|
||||
done < <(node ./scripts/runtime-lock.mjs asset-list)
|
||||
echo "missing=$missing" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Dispatch private runtime build
|
||||
if: steps.runtime-assets.outputs.missing == '1'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RUNTIME_BUILD_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_TAG="${GITHUB_REF#refs/tags/}"
|
||||
SOURCE_REPO="$(node ./scripts/runtime-lock.mjs source-repository)"
|
||||
SOURCE_REF="$(node ./scripts/runtime-lock.mjs source-ref)"
|
||||
RUNTIME_VERSION="$(node ./scripts/runtime-lock.mjs version)"
|
||||
gh api \
|
||||
--method POST \
|
||||
"repos/${SOURCE_REPO}/actions/workflows/release-runtime.yml/dispatches" \
|
||||
-f ref=main \
|
||||
-f inputs[source_ref]="$SOURCE_REF" \
|
||||
-f inputs[runtime_version]="$RUNTIME_VERSION" \
|
||||
-f inputs[target_release_repo]="$GITHUB_REPOSITORY" \
|
||||
-f inputs[target_release_tag]="$TARGET_TAG"
|
||||
|
||||
- name: Wait for runtime assets
|
||||
if: steps.runtime-assets.outputs.missing == '1'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
for attempt in $(seq 1 60); do
|
||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
all_found=1
|
||||
while IFS= read -r asset; do
|
||||
[ -n "$asset" ] || continue
|
||||
if ! grep -Fqx "$asset" <<<"$existing"; then
|
||||
all_found=0
|
||||
break
|
||||
fi
|
||||
done < <(node ./scripts/runtime-lock.mjs asset-list)
|
||||
|
||||
if [ "$all_found" -eq 1 ]; then
|
||||
echo "Runtime assets are ready"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for runtime assets - attempt $attempt/60"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "Timed out waiting for runtime assets in release $TAG" >&2
|
||||
exit 1
|
||||
|
||||
release-mac:
|
||||
needs: build
|
||||
needs: [build, prepare-runtime]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -81,10 +167,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist
|
||||
|
||||
|
|
@ -92,9 +178,9 @@ jobs:
|
|||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
|
|
@ -111,6 +197,33 @@ jobs:
|
|||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Resolve runtime asset name (macOS ${{ matrix.arch }})
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
id: runtime-asset
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ matrix.arch }}" == "arm64" ]]; then
|
||||
platform="darwin-arm64"
|
||||
else
|
||||
platform="darwin-x64"
|
||||
fi
|
||||
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name "$platform")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage bundled runtime (macOS ${{ matrix.arch }})
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
rm -rf .runtime-download resources/runtime
|
||||
mkdir -p .runtime-download resources/runtime
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
|
||||
tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download
|
||||
cp -R .runtime-download/runtime/. resources/runtime/
|
||||
|
||||
- name: Build app (macOS ${{ matrix.arch }})
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
|
|
@ -126,6 +239,9 @@ jobs:
|
|||
test -f dist-electron/preload/index.js
|
||||
test -f out/renderer/index.html
|
||||
test -f mcp-server/dist/index.js
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
|
||||
test -f resources/runtime/VERSION
|
||||
fi
|
||||
|
||||
- name: Package (macOS ${{ matrix.arch }})
|
||||
env:
|
||||
|
|
@ -137,6 +253,9 @@ jobs:
|
|||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: ${{ matrix.dist_command }} --publish never
|
||||
|
||||
- name: Validate packaged bundle (macOS ${{ matrix.arch }})
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Claude Agent Teams UI.app" darwin ${{ matrix.arch }}
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
|
|
@ -152,15 +271,15 @@ jobs:
|
|||
done
|
||||
|
||||
release-win:
|
||||
needs: build
|
||||
needs: [build, prepare-runtime]
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist
|
||||
|
||||
|
|
@ -168,9 +287,9 @@ jobs:
|
|||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
|
|
@ -188,6 +307,29 @@ jobs:
|
|||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Resolve runtime asset name (Windows)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
id: runtime-asset
|
||||
shell: bash
|
||||
run: |
|
||||
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name win32-x64)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage bundled runtime (Windows)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$tag = $env:GITHUB_REF.Replace('refs/tags/', '')
|
||||
Remove-Item .runtime-download -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item resources/runtime/* -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path .runtime-download | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path resources/runtime | Out-Null
|
||||
gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
|
||||
Expand-Archive -Path ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -DestinationPath .runtime-download/unpacked -Force
|
||||
Copy-Item .runtime-download/unpacked/runtime/* resources/runtime -Recurse -Force
|
||||
|
||||
- name: Build app (Windows)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
|
|
@ -204,12 +346,19 @@ jobs:
|
|||
test -f dist-electron/preload/index.js
|
||||
test -f out/renderer/index.html
|
||||
test -f mcp-server/dist/index.js
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
|
||||
test -f resources/runtime/VERSION
|
||||
fi
|
||||
|
||||
- name: Package (Windows)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm dist:win --publish never
|
||||
|
||||
- name: Validate packaged bundle (Windows)
|
||||
shell: bash
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/win-unpacked" win32 x64
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
|
|
@ -226,15 +375,15 @@ jobs:
|
|||
done
|
||||
|
||||
release-linux:
|
||||
needs: build
|
||||
needs: [build, prepare-runtime]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist
|
||||
|
||||
|
|
@ -242,9 +391,9 @@ jobs:
|
|||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
|
|
@ -266,6 +415,27 @@ jobs:
|
|||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Resolve runtime asset name (Linux)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
id: runtime-asset
|
||||
shell: bash
|
||||
run: |
|
||||
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name linux-x64)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage bundled runtime (Linux)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
rm -rf .runtime-download resources/runtime
|
||||
mkdir -p .runtime-download resources/runtime
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
|
||||
tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download
|
||||
cp -R .runtime-download/runtime/. resources/runtime/
|
||||
|
||||
- name: Build app (Linux)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
|
|
@ -281,12 +451,18 @@ jobs:
|
|||
test -f dist-electron/preload/index.js
|
||||
test -f out/renderer/index.html
|
||||
test -f mcp-server/dist/index.js
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
|
||||
test -f resources/runtime/VERSION
|
||||
fi
|
||||
|
||||
- name: Package (Linux)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm dist:linux --publish never
|
||||
|
||||
- name: Validate packaged bundle (Linux)
|
||||
run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
|
|
@ -313,9 +489,12 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
declare -A FILES=(
|
||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
||||
|
|
@ -327,18 +506,13 @@ jobs:
|
|||
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman"
|
||||
)
|
||||
|
||||
# Remove old stable assets (ignore errors if they don't exist)
|
||||
for STABLE_NAME in "${!FILES[@]}"; do
|
||||
gh release delete-asset "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --yes 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Download versioned files and re-upload with stable names
|
||||
for STABLE_NAME in "${!FILES[@]}"; do
|
||||
VERSIONED_NAME="${FILES[$STABLE_NAME]}"
|
||||
echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}"
|
||||
curl -fSL -o "$STABLE_NAME" "${DOWNLOAD_BASE}/${VERSIONED_NAME}" && \
|
||||
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber
|
||||
rm -f "$STABLE_NAME"
|
||||
curl -fSL -o "${TMP_DIR}/${VERSIONED_NAME}" "${DOWNLOAD_BASE}/${VERSIONED_NAME}"
|
||||
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}"
|
||||
gh release upload "v${VERSION}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber
|
||||
done
|
||||
|
||||
- name: Publish canonical updater metadata
|
||||
|
|
@ -397,26 +571,25 @@ jobs:
|
|||
EOF
|
||||
|
||||
# Canonical macOS feed.
|
||||
# electron-updater consumes only one latest-mac.yml asset on GitHub releases.
|
||||
# We publish the x64 feed here because it works on Intel Macs and remains
|
||||
# installable on Apple Silicon via Rosetta until we add arch-specific feed
|
||||
# selection or universal packaging.
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-mac.zip"
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}.dmg"
|
||||
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
|
||||
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
|
||||
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}.dmg)"
|
||||
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}.dmg)"
|
||||
# electron-updater on GitHub still consumes a single latest-mac.yml, so we
|
||||
# publish the Apple Silicon feed here and suppress Intel auto-update in-app
|
||||
# until we switch to universal packaging or an arch-aware provider.
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip"
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
||||
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
|
||||
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
|
||||
cat > latest-mac.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}-mac.zip
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
|
||||
sha512: ${MAC_ZIP_SHA}
|
||||
size: ${MAC_ZIP_SIZE}
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}.dmg
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}-arm64.dmg
|
||||
sha512: ${MAC_DMG_SHA}
|
||||
size: ${MAC_DMG_SIZE}
|
||||
path: Claude.Agent.Teams.UI-${VERSION}-mac.zip
|
||||
path: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
|
||||
sha512: ${MAC_ZIP_SHA}
|
||||
releaseDate: '${RELEASE_DATE}'
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ A new approach to task management with AI agent teams.
|
|||
| **Per-task code review** | ✅ Accept / reject / comment | ⚠️ PR-level only | ⚠️ File-level only | ✅ BugBot on PRs | ❌ |
|
||||
| **Flexible autonomy** | ✅ Granular settings, per-action approval, notifications | ❌ | ⚠️ Plan approval only | ✅ | ✅ |
|
||||
| **Git worktree isolation** | ✅ Optional | ⚠️ Mandatory | ⚠️ Mandatory | ✅ | ✅ |
|
||||
| **Multi-agent backend** | 🗓️ [In development](https://github.com/Alishahryar1/free-claude-code) | ✅ 6+ agents | ✅ 11 providers | ✅ Multi-model | — |
|
||||
| **Multi-agent backend** | ✅ Claude, Codex, more coming soon | ✅ 6+ agents | ✅ 11 providers | ✅ Multi-model | — |
|
||||
| **Price** | **Free** | Free / $30 user/mo | Free | $0–$200/mo | Claude subscription |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -255,6 +255,43 @@ function resolveTeamMembers(paths) {
|
|||
};
|
||||
}
|
||||
|
||||
function getCurrentRuntimeMemberIdentity() {
|
||||
const args = Array.isArray(process.argv) ? process.argv.slice(2) : [];
|
||||
let agentName = '';
|
||||
let agentId = '';
|
||||
let teamName = '';
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = typeof args[i] === 'string' ? args[i] : '';
|
||||
const next = typeof args[i + 1] === 'string' ? args[i + 1].trim() : '';
|
||||
if (!next) continue;
|
||||
if (arg === '--agent-name') {
|
||||
agentName = next;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--agent-id') {
|
||||
agentId = next;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--team-name') {
|
||||
teamName = next;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAgentName = typeof agentName === 'string' ? agentName.trim() : '';
|
||||
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
const normalizedTeamName = typeof teamName === 'string' ? teamName.trim() : '';
|
||||
if (!normalizedAgentName && !normalizedAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
agentName: normalizedAgentName,
|
||||
agentId: normalizedAgentId,
|
||||
teamName: normalizedTeamName,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLeadSessionId(paths) {
|
||||
const config = readTeamConfig(paths);
|
||||
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
|
||||
|
|
@ -459,6 +496,7 @@ module.exports = {
|
|||
readMembersMeta,
|
||||
readTeamConfig,
|
||||
resolveTeamMembers,
|
||||
getCurrentRuntimeMemberIdentity,
|
||||
resolveLeadSessionId,
|
||||
saveTaskAttachmentFile,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ function isSameTaskMember(left, right, leadName) {
|
|||
);
|
||||
}
|
||||
|
||||
function mergeMemberRecord(base, overlay) {
|
||||
return {
|
||||
...(base && typeof base === 'object' ? base : {}),
|
||||
...(overlay && typeof overlay === 'object' ? overlay : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function quoteMarkdown(text) {
|
||||
return String(text)
|
||||
.split('\n')
|
||||
|
|
@ -563,13 +570,14 @@ Failure to follow this protocol means the task board will show incorrect status.
|
|||
* Context-free — does NOT follow the (context, ...) convention.
|
||||
*/
|
||||
function buildProcessProtocolText(teamName) {
|
||||
return `BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
|
||||
return `BACKGROUND SERVICE PROCESS REGISTRATION — this is ONLY for extra background services started by teammates (dev server, watcher, database, etc.). It is NOT a list of teammate agents themselves.
|
||||
1. Launch with & to get PID:
|
||||
pnpm dev &
|
||||
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
|
||||
{ teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" }
|
||||
3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list:
|
||||
{ teamName: "${teamName}" }
|
||||
process_list shows ONLY registered background services for the team. It does NOT show whether teammate agents themselves are alive.
|
||||
4. When stopping a process, use MCP tool process_stop:
|
||||
{ teamName: "${teamName}", pid: <PID> }
|
||||
5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister:
|
||||
|
|
@ -606,9 +614,43 @@ async function memberBriefing(context, memberName) {
|
|||
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
|
||||
throw new Error(`Member is removed from the team: ${requestedMemberName}`);
|
||||
}
|
||||
const member =
|
||||
let member =
|
||||
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
||||
null;
|
||||
if (!member) {
|
||||
const runtimeIdentity = runtimeHelpers.getCurrentRuntimeMemberIdentity();
|
||||
const runtimeAgentName = normalizeMemberName(runtimeIdentity && runtimeIdentity.agentName);
|
||||
const runtimeAgentId = String((runtimeIdentity && runtimeIdentity.agentId) || '').trim().toLowerCase();
|
||||
const runtimeTeamName = String((runtimeIdentity && runtimeIdentity.teamName) || '').trim().toLowerCase();
|
||||
const requestedAgentId = `${requestedMemberKey}@${String(context.teamName || '').trim().toLowerCase()}`;
|
||||
const isCurrentRuntimeMember =
|
||||
requestedMemberKey &&
|
||||
((runtimeAgentName && runtimeAgentName === requestedMemberKey) ||
|
||||
(runtimeAgentId && runtimeAgentId === requestedAgentId)) &&
|
||||
(!runtimeTeamName || runtimeTeamName === String(context.teamName || '').trim().toLowerCase());
|
||||
if (isCurrentRuntimeMember) {
|
||||
const configMembers = Array.isArray(config.members) ? config.members : [];
|
||||
const configMember =
|
||||
configMembers.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
||||
null;
|
||||
const metaMember =
|
||||
Array.isArray(resolved.members)
|
||||
? resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey)
|
||||
: null;
|
||||
member = mergeMemberRecord(
|
||||
{
|
||||
name: requestedMemberName,
|
||||
...(runtimeIdentity && runtimeIdentity.agentName
|
||||
? { name: String(runtimeIdentity.agentName).trim() }
|
||||
: {}),
|
||||
...(typeof config.projectPath === 'string' && config.projectPath.trim()
|
||||
? { cwd: config.projectPath.trim() }
|
||||
: {}),
|
||||
},
|
||||
mergeMemberRecord(configMember || {}, metaMember || {})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!member) {
|
||||
throw new Error(
|
||||
`Member not found in team metadata or inboxes: ${requestedMemberName}`
|
||||
|
|
@ -730,4 +772,4 @@ module.exports = {
|
|||
updateTask: (context, taskRef, updater) =>
|
||||
taskStore.updateTask(context.paths, taskRef, updater),
|
||||
updateTaskFields,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -235,7 +235,12 @@ Without these secrets, macOS builds will be unsigned (users need to bypass Gatek
|
|||
|
||||
## Auto-Update
|
||||
|
||||
electron-builder generates `latest-mac.yml`, `latest.yml`, `latest-linux.yml` alongside release artifacts. These files enable the built-in auto-updater — users get notified when a new version is available.
|
||||
The release workflow publishes canonical updater metadata after all platform assets are uploaded:
|
||||
- `latest.yml` for Windows
|
||||
- `latest-linux.yml` for Linux
|
||||
- `latest-mac.yml` for macOS
|
||||
|
||||
⚠️ `latest-mac.yml` is currently Apple Silicon first because `electron-updater` on GitHub releases still uses a single macOS metadata file. Intel Mac users keep manual download support, while automatic macOS updates stay aligned with the native arm64 build until we move to universal packaging or an arch-aware provider.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
|
|
|
|||
825
docs/claude-multimodel-integration-plan.md
Normal file
825
docs/claude-multimodel-integration-plan.md
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
# Claude Multimodel Integration Plan
|
||||
|
||||
## Summary
|
||||
|
||||
`claude_team` will integrate with a single CLI runtime: `claude-multimodel`.
|
||||
|
||||
Inside that runtime, the app will track multiple provider states independently:
|
||||
|
||||
- `anthropic`
|
||||
- `codex`
|
||||
|
||||
The dashboard will show one grouped status banner for the runtime and its providers.
|
||||
That grouped banner should replace the current single-runtime dashboard banner, not sit beside a second provider banner.
|
||||
|
||||
Provider and model selection will happen at process launch time, not in the banner.
|
||||
|
||||
This avoids duplicating auth logic in `claude_team` and keeps `claude-multimodel` responsible
|
||||
for provider login, token storage, and provider capability reporting.
|
||||
|
||||
## Core Decisions
|
||||
|
||||
### 1. Runtime model
|
||||
|
||||
There is one runtime:
|
||||
|
||||
- `claude-multimodel`
|
||||
|
||||
There are not multiple runtime binaries for this feature.
|
||||
`Anthropic` and `Codex` are providers inside the same runtime.
|
||||
|
||||
Important consequence:
|
||||
|
||||
- there is no separate `Codex` binary to install
|
||||
- there is no separate `Codex` version to display
|
||||
- there is no separate `Codex` updater flow
|
||||
|
||||
### 2. Provider model
|
||||
|
||||
Provider status must be tracked independently.
|
||||
|
||||
Supported initial providers:
|
||||
|
||||
- `anthropic`
|
||||
- `codex`
|
||||
|
||||
The user may be authenticated with both at the same time.
|
||||
The UI must show both statuses independently without trying to choose a global active provider.
|
||||
|
||||
This provider model must also remain compatible with future providers that expose
|
||||
multiple internal backend paths under one public provider id.
|
||||
|
||||
Example:
|
||||
|
||||
- public provider: `gemini`
|
||||
- internal runtime backend: `api` or `cli`
|
||||
|
||||
In that scenario, `claude_team` should still treat `gemini` as one provider row.
|
||||
Backend choice is diagnostic/runtime metadata inside the provider, not a second provider id.
|
||||
|
||||
Provider support must be treated per execution mode, not just globally.
|
||||
At minimum, the integration needs to distinguish:
|
||||
|
||||
- interactive team launch / resume
|
||||
- scheduled one-shot execution
|
||||
|
||||
Internal naming rule:
|
||||
|
||||
- app-level provider id: `codex`
|
||||
- runtime/provider label in UI: `Codex`
|
||||
- this maps to the OpenAI/Codex path inside `claude-multimodel`
|
||||
- during migration, runtime-internal naming may still use `openai`; the bridge layer must normalize that to app-level `codex`
|
||||
|
||||
Backward-compatibility rule:
|
||||
|
||||
- existing persisted launches/schedules/teams that do not yet have `providerId` must default safely to `anthropic`
|
||||
- missing `providerId` in old metadata must never block resume, launch, or schedule execution
|
||||
|
||||
### 3. Launch-time selection
|
||||
|
||||
Banner state is informational only.
|
||||
|
||||
Provider selection happens when launching a task/team/process:
|
||||
|
||||
- selected provider
|
||||
- selected model
|
||||
|
||||
Future extension:
|
||||
|
||||
- per-teammate provider
|
||||
- per-teammate model
|
||||
|
||||
Defaulting rule:
|
||||
|
||||
- for backward compatibility, flows with no explicit provider should default to `anthropic`
|
||||
- provider defaulting must be explicit in code and persistence, not inferred from whichever UI tab/control happened to be last open
|
||||
- model defaults must be provider-aware rather than using a single global default such as `opus`
|
||||
- if a provider later adds internal backend selection, backend resolution must not silently change the app-level `providerId`
|
||||
|
||||
## Goals
|
||||
|
||||
- Show runtime status for `claude-multimodel`
|
||||
- Show separate provider status for `Anthropic` and `Codex`
|
||||
- Support login/logout/status checks through the runtime CLI
|
||||
- Reuse the existing binary resolver and terminal-based login modal flow where possible
|
||||
- Avoid reading runtime token/config internals directly from `claude_team`
|
||||
- Keep current team launch flow extensible for provider/model selection
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No separate `Codex` installer
|
||||
- No fake `Codex` download flow in the UI
|
||||
- No separate `Codex` runtime version
|
||||
- No full runtime abstraction layer right now
|
||||
- No per-teammate model/provider implementation in this phase
|
||||
|
||||
## Architecture
|
||||
|
||||
### Runtime ownership
|
||||
|
||||
`claude-multimodel` owns:
|
||||
|
||||
- provider auth flows
|
||||
- provider token storage
|
||||
- provider auth verification
|
||||
- provider model availability reporting
|
||||
|
||||
`claude_team` owns:
|
||||
|
||||
- UI rendering
|
||||
- caching and refreshing status
|
||||
- launch-time provider/model selection
|
||||
- mapping runtime status into app-friendly DTOs
|
||||
- reusing shared CLI probing helpers instead of duplicating binary/version logic
|
||||
- ensuring per-process provider env/args are isolated and do not leak across parallel launches
|
||||
- validating provider support for the concrete execution mode before spawn
|
||||
|
||||
### Service boundary in `claude_team`
|
||||
|
||||
Add a new main-process service:
|
||||
|
||||
- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- resolve runtime binary path
|
||||
- get runtime version
|
||||
- get provider auth status
|
||||
- get provider model lists
|
||||
- aggregate banner DTO
|
||||
- trigger provider login/logout commands
|
||||
|
||||
This service should be separate from `CliInstallerService`.
|
||||
Installer/update concerns and provider/auth concerns are different responsibilities.
|
||||
|
||||
However, the implementation should reuse existing low-level pieces where possible:
|
||||
|
||||
- `ClaudeBinaryResolver`
|
||||
- existing terminal modal login pattern
|
||||
- existing CLI version probing helpers, if they are extracted into a shared utility
|
||||
|
||||
But existing Claude-specific auth/model logic must not be reused as-is for provider rows:
|
||||
|
||||
- current installer auth probing is Anthropic-oriented
|
||||
- current model parsing and display utilities are Claude-oriented
|
||||
- provider rows must be driven by runtime provider data, not inferred from Claude-only helpers
|
||||
|
||||
The service should also enforce bounded status-check behavior:
|
||||
|
||||
- use timeouts for each runtime CLI command
|
||||
- keep partial results if one command fails
|
||||
- cache recent successful results briefly to avoid noisy repeated probes
|
||||
|
||||
## Required CLI Contract in `claude-multimodel`
|
||||
|
||||
The runtime should expose machine-readable commands.
|
||||
|
||||
### 1. Runtime version
|
||||
|
||||
Already available:
|
||||
|
||||
```bash
|
||||
claude-multimodel --version
|
||||
```
|
||||
|
||||
Used for:
|
||||
|
||||
- runtime version display in banner header
|
||||
|
||||
Version display rule:
|
||||
|
||||
- the app should normalize dev/build suffixes before rendering the banner header
|
||||
- example:
|
||||
- raw: `2.1.87-dev.20260401.t145625.sha38c09970 (Claude Code)`
|
||||
- shown: `Claude 2.1.87`
|
||||
|
||||
### 2. Provider auth status
|
||||
|
||||
Add:
|
||||
|
||||
```bash
|
||||
claude-multimodel auth status --json --provider all
|
||||
```
|
||||
|
||||
Contract requirements:
|
||||
|
||||
- non-interactive only
|
||||
- must never open a browser or prompt for user input
|
||||
- must be side-effect free:
|
||||
- no provider switching
|
||||
- no token mutation unless the runtime explicitly performs a safe read-only refresh internally
|
||||
- should return partial provider results when possible instead of failing the entire command on one provider-specific check
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"supported": true,
|
||||
"authenticated": true,
|
||||
"authMethod": "oauth",
|
||||
"verificationState": "verified",
|
||||
"canLoginFromUi": true,
|
||||
"capabilities": {
|
||||
"teamLaunch": true,
|
||||
"oneShot": true
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"supported": true,
|
||||
"authenticated": false,
|
||||
"authMethod": null,
|
||||
"verificationState": "verified",
|
||||
"canLoginFromUi": true,
|
||||
"capabilities": {
|
||||
"teamLaunch": true,
|
||||
"oneShot": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- no global `active` provider state belongs in this contract
|
||||
- provider choice happens per launch request, not in global status
|
||||
- `authenticated` means the runtime considers usable credentials/session state present for that provider
|
||||
- `verificationState` must distinguish `verified` from `unknown` / `offline` / `error`
|
||||
- `supported` means the current runtime build knows how to use that provider
|
||||
- `capabilities` means the current runtime build knows which execution modes can use that provider
|
||||
|
||||
### 3. Provider model lists
|
||||
|
||||
Add:
|
||||
|
||||
```bash
|
||||
claude-multimodel model list --json --provider all
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"models": ["opus", "sonnet", "haiku"]
|
||||
},
|
||||
"codex": {
|
||||
"models": ["gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is intentionally separate from auth status so auth and capability failures can be handled independently.
|
||||
|
||||
Model listing should be available even when the provider is not currently authenticated, whenever the runtime can determine the model set statically.
|
||||
|
||||
### 4. Provider login
|
||||
|
||||
Add:
|
||||
|
||||
```bash
|
||||
claude-multimodel auth login --provider anthropic
|
||||
claude-multimodel auth login --provider codex
|
||||
```
|
||||
|
||||
```bash
|
||||
claude-multimodel auth logout --provider anthropic
|
||||
claude-multimodel auth logout --provider codex
|
||||
```
|
||||
|
||||
This allows `claude_team` to reuse runtime-managed login flows instead of implementing OAuth directly.
|
||||
|
||||
## Status Model in `claude_team`
|
||||
|
||||
Recommended DTO:
|
||||
|
||||
```ts
|
||||
type ProviderId = 'anthropic' | 'codex';
|
||||
|
||||
interface ProviderStatus {
|
||||
providerId: ProviderId;
|
||||
displayName: string;
|
||||
supported: boolean;
|
||||
authenticated: boolean;
|
||||
authMethod: 'oauth' | 'api_key' | 'external' | 'unknown' | null;
|
||||
verificationState: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
statusMessage?: string | null;
|
||||
models: string[];
|
||||
canLoginFromUi: boolean;
|
||||
capabilities: {
|
||||
teamLaunch: boolean;
|
||||
oneShot: boolean;
|
||||
};
|
||||
backend?: {
|
||||
kind: string;
|
||||
label: string;
|
||||
endpointLabel?: string | null;
|
||||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ClaudeMultimodelDashboardStatus {
|
||||
runtime: {
|
||||
id: 'claude-multimodel';
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
};
|
||||
providers: ProviderStatus[];
|
||||
}
|
||||
```
|
||||
|
||||
## Dashboard Banner
|
||||
|
||||
One grouped banner should be shown.
|
||||
It should replace the current dashboard runtime banner, not add a second top-level banner.
|
||||
|
||||
### Header
|
||||
|
||||
- runtime name/version, for example: `Claude 2.1.87`
|
||||
- runtime auth-independent actions like `Extensions`
|
||||
|
||||
For `claude-multimodel` mode:
|
||||
|
||||
- keep the runtime version in the header
|
||||
- hide runtime self-update UI
|
||||
- hide binary path UI
|
||||
|
||||
### Provider rows
|
||||
|
||||
One row per provider:
|
||||
|
||||
- `Anthropic`
|
||||
- `Codex`
|
||||
|
||||
Each row may show:
|
||||
|
||||
- authenticated state
|
||||
- auth method
|
||||
- verification state when status is uncertain
|
||||
- model list if available
|
||||
- backend diagnostics when the provider has internal backend resolution
|
||||
- actions:
|
||||
- `Login` when unauthenticated
|
||||
- `Logout` when authenticated
|
||||
- `Re-check` always
|
||||
|
||||
### Important UI rules
|
||||
|
||||
- Do not show a fake `Codex version`
|
||||
- Do not show a separate `Codex installer`
|
||||
- Do not show a fake `Download Codex` action
|
||||
- Do not force a global provider choice from the banner
|
||||
- If a provider has multiple internal backends, show them as provider diagnostics, not as separate provider rows
|
||||
- Partial success must be visible:
|
||||
- Anthropic ok, Codex missing
|
||||
- Codex ok, Anthropic missing
|
||||
|
||||
## Launch Flow Changes
|
||||
|
||||
Current launch flow already supports a team-level `model`.
|
||||
Current UI also already has a provider affordance in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx), but its logic is still placeholder/coming-soon. That placeholder currently uses `openai`; it should be normalized or mapped to the app-level provider id `codex` during implementation.
|
||||
|
||||
Current code paths that must be kept in sync:
|
||||
|
||||
- shared types in [team.ts](../../src/shared/types/team.ts)
|
||||
- HTTP launch parsing in [teams.ts](../../src/main/http/teams.ts)
|
||||
- IPC launch/create validation in [teams.ts](../../src/main/ipc/teams.ts)
|
||||
- persisted draft metadata in [TeamMetaStore.ts](../../src/main/services/team/TeamMetaStore.ts)
|
||||
- scheduled one-shot config in [schedule.ts](../../src/shared/types/schedule.ts)
|
||||
- scheduled execution in [ScheduledTaskExecutor.ts](../../src/main/services/schedule/ScheduledTaskExecutor.ts)
|
||||
- provider/model UI helpers in [TeamModelSelector.tsx](../../src/renderer/components/team/dialogs/TeamModelSelector.tsx)
|
||||
- model display parsing in [modelParser.ts](../../src/shared/utils/modelParser.ts)
|
||||
- launch dialog state persistence in [LaunchTeamDialog.tsx](../../src/renderer/components/team/dialogs/LaunchTeamDialog.tsx)
|
||||
- create dialog state persistence in [CreateTeamDialog.tsx](../../src/renderer/components/team/dialogs/CreateTeamDialog.tsx)
|
||||
- saved draft restore in [teams.ts](../../src/main/ipc/teams.ts)
|
||||
- launch param persistence in the renderer store
|
||||
- slash-command metadata in [slashCommands.ts](../../src/shared/utils/slashCommands.ts)
|
||||
|
||||
Next step is to extend launch requests to include provider selection:
|
||||
|
||||
```ts
|
||||
interface TeamLaunchRequest {
|
||||
providerId?: 'anthropic' | 'codex';
|
||||
model?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Future-compatible extension for providers with internal backend preference:
|
||||
|
||||
```ts
|
||||
interface TeamLaunchRequest {
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
providerOptions?: {
|
||||
geminiBackendPreference?: 'auto' | 'api' | 'cli';
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Provider selection must be applied only to the spawned child process environment/args.
|
||||
It must not mutate a global app-wide runtime provider state.
|
||||
|
||||
This launch-time provider/model selection must be honored consistently across all spawn paths:
|
||||
|
||||
- manual team launch
|
||||
- draft team launch that redirects into `createTeam`
|
||||
- scheduled/background execution
|
||||
- resume/retry/restart flows
|
||||
- any other non-interactive process launch path that currently inherits team/model settings
|
||||
|
||||
Implementation rule:
|
||||
|
||||
- provider choice must be expressed via child-process-scoped args/env only
|
||||
- conflicting provider env vars must be cleared for that child process
|
||||
- one run using `codex` must not affect another run using `anthropic`
|
||||
|
||||
Future-compatible shape:
|
||||
|
||||
```ts
|
||||
interface TeamLaunchRequest {
|
||||
providerId?: 'anthropic' | 'codex';
|
||||
model?: string;
|
||||
memberProviders?: Record<string, 'anthropic' | 'codex'>;
|
||||
memberModels?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows future per-teammate routing without redesigning the API again.
|
||||
|
||||
Launch-time preflight validation should fail fast before spawning the child process when possible:
|
||||
|
||||
- selected provider is unsupported by the current runtime build
|
||||
- selected provider is clearly unauthenticated
|
||||
- selected model is clearly incompatible with the selected provider
|
||||
|
||||
These checks should produce actionable errors for the user, while still treating cached banner model lists as advisory rather than authoritative.
|
||||
|
||||
The same provider/model fields must also be added to the data that currently persists launch intent:
|
||||
|
||||
- `TeamLaunchRequest`
|
||||
- `TeamCreateRequest` for draft-team fallback
|
||||
- `TeamMetaFile` in `team.meta.json`
|
||||
- `ScheduleLaunchConfig`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- when old persisted data has no `providerId`, treat it as `anthropic`
|
||||
- new writes should persist `providerId` explicitly
|
||||
|
||||
UI persistence rule:
|
||||
|
||||
- provider selection must be persisted independently from model selection
|
||||
- remembered model state must be namespaced by provider, or reset safely on provider switch
|
||||
- a previously remembered Anthropic model must not be auto-applied to Codex, and vice versa
|
||||
|
||||
Launch source-of-truth rule:
|
||||
|
||||
- explicit values from the current launch request win
|
||||
- persisted metadata is fallback only
|
||||
- banner/provider status is informational and must never silently override a launch request
|
||||
- provider-internal backend resolution may inform diagnostics, but it must not overwrite explicit launch-time provider options
|
||||
|
||||
Runtime observation rule:
|
||||
|
||||
- launch-time `providerId` / `model` are the requested starting configuration
|
||||
- observed runtime message metadata may later reflect a different actual model after in-session commands such as `/model`
|
||||
- the app must not conflate "requested at launch" with "currently observed in session"
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Provider auth combinations
|
||||
|
||||
Must support all of these without breaking the banner:
|
||||
|
||||
- Anthropic authenticated, Codex unauthenticated
|
||||
- Codex authenticated, Anthropic unauthenticated
|
||||
- both authenticated
|
||||
- neither authenticated
|
||||
|
||||
All of these must remain first-class supported states in the banner.
|
||||
|
||||
The same rule extends to future providers with internal backends:
|
||||
|
||||
- one provider row may be authenticated while one backend path is degraded
|
||||
- the banner should show provider-level health plus backend diagnostics without splitting into two pseudo-providers
|
||||
|
||||
### Provider supported, but not for this execution mode
|
||||
|
||||
A provider may be authenticated and generally supported, but still unavailable for a specific path:
|
||||
|
||||
- available for one-shot scheduler runs, but not Agent Teams
|
||||
- available for Agent Teams, but not one-shot
|
||||
|
||||
The banner and preflight validation must not flatten these into a single boolean.
|
||||
Capability checks must use the concrete execution mode being launched.
|
||||
|
||||
For providers with internal backends, capability checks may also depend on the resolved backend:
|
||||
|
||||
- provider supported, backend `api` unsupported for this path
|
||||
- provider supported, backend `cli` supported for this path
|
||||
|
||||
That still remains one provider decision surface in `claude_team`.
|
||||
|
||||
### Runtime ok, provider verify uncertain
|
||||
|
||||
If network verification fails:
|
||||
|
||||
- do not downgrade directly to `authenticated = false`
|
||||
- prefer `verificationState = unknown` or `offline`
|
||||
|
||||
### No global active provider
|
||||
|
||||
Because provider choice happens per launch:
|
||||
|
||||
- the status contract must not imply one provider is globally selected
|
||||
- a user can keep both `Anthropic` and `Codex` signed in simultaneously
|
||||
- the banner is informational, not a provider selector
|
||||
|
||||
### Provider selection must not leak between runs
|
||||
|
||||
If one team/process launches with `codex` and another launches with `anthropic`:
|
||||
|
||||
- this must not mutate the banner into a single globally active provider state
|
||||
- launch-specific provider state should stay attached to the launched process/team metadata
|
||||
- dashboard status should continue to reflect provider availability/auth only
|
||||
- child process env must explicitly clear incompatible provider-selection variables before spawn
|
||||
|
||||
### Managed flags vs custom CLI args
|
||||
|
||||
`claude_team` already supports raw `extraCliArgs` / custom CLI args.
|
||||
|
||||
Those args must not be allowed to silently override app-managed launch intent such as:
|
||||
|
||||
- provider selection
|
||||
- model selection
|
||||
- permission mode defaults owned by the app
|
||||
- any future provider-routing flags/env
|
||||
|
||||
The implementation should either:
|
||||
|
||||
- reject conflicting custom args with a validation error, or
|
||||
- define a strict precedence rule where app-managed provider/model flags always win
|
||||
|
||||
but it must not allow ambiguous mixed configuration.
|
||||
|
||||
### Provider-specific model normalization
|
||||
|
||||
Current UI and launch code contain Claude-specific model assumptions, including:
|
||||
|
||||
- `limitContext`
|
||||
- automatic `[1m]` suffixing
|
||||
- defaulting to Claude-family model ids such as `opus[1m]`
|
||||
|
||||
These rules must become provider-aware:
|
||||
|
||||
- Anthropic may keep Claude-specific context/model normalization
|
||||
- Codex must not inherit Claude-only suffix rules
|
||||
- switching provider must not silently rewrite a selected model into an invalid format for that provider
|
||||
|
||||
### Provider-specific selector state
|
||||
|
||||
Current selector state is Anthropic-biased:
|
||||
|
||||
- provider UI is currently hardcoded to Anthropic
|
||||
- model options are static Anthropic options
|
||||
- launch/create dialogs remember one global last-selected model
|
||||
|
||||
The implementation must make selector state provider-aware:
|
||||
|
||||
- provider list must come from supported runtime providers, with unsupported placeholders still disabled
|
||||
- model options must come from the selected provider, not a shared static list
|
||||
- switching provider must either preserve a valid provider-local previous choice or clear to that provider's default
|
||||
- the UI must not display a stale Anthropic label/model when `codex` is selected
|
||||
|
||||
### Resume and restart semantics
|
||||
|
||||
If a team/run is resumed, retried, or restarted later:
|
||||
|
||||
- the persisted launch-time `providerId` and `model` must remain the default for that resumed run
|
||||
- changing banner status or current UI defaults must not silently rewrite old run configuration
|
||||
- explicit user overrides during a new launch are allowed, but implicit fallback to a different provider is not
|
||||
|
||||
The same rule applies to edited schedules and stored drafts:
|
||||
|
||||
- opening an old schedule/draft in a newer UI must not silently rewrite provider/model until the user explicitly saves changes
|
||||
|
||||
If a running session changes model interactively after launch:
|
||||
|
||||
- launch metadata should remain the historical requested starting state
|
||||
- observed session model should come from runtime output/log parsing
|
||||
- resume should continue from the actual session state instead of blindly reimposing old defaults unless the user explicitly requests a fresh launch
|
||||
|
||||
### Draft-team launch fallback
|
||||
|
||||
`claude_team` can redirect a launch request into `createTeam` when only `team.meta.json` exists.
|
||||
|
||||
That fallback must preserve the same provider/model intent:
|
||||
|
||||
- `providerId` must survive the launch -> create redirect
|
||||
- draft metadata must not drop provider selection
|
||||
- create-time validation must match launch-time validation
|
||||
|
||||
### Models unavailable
|
||||
|
||||
If model listing fails but auth status succeeds:
|
||||
|
||||
- keep provider visible
|
||||
- show auth state
|
||||
- omit or gray out model list
|
||||
|
||||
If model listing succeeds for an unauthenticated provider:
|
||||
|
||||
- still show the model list
|
||||
- do not block model visibility on login state
|
||||
|
||||
### Provider-specific model display parsing
|
||||
|
||||
Current shared model display utilities are Claude-oriented.
|
||||
|
||||
The integration must not assume:
|
||||
|
||||
- every model id starts with `claude-`
|
||||
- every provider model can be parsed into Claude family/version semantics
|
||||
|
||||
For non-Claude providers, the UI should fall back to generic provider/model labels unless a provider-specific parser is added intentionally.
|
||||
|
||||
This also applies to in-session command output:
|
||||
|
||||
- `/model` command output or equivalent runtime messages may describe state transitions differently per provider
|
||||
- the UI should not assume Anthropic-specific phrasing when interpreting provider/model changes
|
||||
|
||||
If model lists differ by execution mode in a future runtime build:
|
||||
|
||||
- the contract should evolve via `schemaVersion`
|
||||
- old app versions should continue to degrade safely instead of assuming one global model set
|
||||
|
||||
If model lists are temporarily unavailable for the selected provider in the launch dialog:
|
||||
|
||||
- the UI should still allow safe fallback behavior for backward-compatible Anthropic flows
|
||||
- Codex flows should avoid inventing a default model id that did not come from the runtime or explicit user choice
|
||||
|
||||
### Model list is advisory, not launch authority
|
||||
|
||||
Provider model lists in the banner are helpful capability hints only:
|
||||
|
||||
- the actual launch path must still validate provider/model compatibility at spawn time
|
||||
- cached model lists must not be treated as proof that a launch will succeed
|
||||
- auth changes, subscription changes, or runtime updates may change model availability between refresh and launch
|
||||
|
||||
### Older runtime build
|
||||
|
||||
If the runtime does not yet support the new JSON commands:
|
||||
|
||||
- show runtime status normally
|
||||
- show provider section as unavailable
|
||||
- include a message like `Provider status not supported by current claude-multimodel build`
|
||||
|
||||
### Login/logout refresh behavior
|
||||
|
||||
After a login or logout flow in the terminal modal:
|
||||
|
||||
- the app must invalidate cached provider status
|
||||
- the banner must refresh automatically
|
||||
- a canceled login must not be treated as auth failure
|
||||
|
||||
Refresh handling must also be race-safe:
|
||||
|
||||
- older async refresh results must not overwrite newer ones
|
||||
- concurrent login/logout/re-check actions must not leave the banner in a stale mixed state
|
||||
- provider rows should expose a temporary loading/pending state while a provider-specific action is in flight
|
||||
|
||||
### Running teams during auth changes
|
||||
|
||||
If the user logs in, logs out, or changes provider credentials while teams are already running:
|
||||
|
||||
- the banner should refresh to reflect the new provider status
|
||||
- already-running teams must not be implicitly reconfigured by the dashboard refresh itself
|
||||
- launch-time provider/model metadata for existing runs should remain attached to those runs
|
||||
|
||||
Provider status changes should also avoid leaking sensitive details into UI logs:
|
||||
|
||||
- no token values
|
||||
- no raw OAuth payloads
|
||||
- only high-level auth method / verification state / actionable error text
|
||||
|
||||
### Runtime missing
|
||||
|
||||
If `claude-multimodel` is missing or not executable:
|
||||
|
||||
- show runtime-level failure in the banner header
|
||||
- provider rows should be disabled or shown as unavailable
|
||||
- do not render stale provider state as if it were fresh
|
||||
|
||||
### Slow or hanging status commands
|
||||
|
||||
If one or more runtime CLI status commands are slow or hang:
|
||||
|
||||
- the dashboard must not block indefinitely
|
||||
- show the last good known state when available
|
||||
- mark uncertain sections as stale/unknown instead of replacing them with false negatives
|
||||
|
||||
### Mixed command support during rollout
|
||||
|
||||
During migration, some runtime builds may support:
|
||||
|
||||
- version probing
|
||||
- auth status JSON
|
||||
- but not model list JSON
|
||||
|
||||
The app should degrade gracefully per capability instead of requiring all commands to work at once.
|
||||
|
||||
### Provider-specific status degradation
|
||||
|
||||
If one provider status check succeeds and another degrades:
|
||||
|
||||
- keep the successful provider row fully usable
|
||||
- mark only the failing provider row as `unknown` / `error`
|
||||
- do not collapse the whole banner into a global failure state
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1. Runtime CLI contract
|
||||
|
||||
In `claude-multimodel`:
|
||||
|
||||
- add `auth status --json --provider all`
|
||||
- add `model list --json --provider all`
|
||||
- add `auth login --provider anthropic|codex`
|
||||
- add `auth logout --provider anthropic|codex`
|
||||
|
||||
### Phase 2. Bridge service in `claude_team`
|
||||
|
||||
Add:
|
||||
|
||||
- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts`
|
||||
|
||||
Implement:
|
||||
|
||||
- `getRuntimeVersion()`
|
||||
- `getProviderAuthStatus()`
|
||||
- `getProviderModels()`
|
||||
- `getDashboardStatus()`
|
||||
- `login(providerId)`
|
||||
- `logout(providerId)`
|
||||
|
||||
The service should aggregate the banner DTO from multiple runtime CLI calls while keeping partial results usable.
|
||||
|
||||
### Phase 3. Banner integration
|
||||
|
||||
Refactor the dashboard CLI banner into a grouped runtime/provider banner:
|
||||
|
||||
- runtime header
|
||||
- Anthropic provider row
|
||||
- Codex provider row
|
||||
|
||||
This phase should also explicitly hide runtime path/update controls for `claude-multimodel` mode.
|
||||
|
||||
### Phase 4. Launch integration
|
||||
|
||||
Extend launch/create dialogs and IPC payloads:
|
||||
|
||||
- add `providerId`
|
||||
- wire provider choice into spawned CLI args/env
|
||||
- reuse the existing provider affordance in `TeamModelSelector` instead of creating a second provider selector
|
||||
|
||||
This phase must explicitly define the child-process env mapping for provider selection, including clearing conflicting provider flags.
|
||||
It must also persist launch-time `providerId` alongside `model` in team/run metadata so existing runs remain inspectable after refresh.
|
||||
It must update both the interactive team runtime path and the scheduler one-shot path.
|
||||
|
||||
Phase 4 should also explicitly hide or keep disabled unsupported placeholder providers in `TeamModelSelector` until their runtime support actually exists. In Phase 1/2/3, only `Anthropic` and `Codex` should move out of placeholder behavior.
|
||||
|
||||
### Phase 5. Future teammate-level routing
|
||||
|
||||
Later extension:
|
||||
|
||||
- `memberProviders`
|
||||
- `memberModels`
|
||||
|
||||
This phase should happen only after team-level provider/model selection is stable.
|
||||
It will likely require both `claude_team` changes and runtime support in `claude-multimodel` for teammate-level routing.
|
||||
|
||||
## Why This Plan
|
||||
|
||||
This plan keeps the ownership boundary clean:
|
||||
|
||||
- `claude-multimodel` remains the source of truth for auth and provider capabilities
|
||||
- `claude_team` remains a UI/orchestration layer
|
||||
|
||||
It also avoids the two bad extremes:
|
||||
|
||||
- no duplicated OAuth/token logic in `claude_team`
|
||||
- no premature heavy runtime abstraction layer
|
||||
|
||||
This is the smallest architecture that is still robust enough for:
|
||||
|
||||
- multi-provider status
|
||||
- launch-time provider selection
|
||||
- future per-agent provider/model routing
|
||||
- shared runtime ownership without duplicating auth logic in `claude_team`
|
||||
1261
docs/research/get-team-data-parallel-read-plan.md
Normal file
1261
docs/research/get-team-data-parallel-read-plan.md
Normal file
File diff suppressed because it is too large
Load diff
587
docs/research/lead-session-cache-plan.md
Normal file
587
docs/research/lead-session-cache-plan.md
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
# Lead Session Parsing Cache Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce repeated `team:getData` cost for active teams by avoiding reparsing the same resolved lead-session JSONL file when the file has not changed.
|
||||
|
||||
Primary target:
|
||||
|
||||
- repeated refreshes of a visible active team
|
||||
|
||||
Not a primary target for phase 1:
|
||||
|
||||
- the very first uncached team open
|
||||
|
||||
Reason:
|
||||
|
||||
- the first miss still runs the current extraction logic
|
||||
- phase 1 is a warm-path optimization, not a cold-path extraction rewrite
|
||||
|
||||
## Why This Is The Safest First Optimization
|
||||
|
||||
Today, `getTeamData()` repeatedly pays for lead-session history assembly:
|
||||
|
||||
1. resolve candidate project session directories
|
||||
2. list `.jsonl` files
|
||||
3. find the matching session file
|
||||
4. parse the same JSONL twice
|
||||
5. merge, sort, and dedup messages
|
||||
6. repeat the same work on the next refresh even if the file is unchanged
|
||||
7. still duplicate work under concurrent misses because there is no in-flight coalescing here
|
||||
|
||||
Relevant code:
|
||||
|
||||
- [src/main/services/team/TeamDataService.ts#L494](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L494)
|
||||
- [src/main/services/team/TeamDataService.ts#L2150](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L2150)
|
||||
- [src/main/services/team/TeamDataService.ts#L2324](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L2324)
|
||||
- [src/main/services/team/leadSessionMessageExtractor.ts#L96](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/leadSessionMessageExtractor.ts#L96)
|
||||
- [src/main/utils/pathDecoder.ts#L5](/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathDecoder.ts#L5)
|
||||
|
||||
This is safer than introducing a new lightweight IPC path because it does not change:
|
||||
|
||||
- what data is returned
|
||||
- when refresh happens
|
||||
- how live `lead_process` rows merge with persisted history
|
||||
- any renderer/main truth boundary
|
||||
|
||||
## Guardrails
|
||||
|
||||
This work touches `main process / IPC` and team detail truth assembly.
|
||||
|
||||
Main failure modes to avoid:
|
||||
|
||||
- stale lead-session history shown after the file changed
|
||||
- cache poisoning due to file mutation during parse
|
||||
- cross-request mutation bleed from shared cached objects
|
||||
- cache key mistakes caused by lossy path encoding assumptions
|
||||
|
||||
The change should preserve:
|
||||
|
||||
- current message shapes
|
||||
- current message IDs
|
||||
- current extraction semantics
|
||||
- current team detail IPC contract
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- add a bounded in-memory cache for parsed lead-session results
|
||||
- reuse cached results only when the exact resolved session file is unchanged
|
||||
- coalesce concurrent misses for the same file signature
|
||||
- keep the returned `InboxMessage[]` shape unchanged
|
||||
- add focused tests for freshness, invalidation, concurrency, and mutation safety
|
||||
|
||||
### Out of scope
|
||||
|
||||
- new IPC endpoints
|
||||
- renderer refresh strategy changes
|
||||
- changing `lead_process` merge/dedup logic
|
||||
- replacing file scans with watchers
|
||||
- rewriting extraction into a brand-new single-pass parser in phase 1
|
||||
- caching project-dir resolution in phase 1
|
||||
|
||||
## Design Constraints
|
||||
|
||||
### 1. Cache after `jsonlPath` resolution, not before
|
||||
|
||||
Do not cache by `projectPath` or encoded project dir.
|
||||
|
||||
Why:
|
||||
|
||||
- `encodePath()` is lossy for paths containing dashes
|
||||
- `getLeadProjectDirCandidates()` already exists because one project can require multiple candidate directory probes
|
||||
|
||||
Relevant code:
|
||||
|
||||
- [src/main/services/team/TeamDataService.ts#L2135](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamDataService.ts#L2135)
|
||||
- [src/main/utils/pathDecoder.ts#L27](/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathDecoder.ts#L27)
|
||||
|
||||
Rule:
|
||||
|
||||
- cache only after the final `jsonlPath` has already been concretely resolved
|
||||
|
||||
### 2. No TTL-based freshness
|
||||
|
||||
This cache must not use time-based correctness semantics.
|
||||
|
||||
There is a TTL-based advisory cache elsewhere:
|
||||
|
||||
- [src/main/services/team/TeamMemberRuntimeAdvisoryService.ts](/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts)
|
||||
|
||||
That pattern is not appropriate here because lead-session rows are part of user-visible truth.
|
||||
|
||||
Rule:
|
||||
|
||||
- freshness comes only from file signature for the exact `jsonlPath`
|
||||
- timestamps may be stored for eviction bookkeeping or debug only
|
||||
- no "fresh for 5 seconds" shortcuts
|
||||
|
||||
### 3. Instance-scoped cache, not module-global
|
||||
|
||||
Preferred ownership:
|
||||
|
||||
- `TeamDataService` owns the cache instance
|
||||
- optionally inject the cache into `TeamDataService` for tests
|
||||
|
||||
Do not use:
|
||||
|
||||
- a hidden module-global singleton
|
||||
|
||||
Why:
|
||||
|
||||
- the app already uses a long-lived `TeamDataService`
|
||||
- tests should get fresh state with a fresh service instance
|
||||
- module-global state would make isolation and failure analysis worse
|
||||
|
||||
### 4. First patch optimizes warm refreshes, not cold misses
|
||||
|
||||
Phase 1 still leaves one important cost untouched:
|
||||
|
||||
- on the first uncached read, the file is still parsed using the existing two-pass extraction path
|
||||
|
||||
That is acceptable because:
|
||||
|
||||
- it keeps correctness risk low
|
||||
- it still removes repeated hot-refresh cost
|
||||
|
||||
If cold misses remain too slow after this patch, the next candidate optimization is a single-pass extractor, not a riskier cache broadening.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
### 1. Cache the combined per-file extraction result
|
||||
|
||||
Integration point:
|
||||
|
||||
- `extractLeadSessionTextsFromJsonl(...)` in `TeamDataService`
|
||||
|
||||
Why here:
|
||||
|
||||
- it already defines the combined contract used by `getTeamData()`
|
||||
- it merges assistant thoughts plus slash-command results
|
||||
- it provides one place to attach both memoization and in-flight coalescing
|
||||
- it avoids two separate caches that could drift
|
||||
|
||||
Do not cache:
|
||||
|
||||
- raw `projectPath -> jsonlPath` resolution
|
||||
- whole-team assembled message lists
|
||||
|
||||
### 2. Cache key
|
||||
|
||||
Phase-1 key should include:
|
||||
|
||||
- `jsonlPath`
|
||||
- `leadSessionId`
|
||||
- `leadName`
|
||||
- requested `maxTexts`
|
||||
- cache schema version
|
||||
|
||||
Important tradeoff:
|
||||
|
||||
- keeping `maxTexts` in the key is less optimal for hit rate
|
||||
- but it is safer than canonicalizing to a larger per-session cap in the first patch
|
||||
|
||||
Why this conservative choice is correct:
|
||||
|
||||
- the helper is private, but its caller passes a dynamic `remaining` budget based on earlier sessions
|
||||
- requested-cap keys avoid subtle slicing mistakes in phase 1
|
||||
|
||||
### 3. Freshness signature
|
||||
|
||||
Primary signature:
|
||||
|
||||
- `size`
|
||||
- `mtimeMs`
|
||||
|
||||
Optional tie-breaker:
|
||||
|
||||
- `ctimeMs`
|
||||
|
||||
Why not content hashing in phase 1:
|
||||
|
||||
- it adds more I/O and CPU to the hot path
|
||||
- it makes the "optimization" more expensive
|
||||
|
||||
Accepted residual risk:
|
||||
|
||||
- a same-size rewrite on a coarse-timestamp filesystem is theoretically possible
|
||||
- this is rare enough to accept for phase 1 if the cache is in-memory and tied to exact `jsonlPath`
|
||||
|
||||
### 4. In-flight coalescing
|
||||
|
||||
Memoization alone is not enough.
|
||||
|
||||
If two refreshes miss the cache at once, both can still parse the same file.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- keep a separate `inFlightByKey` map
|
||||
- attach the observed pre-parse signature to the in-flight entry
|
||||
- if another request sees the same key and same observed signature, await that promise
|
||||
- if another request observes a newer signature, do not await the older in-flight promise
|
||||
|
||||
Do not:
|
||||
|
||||
- mix settled entries and in-flight promises in one map
|
||||
|
||||
### 5. Do not cache ambiguous results
|
||||
|
||||
This is the most important safety rule after exact-path scoping.
|
||||
|
||||
If the file changes during parse:
|
||||
|
||||
1. pre-parse stat sees signature `S1`
|
||||
2. parse runs
|
||||
3. post-parse stat sees signature `S2`
|
||||
|
||||
If `S2 !== S1`:
|
||||
|
||||
- return the parsed result to the current caller
|
||||
- do not populate the fulfilled cache
|
||||
- clear the in-flight entry
|
||||
|
||||
Why:
|
||||
|
||||
- the result may still be acceptable as a best-effort snapshot for that one request
|
||||
- but it is not safe enough to promote into cache
|
||||
|
||||
This is stricter and safer than caching under the old signature.
|
||||
|
||||
### 6. Defensive cloning
|
||||
|
||||
Never return the cached array instance directly.
|
||||
|
||||
Why:
|
||||
|
||||
- `getTeamData()` mutates messages during enrichment and dedup flows
|
||||
- shared references would create cross-request contamination
|
||||
|
||||
Clone requirements:
|
||||
|
||||
- clone array
|
||||
- clone each message object
|
||||
- deep-clone nested mutable fields used by render/equality logic:
|
||||
- `taskRefs`
|
||||
- `attachments`
|
||||
- `toolCalls`
|
||||
- `slashCommand`
|
||||
- `commandOutput`
|
||||
|
||||
Do not:
|
||||
|
||||
- attach cache metadata to returned message objects
|
||||
|
||||
Relevant renderer equality code:
|
||||
|
||||
- [src/renderer/utils/messageRenderEquality.ts](/Users/belief/dev/projects/claude/claude_team/src/renderer/utils/messageRenderEquality.ts)
|
||||
|
||||
### 7. Bounded storage
|
||||
|
||||
Use a small cap, for example:
|
||||
|
||||
- `64` fulfilled entries
|
||||
|
||||
Eviction:
|
||||
|
||||
- insertion-order `Map`
|
||||
- evict oldest fulfilled entry when exceeding cap
|
||||
|
||||
The cache is best-effort only:
|
||||
|
||||
- any entry may be dropped at any time
|
||||
- eviction must never affect correctness, only latency
|
||||
|
||||
### 8. Do not cache failures
|
||||
|
||||
If stat, open, read, or parse fails:
|
||||
|
||||
- preserve current behavior
|
||||
- do not cache the error
|
||||
- do not leave a rejected promise in `inFlightByKey`
|
||||
|
||||
Why:
|
||||
|
||||
- JSONL writes are concurrent
|
||||
- transient failures must not become sticky stale state
|
||||
|
||||
## Detailed Implementation Plan
|
||||
|
||||
### Phase 1. Add a cache helper
|
||||
|
||||
Suggested file:
|
||||
|
||||
- `src/main/services/team/cache/LeadSessionParseCache.ts`
|
||||
|
||||
Suggested operations:
|
||||
|
||||
- `getIfFresh(key, signature)`
|
||||
- `getInFlight(key, signature)`
|
||||
- `setInFlight(key, signature, promise)`
|
||||
- `set(key, signature, messages)`
|
||||
- `clearForPath(jsonlPath)`
|
||||
- optional `clearAll()`
|
||||
|
||||
Helper responsibilities:
|
||||
|
||||
- normalize key
|
||||
- compare signatures
|
||||
- return defensive clones
|
||||
- coalesce concurrent misses
|
||||
- bound fulfilled entries
|
||||
- keep settled cache and in-flight state separate
|
||||
|
||||
### Phase 2. Wire it into `TeamDataService`
|
||||
|
||||
Change only `extractLeadSessionTextsFromJsonl(...)`.
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. stat file before parse
|
||||
2. build key
|
||||
3. try fulfilled cache hit
|
||||
4. if miss, try matching in-flight entry
|
||||
5. if no matching in-flight entry, create one
|
||||
6. run current combined extraction using existing helpers unchanged
|
||||
7. stat file again after parse
|
||||
8. if post-parse signature matches pre-parse signature, populate fulfilled cache
|
||||
9. otherwise skip cache population
|
||||
10. clear in-flight entry in `finally`
|
||||
|
||||
Strict non-goals while wiring:
|
||||
|
||||
- do not change message ordering
|
||||
- do not change trimming semantics
|
||||
- do not change message IDs
|
||||
- do not change assistant-thought extraction logic
|
||||
- do not change slash-command result extraction logic
|
||||
- do not change project-dir resolution logic
|
||||
|
||||
### Phase 3. Tests
|
||||
|
||||
Prefer focused tests at the `TeamDataService` layer because the cache integrates there.
|
||||
|
||||
Potentially add a dedicated cache helper test if the helper becomes non-trivial.
|
||||
|
||||
### Phase 4. Re-evaluate after profiling
|
||||
|
||||
Only if needed after phase 1:
|
||||
|
||||
1. optional directory-listing cache
|
||||
2. optional canonical per-session cache not keyed by requested `maxTexts`
|
||||
3. optional single-pass extractor for cold misses
|
||||
|
||||
Those are phase 2+ because each increases semantic risk.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### 1. File appended during read
|
||||
|
||||
Current behavior already tolerates partial trailing lines by skipping invalid JSON lines.
|
||||
|
||||
Phase-1 rule:
|
||||
|
||||
- if signature changed during parse, do not cache result
|
||||
|
||||
### 2. File deleted or moved after a previous successful cache fill
|
||||
|
||||
Rule:
|
||||
|
||||
- every cache lookup must be preceded by a fresh `stat`
|
||||
- no successful fresh `stat` means no cache hit
|
||||
|
||||
### 3. Different requested `maxTexts`
|
||||
|
||||
Phase-1 rule:
|
||||
|
||||
- requested cap stays in the cache key
|
||||
|
||||
Why:
|
||||
|
||||
- lower hit rate is acceptable
|
||||
- semantic conservatism is more important in this area
|
||||
|
||||
### 4. `leadName` changes while file is unchanged
|
||||
|
||||
Rule:
|
||||
|
||||
- include `leadName` in the key
|
||||
|
||||
### 5. Concurrent refreshes
|
||||
|
||||
Rule:
|
||||
|
||||
- only same-key, same-signature requests may share an in-flight promise
|
||||
|
||||
### 6. Newer request races an older in-flight parse
|
||||
|
||||
If request A saw signature `S1` and request B sees `S2`:
|
||||
|
||||
- request B must not await A's in-flight promise
|
||||
|
||||
### 7. Path ambiguity from encoded project dirs
|
||||
|
||||
Rule:
|
||||
|
||||
- never treat encoded project dir as canonical cache identity
|
||||
- only the resolved `jsonlPath` is canonical enough for phase 1
|
||||
|
||||
### 8. Fresh service instances in tests
|
||||
|
||||
Rule:
|
||||
|
||||
- new `TeamDataService` instance should imply fresh cache state
|
||||
|
||||
## Areas With Lower Confidence
|
||||
|
||||
### 1. File signature granularity
|
||||
|
||||
Confidence: medium.
|
||||
|
||||
Why:
|
||||
|
||||
- filesystem timestamp granularity differs
|
||||
- same-size rewrites are rare but possible
|
||||
|
||||
Decision:
|
||||
|
||||
- use `size + mtimeMs` primarily
|
||||
- optionally add `ctimeMs` as tie-breaker
|
||||
- document residual risk instead of overengineering phase 1
|
||||
|
||||
### 2. Requested-cap keys vs canonical per-session cache
|
||||
|
||||
Confidence: medium.
|
||||
|
||||
Why:
|
||||
|
||||
- canonical caching could improve hit rate
|
||||
- but requested-cap keys are more obviously equivalent to today's behavior
|
||||
|
||||
Decision:
|
||||
|
||||
- requested-cap keys in phase 1
|
||||
|
||||
### 3. In-flight signature matching
|
||||
|
||||
Confidence: medium.
|
||||
|
||||
Why:
|
||||
|
||||
- easy to implement incorrectly if signature is not stored with the in-flight promise
|
||||
|
||||
Decision:
|
||||
|
||||
- store signature alongside each in-flight promise
|
||||
- require exact signature match before reuse
|
||||
|
||||
### 4. Post-parse signature mismatch handling
|
||||
|
||||
Confidence: medium-high.
|
||||
|
||||
Why:
|
||||
|
||||
- returning but not caching ambiguous results is semantically conservative
|
||||
- but this still leaves one request with a best-effort snapshot rather than a strong snapshot
|
||||
|
||||
Decision:
|
||||
|
||||
- accept current best-effort behavior for the single request
|
||||
- forbid cache population on mismatch
|
||||
|
||||
### 5. Cold-load expectations
|
||||
|
||||
Confidence: high.
|
||||
|
||||
Why:
|
||||
|
||||
- phase 1 clearly improves warm refreshes more than first opens
|
||||
|
||||
Decision:
|
||||
|
||||
- state that explicitly
|
||||
- do not oversell the patch
|
||||
|
||||
## Testing Plan
|
||||
|
||||
In [test/main/services/team/TeamDataService.test.ts](/Users/belief/dev/projects/claude/claude_team/test/main/services/team/TeamDataService.test.ts) or a dedicated cache test:
|
||||
|
||||
1. repeated extraction of the same unchanged JSONL uses cached results
|
||||
2. append invalidates cache and returns fresh results
|
||||
3. changing `leadName` does not reuse stale sender data
|
||||
4. returned cached results are cloned and caller mutation does not poison the next read
|
||||
5. different `maxTexts` requests do not incorrectly reuse the wrong slice
|
||||
6. two concurrent requests for the same uncached file coalesce into one parse
|
||||
7. missing or deleted JSONL does not return stale cached content
|
||||
8. partial trailing line remains tolerated and does not get cached as a sticky failure
|
||||
9. path with dashes and underscores still works once `jsonlPath` is resolved
|
||||
10. if a newer request sees a newer signature, it does not await an older in-flight parse
|
||||
11. if the file changes during parse, result is returned but not stored in fulfilled cache
|
||||
12. fresh `TeamDataService` instances do not share hidden cache state
|
||||
|
||||
In [test/main/services/team/leadSessionMessageExtractor.test.ts](/Users/belief/dev/projects/claude/claude_team/test/main/services/team/leadSessionMessageExtractor.test.ts):
|
||||
|
||||
13. existing extraction semantics remain unchanged
|
||||
|
||||
Existing behavior that must still hold:
|
||||
|
||||
- slash-command result merging
|
||||
- message ordering
|
||||
- stable message IDs
|
||||
|
||||
## Verification
|
||||
|
||||
Targeted first:
|
||||
|
||||
```bash
|
||||
bun test test/main/services/team/leadSessionMessageExtractor.test.ts test/main/services/team/TeamDataService.test.ts
|
||||
```
|
||||
|
||||
Then broader team-service confidence if needed:
|
||||
|
||||
```bash
|
||||
bun test test/main/services/team
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The change is successful if:
|
||||
|
||||
1. repeated `team:getData` for an unchanged active team avoids reparsing the same session file
|
||||
2. new lead-session output appears after the file changes without manual invalidation
|
||||
3. concurrent refreshes do not duplicate parse work for the same signature
|
||||
4. newer signatures never reuse older in-flight parse results
|
||||
5. ambiguous mid-read results are never committed into fulfilled cache
|
||||
6. path-resolution behavior for project session discovery remains unchanged
|
||||
7. existing TeamDataService and extractor tests still pass
|
||||
8. no duplicate or missing lead thoughts appear compared with current behavior
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. add cache helper with bounded fulfilled entries and separate in-flight map
|
||||
2. make it instance-scoped or injectable into `TeamDataService`
|
||||
3. integrate only at `extractLeadSessionTextsFromJsonl(...)`
|
||||
4. add pre-parse and post-parse signature checks
|
||||
5. add focused tests for hit, miss, invalidation, mutation safety, concurrency, and no-cache-on-ambiguous-read
|
||||
6. run targeted tests
|
||||
7. only then consider directory-listing cache or single-pass extraction if profiling still points there
|
||||
|
||||
## Recommendation
|
||||
|
||||
Recommended phase-1 approach:
|
||||
|
||||
- parse cache
|
||||
- in-flight coalescing
|
||||
- no cache population if the file changed during parse
|
||||
|
||||
Ratings:
|
||||
|
||||
- Parse-cache-only first step: 🎯 9 🛡️ 9 🧠 4
|
||||
- Parse-cache plus in-flight coalescing: 🎯 9 🛡️ 9 🧠 5
|
||||
- Parse-cache plus in-flight plus no-cache-on-mid-read-mutation: 🎯 9 🛡️ 10 🧠 6
|
||||
- Plus directory-listing cache in the same patch: 🎯 5 🛡️ 6 🧠 7
|
||||
- Skip cache and jump straight to new lightweight IPC API: 🎯 8 🛡️ 7 🧠 7
|
||||
|
||||
Estimated patch size for the recommended step:
|
||||
|
||||
- around 180-320 changed lines
|
||||
|
|
@ -20,14 +20,16 @@ const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-
|
|||
// but they have pure JS fallbacks when the native module isn't available.
|
||||
function nativeModuleStub(): Plugin {
|
||||
const STUB_ID = '\0native-stub'
|
||||
const NODE_MODULE_RE = /\.node(?:\?.*)?$/
|
||||
return {
|
||||
name: 'native-module-stub',
|
||||
enforce: 'pre',
|
||||
resolveId(source) {
|
||||
if (source.endsWith('.node')) return STUB_ID
|
||||
if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`
|
||||
return null
|
||||
},
|
||||
load(id) {
|
||||
if (id === STUB_ID) return 'export default {}'
|
||||
if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return 'export default {}'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +73,9 @@ export default defineConfig({
|
|||
externalizeDeps: {
|
||||
exclude: bundledDeps
|
||||
},
|
||||
commonjsOptions: {
|
||||
strictRequires: [/node_modules\/.*ssh2\//],
|
||||
},
|
||||
sourcemap: 'hidden',
|
||||
outDir: 'dist-electron/main',
|
||||
rollupOptions: {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ const toolContextSchema = {
|
|||
export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'process_register',
|
||||
description: 'Register a running process for a team member',
|
||||
description:
|
||||
'Register a background service started by a teammate, such as a dev server, watcher, or database. This is not for teammate-agent liveness.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
|
|
@ -51,7 +52,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
name: 'process_list',
|
||||
description: 'List registered team processes',
|
||||
description:
|
||||
'List registered background services for the team, such as dev servers, watchers, or databases. This does not show teammate-agent liveness.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
|
|
@ -63,7 +65,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
name: 'process_unregister',
|
||||
description: 'Unregister a previously registered process',
|
||||
description:
|
||||
'Unregister a previously registered background service while keeping teammate-agent state separate.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
|
|
@ -76,7 +79,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
name: 'process_stop',
|
||||
description: 'Mark a registered process as stopped while preserving history',
|
||||
description:
|
||||
'Mark a registered background service as stopped while preserving history. This is not for stopping teammate agents.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { agentBlocks, getController } from '../controller';
|
||||
|
|
@ -12,6 +14,10 @@ const toolContextSchema = {
|
|||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
const ALWAYS_LOAD_META = {
|
||||
'anthropic/alwaysLoad': true,
|
||||
} as const;
|
||||
|
||||
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
||||
|
||||
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
|
||||
|
|
@ -52,6 +58,42 @@ function buildCreateTaskPayload(params: {
|
|||
};
|
||||
}
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
|
|
@ -81,6 +123,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
prompt,
|
||||
startImmediately,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
const controller = getController(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
|
|
@ -139,23 +182,34 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
prompt,
|
||||
startImmediately,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
const controller = getController(teamName, claudeDir);
|
||||
|
||||
// 1. Lookup message by exact messageId
|
||||
const { message } = controller.messages.lookupMessage(messageId);
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
({ message } = controller.messages.lookupMessage(messageId));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Message not found:')) {
|
||||
throw new Error(
|
||||
`${error.message}. task_create_from_message only works with the explicit User MessageId shown in the relay prompt for a user_sent message. Do not use teammate inbox ids or guessed ids.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 2. Reject if message source is not user-originated
|
||||
const source = typeof message.source === 'string' ? message.source : '';
|
||||
if (!USER_ORIGINATED_SOURCES.has(source)) {
|
||||
throw new Error(
|
||||
`Message source "${source}" is not user-originated. Only user_sent messages are eligible.`
|
||||
`Message source "${source}" is not user-originated. task_create_from_message only accepts explicit user_sent messages from the relay prompt. For teammate, system, or cross-team messages, use task_create instead.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Reject relay copies explicitly
|
||||
if (typeof message.relayOfMessageId === 'string' && message.relayOfMessageId.trim()) {
|
||||
throw new Error(
|
||||
'Cannot create task from a relay copy. Use the original message instead.'
|
||||
'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -468,6 +522,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
server.addTool({
|
||||
name: 'member_briefing',
|
||||
description: 'Get bootstrap briefing for a team member',
|
||||
_meta: ALWAYS_LOAD_META,
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,41 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function parseJsonToolResult(result: unknown) {
|
||||
const text = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text;
|
||||
const response = result as {
|
||||
content?: Array<{ text?: string }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
const text = response.content?.[0]?.text;
|
||||
if (response.isError) {
|
||||
throw new Error(text ?? 'Tool returned an unspecified error');
|
||||
}
|
||||
return JSON.parse(text ?? 'null');
|
||||
}
|
||||
|
||||
async function writeTeamConfig(claudeDir: string, teamName: string) {
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
await mkdir(teamDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: teamName,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
class McpStdIoClient {
|
||||
private readonly child: ChildProcessWithoutNullStreams;
|
||||
private stdoutBuffer = '';
|
||||
|
|
@ -102,6 +129,7 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
});
|
||||
|
||||
it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => {
|
||||
await writeTeamConfig(claudeDir, 'e2e-team');
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -665,6 +665,13 @@ describe('agent-teams-mcp tools', () => {
|
|||
it('covers review_request_changes and full process lifecycle tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'beta';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'reviewer' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const createdTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
|
|
@ -861,6 +868,12 @@ describe('agent-teams-mcp tools', () => {
|
|||
it('task_add_comment succeeds even when owner inbox write fails', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'resilience';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const task = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
|
|
@ -1109,7 +1122,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'nonexistent-msg',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('Message not found: nonexistent-msg');
|
||||
).rejects.toThrow(
|
||||
'Message not found: nonexistent-msg. task_create_from_message only works with the explicit User MessageId'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-user-originated message sources', async () => {
|
||||
|
|
@ -1136,7 +1151,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId,
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects lead_process and cross_team sources explicitly', async () => {
|
||||
|
|
@ -1170,7 +1187,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'msg-lead-001',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
|
|
@ -1179,7 +1198,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'msg-cross-001',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects messages without an explicit source field (fail closed)', async () => {
|
||||
|
|
@ -1205,7 +1226,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'msg-no-source',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects relay copies', async () => {
|
||||
|
|
@ -1233,7 +1256,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId,
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('relay copy');
|
||||
).rejects.toThrow(
|
||||
'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves attachment metadata without blob copying', async () => {
|
||||
|
|
@ -1430,4 +1455,33 @@ describe('agent-teams-mcp tools', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for task_create when team config does not exist', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
|
||||
await expect(
|
||||
getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
subject: 'Phantom task should fail',
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for task_create_from_message when team config does not exist', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
messageId: 'msg-1',
|
||||
subject: 'Phantom task should fail',
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
17
package.json
17
package.json
|
|
@ -18,7 +18,7 @@
|
|||
},
|
||||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"build": "electron-vite build",
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
"standalone:build": "electron-vite build && vite build --config docker/vite.standalone.config.ts",
|
||||
"standalone:start": "node dist-standalone/index.cjs",
|
||||
"prepare": "husky",
|
||||
"postinstall": "electron-rebuild -f -o node-pty || echo 'node-pty rebuild failed (terminal will be disabled)'"
|
||||
"postinstall": "electron-rebuild -f -o node-pty,ssh2,cpu-features || echo 'native Electron rebuild failed (terminal/ssh features may be degraded)'"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx,js,jsx}": [
|
||||
|
|
@ -230,6 +230,10 @@
|
|||
"from": "resources/pricing.json",
|
||||
"to": "pricing.json"
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime",
|
||||
"to": "runtime"
|
||||
},
|
||||
{
|
||||
"from": "mcp-server/dist/index.js",
|
||||
"to": "mcp-server/index.js"
|
||||
|
|
@ -240,6 +244,7 @@
|
|||
}
|
||||
],
|
||||
"npmRebuild": false,
|
||||
"afterPack": "scripts/electron-builder/afterPack.cjs",
|
||||
"extraMetadata": {
|
||||
"main": "dist-electron/main/index.cjs"
|
||||
},
|
||||
|
|
@ -330,5 +335,11 @@
|
|||
"ignoreBinaries": [
|
||||
"pkg"
|
||||
]
|
||||
}
|
||||
},
|
||||
"workspaces": [
|
||||
"agent-teams-controller",
|
||||
"mcp-server",
|
||||
"landing",
|
||||
"packages/agent-graph"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@
|
|||
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import { COLORS, getStateColor, alphaHex } from '../constants/colors';
|
||||
import { NODE, AGENT_DRAW, CONTEXT_RING, ANIM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
|
||||
import {
|
||||
NODE,
|
||||
AGENT_DRAW,
|
||||
CONTEXT_RING,
|
||||
ANIM,
|
||||
MIN_VISIBLE_OPACITY,
|
||||
} from '../constants/canvas-constants';
|
||||
import { drawHexagon } from './draw-misc';
|
||||
import { getAgentGlowSprite, ensureHex, hexWithAlpha } from './render-cache';
|
||||
|
||||
|
|
@ -18,7 +24,7 @@ export function drawAgents(
|
|||
nodes: GraphNode[],
|
||||
time: number,
|
||||
selectedId: string | null,
|
||||
hoveredId: string | null,
|
||||
hoveredId: string | null
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'member' && node.kind !== 'lead') continue;
|
||||
|
|
@ -48,7 +54,7 @@ export function drawAgents(
|
|||
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
|
||||
|
||||
// Breathing animation + spawn/waiting effects
|
||||
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
|
||||
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel);
|
||||
|
||||
// Pending approval indicator: pulsing amber ring
|
||||
if (node.pendingApproval) {
|
||||
|
|
@ -72,7 +78,10 @@ export function drawAgents(
|
|||
}
|
||||
|
||||
// Working indicator: subtle spinning arc when member has active task
|
||||
if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) {
|
||||
if (
|
||||
node.currentTaskId &&
|
||||
(node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')
|
||||
) {
|
||||
const ringR = r + 4;
|
||||
const rotation = time * 1.5;
|
||||
ctx.beginPath();
|
||||
|
|
@ -88,7 +97,7 @@ export function drawAgents(
|
|||
|
||||
// Name + role label (single line: "jack · developer")
|
||||
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
|
||||
drawLabel(ctx, x, y, r, labelText, color);
|
||||
drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
|
||||
|
||||
// TODO: Context ring disabled — LeadContextUsage.percent is unreliable
|
||||
// (jumps due to cache_read variance, contextWindow mismatch with actual model).
|
||||
|
|
@ -114,7 +123,7 @@ export function drawCrossTeamNodes(
|
|||
nodes: GraphNode[],
|
||||
time: number,
|
||||
selectedId: string | null,
|
||||
hoveredId: string | null,
|
||||
hoveredId: string | null
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'crossteam') continue;
|
||||
|
|
@ -191,7 +200,13 @@ function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r:
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawGlow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string): void {
|
||||
function drawGlow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
color: string
|
||||
): void {
|
||||
const outerR = r + AGENT_DRAW.glowPadding;
|
||||
const sprite = getAgentGlowSprite(color, r * 0.5, outerR);
|
||||
ctx.drawImage(sprite, x - outerR, y - outerR);
|
||||
|
|
@ -206,19 +221,18 @@ function drawHexBody(
|
|||
state: string,
|
||||
time: number,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
isHovered: boolean
|
||||
): void {
|
||||
// Interior fill
|
||||
drawHexagon(ctx, x, y, r);
|
||||
ctx.fillStyle = isSelected
|
||||
? 'rgba(100, 200, 255, 0.15)'
|
||||
: COLORS.nodeInterior;
|
||||
ctx.fillStyle = isSelected ? 'rgba(100, 200, 255, 0.15)' : COLORS.nodeInterior;
|
||||
ctx.fill();
|
||||
|
||||
// Scanline effect
|
||||
const scanSpeed = state === 'active' || state === 'thinking' || state === 'tool_calling'
|
||||
? ANIM.scanline.active
|
||||
: ANIM.scanline.normal;
|
||||
const scanSpeed =
|
||||
state === 'active' || state === 'thinking' || state === 'tool_calling'
|
||||
? ANIM.scanline.active
|
||||
: ANIM.scanline.normal;
|
||||
const scanY = ((time * scanSpeed) % (r * 2)) - r;
|
||||
ctx.save();
|
||||
drawHexagon(ctx, x, y, r);
|
||||
|
|
@ -227,7 +241,7 @@ function drawHexBody(
|
|||
x,
|
||||
y + scanY - AGENT_DRAW.scanlineHalfH,
|
||||
x,
|
||||
y + scanY + AGENT_DRAW.scanlineHalfH,
|
||||
y + scanY + AGENT_DRAW.scanlineHalfH
|
||||
);
|
||||
grad.addColorStop(0, hexWithAlpha(color, 0));
|
||||
grad.addColorStop(0.5, hexWithAlpha(color, 0.13));
|
||||
|
|
@ -243,11 +257,7 @@ function drawHexBody(
|
|||
ctx.stroke();
|
||||
}
|
||||
|
||||
function truncateCardText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
): string {
|
||||
function truncateCardText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let out = text;
|
||||
while (out.length > 1 && ctx.measureText(`${out}...`).width > maxWidth) {
|
||||
|
|
@ -262,7 +272,7 @@ function drawToolCard(
|
|||
y: number,
|
||||
r: number,
|
||||
tool: NonNullable<GraphNode['activeTool']>,
|
||||
time: number,
|
||||
time: number
|
||||
): void {
|
||||
const labelBase = tool.preview ? `${tool.name}: ${tool.preview}` : tool.name;
|
||||
const labelText =
|
||||
|
|
@ -300,13 +310,7 @@ function drawToolCard(
|
|||
|
||||
if (tool.state === 'running') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
indicatorX,
|
||||
indicatorY,
|
||||
4.5,
|
||||
time * 3,
|
||||
time * 3 + Math.PI * 1.2,
|
||||
);
|
||||
ctx.arc(indicatorX, indicatorY, 4.5, time * 3, time * 3 + Math.PI * 1.2);
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.stroke();
|
||||
|
|
@ -332,7 +336,11 @@ function drawBreathing(
|
|||
state: string,
|
||||
time: number,
|
||||
spawnStatus?: GraphNode['spawnStatus'],
|
||||
runtimeLabel?: string
|
||||
): void {
|
||||
const hasRuntimeLabel = Boolean(runtimeLabel?.trim());
|
||||
const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14);
|
||||
|
||||
// Spawning: bright animated double ring + radial glow
|
||||
if (spawnStatus === 'spawning') {
|
||||
const ringR = r + AGENT_DRAW.orbitParticleOffset;
|
||||
|
|
@ -370,7 +378,7 @@ function drawBreathing(
|
|||
ctx.font = '7px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2));
|
||||
ctx.fillText('connecting...', x, y + r + AGENT_DRAW.labelYOffset + 14);
|
||||
ctx.fillText('connecting...', x, serviceLabelY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +405,7 @@ function drawBreathing(
|
|||
ctx.font = '7px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5));
|
||||
ctx.fillText('waiting...', x, y + r + AGENT_DRAW.labelYOffset + 14);
|
||||
ctx.fillText('waiting...', x, serviceLabelY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -473,7 +481,7 @@ function drawAvatar(
|
|||
name: string,
|
||||
color: string,
|
||||
isLead: boolean,
|
||||
avatarUrl?: string,
|
||||
avatarUrl?: string
|
||||
): void {
|
||||
const avatarR = r * 0.6;
|
||||
|
||||
|
|
@ -509,6 +517,7 @@ function drawLabel(
|
|||
r: number,
|
||||
label: string,
|
||||
color: string,
|
||||
runtimeLabel?: string
|
||||
): void {
|
||||
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
||||
ctx.font = '9px monospace';
|
||||
|
|
@ -516,6 +525,26 @@ function drawLabel(
|
|||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x, labelY);
|
||||
|
||||
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
||||
if (!trimmedRuntimeLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.font = '8px monospace';
|
||||
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
|
||||
ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10);
|
||||
}
|
||||
|
||||
function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
|
||||
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
|
||||
if (ctx.measureText(label).width <= maxWidth) return label;
|
||||
|
||||
let out = label;
|
||||
while (out.length > 1 && ctx.measureText(`${out}…`).width > maxWidth) {
|
||||
out = out.slice(0, -1);
|
||||
}
|
||||
return `${out}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -527,7 +556,7 @@ export function drawContextRing(
|
|||
y: number,
|
||||
r: number,
|
||||
usage: number,
|
||||
time: number,
|
||||
time: number
|
||||
): void {
|
||||
const ringR = r + CONTEXT_RING.ringOffset;
|
||||
const startAngle = -Math.PI / 2;
|
||||
|
|
@ -576,7 +605,7 @@ function drawSelectionRing(
|
|||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
color: string,
|
||||
color: string
|
||||
): void {
|
||||
drawHexagon(ctx, x, y, r + 4);
|
||||
ctx.strokeStyle = hexWithAlpha(color, 0.67);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export interface GraphNode {
|
|||
// ─── Member/Lead-specific ──────────────────────────────────────────────
|
||||
/** Agent role description */
|
||||
role?: string;
|
||||
/** Compact provider/model/effort summary shown under the label */
|
||||
runtimeLabel?: string;
|
||||
/** Avatar image URL (e.g., robohash) */
|
||||
avatarUrl?: string;
|
||||
/** Spawn lifecycle status */
|
||||
|
|
|
|||
|
|
@ -523,6 +523,9 @@ importers:
|
|||
d3-force:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
lucide-react:
|
||||
specifier: '>=0.300.0'
|
||||
version: 0.577.0(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.0.0
|
||||
version: 18.3.1
|
||||
|
|
@ -15340,6 +15343,14 @@ snapshots:
|
|||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
|
|
@ -19287,6 +19298,10 @@ snapshots:
|
|||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
lucide-react@0.577.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
lucide-react@0.577.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
|
@ -22802,7 +22817,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1
resources/runtime/.gitkeep
Normal file
1
resources/runtime/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
28
runtime.lock.json
Normal file
28
runtime.lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
516
scripts/dev-with-runtime.mjs
Normal file
516
scripts/dev-with-runtime.mjs
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uiRepoRoot = path.resolve(scriptDir, '..');
|
||||
const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim() ?? '';
|
||||
const explicitRuntimeCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ?? '';
|
||||
const runtimeLockPath = path.join(uiRepoRoot, 'runtime.lock.json');
|
||||
const defaultRuntimeCacheRoot = path.join(os.homedir(), '.agent-teams', 'runtime-cache');
|
||||
const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
|
||||
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||
: defaultRuntimeCacheRoot;
|
||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||
|
||||
function runOrExit(cmd, args, options = {}) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run ${cmd}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runAndCapture(cmd, args, options = {}) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: 'utf8',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run ${cmd}: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const details = [result.stdout, result.stderr]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
throw new Error(`Command failed: ${cmd} ${args.join(' ')}${details ? `\n${details}` : ''}`);
|
||||
}
|
||||
|
||||
return result.stdout?.trim() ?? '';
|
||||
}
|
||||
|
||||
function readPackageManagerCommand(repoRoot) {
|
||||
const packageJsonPath = path.join(repoRoot, 'package.json');
|
||||
const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(rawPackageJson);
|
||||
const rawPackageManager = packageJson.packageManager;
|
||||
|
||||
if (typeof rawPackageManager !== 'string' || rawPackageManager.trim().length === 0) {
|
||||
return 'pnpm';
|
||||
}
|
||||
|
||||
const [packageManagerName] = rawPackageManager.trim().split('@', 1);
|
||||
if (!packageManagerName) {
|
||||
return 'pnpm';
|
||||
}
|
||||
|
||||
return packageManagerName;
|
||||
}
|
||||
|
||||
function readRuntimeLock() {
|
||||
return JSON.parse(fs.readFileSync(runtimeLockPath, 'utf8'));
|
||||
}
|
||||
|
||||
function getPlatformAssetKey() {
|
||||
const platformKey = `${process.platform}-${process.arch}`;
|
||||
|
||||
switch (platformKey) {
|
||||
case 'darwin-arm64':
|
||||
case 'darwin-x64':
|
||||
case 'linux-x64':
|
||||
case 'win32-x64':
|
||||
return platformKey;
|
||||
default:
|
||||
throw new Error(
|
||||
`Dev runtime bootstrap does not support this platform yet: ${process.platform}/${process.arch}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getReleaseAssetUrl(runtimeLock, asset) {
|
||||
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${runtimeLock.sourceRef}/${encodeURIComponent(asset.file)}`;
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function truncateMiddle(value, maxLength) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (maxLength <= 3) {
|
||||
return value.slice(0, maxLength);
|
||||
}
|
||||
|
||||
const visibleChars = maxLength - 3;
|
||||
const headLength = Math.ceil(visibleChars / 2);
|
||||
const tailLength = Math.floor(visibleChars / 2);
|
||||
return `${value.slice(0, headLength)}...${value.slice(value.length - tailLength)}`;
|
||||
}
|
||||
|
||||
function buildProgressBar(progressRatio, width) {
|
||||
const safeWidth = Math.max(10, width);
|
||||
const clampedRatio = Number.isFinite(progressRatio)
|
||||
? Math.min(1, Math.max(0, progressRatio))
|
||||
: 0;
|
||||
const filledWidth = Math.round(safeWidth * clampedRatio);
|
||||
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
|
||||
}
|
||||
|
||||
function supportsProgressRedraw() {
|
||||
return Boolean(process.stdout.isTTY && process.env.TERM && process.env.TERM !== 'dumb');
|
||||
}
|
||||
|
||||
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
|
||||
const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
|
||||
const ratio = hasTotal ? writtenBytes / totalBytes : 0;
|
||||
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
|
||||
const bytesText = hasTotal
|
||||
? `${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`
|
||||
: `${formatBytes(writtenBytes)}`;
|
||||
const barWidth = hasTotal ? Math.min(24, Math.max(10, Math.floor(columns * 0.18))) : 0;
|
||||
const barText = hasTotal ? ` [${buildProgressBar(ratio, barWidth)}]` : '';
|
||||
const fixedParts = `${barText} ${bytesText}${percentText}`.trimStart();
|
||||
const availableLabelWidth = Math.max(16, columns - fixedParts.length - 1);
|
||||
const labelText = truncateMiddle(label, availableLabelWidth);
|
||||
|
||||
return `${labelText}${fixedParts ? ` ${fixedParts}` : ''}`;
|
||||
}
|
||||
|
||||
function formatProgressSummary(writtenBytes, totalBytes, hasTotal) {
|
||||
if (hasTotal) {
|
||||
const ratio = writtenBytes / totalBytes;
|
||||
return `Runtime download ${Math.floor(ratio * 100)}% - ${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`;
|
||||
}
|
||||
|
||||
return `Runtime download - ${formatBytes(writtenBytes)}`;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function readBinaryVersion(binaryPath) {
|
||||
return runAndCapture(binaryPath, ['--version']);
|
||||
}
|
||||
|
||||
function isExecutable(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return fs.statSync(filePath).isFile();
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCachedBinaryValid(binaryPath, expectedVersion) {
|
||||
if (!isExecutable(binaryPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return readBinaryVersion(binaryPath).includes(expectedVersion);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWithProgress(url, destinationPath) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'user-agent': 'claude-team-dev-runtime-bootstrap',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download runtime asset: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const totalBytes = Number.parseInt(response.headers.get('content-length') ?? '', 10);
|
||||
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
|
||||
const writer = fs.createWriteStream(destinationPath);
|
||||
const reader = response.body.getReader();
|
||||
let writtenBytes = 0;
|
||||
let lastPrintedAt = 0;
|
||||
let lastLoggedPercent = -1;
|
||||
let lastLoggedBytes = 0;
|
||||
const label = `Downloading runtime ${path.basename(destinationPath)}`;
|
||||
const canRedraw = supportsProgressRedraw();
|
||||
|
||||
if (canRedraw) {
|
||||
process.stdout.write(formatProgressLine(label, 0, totalBytes, hasTotal));
|
||||
} else {
|
||||
process.stdout.write(`${label}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!writer.write(Buffer.from(value))) {
|
||||
await once(writer, 'drain');
|
||||
}
|
||||
writtenBytes += value.byteLength;
|
||||
|
||||
const now = Date.now();
|
||||
if (canRedraw && (now - lastPrintedAt >= 150 || writtenBytes === totalBytes)) {
|
||||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
process.stdout.write(formatProgressLine(label, writtenBytes, totalBytes, hasTotal));
|
||||
lastPrintedAt = now;
|
||||
} else if (!canRedraw) {
|
||||
const nextPercent = hasTotal ? Math.floor((writtenBytes / totalBytes) * 100) : null;
|
||||
const shouldLogPercent =
|
||||
nextPercent !== null && (nextPercent === 100 || nextPercent >= lastLoggedPercent + 5);
|
||||
const shouldLogBytes =
|
||||
nextPercent === null && writtenBytes >= lastLoggedBytes + 5 * 1024 * 1024;
|
||||
|
||||
if (shouldLogPercent || shouldLogBytes) {
|
||||
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
if (nextPercent !== null) {
|
||||
lastLoggedPercent = nextPercent;
|
||||
} else {
|
||||
lastLoggedBytes = writtenBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.end((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (canRedraw) {
|
||||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
} else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) {
|
||||
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractArchive(archivePath, extractDir, archiveKind) {
|
||||
ensureDir(extractDir);
|
||||
|
||||
if (archiveKind === 'tar.gz') {
|
||||
runOrExit('tar', ['-xzf', archivePath, '-C', extractDir]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (archiveKind === 'zip') {
|
||||
if (process.platform === 'win32') {
|
||||
runOrExit('powershell', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}' -Force`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
runOrExit('unzip', ['-oq', archivePath, '-d', extractDir]);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported runtime archive kind: ${archiveKind}`);
|
||||
}
|
||||
|
||||
function findExtractedBinary(extractDir, binaryName) {
|
||||
const directCandidate = path.join(extractDir, 'runtime', binaryName);
|
||||
if (fs.existsSync(directCandidate)) {
|
||||
return directCandidate;
|
||||
}
|
||||
|
||||
const fallbackCandidate = path.join(extractDir, binaryName);
|
||||
if (fs.existsSync(fallbackCandidate)) {
|
||||
return fallbackCandidate;
|
||||
}
|
||||
|
||||
throw new Error(`Extracted runtime archive does not contain ${binaryName}`);
|
||||
}
|
||||
|
||||
async function acquireBootstrapLock(lockPath) {
|
||||
const waitDeadline = Date.now() + 120_000;
|
||||
let announcedWait = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await fs.promises.open(lockPath, 'wx');
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!announcedWait) {
|
||||
process.stdout.write('Waiting for another runtime bootstrap to finish...\n');
|
||||
announcedWait = true;
|
||||
}
|
||||
|
||||
if (Date.now() >= waitDeadline) {
|
||||
throw new Error(`Timed out waiting for runtime bootstrap lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
await sleep(750);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBootstrappedRuntime() {
|
||||
const runtimeLock = readRuntimeLock();
|
||||
const platformKey = getPlatformAssetKey();
|
||||
const asset = runtimeLock.assets[platformKey];
|
||||
if (!asset) {
|
||||
throw new Error(`No runtime asset configured for ${platformKey}`);
|
||||
}
|
||||
|
||||
const cacheDir = path.join(runtimeCacheRoot, runtimeLock.version, platformKey);
|
||||
const cachedBinaryPath = path.join(cacheDir, asset.binaryName);
|
||||
|
||||
if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) {
|
||||
return {
|
||||
binaryPath: cachedBinaryPath,
|
||||
versionText: readBinaryVersion(cachedBinaryPath),
|
||||
sourceLabel: `cached release ${runtimeLock.sourceRef}`,
|
||||
cacheDir,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
ensureDir(cacheDir);
|
||||
const lockHandle = await acquireBootstrapLock(path.join(cacheDir, '.bootstrap.lock'));
|
||||
|
||||
try {
|
||||
if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) {
|
||||
return {
|
||||
binaryPath: cachedBinaryPath,
|
||||
versionText: readBinaryVersion(cachedBinaryPath),
|
||||
sourceLabel: `cached release ${runtimeLock.sourceRef}`,
|
||||
cacheDir,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
const workDir = path.join(cacheDir, `.bootstrap-${process.pid}-${Date.now()}`);
|
||||
ensureDir(workDir);
|
||||
|
||||
try {
|
||||
const archivePath = path.join(workDir, asset.file);
|
||||
await downloadWithProgress(getReleaseAssetUrl(runtimeLock, asset), archivePath);
|
||||
|
||||
const extractDir = path.join(workDir, 'extracted');
|
||||
extractArchive(archivePath, extractDir, asset.archiveKind);
|
||||
|
||||
const extractedBinaryPath = findExtractedBinary(extractDir, asset.binaryName);
|
||||
const nextBinaryPath = `${cachedBinaryPath}.tmp`;
|
||||
await fs.promises.copyFile(extractedBinaryPath, nextBinaryPath);
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(nextBinaryPath, 0o755);
|
||||
}
|
||||
|
||||
await fs.promises.rm(cachedBinaryPath, { force: true });
|
||||
await fs.promises.rename(nextBinaryPath, cachedBinaryPath);
|
||||
|
||||
const versionText = readBinaryVersion(cachedBinaryPath);
|
||||
if (!versionText.includes(runtimeLock.version)) {
|
||||
await fs.promises.rm(cachedBinaryPath, { force: true });
|
||||
throw new Error(
|
||||
`Bootstrapped runtime version mismatch. Expected ${runtimeLock.version}, got: ${versionText}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
binaryPath: cachedBinaryPath,
|
||||
versionText,
|
||||
sourceLabel: `downloaded release ${runtimeLock.sourceRef}`,
|
||||
cacheDir,
|
||||
downloaded: true,
|
||||
};
|
||||
} finally {
|
||||
await fs.promises.rm(nextBinaryPath, { force: true });
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
await lockHandle.close();
|
||||
await fs.promises.rm(path.join(cacheDir, '.bootstrap.lock'), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeRepoRoot(repoRoot) {
|
||||
const runtimePackageJsonPath = path.join(repoRoot, 'package.json');
|
||||
if (!fs.existsSync(runtimePackageJsonPath)) {
|
||||
console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${repoRoot}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRuntimeCli() {
|
||||
if (explicitRuntimeCliPath) {
|
||||
if (!isExecutable(explicitRuntimeCliPath)) {
|
||||
throw new Error(
|
||||
`CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH is not executable: ${explicitRuntimeCliPath}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
binaryPath: explicitRuntimeCliPath,
|
||||
versionText: readBinaryVersion(explicitRuntimeCliPath),
|
||||
sourceLabel: `explicit runtime override ${explicitRuntimeCliPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimeRepoRoot) {
|
||||
validateRuntimeRepoRoot(runtimeRepoRoot);
|
||||
const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot);
|
||||
|
||||
runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot });
|
||||
|
||||
const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev');
|
||||
return {
|
||||
binaryPath: runtimeCliPath,
|
||||
versionText: readBinaryVersion(runtimeCliPath),
|
||||
sourceLabel: `local runtime repo ${runtimeRepoRoot}`,
|
||||
};
|
||||
}
|
||||
|
||||
return ensureBootstrappedRuntime();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const resolvedRuntime = await resolveRuntimeCli();
|
||||
|
||||
if (shouldPrintRuntimePath) {
|
||||
process.stdout.write(`${resolvedRuntime.binaryPath}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`Using runtime from ${resolvedRuntime.sourceLabel}\n`);
|
||||
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
|
||||
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
|
||||
}
|
||||
process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`);
|
||||
|
||||
const uiEnv = {
|
||||
...process.env,
|
||||
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath,
|
||||
};
|
||||
delete uiEnv.CLAUDE_CLI_PATH;
|
||||
|
||||
runOrExit('pnpm', ['exec', 'electron-vite', 'dev'], {
|
||||
cwd: uiRepoRoot,
|
||||
env: uiEnv,
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
392
scripts/electron-builder/afterPack.cjs
Normal file
392
scripts/electron-builder/afterPack.cjs
Normal 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,
|
||||
};
|
||||
35
scripts/electron-builder/verifyBundle.cjs
Normal file
35
scripts/electron-builder/verifyBundle.cjs
Normal 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);
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
/**
|
||||
* Fetch latest model pricing from LiteLLM and save to renderer assets.
|
||||
* Filters to Claude models only to reduce bundle size.
|
||||
* Filters to the models this app currently exposes in the UI/runtime to reduce bundle size.
|
||||
* Runs automatically during prebuild.
|
||||
*/
|
||||
|
||||
|
|
@ -41,11 +41,28 @@ function isValidModelPricing(entry: unknown): entry is ModelPricing {
|
|||
);
|
||||
}
|
||||
|
||||
const EXPLICIT_MODEL_ALLOWLIST = new Set([
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex-mini',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
]);
|
||||
|
||||
function isClaudeModel(modelName: string): boolean {
|
||||
const lower = modelName.toLowerCase();
|
||||
return lower.includes('claude');
|
||||
}
|
||||
|
||||
function isIncludedModel(modelName: string): boolean {
|
||||
return isClaudeModel(modelName) || EXPLICIT_MODEL_ALLOWLIST.has(modelName);
|
||||
}
|
||||
|
||||
async function fetchPricingData(): Promise<Record<string, ModelPricing>> {
|
||||
console.log('Fetching pricing data from LiteLLM...');
|
||||
|
||||
|
|
@ -63,16 +80,22 @@ async function fetchPricingData(): Promise<Record<string, ModelPricing>> {
|
|||
const data = (await response.json()) as Record<string, unknown>;
|
||||
console.log(`Fetched pricing for ${Object.keys(data).length} models`);
|
||||
|
||||
// Filter to Claude models only and validate entries
|
||||
const claudeModels: Record<string, ModelPricing> = {};
|
||||
// Filter to the models currently exposed by this app and validate entries.
|
||||
const selectedModels: Record<string, ModelPricing> = {};
|
||||
for (const [modelName, entry] of Object.entries(data)) {
|
||||
if (isClaudeModel(modelName) && isValidModelPricing(entry)) {
|
||||
claudeModels[modelName] = entry;
|
||||
if (isIncludedModel(modelName) && isValidModelPricing(entry)) {
|
||||
selectedModels[modelName] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Filtered to ${Object.keys(claudeModels).length} Claude models`);
|
||||
return claudeModels;
|
||||
// LiteLLM currently publishes no priced top-level entry for gpt-5.3-codex-spark.
|
||||
// Keep cost estimation non-zero by aliasing it to the closest published Codex tier.
|
||||
if (!selectedModels['gpt-5.3-codex-spark'] && selectedModels['gpt-5.3-codex']) {
|
||||
selectedModels['gpt-5.3-codex-spark'] = selectedModels['gpt-5.3-codex'];
|
||||
}
|
||||
|
||||
console.log(`Filtered to ${Object.keys(selectedModels).length} supported models`);
|
||||
return selectedModels;
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
|
|
|
|||
61
scripts/runtime-lock.mjs
Normal file
61
scripts/runtime-lock.mjs
Normal 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>'
|
||||
);
|
||||
}
|
||||
|
|
@ -89,6 +89,16 @@ function assertOptionalEffort(value: unknown): EffortLevel | undefined {
|
|||
|
||||
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
|
||||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const providerId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: payload.providerId == null || payload.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: (() => {
|
||||
throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini');
|
||||
})();
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort);
|
||||
|
|
@ -100,6 +110,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
return {
|
||||
teamName,
|
||||
cwd: assertAbsoluteCwd(payload.cwd),
|
||||
providerId,
|
||||
...(prompt && {
|
||||
prompt,
|
||||
}),
|
||||
|
|
@ -164,7 +175,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
|
||||
const teamProvisioningService = getTeamProvisioningService(services);
|
||||
teamProvisioningService.stopTeam(validatedTeamName.value!);
|
||||
return reply.send(teamProvisioningService.getRuntimeState(validatedTeamName.value!));
|
||||
return reply.send(await teamProvisioningService.getRuntimeState(validatedTeamName.value!));
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error(
|
||||
|
|
@ -187,7 +198,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
}
|
||||
|
||||
return reply.send(
|
||||
getTeamProvisioningService(services).getRuntimeState(validatedTeamName.value!)
|
||||
await getTeamProvisioningService(services).getRuntimeState(validatedTeamName.value!)
|
||||
);
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
|
|
@ -225,9 +236,11 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
app.get('/api/teams/runtime/alive', async (_request, reply) => {
|
||||
try {
|
||||
const teamProvisioningService = getTeamProvisioningService(services);
|
||||
const runtimeStates = teamProvisioningService
|
||||
.getAliveTeams()
|
||||
.map((teamName) => teamProvisioningService.getRuntimeState(teamName));
|
||||
const runtimeStates = await Promise.all(
|
||||
teamProvisioningService
|
||||
.getAliveTeams()
|
||||
.map((teamName) => teamProvisioningService.getRuntimeState(teamName))
|
||||
);
|
||||
return reply.send(runtimeStates);
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ import {
|
|||
getTrafficLightPositionForZoom,
|
||||
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
||||
} from '@shared/constants';
|
||||
import { isInboxNoiseMessage, parseInboxJson } from '@shared/utils/inboxNoise';
|
||||
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
|
|
@ -62,6 +63,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
|||
import { setReviewMainWindow } from './ipc/review';
|
||||
import {
|
||||
ApiKeyService,
|
||||
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||
ExtensionFacadeService,
|
||||
GlamaMcpEnrichmentService,
|
||||
McpCatalogAggregator,
|
||||
|
|
@ -84,6 +86,11 @@ import {
|
|||
writeTeamControlApiState,
|
||||
} from './services/team/TeamControlApiState';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
||||
import {
|
||||
createTeamReconcileDrainScheduler,
|
||||
type TeamReconcileTrigger,
|
||||
} from './services/team/TeamReconcileDrainScheduler';
|
||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||
import { getAppIconPath } from './utils/appIcon';
|
||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||
|
|
@ -272,7 +279,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||
if (isInboxNoiseMessage(msg.text)) continue;
|
||||
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||
|
||||
const fromLabel = msg.from || 'Unknown';
|
||||
const extracted = extractNotificationContent(msg.text);
|
||||
|
|
@ -343,7 +350,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise
|
||||
if (isInboxNoiseMessage(msg.text)) continue;
|
||||
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||
|
||||
const fromLabel = msg.from || 'team-lead';
|
||||
const extracted = extractNotificationContent(msg.text);
|
||||
|
|
@ -507,6 +514,27 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
context.fileWatcher.on('todo-change', todoChangeHandler);
|
||||
todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler);
|
||||
|
||||
const reconcileScheduler = teamDataService
|
||||
? createTeamReconcileDrainScheduler({
|
||||
run: async (teamName: string, trigger: TeamReconcileTrigger) => {
|
||||
try {
|
||||
await teamDataService.reconcileTeamArtifacts(teamName, trigger);
|
||||
} catch (e) {
|
||||
if (trigger.source === 'task') {
|
||||
logger.warn(
|
||||
`[FileWatcher] task reconcile failed for ${teamName} detail=${trigger.detail}: ${String(e)}`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[FileWatcher] reconcile failed for ${teamName} source=${trigger.source} detail=${trigger.detail}: ${String(e)}`
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// Forward team-change events to renderer and HTTP SSE
|
||||
const teamChangeHandler = (event: unknown): void => {
|
||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||
|
|
@ -522,12 +550,8 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
if (row.type === 'inbox') {
|
||||
if (teamDataService) {
|
||||
void teamDataService
|
||||
.reconcileTeamArtifacts(teamName)
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
|
||||
);
|
||||
if (reconcileScheduler) {
|
||||
reconcileScheduler.schedule(teamName, { source: 'inbox', detail });
|
||||
}
|
||||
|
||||
// Relay inbox changes into active runtime recipients.
|
||||
|
|
@ -536,9 +560,6 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
if (match && teamDataService) {
|
||||
const inboxName = match[1];
|
||||
|
||||
// Mark member as online when their first inbox message arrives (spawn tracking).
|
||||
teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName);
|
||||
|
||||
void teamDataService
|
||||
.getLeadMemberName(teamName)
|
||||
.then((leadName) => {
|
||||
|
|
@ -588,11 +609,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
|
||||
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
||||
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
||||
void teamDataService
|
||||
.reconcileTeamArtifacts(teamName)
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
|
||||
);
|
||||
reconcileScheduler?.schedule(teamName, { source: 'task', detail });
|
||||
|
||||
const taskId = detail.replace('.json', '');
|
||||
void teamDataService
|
||||
|
|
@ -625,7 +642,10 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
}
|
||||
};
|
||||
context.fileWatcher.on('team-change', teamChangeHandler);
|
||||
teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler);
|
||||
teamChangeCleanup = () => {
|
||||
context.fileWatcher.off('team-change', teamChangeHandler);
|
||||
reconcileScheduler?.dispose();
|
||||
};
|
||||
|
||||
logger.info(`FileWatcher events wired for context: ${context.id}`);
|
||||
}
|
||||
|
|
@ -716,7 +736,7 @@ function reconfigureLocalContextForClaudeRoot(): void {
|
|||
/**
|
||||
* Initializes all services.
|
||||
*/
|
||||
function initializeServices(): void {
|
||||
async function initializeServices(): Promise<void> {
|
||||
logger.info('Initializing services...');
|
||||
|
||||
// Initialize SSH connection manager
|
||||
|
|
@ -758,7 +778,12 @@ function initializeServices(): void {
|
|||
updaterService = new UpdaterService();
|
||||
cliInstallerService = new CliInstallerService();
|
||||
ptyTerminalService = new PtyTerminalService();
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
||||
teamMemberLogsFinder
|
||||
);
|
||||
teamDataService = new TeamDataService();
|
||||
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
|
||||
teamProvisioningService = new TeamProvisioningService();
|
||||
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
|
||||
void new TeamMcpConfigBuilder().gcStaleConfigs();
|
||||
|
|
@ -788,7 +813,6 @@ function initializeServices(): void {
|
|||
);
|
||||
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
|
||||
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
|
|
@ -839,6 +863,7 @@ function initializeServices(): void {
|
|||
const pluginInstallService = new PluginInstallService(pluginCatalogService);
|
||||
const mcpInstallService = new McpInstallService(mcpAggregator);
|
||||
const apiKeyService = new ApiKeyService();
|
||||
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||
// warmup() and ensureInstalled() are deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
httpServer = new HttpServer();
|
||||
|
|
@ -1392,7 +1417,7 @@ function createWindow(): void {
|
|||
/**
|
||||
* Application ready handler.
|
||||
*/
|
||||
void app.whenReady().then(() => {
|
||||
void app.whenReady().then(async () => {
|
||||
logger.info('App ready, initializing...');
|
||||
|
||||
// Pre-warm interactive shell env cache (non-blocking).
|
||||
|
|
@ -1403,7 +1428,7 @@ void app.whenReady().then(() => {
|
|||
|
||||
try {
|
||||
// Initialize services first
|
||||
initializeServices();
|
||||
await initializeServices();
|
||||
|
||||
// Apply configuration settings
|
||||
const config = configManager.getConfig();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import {
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
CLI_INSTALLER_INSTALL,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||
|
|
@ -17,13 +18,20 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { CliInstallerService } from '../services';
|
||||
import type { CliInstallationStatus, IpcResult } from '@shared/types';
|
||||
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliProviderId,
|
||||
CliProviderStatus,
|
||||
IpcResult,
|
||||
} from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:cliInstaller');
|
||||
|
||||
let service: CliInstallerService;
|
||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||
const STATUS_CACHE_TTL_MS = 5_000;
|
||||
|
||||
|
|
@ -39,6 +47,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer
|
|||
*/
|
||||
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
|
||||
ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus);
|
||||
ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall);
|
||||
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
|
||||
|
||||
|
|
@ -50,6 +59,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
|||
*/
|
||||
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
|
||||
|
||||
|
|
@ -98,6 +108,58 @@ async function handleGetStatus(
|
|||
}
|
||||
}
|
||||
|
||||
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
|
||||
if (!cachedStatus || !providerStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProviders = cachedStatus.value.providers.map((provider) =>
|
||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
);
|
||||
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||
|
||||
cachedStatus = {
|
||||
value: {
|
||||
...cachedStatus.value,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
},
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetProviderStatus(
|
||||
_event: IpcMainInvokeEvent,
|
||||
providerId: CliProviderId
|
||||
): Promise<IpcResult<CliProviderStatus | null>> {
|
||||
try {
|
||||
const inFlight = providerStatusInFlight.get(providerId);
|
||||
if (inFlight) {
|
||||
const status = await inFlight;
|
||||
return { success: true, data: status };
|
||||
}
|
||||
|
||||
const request = service
|
||||
.getProviderStatus(providerId)
|
||||
.then((status) => {
|
||||
patchCachedProviderStatus(status);
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
providerStatusInFlight.delete(providerId);
|
||||
});
|
||||
|
||||
providerStatusInFlight.set(providerId, request);
|
||||
const status = await request;
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logger.error(`Error in cliInstaller:getProviderStatus(${providerId}):`, msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> {
|
||||
try {
|
||||
await service.install();
|
||||
|
|
@ -111,5 +173,7 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
|
|||
|
||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||
cachedStatus = null;
|
||||
providerStatusInFlight.clear();
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
HttpServerConfig,
|
||||
NotificationConfig,
|
||||
NotificationTrigger,
|
||||
ProviderConnectionsConfig,
|
||||
RuntimeConfig,
|
||||
SshPersistConfig,
|
||||
} from '../services';
|
||||
|
||||
|
|
@ -31,6 +33,8 @@ interface ValidationFailure {
|
|||
export type ConfigUpdateValidationResult =
|
||||
| ValidationSuccess<'notifications'>
|
||||
| ValidationSuccess<'general'>
|
||||
| ValidationSuccess<'providerConnections'>
|
||||
| ValidationSuccess<'runtime'>
|
||||
| ValidationSuccess<'display'>
|
||||
| ValidationSuccess<'httpServer'>
|
||||
| ValidationSuccess<'ssh'>
|
||||
|
|
@ -39,6 +43,8 @@ export type ConfigUpdateValidationResult =
|
|||
const VALID_SECTIONS = new Set<ConfigSection>([
|
||||
'notifications',
|
||||
'general',
|
||||
'providerConnections',
|
||||
'runtime',
|
||||
'display',
|
||||
'httpServer',
|
||||
'ssh',
|
||||
|
|
@ -286,6 +292,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
|||
'showDockIcon',
|
||||
'theme',
|
||||
'defaultTab',
|
||||
'multimodelEnabled',
|
||||
'claudeRootPath',
|
||||
'agentLanguage',
|
||||
'autoExpandAIGroups',
|
||||
|
|
@ -328,6 +335,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
|||
}
|
||||
result.defaultTab = value;
|
||||
break;
|
||||
case 'multimodelEnabled':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: 'general.multimodelEnabled must be a boolean' };
|
||||
}
|
||||
result.multimodelEnabled = value;
|
||||
break;
|
||||
case 'claudeRootPath':
|
||||
if (value === null) {
|
||||
result.claudeRootPath = null;
|
||||
|
|
@ -391,6 +404,148 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
|||
};
|
||||
}
|
||||
|
||||
function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | ValidationFailure {
|
||||
if (!isPlainObject(data)) {
|
||||
return { valid: false, error: 'runtime update must be an object' };
|
||||
}
|
||||
|
||||
const result: Partial<RuntimeConfig> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key !== 'providerBackends') {
|
||||
return { valid: false, error: `runtime.${key} is not a valid setting` };
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return { valid: false, error: 'runtime.providerBackends must be an object' };
|
||||
}
|
||||
|
||||
const providerBackends: Partial<RuntimeConfig['providerBackends']> = {};
|
||||
|
||||
for (const [providerId, backendId] of Object.entries(value)) {
|
||||
if (providerId === 'gemini') {
|
||||
if (backendId !== 'auto' && backendId !== 'api' && backendId !== 'cli-sdk') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'runtime.providerBackends.gemini must be one of: auto, api, cli-sdk',
|
||||
};
|
||||
}
|
||||
providerBackends.gemini = backendId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
if (backendId !== 'auto' && backendId !== 'adapter') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'runtime.providerBackends.codex must be one of: auto, adapter',
|
||||
};
|
||||
}
|
||||
providerBackends.codex = backendId;
|
||||
continue;
|
||||
}
|
||||
|
||||
return { valid: false, error: `runtime.providerBackends.${providerId} is not supported` };
|
||||
}
|
||||
|
||||
result.providerBackends = providerBackends as RuntimeConfig['providerBackends'];
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
section: 'runtime',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
function validateProviderConnectionsSection(
|
||||
data: unknown
|
||||
): ValidationSuccess<'providerConnections'> | ValidationFailure {
|
||||
if (!isPlainObject(data)) {
|
||||
return { valid: false, error: 'providerConnections update must be an object' };
|
||||
}
|
||||
|
||||
const result: Partial<ProviderConnectionsConfig> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key !== 'anthropic' && key !== 'codex') {
|
||||
return { valid: false, error: `providerConnections.${key} is not a valid setting` };
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return { valid: false, error: `providerConnections.${key} must be an object` };
|
||||
}
|
||||
|
||||
if (key === 'anthropic') {
|
||||
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
|
||||
|
||||
for (const [connectionKey, connectionValue] of Object.entries(value)) {
|
||||
if (connectionKey !== 'authMode') {
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
connectionValue !== 'auto' &&
|
||||
connectionValue !== 'oauth' &&
|
||||
connectionValue !== 'api_key'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key',
|
||||
};
|
||||
}
|
||||
|
||||
anthropicUpdate.authMode = connectionValue;
|
||||
}
|
||||
|
||||
result.anthropic = anthropicUpdate as ProviderConnectionsConfig['anthropic'];
|
||||
continue;
|
||||
}
|
||||
|
||||
const codexUpdate: Partial<ProviderConnectionsConfig['codex']> = {};
|
||||
|
||||
for (const [connectionKey, connectionValue] of Object.entries(value)) {
|
||||
if (connectionKey === 'apiKeyBetaEnabled') {
|
||||
if (typeof connectionValue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.apiKeyBetaEnabled must be a boolean',
|
||||
};
|
||||
}
|
||||
codexUpdate.apiKeyBetaEnabled = connectionValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (connectionKey === 'authMode') {
|
||||
if (connectionValue !== 'oauth' && connectionValue !== 'api_key') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.authMode must be one of: oauth, api_key',
|
||||
};
|
||||
}
|
||||
codexUpdate.authMode = connectionValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.codex.${connectionKey} is not a valid setting`,
|
||||
};
|
||||
}
|
||||
|
||||
result.codex = codexUpdate as ProviderConnectionsConfig['codex'];
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
section: 'providerConnections',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure {
|
||||
if (!isPlainObject(data)) {
|
||||
return { valid: false, error: 'display update must be an object' };
|
||||
|
|
@ -537,7 +692,8 @@ export function validateConfigUpdatePayload(
|
|||
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Section must be one of: notifications, general, display, httpServer, ssh',
|
||||
error:
|
||||
'Section must be one of: notifications, general, providerConnections, runtime, display, httpServer, ssh',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -546,6 +702,10 @@ export function validateConfigUpdatePayload(
|
|||
return validateNotificationsSection(data);
|
||||
case 'general':
|
||||
return validateGeneralSection(data);
|
||||
case 'providerConnections':
|
||||
return validateProviderConnectionsSection(data);
|
||||
case 'runtime':
|
||||
return validateRuntimeSection(data);
|
||||
case 'display':
|
||||
return validateDisplaySection(data);
|
||||
case 'httpServer':
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ import { createLogger } from '@shared/utils/logger';
|
|||
|
||||
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
||||
|
||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||
import {
|
||||
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||
type ApiKeyService,
|
||||
} from '../services/extensions/apikeys/ApiKeyService';
|
||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
|
|
@ -388,7 +391,12 @@ async function handleApiKeysSave(
|
|||
): Promise<IpcResult<ApiKeyEntry>> {
|
||||
return wrapHandler('apiKeysSave', () => {
|
||||
if (!request) throw new Error('Request is required');
|
||||
return getApiKeyService().save(request);
|
||||
return getApiKeyService()
|
||||
.save(request)
|
||||
.then(async (entry) => {
|
||||
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||
return entry;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -398,7 +406,11 @@ async function handleApiKeysDelete(
|
|||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('apiKeysDelete', () => {
|
||||
if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
|
||||
return getApiKeyService().delete(id);
|
||||
return getApiKeyService()
|
||||
.delete(id)
|
||||
.then(async () => {
|
||||
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import {
|
|||
registerTerminalHandlers,
|
||||
removeTerminalHandlers,
|
||||
} from './terminal';
|
||||
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
|
||||
import {
|
||||
initializeUpdaterHandlers,
|
||||
registerUpdaterHandlers,
|
||||
|
|
@ -241,6 +242,7 @@ export function initializeIpcHandlers(
|
|||
if (ptyTerminal) {
|
||||
registerTerminalHandlers(ipcMain);
|
||||
}
|
||||
registerTmuxHandlers(ipcMain);
|
||||
if (httpServerDeps) {
|
||||
registerHttpServerHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -279,6 +281,7 @@ export function removeIpcHandlers(): void {
|
|||
removeScheduleHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeTerminalHandlers(ipcMain);
|
||||
removeTmuxHandlers(ipcMain);
|
||||
removeHttpServerHandlers(ipcMain);
|
||||
removeExtensionHandlers(ipcMain);
|
||||
removeSkillsHandlers(ipcMain);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ import {
|
|||
TEAM_VALIDATE_CLI_ARGS,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks';
|
||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||
|
|
@ -103,6 +103,10 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
|||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
import {
|
||||
buildReplaceMembersDiff,
|
||||
buildReplaceMembersSummaryMessage,
|
||||
} from '../services/team/memberUpdateNotifications';
|
||||
|
||||
import {
|
||||
validateFromField,
|
||||
|
|
@ -177,6 +181,98 @@ const logger = createLogger('IPC:teams');
|
|||
const seenRateLimitKeys = new Set<string>();
|
||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
async function getDurableLeadTeammateRoster(
|
||||
teamName: string,
|
||||
leadName: string
|
||||
): Promise<Array<{ name: string; role?: string }>> {
|
||||
const normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? '';
|
||||
const leadLower = normalize(leadName);
|
||||
const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0));
|
||||
|
||||
try {
|
||||
const members = await new TeamMembersMetaStore().getMembers(teamName);
|
||||
const teammates = members
|
||||
.filter((member) => !member.removedAt)
|
||||
.filter((member) => {
|
||||
const lower = normalize(member.name);
|
||||
return lower.length > 0 && !reserved.has(lower);
|
||||
})
|
||||
.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
role:
|
||||
typeof member.role === 'string' && member.role.trim().length > 0
|
||||
? member.role.trim()
|
||||
: undefined,
|
||||
}));
|
||||
if (teammates.length > 0) return teammates;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[teams:sendMessage] Failed to read members.meta roster for "${teamName}": ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getTeamDataService().getTeamData(teamName);
|
||||
return data.members
|
||||
.filter((member) => !member.removedAt)
|
||||
.filter((member) => {
|
||||
const lower = normalize(member.name);
|
||||
return lower.length > 0 && !reserved.has(lower);
|
||||
})
|
||||
.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
role:
|
||||
typeof member.role === 'string' && member.role.trim().length > 0
|
||||
? member.role.trim()
|
||||
: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[teams:sendMessage] Failed to read fallback team roster for "${teamName}": ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildLeadRosterContextBlock(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
teammates: Array<{ name: string; role?: string }>
|
||||
): string | null {
|
||||
if (teammates.length === 0) return null;
|
||||
|
||||
const summary = teammates
|
||||
.map((member) => (member.role ? `${member.name} (${member.role})` : member.name))
|
||||
.join(', ');
|
||||
|
||||
return [
|
||||
`Current durable team context:`,
|
||||
`- Team name: ${teamName}`,
|
||||
`- You are the live team lead "${leadName}"`,
|
||||
`- Persistent teammates currently configured: ${summary}`,
|
||||
`- This team is NOT in solo mode`,
|
||||
`- If the user asks who is on the team, answer from this durable roster unless newer durable state explicitly says otherwise.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string | null {
|
||||
if (actionMode !== 'delegate') return null;
|
||||
|
||||
return wrapAgentBlock(
|
||||
[
|
||||
'DELEGATE MODE USER ACK CONTRACT:',
|
||||
'Before any task creation, delegation, or other tool use, begin your next assistant response with one short human-readable acknowledgement to the user.',
|
||||
'That acknowledgement must be visible plain text, not only an agent-only block.',
|
||||
'Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.',
|
||||
'After that visible acknowledgement, continue with delegation/orchestration in the same turn.',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory set of API error message keys already processed.
|
||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
||||
|
|
@ -539,6 +635,7 @@ async function handleGetData(
|
|||
const tn = validated.value!;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamData;
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
// Prefer worker thread to keep main event loop responsive
|
||||
const worker = getTeamDataWorkerClient();
|
||||
|
|
@ -571,6 +668,8 @@ async function handleGetData(
|
|||
}
|
||||
logger.error(`[teams:getData] ${message}`);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
setCurrentMainOp(null);
|
||||
}
|
||||
const getDataMs = Date.now() - startedAt;
|
||||
|
||||
|
|
@ -858,6 +957,32 @@ function isValidEffort(value: unknown): value is EffortLevel {
|
|||
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
|
||||
}
|
||||
|
||||
function parseOptionalMemberProviderId(
|
||||
value: unknown
|
||||
):
|
||||
| { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined }
|
||||
| { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (value === 'anthropic' || value === 'codex' || value === 'gemini') {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' };
|
||||
}
|
||||
|
||||
function parseOptionalMemberEffort(
|
||||
value: unknown
|
||||
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (isValidEffort(value)) {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return { valid: false, error: 'member effort must be low, medium, or high' };
|
||||
}
|
||||
|
||||
async function validateProvisioningRequest(
|
||||
request: unknown
|
||||
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
|
||||
|
|
@ -909,10 +1034,22 @@ async function validateProvisioningRequest(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { valid: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(member as { providerId?: unknown }).providerId
|
||||
);
|
||||
if (!providerValidation.valid) {
|
||||
return { valid: false, error: providerValidation.error };
|
||||
}
|
||||
const model = (member as { model?: unknown }).model;
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { valid: false, error: 'member model must be string' };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -978,6 +1115,12 @@ async function validateProvisioningRequest(
|
|||
members,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
skipPermissions:
|
||||
|
|
@ -1114,6 +1257,16 @@ async function handleLaunchTeam(
|
|||
color: meta?.color,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: meta?.providerId === 'codex'
|
||||
? 'codex'
|
||||
: meta?.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||
|
|
@ -1125,7 +1278,14 @@ async function handleLaunchTeam(
|
|||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow })),
|
||||
members: members.map((m) => ({
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
})),
|
||||
};
|
||||
|
||||
return wrapTeamHandler('create', () =>
|
||||
|
|
@ -1147,6 +1307,12 @@ async function handleLaunchTeam(
|
|||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
|
|
@ -1199,9 +1365,13 @@ async function handleValidateCliArgs(
|
|||
|
||||
async function handlePrepareProvisioning(
|
||||
_event: IpcMainInvokeEvent,
|
||||
cwd: unknown
|
||||
cwd: unknown,
|
||||
providerId: unknown,
|
||||
providerIds: unknown
|
||||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||
let validatedCwd: string | undefined;
|
||||
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||||
let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined;
|
||||
if (cwd !== undefined) {
|
||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||
return { success: false, error: 'cwd must be a non-empty string' };
|
||||
|
|
@ -1211,8 +1381,32 @@ async function handlePrepareProvisioning(
|
|||
return { success: false, error: 'cwd must be an absolute path' };
|
||||
}
|
||||
}
|
||||
if (providerId !== undefined) {
|
||||
if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') {
|
||||
return { success: false, error: 'providerId must be anthropic, codex, or gemini' };
|
||||
}
|
||||
validatedProviderId = providerId;
|
||||
}
|
||||
if (providerIds !== undefined) {
|
||||
if (!Array.isArray(providerIds)) {
|
||||
return { success: false, error: 'providerIds must be an array when provided' };
|
||||
}
|
||||
const normalized: Array<'anthropic' | 'codex' | 'gemini'> = [];
|
||||
for (const entry of providerIds) {
|
||||
if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') {
|
||||
return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' };
|
||||
}
|
||||
if (!normalized.includes(entry)) {
|
||||
normalized.push(entry);
|
||||
}
|
||||
}
|
||||
validatedProviderIds = normalized;
|
||||
}
|
||||
return wrapTeamHandler('prepareProvisioning', () =>
|
||||
getTeamProvisioningService().prepareForProvisioning(validatedCwd)
|
||||
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
|
||||
providerId: validatedProviderId,
|
||||
providerIds: validatedProviderIds,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1375,6 +1569,7 @@ function buildMessageDeliveryText(
|
|||
opts: {
|
||||
actionMode?: AgentActionMode;
|
||||
isLeadRecipient: boolean;
|
||||
replyRecipient?: string;
|
||||
}
|
||||
): string {
|
||||
const hiddenBlocks: string[] = [];
|
||||
|
|
@ -1383,12 +1578,29 @@ function buildMessageDeliveryText(
|
|||
hiddenBlocks.push(actionModeBlock);
|
||||
}
|
||||
if (!opts.isLeadRecipient) {
|
||||
const replyRecipient =
|
||||
typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0
|
||||
? opts.replyRecipient.trim()
|
||||
: 'user';
|
||||
const senderDescriptor = replyRecipient === 'user' ? 'the human user' : `"${replyRecipient}"`;
|
||||
hiddenBlocks.push(
|
||||
[
|
||||
AGENT_BLOCK_OPEN,
|
||||
'You received a direct message from the human user via the UI.',
|
||||
'Please reply back to recipient "user" with a short, human-readable answer.',
|
||||
`You received a direct message from ${senderDescriptor} via the UI.`,
|
||||
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
|
||||
`CRITICAL: The destination must be exactly to="${replyRecipient}".`,
|
||||
'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.',
|
||||
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
|
||||
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
|
||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||
...(replyRecipient === 'user'
|
||||
? [
|
||||
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
|
||||
'Only after that first acknowledgement may you message the lead or another teammate.',
|
||||
'After you get the needed information, send the final answer back to "user".',
|
||||
'Do NOT stay silent while you go ask someone else.',
|
||||
]
|
||||
: []),
|
||||
AGENT_BLOCK_CLOSE,
|
||||
].join('\n')
|
||||
);
|
||||
|
|
@ -1522,6 +1734,9 @@ async function handleSendMessage(
|
|||
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
const resolvedLeadName = leadName ?? memberName;
|
||||
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
|
||||
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
|
||||
const delegateAckBlock = buildLeadDirectDelegateAckBlock(actionMode);
|
||||
// Pre-generate stable messageId so both stdin and persistence use the same identity.
|
||||
// This allows the lead to call task_create_from_message with the exact messageId.
|
||||
const preGeneratedMessageId = crypto.randomUUID();
|
||||
|
|
@ -1539,6 +1754,8 @@ async function handleSendMessage(
|
|||
: [
|
||||
`You received a direct message from the user.`,
|
||||
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
|
||||
...(rosterContextBlock ? [rosterContextBlock] : []),
|
||||
...(delegateAckBlock ? [delegateAckBlock] : []),
|
||||
AGENT_BLOCK_OPEN,
|
||||
`MessageId: ${preGeneratedMessageId}`,
|
||||
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
|
||||
|
|
@ -1646,6 +1863,7 @@ async function handleSendMessage(
|
|||
const memberDeliveryText = buildMessageDeliveryText(baseText, {
|
||||
actionMode,
|
||||
isLeadRecipient,
|
||||
replyRecipient: typeof payload.from === 'string' ? payload.from : 'user',
|
||||
});
|
||||
const result = await getTeamDataService().sendMessage(tn, {
|
||||
member: memberName,
|
||||
|
|
@ -2105,10 +2323,27 @@ async function handleCreateConfig(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(member as { providerId?: unknown }).providerId
|
||||
);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const model = (member as { model?: unknown }).model;
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2348,10 +2583,13 @@ async function handleAddMember(
|
|||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: 'Invalid payload' };
|
||||
}
|
||||
const { name, role, workflow } = payload as {
|
||||
const { name, role, workflow, providerId, model } = payload as {
|
||||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
};
|
||||
const vName = validateTeammateName(name);
|
||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||
|
|
@ -2361,6 +2599,17 @@ async function handleAddMember(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'workflow must be a string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addMember', async () => {
|
||||
const tn = vTeam.value!;
|
||||
|
|
@ -2369,6 +2618,9 @@ async function handleAddMember(
|
|||
name: memberName,
|
||||
role: role,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
|
||||
// If team is alive, notify the lead to spawn the new teammate
|
||||
|
|
@ -2391,6 +2643,9 @@ async function handleAddMember(
|
|||
name: memberName,
|
||||
...(typeof role === 'string' ? { role } : {}),
|
||||
...(typeof workflow === 'string' ? { workflow } : {}),
|
||||
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
|
||||
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
|
||||
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||||
});
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
|
|
@ -2417,12 +2672,26 @@ async function handleReplaceMembers(
|
|||
return { success: false, error: 'members must be an array' };
|
||||
}
|
||||
const seenNames = new Set<string>();
|
||||
const members: { name: string; role?: string; workflow?: string }[] = [];
|
||||
const members: {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
}[] = [];
|
||||
for (const item of payload.members) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return { success: false, error: 'member must be object' };
|
||||
}
|
||||
const m = item as { name?: unknown; role?: unknown; workflow?: unknown };
|
||||
const m = item as {
|
||||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
};
|
||||
const vName = validateTeammateName(m.name);
|
||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||
const name = vName.value!;
|
||||
|
|
@ -2434,15 +2703,73 @@ async function handleReplaceMembers(
|
|||
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(m as { providerId?: unknown }).providerId
|
||||
);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
if (m.model !== undefined && typeof m.model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name,
|
||||
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
||||
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
return wrapTeamHandler('replaceMembers', async () => {
|
||||
await getTeamDataService().replaceMembers(vTeam.value!, { members });
|
||||
const tn = vTeam.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembers = (await teamDataService.getTeamData(tn)).members;
|
||||
const diff = buildReplaceMembersDiff(previousMembers, members);
|
||||
|
||||
await teamDataService.replaceMembers(tn, { members });
|
||||
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (!provisioning.isTeamAlive(tn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let leadName = 'team-lead';
|
||||
let displayName = tn;
|
||||
try {
|
||||
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
|
||||
teamDataService.getLeadMemberName(tn),
|
||||
teamDataService.getTeamDisplayName(tn),
|
||||
]);
|
||||
leadName = resolvedLeadName || 'team-lead';
|
||||
displayName = resolvedDisplayName || tn;
|
||||
} catch {
|
||||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
|
||||
for (const addedMember of diff.added) {
|
||||
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember);
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
} catch {
|
||||
logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`);
|
||||
}
|
||||
}
|
||||
|
||||
const summaryMessage = buildReplaceMembersSummaryMessage(diff);
|
||||
if (!summaryMessage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, summaryMessage);
|
||||
} catch {
|
||||
logger.warn(`Failed to notify lead about member updates in ${tn}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3150,6 +3477,7 @@ async function handleGetSavedRequest(
|
|||
color: meta.color,
|
||||
cwd: meta.cwd,
|
||||
prompt: meta.prompt,
|
||||
providerId: meta.providerId ?? 'anthropic',
|
||||
model: meta.model,
|
||||
effort: meta.effort as TeamCreateRequest['effort'],
|
||||
skipPermissions: meta.skipPermissions,
|
||||
|
|
@ -3160,6 +3488,9 @@ async function handleGetSavedRequest(
|
|||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
138
src/main/ipc/tmux.ts
Normal file
138
src/main/ipc/tmux.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { TMUX_GET_STATUS } from '@preload/constants/ipcChannels';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
import type { TmuxPlatform, TmuxStatus, IpcResult } from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:tmux');
|
||||
|
||||
let cachedStatus: { value: TmuxStatus; at: number } | null = null;
|
||||
let statusInFlight: Promise<TmuxStatus> | null = null;
|
||||
const STATUS_CACHE_TTL_MS = 10_000;
|
||||
|
||||
function mapPlatform(platform: NodeJS.Platform): TmuxPlatform {
|
||||
if (platform === 'darwin' || platform === 'linux' || platform === 'win32') {
|
||||
return platform;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function execFileAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeout: number
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { timeout }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBinaryPath(platform: TmuxPlatform): Promise<string | null> {
|
||||
const locator = platform === 'win32' ? 'where' : 'which';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(locator, ['tmux'], 2_000);
|
||||
const firstLine = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
return firstLine ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeTmuxStatus(): Promise<TmuxStatus> {
|
||||
const platform = mapPlatform(process.platform);
|
||||
const nativeSupported = platform === 'darwin' || platform === 'linux';
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('tmux', ['-V'], 3_000);
|
||||
const version = (stdout || stderr).trim() || null;
|
||||
const binaryPath = await resolveBinaryPath(platform);
|
||||
return {
|
||||
available: true,
|
||||
version,
|
||||
binaryPath,
|
||||
platform,
|
||||
nativeSupported,
|
||||
checkedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const missing =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
((error as { code?: string }).code === 'ENOENT' ||
|
||||
(error as { code?: string }).code === 'ENOEXEC');
|
||||
|
||||
if (missing) {
|
||||
return {
|
||||
available: false,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
platform,
|
||||
nativeSupported,
|
||||
checkedAt,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(`tmux status check failed: ${message}`);
|
||||
return {
|
||||
available: false,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
platform,
|
||||
nativeSupported,
|
||||
checkedAt,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetStatus(_event: IpcMainInvokeEvent): Promise<IpcResult<TmuxStatus>> {
|
||||
try {
|
||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
||||
return { success: true, data: cachedStatus.value };
|
||||
}
|
||||
|
||||
if (!statusInFlight) {
|
||||
statusInFlight = computeTmuxStatus()
|
||||
.then((status) => {
|
||||
cachedStatus = { value: status, at: Date.now() };
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
statusInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
const status = await statusInFlight;
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
logger.error('Error in tmux:getStatus:', message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTmuxHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(TMUX_GET_STATUS, handleGetStatus);
|
||||
logger.info('tmux handlers registered');
|
||||
}
|
||||
|
||||
export function removeTmuxHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(TMUX_GET_STATUS);
|
||||
logger.info('tmux handlers removed');
|
||||
}
|
||||
|
|
@ -39,11 +39,7 @@ import {
|
|||
type SessionsPaginationOptions,
|
||||
type WorktreeSource,
|
||||
} from '@main/types';
|
||||
import {
|
||||
analyzeSessionFileMetadata,
|
||||
extractCwd,
|
||||
extractFirstUserMessagePreview,
|
||||
} from '@main/utils/jsonl';
|
||||
import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl';
|
||||
import {
|
||||
buildSessionPath,
|
||||
buildSubagentsPath,
|
||||
|
|
@ -1035,20 +1031,30 @@ export class ProjectScanner {
|
|||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||
const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime;
|
||||
const cachedPreview = this.sessionPreviewCache.get(filePath);
|
||||
const preview =
|
||||
cachedPreview?.mtimeMs === effectiveMtime && cachedPreview.size === effectiveSize
|
||||
? cachedPreview.preview
|
||||
: await this.extractLightPreviewWithRetry(filePath);
|
||||
if (cachedPreview?.mtimeMs !== effectiveMtime || cachedPreview.size !== effectiveSize) {
|
||||
this.sessionPreviewCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
preview,
|
||||
});
|
||||
let metadata: Awaited<ReturnType<typeof analyzeSessionFileMetadata>>;
|
||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||
if (cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize) {
|
||||
metadata = cachedMetadata.metadata;
|
||||
} else {
|
||||
try {
|
||||
metadata = await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
||||
this.sessionMetadataCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to analyze session metadata for ${filePath}:`, error);
|
||||
metadata = {
|
||||
firstUserMessage: null,
|
||||
messageCount: 0,
|
||||
isOngoing: false,
|
||||
gitBranch: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const metadataLevel: SessionMetadataLevel = 'light';
|
||||
const previewTimestampMs = this.parseTimestampMs(preview?.timestamp);
|
||||
const previewTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp);
|
||||
const createdAt =
|
||||
previewTimestampMs !== null && Number.isFinite(previewTimestampMs)
|
||||
? previewTimestampMs
|
||||
|
|
@ -1059,10 +1065,10 @@ export class ProjectScanner {
|
|||
projectId,
|
||||
projectPath,
|
||||
createdAt: Math.floor(createdAt),
|
||||
firstMessage: preview?.text,
|
||||
messageTimestamp: preview?.timestamp,
|
||||
firstMessage: metadata.firstUserMessage?.text,
|
||||
messageTimestamp: metadata.firstUserMessage?.timestamp,
|
||||
hasSubagents: false,
|
||||
messageCount: 0,
|
||||
messageCount: metadata.messageCount,
|
||||
metadataLevel,
|
||||
};
|
||||
}
|
||||
|
|
@ -1459,31 +1465,6 @@ export class ProjectScanner {
|
|||
return results;
|
||||
}
|
||||
|
||||
private async extractLightPreviewWithRetry(
|
||||
filePath: string
|
||||
): Promise<{ text: string; timestamp: string } | null> {
|
||||
const maxAttempts = this.fsProvider.type === 'ssh' ? 3 : 1;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await extractFirstUserMessagePreview(filePath, this.fsProvider);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxAttempts && this.isTransientFsError(error)) {
|
||||
await this.sleep(50 * attempt);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
logger.debug(`Failed to extract light preview for ${filePath}:`, lastError);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getErrorCode(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
|
|
|
|||
|
|
@ -50,10 +50,13 @@ const PBKDF2_ITERATIONS = 100_000;
|
|||
const PBKDF2_KEY_BYTES = 32;
|
||||
const PBKDF2_SALT = 'claude-apikey-storage-v1';
|
||||
|
||||
export const RUNTIME_MANAGED_API_KEY_ENV_VARS = ['GEMINI_API_KEY'] as const;
|
||||
|
||||
export class ApiKeyService {
|
||||
private readonly filePath: string;
|
||||
private cache: StoredApiKey[] | null = null;
|
||||
private aesKey: Buffer | null = null;
|
||||
private readonly originalProcessEnv = new Map<string, string | undefined>();
|
||||
|
||||
constructor(claudeDir?: string) {
|
||||
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
|
||||
|
|
@ -144,6 +147,24 @@ export class ApiKeyService {
|
|||
}));
|
||||
}
|
||||
|
||||
async lookupPreferred(envVarName: string): Promise<ApiKeyLookupResult | null> {
|
||||
const keys = await this.readStore();
|
||||
const matching = keys.filter((key) => key.envVarName === envVarName);
|
||||
const preferred =
|
||||
matching.find((key) => key.scope === 'user') ??
|
||||
matching.find((key) => key.scope === 'project') ??
|
||||
null;
|
||||
|
||||
if (!preferred) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
envVarName: preferred.envVarName,
|
||||
value: this.decrypt(preferred),
|
||||
};
|
||||
}
|
||||
|
||||
async getStorageStatus(): Promise<ApiKeyStorageStatus> {
|
||||
const secure = this.isSecureBackend();
|
||||
const backend = this.getBackendName();
|
||||
|
|
@ -163,6 +184,31 @@ export class ApiKeyService {
|
|||
};
|
||||
}
|
||||
|
||||
async syncProcessEnv(envVarNames: readonly string[]): Promise<void> {
|
||||
if (!envVarNames.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const envVarName of envVarNames) {
|
||||
if (!this.originalProcessEnv.has(envVarName)) {
|
||||
this.originalProcessEnv.set(envVarName, process.env[envVarName]);
|
||||
}
|
||||
|
||||
const nextValue = (await this.lookupPreferred(envVarName))?.value;
|
||||
if (nextValue && nextValue.trim().length > 0) {
|
||||
process.env[envVarName] = nextValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalValue = this.originalProcessEnv.get(envVarName);
|
||||
if (typeof originalValue === 'string' && originalValue.length > 0) {
|
||||
process.env[envVarName] = originalValue;
|
||||
} else {
|
||||
delete process.env[envVarName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Encryption ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Extension services barrel export.
|
||||
*/
|
||||
|
||||
export { ApiKeyService } from './apikeys/ApiKeyService';
|
||||
export { ApiKeyService, RUNTIME_MANAGED_API_KEY_ENV_VARS } from './apikeys/ApiKeyService';
|
||||
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
||||
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ const logger = createLogger('Extensions:SkillParser');
|
|||
const ALLOWED_FRONTMATTER_KEYS = new Set([
|
||||
'name',
|
||||
'description',
|
||||
// Third-party skills often include a semantic version in frontmatter.
|
||||
'version',
|
||||
'license',
|
||||
'compatibility',
|
||||
'metadata',
|
||||
|
|
|
|||
|
|
@ -37,9 +37,17 @@ import https from 'https';
|
|||
import { tmpdir } from 'os';
|
||||
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
|
||||
|
||||
import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridgeService';
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor';
|
||||
|
||||
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types';
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliInstallerProgress,
|
||||
CliPlatform,
|
||||
CliProviderId,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { IncomingMessage } from 'http';
|
||||
|
||||
|
|
@ -123,6 +131,24 @@ const DIAG_PATH_HEAD = 400;
|
|||
const DIAG_HOME_PREVIEW = 120;
|
||||
const DIAG_AUTH_STDOUT_TAIL = 160;
|
||||
|
||||
function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallationStatus {
|
||||
return {
|
||||
...status,
|
||||
launchError: status.launchError ?? null,
|
||||
providers: status.providers.map((provider) => ({
|
||||
...provider,
|
||||
capabilities: { ...provider.capabilities },
|
||||
selectedBackendId: provider.selectedBackendId ?? null,
|
||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
|
||||
externalRuntimeDiagnostics:
|
||||
provider.externalRuntimeDiagnostics?.map((diagnostic) => ({ ...diagnostic })) ?? [],
|
||||
backend: provider.backend ? { ...provider.backend } : null,
|
||||
models: [...provider.models],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
|
@ -224,6 +250,10 @@ export function normalizeVersion(raw: string): string {
|
|||
return match ? match[0] : raw.trim();
|
||||
}
|
||||
|
||||
function isSemverVersion(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && /^\d{1,10}\.\d{1,10}\.\d{1,10}$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver strings numerically.
|
||||
* Returns true if `installed` is strictly older than `latest`.
|
||||
|
|
@ -297,6 +327,7 @@ function resetGatherDiag(diag: CliInstallerStatusRunDiag): void {
|
|||
export class CliInstallerService {
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private installing = false;
|
||||
private readonly multimodelBridgeService = new ClaudeMultimodelBridgeService();
|
||||
|
||||
private electronMetaForDiag(): Record<string, unknown> {
|
||||
try {
|
||||
|
|
@ -345,6 +376,7 @@ export class CliInstallerService {
|
|||
installed: r.installed,
|
||||
binaryPath: r.binaryPath ? clipHeadForDiag(r.binaryPath, DIAG_PATH_HEAD) : null,
|
||||
installedVersion: r.installedVersion,
|
||||
launchError: r.launchError ?? null,
|
||||
authLoggedIn: r.authLoggedIn,
|
||||
authMethod: r.authMethod,
|
||||
latestVersion: r.latestVersion,
|
||||
|
|
@ -370,20 +402,67 @@ export class CliInstallerService {
|
|||
return buildEnrichedEnv(binaryPath);
|
||||
}
|
||||
|
||||
private createInitialStatus(): CliInstallationStatus {
|
||||
const flavor = getConfiguredCliFlavor();
|
||||
const ui = getCliFlavorUiOptions(flavor);
|
||||
const providers =
|
||||
flavor === 'agent_teams_orchestrator'
|
||||
? (
|
||||
[
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
},
|
||||
{
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
},
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown' as const,
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: null,
|
||||
}))
|
||||
: [];
|
||||
return {
|
||||
flavor,
|
||||
displayName: ui.displayName,
|
||||
supportsSelfUpdate: ui.supportsSelfUpdate,
|
||||
showVersionDetails: ui.showVersionDetails,
|
||||
showBinaryPath: ui.showBinaryPath,
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
launchError: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: true,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
const result: CliInstallationStatus = {
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authMethod: null,
|
||||
};
|
||||
const result = this.createInitialStatus();
|
||||
|
||||
// Run the actual status gathering with an overall timeout.
|
||||
// On timeout, return whatever partial result was collected so far.
|
||||
|
|
@ -418,6 +497,28 @@ export class CliInstallerService {
|
|||
}
|
||||
}
|
||||
|
||||
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flavor = getConfiguredCliFlavor();
|
||||
if (flavor !== 'agent_teams_orchestrator') {
|
||||
const fullStatus = await this.getStatus();
|
||||
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
|
||||
}
|
||||
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
if (!versionProbe.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers CLI status information, mutating the provided result object.
|
||||
* Split from getStatus() to enable overall timeout via Promise.race —
|
||||
|
|
@ -435,32 +536,120 @@ export class CliInstallerService {
|
|||
const r = ref.current;
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (binaryPath) {
|
||||
r.installed = true;
|
||||
r.binaryPath = binaryPath;
|
||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||
if (versionProbe.ok) {
|
||||
r.installed = true;
|
||||
r.installedVersion = versionProbe.version;
|
||||
r.launchError = null;
|
||||
r.authStatusChecking = true;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
env: this.envForCli(binaryPath),
|
||||
});
|
||||
r.installedVersion = normalizeVersion(stdout);
|
||||
logger.info(
|
||||
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"`
|
||||
// Auth and GCS version check are independent — run in parallel.
|
||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||
await Promise.all([
|
||||
this.checkAuthStatus(binaryPath, r, diag),
|
||||
r.supportsSelfUpdate ? this.fetchLatestVersion(r) : Promise.resolve(),
|
||||
]);
|
||||
} else {
|
||||
diag.versionError = versionProbe.error;
|
||||
r.installed = false;
|
||||
r.installedVersion = null;
|
||||
r.launchError = versionProbe.error;
|
||||
r.authStatusChecking = false;
|
||||
this.markProvidersUnavailable(
|
||||
r,
|
||||
r.binaryPath ? 'Runtime found, but startup health check failed.' : 'Runtime unavailable.'
|
||||
);
|
||||
} catch (err) {
|
||||
diag.versionError = getErrorMessage(err);
|
||||
logger.warn('Failed to get CLI version:', diag.versionError);
|
||||
if (diag.versionError) {
|
||||
logger.warn('Failed to get CLI version:', diag.versionError);
|
||||
}
|
||||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
}
|
||||
|
||||
// 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), this.fetchLatestVersion(r)]);
|
||||
} else {
|
||||
// No binary — still check latest version for "install" prompt
|
||||
await this.fetchLatestVersion(r);
|
||||
r.authStatusChecking = false;
|
||||
r.launchError = null;
|
||||
this.markProvidersUnavailable(r, 'Runtime not found.');
|
||||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
}
|
||||
}
|
||||
|
||||
private async probeCliVersion(
|
||||
binaryPath: string
|
||||
): Promise<{ ok: true; version: string | null } | { 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.' };
|
||||
}
|
||||
|
||||
if (isSemverVersion(version)) {
|
||||
logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`);
|
||||
return { ok: true, version };
|
||||
}
|
||||
|
||||
const inferredVersion = await this.inferInstalledCliVersionFromPath(binaryPath);
|
||||
if (inferredVersion) {
|
||||
logger.info(
|
||||
`Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"`
|
||||
);
|
||||
return { ok: true, version: inferredVersion };
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Installed CLI returned a non-semver version string: "${stdout.trim()}". ` +
|
||||
'Treating the binary as healthy, but omitting version details.'
|
||||
);
|
||||
return { ok: true, version: null };
|
||||
} catch (err) {
|
||||
return { ok: false, error: getErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async inferInstalledCliVersionFromPath(binaryPath: string): Promise<string | null> {
|
||||
try {
|
||||
const resolvedPath = await fsp.realpath(binaryPath);
|
||||
if (!/[\\/]+versions[\\/]+/.test(resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inferredVersion = normalizeVersion(resolvedPath);
|
||||
return isSemverVersion(inferredVersion) ? inferredVersion : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private markProvidersUnavailable(result: CliInstallationStatus, message: string): void {
|
||||
if (result.flavor !== 'agent_teams_orchestrator') {
|
||||
return;
|
||||
}
|
||||
|
||||
result.providers = result.providers.map((provider) => ({
|
||||
...provider,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
statusMessage: message,
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
backend: null,
|
||||
}));
|
||||
result.authLoggedIn = false;
|
||||
result.authMethod = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check auth status with retry — covers stale lock files after Ctrl+C interruption.
|
||||
* Wrapped in its own timeout to prevent slow auth from blocking the overall status.
|
||||
|
|
@ -472,6 +661,34 @@ export class CliInstallerService {
|
|||
result: CliInstallationStatus,
|
||||
diag: CliInstallerStatusRunDiag
|
||||
): Promise<void> {
|
||||
if (result.flavor === 'agent_teams_orchestrator') {
|
||||
result.authStatusChecking = true;
|
||||
try {
|
||||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||
binaryPath,
|
||||
(providersSnapshot) => {
|
||||
result.providers = providersSnapshot;
|
||||
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
||||
}
|
||||
);
|
||||
result.providers = providers;
|
||||
result.authLoggedIn = providers.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
result.authStatusChecking = false;
|
||||
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const doCheck = async (): Promise<void> => {
|
||||
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
|
||||
diag.authAttempts = authAttempt;
|
||||
|
|
@ -485,6 +702,7 @@ export class CliInstallerService {
|
|||
const auth = parseClaudeAuthStatusStdout(authStdout);
|
||||
result.authLoggedIn = auth.loggedIn === true;
|
||||
result.authMethod = auth.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
diag.authLastError = null;
|
||||
logger.info(
|
||||
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
|
||||
|
|
@ -505,6 +723,7 @@ export class CliInstallerService {
|
|||
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${msg}`
|
||||
);
|
||||
result.authLoggedIn = false;
|
||||
result.authStatusChecking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -528,6 +747,7 @@ export class CliInstallerService {
|
|||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
result.authStatusChecking = false;
|
||||
diag.authTimedOut = hitAuthTimeout;
|
||||
}
|
||||
}
|
||||
|
|
@ -559,6 +779,13 @@ export class CliInstallerService {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async install(): Promise<void> {
|
||||
if (!getCliFlavorUiOptions(getConfiguredCliFlavor()).supportsSelfUpdate) {
|
||||
const error = 'Updates are disabled for the configured agent_teams_orchestrator runtime.';
|
||||
logger.warn(error);
|
||||
this.sendProgress({ type: 'error', error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.installing) {
|
||||
this.sendProgress({ type: 'error', error: 'Installation already in progress' });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* - Handle JSON parse errors gracefully
|
||||
*/
|
||||
|
||||
import { getHomeDir, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import { getClaudeBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import { validateRegexPattern } from '@main/utils/regexValidation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -23,9 +23,11 @@ import type { SshConnectionProfile } from '@shared/types/api';
|
|||
|
||||
const logger = createLogger('Service:ConfigManager');
|
||||
|
||||
const CONFIG_DIR = path.join(getHomeDir(), '.claude');
|
||||
const CONFIG_FILENAME = 'claude-devtools-config.json';
|
||||
const DEFAULT_CONFIG_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
|
||||
|
||||
function getDefaultConfigPath(): string {
|
||||
return path.join(getClaudeBasePath(), CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Types
|
||||
|
|
@ -204,6 +206,7 @@ export interface GeneralConfig {
|
|||
showDockIcon: boolean;
|
||||
theme: 'dark' | 'light' | 'system';
|
||||
defaultTab: 'dashboard' | 'last-session';
|
||||
multimodelEnabled: boolean;
|
||||
claudeRootPath: string | null;
|
||||
agentLanguage: string;
|
||||
autoExpandAIGroups: boolean;
|
||||
|
|
@ -214,6 +217,26 @@ export interface GeneralConfig {
|
|||
telemetryEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
providerBackends: {
|
||||
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||
codex: 'auto' | 'adapter';
|
||||
};
|
||||
}
|
||||
|
||||
export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
|
||||
export type CodexProviderConnectionAuthMode = Exclude<ProviderConnectionAuthMode, 'auto'>;
|
||||
|
||||
export interface ProviderConnectionsConfig {
|
||||
anthropic: {
|
||||
authMode: ProviderConnectionAuthMode;
|
||||
};
|
||||
codex: {
|
||||
apiKeyBetaEnabled: boolean;
|
||||
authMode: CodexProviderConnectionAuthMode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
showTimestamps: boolean;
|
||||
compactMode: boolean;
|
||||
|
|
@ -246,6 +269,8 @@ export interface HttpServerConfig {
|
|||
export interface AppConfig {
|
||||
notifications: NotificationConfig;
|
||||
general: GeneralConfig;
|
||||
providerConnections: ProviderConnectionsConfig;
|
||||
runtime: RuntimeConfig;
|
||||
display: DisplayConfig;
|
||||
sessions: SessionsConfig;
|
||||
ssh: SshPersistConfig;
|
||||
|
|
@ -290,6 +315,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
showDockIcon: true,
|
||||
theme: 'dark',
|
||||
defaultTab: 'dashboard',
|
||||
multimodelEnabled: true,
|
||||
claudeRootPath: null,
|
||||
agentLanguage: 'system',
|
||||
autoExpandAIGroups: false,
|
||||
|
|
@ -297,6 +323,21 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
customProjectPaths: [],
|
||||
telemetryEnabled: true,
|
||||
},
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: false,
|
||||
authMode: 'oauth',
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto',
|
||||
codex: 'auto',
|
||||
},
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
compactMode: false,
|
||||
|
|
@ -361,7 +402,7 @@ export class ConfigManager {
|
|||
private triggerManager: TriggerManager;
|
||||
|
||||
constructor(configPath?: string) {
|
||||
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
|
||||
this.configPath = configPath ?? getDefaultConfigPath();
|
||||
this.config = this.loadConfig();
|
||||
setClaudeBasePathOverride(this.config.general.claudeRootPath);
|
||||
this.triggerManager = new TriggerManager(this.config.notifications.triggers, () =>
|
||||
|
|
@ -466,6 +507,22 @@ export class ConfigManager {
|
|||
triggers: mergedTriggers,
|
||||
},
|
||||
general: mergedGeneral,
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
...DEFAULT_CONFIG.providerConnections.anthropic,
|
||||
...(loaded.providerConnections?.anthropic ?? {}),
|
||||
},
|
||||
codex: {
|
||||
...DEFAULT_CONFIG.providerConnections.codex,
|
||||
...(loaded.providerConnections?.codex ?? {}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
...DEFAULT_CONFIG.runtime.providerBackends,
|
||||
...(loaded.runtime?.providerBackends ?? {}),
|
||||
},
|
||||
},
|
||||
display: {
|
||||
...DEFAULT_CONFIG.display,
|
||||
...(loaded.display ?? {}),
|
||||
|
|
@ -538,10 +595,36 @@ export class ConfigManager {
|
|||
section: K,
|
||||
data: Partial<AppConfig[K]>
|
||||
): Partial<AppConfig[K]> {
|
||||
if (section !== 'general') {
|
||||
if (section !== 'general' && section !== 'runtime' && section !== 'providerConnections') {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (section === 'runtime') {
|
||||
const runtimeUpdate = data as Partial<RuntimeConfig>;
|
||||
return {
|
||||
...runtimeUpdate,
|
||||
providerBackends: {
|
||||
...this.config.runtime.providerBackends,
|
||||
...runtimeUpdate.providerBackends,
|
||||
},
|
||||
} as unknown as Partial<AppConfig[K]>;
|
||||
}
|
||||
|
||||
if (section === 'providerConnections') {
|
||||
const connectionUpdate = data as Partial<ProviderConnectionsConfig>;
|
||||
return {
|
||||
...connectionUpdate,
|
||||
anthropic: {
|
||||
...this.config.providerConnections.anthropic,
|
||||
...(connectionUpdate.anthropic ?? {}),
|
||||
},
|
||||
codex: {
|
||||
...this.config.providerConnections.codex,
|
||||
...(connectionUpdate.codex ?? {}),
|
||||
},
|
||||
} as unknown as Partial<AppConfig[K]>;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {
|
||||
return data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export function startEventLoopLagMonitor(): void {
|
|||
// Only report meaningful stalls
|
||||
if (maxMs < 250) return;
|
||||
|
||||
// For known IPC/main-thread operations we already emit operation-specific
|
||||
// timing diagnostics. Suppress the generic event-loop warning to avoid
|
||||
// duplicate noisy logs that do not add new debugging value.
|
||||
if (currentOp) return;
|
||||
|
||||
logger.warn(
|
||||
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
|
||||
(currentOp ? ` op=${currentOp}` : '')
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ const WATCHER_RETRY_MS = 2000;
|
|||
const CATCH_UP_INTERVAL_MS = 30_000;
|
||||
/** Only catch-up scan files modified within this window */
|
||||
const CATCH_UP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
/** Retire quiet top-level sessions from best-effort catch-up after this long. */
|
||||
const CATCH_UP_SESSION_RETENTION_MS = 20 * 60 * 1000; // 20 minutes
|
||||
/** Subagent logs are much noisier; retire them sooner from catch-up tracking. */
|
||||
const CATCH_UP_SUBAGENT_RETENTION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */
|
||||
const CATCH_UP_SCAN_BUDGET = 24;
|
||||
|
||||
interface AppendedParseResult {
|
||||
messages: ParsedMessage[];
|
||||
|
|
@ -56,6 +62,7 @@ interface ActiveSessionFile {
|
|||
projectId: string;
|
||||
sessionId: string;
|
||||
subagentId?: string;
|
||||
lastObservedAt: number;
|
||||
}
|
||||
|
||||
export class FileWatcher extends EventEmitter {
|
||||
|
|
@ -81,6 +88,10 @@ export class FileWatcher extends EventEmitter {
|
|||
private activeSessionFiles = new Map<string, ActiveSessionFile>();
|
||||
/** Timer for periodic catch-up scan */
|
||||
private catchUpTimer: NodeJS.Timeout | null = null;
|
||||
/** Prevent overlapping catch-up scans when a previous pass is still running. */
|
||||
private catchUpInProgress = false;
|
||||
/** Round-robin cursor so catch-up work is spread across tracked files. */
|
||||
private catchUpCursor = 0;
|
||||
/** Timer for SSH polling mode (replaces fs.watch) */
|
||||
private pollingTimer: NodeJS.Timeout | null = null;
|
||||
/** Polling interval for SSH mode */
|
||||
|
|
@ -167,6 +178,8 @@ export class FileWatcher extends EventEmitter {
|
|||
*/
|
||||
stop(): void {
|
||||
this.isWatching = false;
|
||||
this.catchUpInProgress = false;
|
||||
this.catchUpCursor = 0;
|
||||
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
|
|
@ -702,7 +715,7 @@ export class FileWatcher extends EventEmitter {
|
|||
if (config.notifications.includeSubagentErrors) {
|
||||
const subagentFilename = path.basename(parts[3], '.jsonl');
|
||||
const subagentId = subagentFilename.replace(/^agent-/, '');
|
||||
this.activeSessionFiles.set(fullPath, { projectId, sessionId, subagentId });
|
||||
this.rememberActiveSessionFile(fullPath, { projectId, sessionId, subagentId });
|
||||
this.detectErrorsInSessionFile(projectId, sessionId, fullPath, subagentId).catch(
|
||||
(err) => {
|
||||
logger.error('Error detecting errors in subagent file:', err);
|
||||
|
|
@ -710,7 +723,7 @@ export class FileWatcher extends EventEmitter {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
this.activeSessionFiles.set(fullPath, { projectId, sessionId });
|
||||
this.rememberActiveSessionFile(fullPath, { projectId, sessionId });
|
||||
this.detectErrorsInSessionFile(projectId, sessionId, fullPath).catch((err) => {
|
||||
logger.error('Error detecting errors in session file:', err);
|
||||
});
|
||||
|
|
@ -857,6 +870,22 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.clear();
|
||||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpCursor = 0;
|
||||
this.catchUpInProgress = false;
|
||||
}
|
||||
|
||||
private rememberActiveSessionFile(
|
||||
filePath: string,
|
||||
info: Omit<ActiveSessionFile, 'lastObservedAt'>
|
||||
): void {
|
||||
this.activeSessionFiles.set(filePath, {
|
||||
...info,
|
||||
lastObservedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
private getCatchUpRetentionMs(info: ActiveSessionFile): number {
|
||||
return info.subagentId ? CATCH_UP_SUBAGENT_RETENTION_MS : CATCH_UP_SESSION_RETENTION_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1084,41 +1113,71 @@ export class FileWatcher extends EventEmitter {
|
|||
* Only checks files modified within the last hour.
|
||||
*/
|
||||
private async runCatchUpScan(): Promise<void> {
|
||||
if (!this.notificationManager || this.activeSessionFiles.size === 0) {
|
||||
if (!this.notificationManager || this.activeSessionFiles.size === 0 || this.catchUpInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
this.catchUpInProgress = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
const entries = [...this.activeSessionFiles.entries()];
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [filePath, info] of this.activeSessionFiles) {
|
||||
try {
|
||||
const stats = await this.fsProvider.stat(filePath);
|
||||
const budget = Math.min(CATCH_UP_SCAN_BUDGET, entries.length);
|
||||
const startIndex = this.catchUpCursor % entries.length;
|
||||
|
||||
// Skip files not modified recently
|
||||
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
|
||||
this.activeSessionFiles.delete(filePath);
|
||||
for (let offset = 0; offset < budget; offset += 1) {
|
||||
if (!this.isWatching) {
|
||||
break;
|
||||
}
|
||||
const [filePath] = entries[(startIndex + offset) % entries.length];
|
||||
const info = this.activeSessionFiles.get(filePath);
|
||||
if (!info) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (now - info.lastObservedAt > this.getCatchUpRetentionMs(info)) {
|
||||
this.clearErrorTracking(filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastSize = this.lastProcessedSize.get(filePath) ?? 0;
|
||||
if (stats.size > lastSize) {
|
||||
logger.info(`FileWatcher: Catch-up scan detected growth in ${filePath}`);
|
||||
await this.detectErrorsInSessionFile(
|
||||
info.projectId,
|
||||
info.sessionId,
|
||||
filePath,
|
||||
info.subagentId
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// File may have been deleted between iterations
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.activeSessionFiles.delete(filePath);
|
||||
this.clearErrorTracking(filePath);
|
||||
} else {
|
||||
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
|
||||
const stats = await this.fsProvider.stat(filePath);
|
||||
|
||||
// Skip files not modified recently
|
||||
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
|
||||
this.clearErrorTracking(filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastSize = this.lastProcessedSize.get(filePath) ?? 0;
|
||||
if (stats.size > lastSize) {
|
||||
logger.info(`FileWatcher: Catch-up scan detected growth in ${filePath}`);
|
||||
this.rememberActiveSessionFile(filePath, {
|
||||
projectId: info.projectId,
|
||||
sessionId: info.sessionId,
|
||||
...(info.subagentId ? { subagentId: info.subagentId } : {}),
|
||||
});
|
||||
await this.detectErrorsInSessionFile(
|
||||
info.projectId,
|
||||
info.sessionId,
|
||||
filePath,
|
||||
info.subagentId
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// File may have been deleted between iterations
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.clearErrorTracking(filePath);
|
||||
} else {
|
||||
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.catchUpCursor = (startIndex + budget) % Math.max(entries.length, 1);
|
||||
} finally {
|
||||
this.catchUpInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,33 +22,14 @@ import { app, net } from 'electron';
|
|||
|
||||
import type { UpdaterStatus } from '@shared/types';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import {
|
||||
getExpectedReleaseAssetUrl,
|
||||
getLatestMacMetadataUrl,
|
||||
isLatestMacMetadataCompatible,
|
||||
} from './updaterReleaseMetadata';
|
||||
|
||||
const logger = createLogger('UpdaterService');
|
||||
|
||||
const REPO_OWNER = '777genius';
|
||||
const REPO_NAME = 'claude_agent_teams_ui';
|
||||
|
||||
/**
|
||||
* Build the expected download URL for the platform-specific installer asset.
|
||||
* Returns null if the current platform is unrecognized.
|
||||
*/
|
||||
function getExpectedAssetUrl(version: string): string | null {
|
||||
const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`;
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return process.arch === 'arm64'
|
||||
? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg`
|
||||
: `${base}/Claude.Agent.Teams.UI-${version}.dmg`;
|
||||
case 'win32':
|
||||
return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`;
|
||||
case 'linux':
|
||||
return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a remote URL exists using a HEAD request.
|
||||
* Follows redirects (GitHub releases use 302 → S3).
|
||||
|
|
@ -62,6 +43,18 @@ async function assetExists(url: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await net.fetch(url, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdaterService {
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private periodicTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
|
@ -154,6 +147,24 @@ export class UpdaterService {
|
|||
return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion));
|
||||
}
|
||||
|
||||
private async hasCompatibleMacFeed(version: string): Promise<boolean> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return true;
|
||||
}
|
||||
if (process.arch !== 'arm64' && process.arch !== 'x64') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metadataUrl = getLatestMacMetadataUrl(version);
|
||||
const metadataText = await fetchText(metadataUrl);
|
||||
if (!metadataText) {
|
||||
logger.warn(`latest-mac.yml is not available for ${version} (${metadataUrl})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return isLatestMacMetadataCompatible(metadataText, version, process.arch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the platform-specific asset exists before notifying the renderer.
|
||||
* If CI hasn't finished uploading the artifact for this OS yet, suppress the
|
||||
|
|
@ -170,7 +181,7 @@ export class UpdaterService {
|
|||
return;
|
||||
}
|
||||
|
||||
const url = getExpectedAssetUrl(info.version);
|
||||
const url = getExpectedReleaseAssetUrl(info.version, process.platform, process.arch);
|
||||
if (url) {
|
||||
const exists = await assetExists(url);
|
||||
if (!exists) {
|
||||
|
|
@ -181,6 +192,13 @@ export class UpdaterService {
|
|||
}
|
||||
}
|
||||
|
||||
if (!(await this.hasCompatibleMacFeed(info.version))) {
|
||||
logger.warn(
|
||||
`latest-mac.yml does not match ${process.platform}/${process.arch}, suppressing update notification`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendStatus({
|
||||
type: 'available',
|
||||
version: info.version,
|
||||
|
|
|
|||
79
src/main/services/infrastructure/updaterReleaseMetadata.ts
Normal file
79
src/main/services/infrastructure/updaterReleaseMetadata.ts
Normal 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));
|
||||
}
|
||||
479
src/main/services/runtime/ClaudeMultimodelBridgeService.ts
Normal file
479
src/main/services/runtime/ClaudeMultimodelBridgeService.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import {
|
||||
getCachedShellEnv,
|
||||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnv,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface ProviderStatusCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
supported?: boolean;
|
||||
authenticated?: boolean;
|
||||
authMethod?: string | null;
|
||||
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
canLoginFromUi?: boolean;
|
||||
statusMessage?: string | null;
|
||||
capabilities?: {
|
||||
teamLaunch?: boolean;
|
||||
oneShot?: boolean;
|
||||
};
|
||||
backend?: {
|
||||
kind?: string;
|
||||
label?: string;
|
||||
endpointLabel?: string | null;
|
||||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface ProviderModelsCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
models?: (string | { id?: string; label?: string; description?: string })[];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface UnifiedRuntimeStatusResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
supported?: boolean;
|
||||
authenticated?: boolean;
|
||||
authMethod?: string | null;
|
||||
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
canLoginFromUi?: boolean;
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
selectedBackendId?: string | null;
|
||||
resolvedBackendId?: string | null;
|
||||
availableBackends?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
selectable?: boolean;
|
||||
recommended?: boolean;
|
||||
available?: boolean;
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
}[];
|
||||
externalRuntimeDiagnostics?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
detected?: boolean;
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
}[];
|
||||
models?: (string | { id?: string; label?: string; description?: string })[];
|
||||
capabilities?: {
|
||||
teamLaunch?: boolean;
|
||||
oneShot?: boolean;
|
||||
};
|
||||
backend?: {
|
||||
kind?: string;
|
||||
label?: string;
|
||||
endpointLabel?: string | null;
|
||||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
|
||||
|
||||
function extractJsonObject<T>(raw: string): T {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed) as T;
|
||||
} catch {
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
||||
}
|
||||
throw new Error('No JSON object found in CLI output');
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus {
|
||||
return {
|
||||
providerId,
|
||||
displayName:
|
||||
providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractModelIds(
|
||||
models: (string | { id?: string; label?: string; description?: string })[] | undefined
|
||||
): string[] {
|
||||
if (!models) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return models.flatMap((model) => {
|
||||
if (typeof model === 'string') {
|
||||
return model;
|
||||
}
|
||||
if (typeof model?.id === 'string' && model.id.trim().length > 0) {
|
||||
return model.id.trim();
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export class ClaudeMultimodelBridgeService {
|
||||
private async buildCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const home =
|
||||
getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE;
|
||||
const env = {
|
||||
...buildEnrichedEnv(binaryPath),
|
||||
...shellEnv,
|
||||
};
|
||||
if (home) {
|
||||
env.HOME = home;
|
||||
}
|
||||
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
||||
return providerConnectionService.applyAllConfiguredConnectionEnv(env);
|
||||
}
|
||||
|
||||
private async buildProviderCliEnv(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId);
|
||||
}
|
||||
|
||||
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes('unknown command') ||
|
||||
lower.includes('unknown option') ||
|
||||
lower.includes('no such command') ||
|
||||
lower.includes('did you mean') ||
|
||||
lower.includes('runtime status')
|
||||
);
|
||||
}
|
||||
|
||||
private mapRuntimeProviderStatus(
|
||||
providerId: CliProviderId,
|
||||
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
||||
): CliProviderStatus {
|
||||
const provider = createDefaultProviderStatus(providerId);
|
||||
if (!runtimeStatus) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
supported: runtimeStatus.supported === true,
|
||||
authenticated: runtimeStatus.authenticated === true,
|
||||
authMethod: runtimeStatus.authMethod ?? null,
|
||||
verificationState: runtimeStatus.verificationState ?? 'unknown',
|
||||
statusMessage: runtimeStatus.statusMessage ?? null,
|
||||
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
|
||||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
},
|
||||
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
||||
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
||||
availableBackends:
|
||||
runtimeStatus.availableBackends?.map((backend) => ({
|
||||
id: backend.id ?? 'unknown',
|
||||
label: backend.label ?? backend.id ?? 'Unknown',
|
||||
description: backend.description ?? '',
|
||||
selectable: backend.selectable !== false,
|
||||
recommended: backend.recommended === true,
|
||||
available: backend.available === true,
|
||||
statusMessage: backend.statusMessage ?? null,
|
||||
detailMessage: backend.detailMessage ?? null,
|
||||
})) ?? [],
|
||||
externalRuntimeDiagnostics:
|
||||
runtimeStatus.externalRuntimeDiagnostics?.map((diagnostic) => ({
|
||||
id: diagnostic.id ?? 'unknown',
|
||||
label: diagnostic.label ?? diagnostic.id ?? 'Unknown',
|
||||
detected: diagnostic.detected === true,
|
||||
statusMessage: diagnostic.statusMessage ?? null,
|
||||
detailMessage: diagnostic.detailMessage ?? null,
|
||||
})) ?? [],
|
||||
models: extractModelIds(runtimeStatus.models),
|
||||
backend: runtimeStatus.backend?.kind
|
||||
? {
|
||||
kind: runtimeStatus.backend.kind,
|
||||
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
|
||||
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
|
||||
projectId: runtimeStatus.backend.projectId ?? null,
|
||||
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getProviderStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = await this.buildCliEnv(binaryPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['runtime', 'status', '--json', '--provider', providerId],
|
||||
{
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
return providerConnectionService.enrichProviderStatus(
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||
logger.warn(
|
||||
`Provider-scoped runtime status unavailable for ${providerId}, falling back to full probe: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const providers = await this.getProviderStatuses(binaryPath);
|
||||
return (
|
||||
providers.find((provider) => provider.providerId === providerId) ??
|
||||
createDefaultProviderStatus(providerId)
|
||||
);
|
||||
}
|
||||
|
||||
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
|
||||
const provider = createDefaultProviderStatus('gemini');
|
||||
const env = await this.buildProviderCliEnv(binaryPath, 'gemini');
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['model', 'list', '--json', '--provider', 'all'],
|
||||
{
|
||||
timeout: PROVIDER_MODELS_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObject<ProviderModelsCommandResponse>(stdout);
|
||||
const models = extractModelIds(parsed.providers?.gemini?.models);
|
||||
if (models.length > 0) {
|
||||
provider.supported = true;
|
||||
provider.models = models;
|
||||
provider.capabilities = {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Gemini model list unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
const authState = await resolveGeminiRuntimeAuth(env);
|
||||
if (authState.authenticated) {
|
||||
provider.authenticated = true;
|
||||
provider.authMethod =
|
||||
authState.authMethod === 'adc_authorized_user' ||
|
||||
authState.authMethod === 'adc_service_account'
|
||||
? `gemini_${authState.authMethod}`
|
||||
: authState.authMethod;
|
||||
provider.verificationState = 'verified';
|
||||
provider.statusMessage = null;
|
||||
if (authState.authMethod === 'cli_oauth_personal') {
|
||||
provider.backend = {
|
||||
kind: 'cli',
|
||||
label: 'Gemini CLI',
|
||||
endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)',
|
||||
projectId: authState.projectId,
|
||||
authMethodDetail: authState.authMethod,
|
||||
};
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
provider.statusMessage =
|
||||
authState.statusMessage ?? 'Set GEMINI_API_KEY or Google ADC to use Gemini.';
|
||||
return provider;
|
||||
}
|
||||
|
||||
async getProviderStatuses(
|
||||
binaryPath: string,
|
||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[]> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = await this.buildCliEnv(binaryPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
const providers = await providerConnectionService.enrichProviderStatuses(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) =>
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||
)
|
||||
);
|
||||
onUpdate?.(providers);
|
||||
return providers;
|
||||
} catch (error) {
|
||||
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||
logger.warn(
|
||||
`Unified runtime status unavailable, falling back to legacy probes: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [statusResult, modelsResult] = await Promise.allSettled([
|
||||
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
}),
|
||||
execCli(binaryPath, ['model', 'list', '--json', '--provider', 'all'], {
|
||||
timeout: PROVIDER_MODELS_TIMEOUT_MS,
|
||||
env,
|
||||
}),
|
||||
]);
|
||||
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
createDefaultProviderStatus(providerId),
|
||||
])
|
||||
);
|
||||
|
||||
if (statusResult.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = extractJsonObject<ProviderStatusCommandResponse>(statusResult.value.stdout);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
|
||||
const runtimeStatus = parsed.providers?.[providerId];
|
||||
if (!runtimeStatus) continue;
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
supported: runtimeStatus.supported === true,
|
||||
authenticated: runtimeStatus.authenticated === true,
|
||||
authMethod: runtimeStatus.authMethod ?? null,
|
||||
verificationState: runtimeStatus.verificationState ?? 'unknown',
|
||||
statusMessage: runtimeStatus.statusMessage ?? null,
|
||||
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
|
||||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
},
|
||||
backend: runtimeStatus.backend?.kind
|
||||
? {
|
||||
kind: runtimeStatus.backend.kind,
|
||||
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
|
||||
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
|
||||
projectId: runtimeStatus.backend.projectId ?? null,
|
||||
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to parse provider auth status JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const message =
|
||||
statusResult.reason instanceof Error
|
||||
? statusResult.reason.message
|
||||
: String(statusResult.reason);
|
||||
logger.warn(`Provider auth status unavailable: ${message}`);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS) {
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
statusMessage: 'Provider status not supported by current claude-multimodel build',
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsResult.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelsResult.value.stdout);
|
||||
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
|
||||
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
|
||||
if (runtimeModels.length === 0) continue;
|
||||
providers.set(providerId, {
|
||||
...providers.get(providerId)!,
|
||||
models: runtimeModels,
|
||||
});
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to parse provider models JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
providers.set('gemini', await this.buildGeminiStatus(binaryPath));
|
||||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
|
||||
const enrichedProviders = await providerConnectionService.enrichProviderStatuses(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!)
|
||||
);
|
||||
onUpdate?.(enrichedProviders);
|
||||
|
||||
return enrichedProviders;
|
||||
}
|
||||
}
|
||||
250
src/main/services/runtime/ProviderConnectionService.ts
Normal file
250
src/main/services/runtime/ProviderConnectionService.ts
Normal 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();
|
||||
144
src/main/services/runtime/geminiRuntimeAuth.ts
Normal file
144
src/main/services/runtime/geminiRuntimeAuth.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type GeminiGlobalConfig = {
|
||||
geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
|
||||
geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
|
||||
geminiLastAuthMethod?: string;
|
||||
geminiProjectId?: string;
|
||||
};
|
||||
|
||||
export type GeminiRuntimeAuthState = {
|
||||
authenticated: boolean;
|
||||
authMethod: string | null;
|
||||
resolvedBackend: 'auto' | 'api' | 'cli-sdk';
|
||||
projectId: string | null;
|
||||
statusMessage: string | null;
|
||||
};
|
||||
|
||||
function normalizeGeminiBackend(
|
||||
value: string | null | undefined
|
||||
): GeminiRuntimeAuthState['resolvedBackend'] {
|
||||
if (value === 'api') return 'api';
|
||||
if (value === 'cli' || value === 'cli-sdk') return 'cli-sdk';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function resolveEffectiveGeminiBackend(
|
||||
requestedBackend: GeminiRuntimeAuthState['resolvedBackend'],
|
||||
authMethod: string | null,
|
||||
hasGeminiApiKey: boolean,
|
||||
hasAdcWithProject: boolean
|
||||
): Exclude<GeminiRuntimeAuthState['resolvedBackend'], 'auto'> | 'auto' {
|
||||
if (requestedBackend !== 'auto') {
|
||||
return requestedBackend;
|
||||
}
|
||||
if (hasGeminiApiKey || hasAdcWithProject) {
|
||||
return 'api';
|
||||
}
|
||||
if (authMethod === 'cli_oauth_personal') {
|
||||
return 'cli-sdk';
|
||||
}
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export async function readGeminiGlobalConfig(
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<GeminiGlobalConfig | null> {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
||||
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
||||
const candidates = configDir
|
||||
? [path.join(configDir, '.config.json')]
|
||||
: home
|
||||
? [path.join(home, '.claude', '.config.json'), path.join(home, '.claude.json')]
|
||||
: [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(candidate, 'utf8');
|
||||
return JSON.parse(raw) as GeminiGlobalConfig;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveGeminiRuntimeAuth(
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<GeminiRuntimeAuthState> {
|
||||
const config = await readGeminiGlobalConfig(env);
|
||||
const resolvedBackend = normalizeGeminiBackend(
|
||||
env.CLAUDE_CODE_GEMINI_BACKEND?.trim() ||
|
||||
config?.geminiResolvedBackend?.trim() ||
|
||||
config?.geminiBackendPreference?.trim()
|
||||
);
|
||||
const authMethod = config?.geminiLastAuthMethod?.trim() ?? null;
|
||||
const projectId =
|
||||
env.GOOGLE_CLOUD_PROJECT?.trim() ||
|
||||
env.GOOGLE_CLOUD_PROJECT_ID?.trim() ||
|
||||
env.GCLOUD_PROJECT?.trim() ||
|
||||
config?.geminiProjectId?.trim() ||
|
||||
null;
|
||||
const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim());
|
||||
const hasAdcWithProject = Boolean(
|
||||
(authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId
|
||||
);
|
||||
const effectiveBackend = resolveEffectiveGeminiBackend(
|
||||
resolvedBackend,
|
||||
authMethod,
|
||||
hasGeminiApiKey,
|
||||
hasAdcWithProject
|
||||
);
|
||||
|
||||
if (hasGeminiApiKey) {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
resolvedBackend: effectiveBackend,
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAdcWithProject) {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
resolvedBackend: effectiveBackend,
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (authMethod === 'cli_oauth_personal' && effectiveBackend === 'cli-sdk') {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
resolvedBackend: 'cli-sdk',
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (authMethod === 'cli_oauth_personal') {
|
||||
return {
|
||||
authenticated: false,
|
||||
authMethod,
|
||||
resolvedBackend: effectiveBackend,
|
||||
projectId,
|
||||
statusMessage:
|
||||
'Gemini CLI OAuth was detected, but the active Gemini backend is not set to CLI SDK.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
authMethod,
|
||||
resolvedBackend,
|
||||
projectId,
|
||||
statusMessage:
|
||||
'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',
|
||||
};
|
||||
}
|
||||
57
src/main/services/runtime/providerRuntimeEnv.ts
Normal file
57
src/main/services/runtime/providerRuntimeEnv.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
const PROVIDER_ROUTING_ENV_KEYS = [
|
||||
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
|
||||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
] as const;
|
||||
|
||||
const BACKEND_SELECTION_ENV_KEYS = [
|
||||
'CLAUDE_CODE_GEMINI_BACKEND',
|
||||
'CLAUDE_CODE_CODEX_BACKEND',
|
||||
] as const;
|
||||
|
||||
export function applyConfiguredRuntimeBackendsEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
runtimeConfig = ConfigManager.getInstance().getConfig().runtime
|
||||
): NodeJS.ProcessEnv {
|
||||
for (const key of BACKEND_SELECTION_ENV_KEYS) {
|
||||
env[key] = undefined;
|
||||
}
|
||||
|
||||
env.CLAUDE_CODE_GEMINI_BACKEND = runtimeConfig.providerBackends.gemini;
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = runtimeConfig.providerBackends.codex;
|
||||
return env;
|
||||
}
|
||||
|
||||
export function applyProviderRuntimeEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: TeamProviderId | undefined
|
||||
): NodeJS.ProcessEnv {
|
||||
const resolvedProvider: TeamProviderId =
|
||||
providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
||||
|
||||
for (const key of PROVIDER_ROUTING_ENV_KEYS) {
|
||||
env[key] = undefined;
|
||||
}
|
||||
|
||||
// Provider overrides must be positive pins. In dev and multimodel desktop
|
||||
// flows the host process can already be routed to codex or gemini, and the
|
||||
// child runtime reapplies settings.env after trust. Mark the env as
|
||||
// host-managed and set the exact entry provider so anthropic teammates do not
|
||||
// silently fall back into the host's current routing world.
|
||||
env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1';
|
||||
env.CLAUDE_CODE_ENTRY_PROVIDER = resolvedProvider;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId {
|
||||
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
||||
}
|
||||
|
|
@ -13,6 +13,11 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
|||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { providerConnectionService } from '../runtime/ProviderConnectionService';
|
||||
import {
|
||||
applyConfiguredRuntimeBackendsEnv,
|
||||
applyProviderRuntimeEnv,
|
||||
} from '../runtime/providerRuntimeEnv';
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
|
||||
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
||||
|
|
@ -101,11 +106,25 @@ export class ScheduledTaskExecutor {
|
|||
|
||||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
||||
|
||||
const env = await providerConnectionService.applyConfiguredConnectionEnv(
|
||||
applyProviderRuntimeEnv(
|
||||
applyConfiguredRuntimeBackendsEnv({
|
||||
...buildEnrichedEnv(binaryPath),
|
||||
...shellEnv,
|
||||
CLAUDECODE: undefined,
|
||||
}),
|
||||
request.config.providerId
|
||||
),
|
||||
request.config.providerId === 'codex' || request.config.providerId === 'gemini'
|
||||
? request.config.providerId
|
||||
: 'anthropic'
|
||||
);
|
||||
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
cwd: request.config.cwd,
|
||||
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
||||
// take precedence over the cached snapshot inside buildEnrichedEnv.
|
||||
env: { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined },
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,8 @@ export class ChangeExtractorService {
|
|||
teamName,
|
||||
taskId,
|
||||
effectiveOptions,
|
||||
effectiveStateBucket
|
||||
effectiveStateBucket,
|
||||
version
|
||||
);
|
||||
|
||||
if (options?.forceFresh !== true) {
|
||||
|
|
@ -407,9 +408,10 @@ export class ChangeExtractorService {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
options: TaskChangeEffectiveOptions,
|
||||
stateBucket: TaskChangeStateBucket
|
||||
stateBucket: TaskChangeStateBucket,
|
||||
version: number
|
||||
): string {
|
||||
return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`;
|
||||
return `${teamName}:${taskId}:v${version}:${this.buildTaskSignature(options, stateBucket)}`;
|
||||
}
|
||||
|
||||
private normalizeFilePathKey(filePath: string): string {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
|
||||
import { getConfiguredCliFlavor } from './cliFlavor';
|
||||
|
||||
async function isExecutable(filePath: string): Promise<boolean> {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
|
|
@ -165,6 +169,40 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
|
|||
return null;
|
||||
}
|
||||
|
||||
async function resolveFromCandidateList(candidates: string[]): Promise<string | null> {
|
||||
for (const candidate of candidates) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveFromDoctorFallback(commandName: string): Promise<string | null> {
|
||||
const candidates = await getDoctorInvokedCandidates(commandName);
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = candidates[index];
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const resolved = await resolveFromExplicitPath(candidate);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveBundledOrchestratorBinary(): Promise<string | null> {
|
||||
const resourcesPath = process.resourcesPath?.trim();
|
||||
if (!resourcesPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binaryName = process.platform === 'win32' ? 'claude-multimodel.exe' : 'claude-multimodel';
|
||||
return resolveFromCandidateList([path.join(resourcesPath, 'runtime', binaryName)]);
|
||||
}
|
||||
|
||||
let cachedPath: string | null | undefined;
|
||||
|
||||
/** Timestamp of last successful cache verification (ms). */
|
||||
|
|
@ -213,8 +251,13 @@ export class ClaudeBinaryResolver {
|
|||
private static async runResolve(): Promise<string | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const enrichedPath = buildMergedCliPath(null);
|
||||
const flavor = getConfiguredCliFlavor();
|
||||
|
||||
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
|
||||
const overrideRaw =
|
||||
flavor === 'agent_teams_orchestrator'
|
||||
? (process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ??
|
||||
process.env.CLAUDE_CLI_PATH?.trim())
|
||||
: process.env.CLAUDE_CLI_PATH?.trim();
|
||||
if (overrideRaw) {
|
||||
const looksLikePath =
|
||||
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
||||
|
|
@ -229,6 +272,39 @@ export class ClaudeBinaryResolver {
|
|||
}
|
||||
}
|
||||
|
||||
if (flavor === 'agent_teams_orchestrator') {
|
||||
const bundledBinary = await resolveBundledOrchestratorBinary();
|
||||
if (bundledBinary) {
|
||||
cachedPath = bundledBinary;
|
||||
cacheVerifiedAt = Date.now();
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// Keep agent_teams_orchestrator resolution generic. Dev flows should
|
||||
// inject an explicit CLI path, while non-dev setups can expose
|
||||
// claude-multimodel on PATH without making this resolver guess a sibling
|
||||
// repo name or folder.
|
||||
const orchestratorBinaryName = 'claude-multimodel';
|
||||
const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath);
|
||||
if (fromPath) {
|
||||
cachedPath = fromPath;
|
||||
cacheVerifiedAt = Date.now();
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
|
||||
if (fromDoctor) {
|
||||
cachedPath = fromDoctor;
|
||||
cacheVerifiedAt = Date.now();
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// agent_teams_orchestrator mode is explicit. If the configured local
|
||||
// runtime is missing, fail closed instead of silently falling back to a
|
||||
// different CLI.
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseBinaryName = 'claude';
|
||||
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
||||
if (fromPath) {
|
||||
|
|
@ -241,9 +317,12 @@ export class ClaudeBinaryResolver {
|
|||
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
|
||||
|
||||
const home = getShellPreferredHome();
|
||||
const vendorBinDir = path.join(getClaudeBasePath(), 'local', 'node_modules', '.bin');
|
||||
const candidateDirs: string[] =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
// Windows: Claude npm-local vendor install
|
||||
vendorBinDir,
|
||||
// Windows: npm global install
|
||||
path.join(home, 'AppData', 'Roaming', 'npm'),
|
||||
// Windows: scoop, chocolatey, and other package managers
|
||||
|
|
@ -256,6 +335,8 @@ export class ClaudeBinaryResolver {
|
|||
...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []),
|
||||
]
|
||||
: [
|
||||
// Unix: Claude npm-local vendor install
|
||||
vendorBinDir,
|
||||
// Unix: native binary installation path (claude install)
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
|
|
@ -286,6 +367,13 @@ export class ClaudeBinaryResolver {
|
|||
return cachedPath;
|
||||
}
|
||||
|
||||
const fromDoctor = await resolveFromDoctorFallback(baseBinaryName);
|
||||
if (fromDoctor) {
|
||||
cachedPath = fromDoctor;
|
||||
cacheVerifiedAt = Date.now();
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// Don't cache null — CLI may be installed later without app restart
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
183
src/main/services/team/ClaudeDoctorProbe.ts
Normal file
183
src/main/services/team/ClaudeDoctorProbe.ts
Normal 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);
|
||||
}
|
||||
825
src/main/services/team/TeamBootstrapStateReader.ts
Normal file
825
src/main/services/team/TeamBootstrapStateReader.ts
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createPersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamProvisioningProgress,
|
||||
TeamRuntimeState,
|
||||
} from '@shared/types';
|
||||
|
||||
const TEAM_BOOTSTRAP_STATE_FILE = 'bootstrap-state.json';
|
||||
const TEAM_BOOTSTRAP_JOURNAL_FILE = 'bootstrap-journal.jsonl';
|
||||
const TEAM_BOOTSTRAP_LOCK_DIR = '.bootstrap.lock';
|
||||
const TEAM_BOOTSTRAP_LOCK_METADATA_FILE = 'metadata.json';
|
||||
const MAX_BOOTSTRAP_STATE_BYTES = 256 * 1024;
|
||||
const MAX_BOOTSTRAP_JOURNAL_BYTES = 256 * 1024;
|
||||
const MAX_BOOTSTRAP_LOCK_METADATA_BYTES = 64 * 1024;
|
||||
const ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 * 60 * 1000;
|
||||
|
||||
type RawBootstrapMemberState = {
|
||||
name?: unknown;
|
||||
status?: unknown;
|
||||
lastAttemptAt?: unknown;
|
||||
lastObservedAt?: unknown;
|
||||
failureReason?: unknown;
|
||||
};
|
||||
|
||||
type RawBootstrapState = {
|
||||
version?: unknown;
|
||||
runId?: unknown;
|
||||
teamName?: unknown;
|
||||
startedAt?: unknown;
|
||||
ownerPid?: unknown;
|
||||
updatedAt?: unknown;
|
||||
phase?: unknown;
|
||||
realTaskSubmissionState?: unknown;
|
||||
members?: unknown;
|
||||
terminal?: unknown;
|
||||
};
|
||||
|
||||
type RawBootstrapJournalRecord =
|
||||
| { ts?: unknown; type?: 'phase'; phase?: unknown }
|
||||
| { ts?: unknown; type?: 'lock'; action?: unknown; ownerPid?: unknown; detail?: unknown }
|
||||
| { ts?: unknown; type?: 'member'; name?: unknown; action?: unknown; detail?: unknown }
|
||||
| { ts?: unknown; type?: 'terminal'; status?: unknown; reason?: unknown }
|
||||
| { ts?: unknown; type?: 'real_task'; state?: unknown; detail?: unknown };
|
||||
|
||||
type RawBootstrapLockMetadata = {
|
||||
pid?: unknown;
|
||||
runId?: unknown;
|
||||
requestHash?: unknown;
|
||||
ownerStartedAt?: unknown;
|
||||
createdAt?: unknown;
|
||||
nonce?: unknown;
|
||||
};
|
||||
|
||||
type BootstrapStateInspection = {
|
||||
raw: RawBootstrapState | null;
|
||||
issue?: string;
|
||||
};
|
||||
|
||||
type BootstrapJournalInspection = {
|
||||
warnings?: string[];
|
||||
issue?: string;
|
||||
lastPhase?: BootstrapRuntimePhase;
|
||||
};
|
||||
|
||||
type BootstrapLockMetadata = {
|
||||
pid: number;
|
||||
runId: string;
|
||||
ownerStartedAt?: number;
|
||||
};
|
||||
|
||||
type BootstrapRuntimePhase =
|
||||
| 'validating_spec'
|
||||
| 'loading_existing_state'
|
||||
| 'acquiring_bootstrap_lock'
|
||||
| 'creating_team'
|
||||
| 'spawning_members'
|
||||
| 'auditing_truth'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled';
|
||||
|
||||
type ComparableStat = {
|
||||
dev?: number;
|
||||
ino?: number;
|
||||
size: number;
|
||||
mode?: number;
|
||||
mtimeMs?: number;
|
||||
};
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function sameFiniteNumber(a: unknown, b: unknown): boolean {
|
||||
return isFiniteNumber(a) && isFiniteNumber(b) && a === b;
|
||||
}
|
||||
|
||||
function didValidatedFileChange(expected: ComparableStat, actual: ComparableStat): boolean {
|
||||
const comparableIdentity =
|
||||
isFiniteNumber(expected.dev) &&
|
||||
isFiniteNumber(actual.dev) &&
|
||||
isFiniteNumber(expected.ino) &&
|
||||
isFiniteNumber(actual.ino);
|
||||
if (comparableIdentity) {
|
||||
return expected.dev !== actual.dev || expected.ino !== actual.ino;
|
||||
}
|
||||
|
||||
if (sameFiniteNumber(expected.dev, actual.dev) && sameFiniteNumber(expected.ino, actual.ino)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
expected.size !== actual.size ||
|
||||
expected.mode !== actual.mode ||
|
||||
expected.mtimeMs !== actual.mtimeMs
|
||||
);
|
||||
}
|
||||
|
||||
async function readBoundRegularUtf8File(
|
||||
targetPath: string,
|
||||
maxBytes: number,
|
||||
messages: {
|
||||
notRegular: string;
|
||||
oversized: string;
|
||||
invalid: string;
|
||||
}
|
||||
): Promise<{ contents: string } | { issue: string } | null> {
|
||||
try {
|
||||
const validated = await fs.promises.lstat(targetPath);
|
||||
if (validated.isSymbolicLink() || !validated.isFile()) {
|
||||
return { issue: messages.notRegular };
|
||||
}
|
||||
if (validated.size > maxBytes) {
|
||||
return { issue: messages.oversized };
|
||||
}
|
||||
|
||||
let handle: fs.promises.FileHandle;
|
||||
try {
|
||||
handle = await fs.promises.open(targetPath, 'r');
|
||||
} catch {
|
||||
return { issue: messages.invalid };
|
||||
}
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
if (!opened.isFile() || didValidatedFileChange(validated, opened)) {
|
||||
return { issue: messages.invalid };
|
||||
}
|
||||
if (opened.size > maxBytes) {
|
||||
return { issue: messages.oversized };
|
||||
}
|
||||
return {
|
||||
contents: await handle.readFile({ encoding: 'utf8' }),
|
||||
};
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
return { issue: messages.invalid };
|
||||
}
|
||||
}
|
||||
|
||||
function isBootstrapPhaseTerminal(phase: BootstrapRuntimePhase): boolean {
|
||||
return phase === 'completed' || phase === 'failed' || phase === 'canceled';
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function classifyBootstrapOwnerState(raw: RawBootstrapState): {
|
||||
ownerDead: boolean;
|
||||
stale: boolean;
|
||||
failureReason?: string;
|
||||
} {
|
||||
const phase = typeof raw.phase === 'string' ? (raw.phase as BootstrapRuntimePhase) : null;
|
||||
if (!phase || isBootstrapPhaseTerminal(phase)) {
|
||||
return { ownerDead: false, stale: false };
|
||||
}
|
||||
|
||||
const ownerPid = typeof raw.ownerPid === 'number' ? raw.ownerPid : null;
|
||||
if (ownerPid === null || isProcessAlive(ownerPid)) {
|
||||
return { ownerDead: false, stale: false };
|
||||
}
|
||||
|
||||
const updatedAtMs =
|
||||
typeof raw.updatedAt === 'number'
|
||||
? raw.updatedAt
|
||||
: typeof raw.updatedAt === 'string'
|
||||
? Date.parse(raw.updatedAt)
|
||||
: NaN;
|
||||
const stale =
|
||||
Number.isFinite(updatedAtMs) &&
|
||||
Date.now() - updatedAtMs >= ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS;
|
||||
|
||||
return {
|
||||
ownerDead: true,
|
||||
stale,
|
||||
failureReason: stale
|
||||
? `bootstrap owner pid ${ownerPid} is gone and persisted bootstrap state is stale`
|
||||
: `bootstrap owner pid ${ownerPid} is gone before bootstrap reached a terminal state`,
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectBootstrapState(teamName: string): Promise<BootstrapStateInspection> {
|
||||
const targetPath = getTeamBootstrapStatePath(teamName);
|
||||
const file = await readBoundRegularUtf8File(targetPath, MAX_BOOTSTRAP_STATE_BYTES, {
|
||||
notRegular:
|
||||
'Persisted deterministic bootstrap state is unreadable because bootstrap-state.json is a symlink or not a regular file.',
|
||||
oversized:
|
||||
'Persisted deterministic bootstrap state is unreadable because bootstrap-state.json is oversized.',
|
||||
invalid:
|
||||
'Persisted deterministic bootstrap state is unreadable because bootstrap-state.json is invalid, truncated, inaccessible, or changed while being read.',
|
||||
});
|
||||
if (!file) {
|
||||
return { raw: null };
|
||||
}
|
||||
if ('issue' in file) {
|
||||
return {
|
||||
raw: null,
|
||||
issue: file.issue,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(file.contents) as RawBootstrapState;
|
||||
if (raw.version !== 1) {
|
||||
return {
|
||||
raw: null,
|
||||
issue:
|
||||
'Persisted deterministic bootstrap state is unreadable because bootstrap-state.json has an unsupported schema version.',
|
||||
};
|
||||
}
|
||||
return { raw };
|
||||
} catch {
|
||||
return {
|
||||
raw: null,
|
||||
issue:
|
||||
'Persisted deterministic bootstrap state is unreadable because bootstrap-state.json is invalid, truncated, inaccessible, or changed while being read.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function readRawBootstrapState(teamName: string): Promise<RawBootstrapState | null> {
|
||||
return (await inspectBootstrapState(teamName)).raw;
|
||||
}
|
||||
|
||||
function getBootstrapProgressProjection(
|
||||
phase: BootstrapRuntimePhase,
|
||||
memberCount: number
|
||||
): { state: Exclude<TeamProvisioningProgress['state'], 'idle'>; message: string } | null {
|
||||
switch (phase) {
|
||||
case 'validating_spec':
|
||||
return {
|
||||
state: 'validating',
|
||||
message: 'Validating deterministic bootstrap spec',
|
||||
};
|
||||
case 'loading_existing_state':
|
||||
return {
|
||||
state: 'configuring',
|
||||
message: 'Loading existing team state',
|
||||
};
|
||||
case 'acquiring_bootstrap_lock':
|
||||
return {
|
||||
state: 'configuring',
|
||||
message: 'Acquiring deterministic bootstrap lock',
|
||||
};
|
||||
case 'creating_team':
|
||||
return {
|
||||
state: 'assembling',
|
||||
message: 'Creating team config',
|
||||
};
|
||||
case 'spawning_members':
|
||||
return {
|
||||
state: 'assembling',
|
||||
message:
|
||||
memberCount > 0
|
||||
? `Spawning teammate runtimes (${memberCount})`
|
||||
: 'Spawning teammate runtimes',
|
||||
};
|
||||
case 'auditing_truth':
|
||||
return {
|
||||
state: 'finalizing',
|
||||
message: 'Auditing registered teammates and bootstrap truth',
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
state: 'ready',
|
||||
message: 'Deterministic bootstrap completed',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
state: 'failed',
|
||||
message: 'Deterministic bootstrap failed',
|
||||
};
|
||||
case 'canceled':
|
||||
return {
|
||||
state: 'cancelled',
|
||||
message: 'Deterministic bootstrap cancelled',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toIso(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeBootstrapMemberState(
|
||||
memberName: string,
|
||||
raw: RawBootstrapMemberState,
|
||||
updatedAt: string
|
||||
): PersistedTeamLaunchMemberState {
|
||||
const status = typeof raw.status === 'string' ? raw.status : 'pending';
|
||||
const hardFailure = status === 'failed';
|
||||
const bootstrapConfirmed = status === 'bootstrap_confirmed';
|
||||
const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive';
|
||||
const agentToolAccepted =
|
||||
bootstrapConfirmed ||
|
||||
runtimeAlive ||
|
||||
status === 'registered' ||
|
||||
status === 'spawn_started' ||
|
||||
hardFailure;
|
||||
|
||||
return {
|
||||
name: memberName,
|
||||
launchState: hardFailure
|
||||
? 'failed_to_start'
|
||||
: bootstrapConfirmed
|
||||
? 'confirmed_alive'
|
||||
: runtimeAlive || agentToolAccepted
|
||||
? 'runtime_pending_bootstrap'
|
||||
: 'starting',
|
||||
agentToolAccepted,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed,
|
||||
hardFailure,
|
||||
hardFailureReason:
|
||||
typeof raw.failureReason === 'string' && raw.failureReason.trim().length > 0
|
||||
? raw.failureReason.trim()
|
||||
: undefined,
|
||||
firstSpawnAcceptedAt: agentToolAccepted ? toIso(raw.lastAttemptAt, updatedAt) : undefined,
|
||||
lastHeartbeatAt: bootstrapConfirmed ? toIso(raw.lastObservedAt, updatedAt) : undefined,
|
||||
lastRuntimeAliveAt: runtimeAlive ? toIso(raw.lastObservedAt, updatedAt) : undefined,
|
||||
lastEvaluatedAt: toIso(raw.lastObservedAt, updatedAt),
|
||||
sources: {
|
||||
configRegistered:
|
||||
status === 'registered' ||
|
||||
status === 'runtime_alive' ||
|
||||
status === 'bootstrap_confirmed' ||
|
||||
hardFailure,
|
||||
processAlive: runtimeAlive || undefined,
|
||||
hardFailureSignal: hardFailure || undefined,
|
||||
},
|
||||
diagnostics: hardFailure
|
||||
? [
|
||||
typeof raw.failureReason === 'string' && raw.failureReason.trim().length > 0
|
||||
? raw.failureReason.trim()
|
||||
: 'deterministic bootstrap failed',
|
||||
]
|
||||
: runtimeAlive
|
||||
? bootstrapConfirmed
|
||||
? ['late heartbeat received']
|
||||
: ['runtime alive', 'waiting for teammate check-in']
|
||||
: agentToolAccepted
|
||||
? ['spawn accepted']
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTeamBootstrapStatePath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, TEAM_BOOTSTRAP_STATE_FILE);
|
||||
}
|
||||
|
||||
function getTeamBootstrapJournalPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, TEAM_BOOTSTRAP_JOURNAL_FILE);
|
||||
}
|
||||
|
||||
function getTeamBootstrapLockMetadataPath(teamName: string): string {
|
||||
return path.join(
|
||||
getTeamsBasePath(),
|
||||
teamName,
|
||||
TEAM_BOOTSTRAP_LOCK_DIR,
|
||||
TEAM_BOOTSTRAP_LOCK_METADATA_FILE
|
||||
);
|
||||
}
|
||||
|
||||
async function readBootstrapLockMetadata(teamName: string): Promise<BootstrapLockMetadata | null> {
|
||||
const targetPath = getTeamBootstrapLockMetadataPath(teamName);
|
||||
const file = await readBoundRegularUtf8File(targetPath, MAX_BOOTSTRAP_LOCK_METADATA_BYTES, {
|
||||
notRegular: '',
|
||||
oversized: '',
|
||||
invalid: '',
|
||||
});
|
||||
if (!file || 'issue' in file) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(file.contents) as RawBootstrapLockMetadata;
|
||||
if (
|
||||
typeof raw.pid !== 'number' ||
|
||||
!Number.isFinite(raw.pid) ||
|
||||
raw.pid <= 0 ||
|
||||
typeof raw.runId !== 'string' ||
|
||||
raw.runId.trim().length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pid: raw.pid,
|
||||
runId: raw.runId.trim(),
|
||||
ownerStartedAt:
|
||||
typeof raw.ownerStartedAt === 'number' && Number.isFinite(raw.ownerStartedAt)
|
||||
? raw.ownerStartedAt
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBootstrapJournalWarnings(teamName: string): Promise<string[] | undefined> {
|
||||
const inspection = await inspectBootstrapJournal(teamName);
|
||||
const warnings = [inspection.issue, ...(inspection.warnings ?? [])].filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
||||
);
|
||||
return warnings.length > 0 ? warnings : undefined;
|
||||
}
|
||||
|
||||
async function inspectBootstrapJournal(teamName: string): Promise<BootstrapJournalInspection> {
|
||||
const targetPath = getTeamBootstrapJournalPath(teamName);
|
||||
const file = await readBoundRegularUtf8File(targetPath, MAX_BOOTSTRAP_JOURNAL_BYTES, {
|
||||
notRegular:
|
||||
'Persisted deterministic bootstrap journal is unreadable because bootstrap-journal.jsonl is a symlink or not a regular file.',
|
||||
oversized:
|
||||
'Persisted deterministic bootstrap journal is unreadable because bootstrap-journal.jsonl is oversized.',
|
||||
invalid:
|
||||
'Persisted deterministic bootstrap journal is unreadable because bootstrap-journal.jsonl is invalid, truncated, inaccessible, or changed while being read.',
|
||||
});
|
||||
if (!file) {
|
||||
return {};
|
||||
}
|
||||
if ('issue' in file) {
|
||||
return {
|
||||
issue: file.issue,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const raw = file.contents;
|
||||
const lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-3);
|
||||
|
||||
const records = lines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as RawBootstrapJournalRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((record): record is RawBootstrapJournalRecord => Boolean(record));
|
||||
|
||||
const messages = records
|
||||
.map((record) => {
|
||||
if (record.type === 'phase' && typeof record.phase === 'string') {
|
||||
return `bootstrap phase: ${record.phase}`;
|
||||
}
|
||||
if (record.type === 'lock' && typeof record.action === 'string') {
|
||||
const owner = typeof record.ownerPid === 'number' ? ` (pid ${record.ownerPid})` : '';
|
||||
return `bootstrap lock ${record.action}${owner}`;
|
||||
}
|
||||
if (
|
||||
record.type === 'member' &&
|
||||
typeof record.name === 'string' &&
|
||||
typeof record.action === 'string'
|
||||
) {
|
||||
return typeof record.detail === 'string' && record.detail.trim().length > 0
|
||||
? `${record.name}: ${record.action} (${record.detail.trim()})`
|
||||
: `${record.name}: ${record.action}`;
|
||||
}
|
||||
if (record.type === 'terminal' && typeof record.status === 'string') {
|
||||
return typeof record.reason === 'string' && record.reason.trim().length > 0
|
||||
? `bootstrap ${record.status}: ${record.reason.trim()}`
|
||||
: `bootstrap ${record.status}`;
|
||||
}
|
||||
if (record.type === 'real_task' && typeof record.state === 'string') {
|
||||
return typeof record.detail === 'string' && record.detail.trim().length > 0
|
||||
? `first task ${record.state}: ${record.detail.trim()}`
|
||||
: `first task ${record.state}`;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
if (lines.length > 0 && messages.length === 0) {
|
||||
return {
|
||||
issue:
|
||||
'Persisted deterministic bootstrap journal is unreadable because bootstrap-journal.jsonl is invalid, truncated, inaccessible, or changed while being read.',
|
||||
};
|
||||
}
|
||||
|
||||
const lastPhaseRecord = [...records]
|
||||
.reverse()
|
||||
.find(
|
||||
(record): record is Extract<RawBootstrapJournalRecord, { type?: 'phase' }> =>
|
||||
record.type === 'phase' && typeof record.phase === 'string'
|
||||
);
|
||||
|
||||
return {
|
||||
...(lastPhaseRecord?.phase
|
||||
? { lastPhase: lastPhaseRecord.phase as BootstrapRuntimePhase }
|
||||
: {}),
|
||||
warnings:
|
||||
messages.length > 0
|
||||
? [`Recent deterministic bootstrap events: ${messages.join(' | ')}`]
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
issue:
|
||||
'Persisted deterministic bootstrap journal is unreadable because bootstrap-journal.jsonl is invalid, truncated, inaccessible, or changed while being read.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function readDegradedBootstrapRuntimeState(
|
||||
teamName: string,
|
||||
stateIssue: string
|
||||
): Promise<TeamRuntimeState | null> {
|
||||
const lockMetadata = await readBootstrapLockMetadata(teamName);
|
||||
if (!lockMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const journalInspection = await inspectBootstrapJournal(teamName);
|
||||
const warnings = [
|
||||
stateIssue,
|
||||
journalInspection.issue,
|
||||
...(journalInspection.warnings ?? []),
|
||||
].filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
const ownerAlive = isProcessAlive(lockMetadata.pid);
|
||||
const now = new Date().toISOString();
|
||||
const degradedProjection =
|
||||
ownerAlive && journalInspection.lastPhase
|
||||
? getBootstrapProgressProjection(journalInspection.lastPhase, 0)
|
||||
: null;
|
||||
const projectedState =
|
||||
degradedProjection &&
|
||||
degradedProjection.state !== 'ready' &&
|
||||
degradedProjection.state !== 'failed' &&
|
||||
degradedProjection.state !== 'cancelled'
|
||||
? degradedProjection.state
|
||||
: 'configuring';
|
||||
const projectedMessage =
|
||||
degradedProjection &&
|
||||
degradedProjection.state !== 'ready' &&
|
||||
degradedProjection.state !== 'failed' &&
|
||||
degradedProjection.state !== 'cancelled'
|
||||
? `${degradedProjection.message} (degraded recovery)`
|
||||
: 'Deterministic bootstrap recovery is degraded because persisted bootstrap state is unreadable';
|
||||
|
||||
return {
|
||||
teamName,
|
||||
isAlive: false,
|
||||
runId: lockMetadata.runId,
|
||||
progress: {
|
||||
runId: lockMetadata.runId,
|
||||
teamName,
|
||||
state: ownerAlive ? projectedState : 'failed',
|
||||
message: ownerAlive
|
||||
? projectedMessage
|
||||
: 'Deterministic bootstrap recovery failed because persisted bootstrap state is unreadable and the bootstrap owner is gone',
|
||||
messageSeverity: 'warning',
|
||||
error: ownerAlive
|
||||
? stateIssue
|
||||
: `${stateIssue} Bootstrap owner pid ${lockMetadata.pid} is not alive.`,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
startedAt:
|
||||
typeof lockMetadata.ownerStartedAt === 'number' &&
|
||||
Number.isFinite(lockMetadata.ownerStartedAt)
|
||||
? new Date(lockMetadata.ownerStartedAt).toISOString()
|
||||
: now,
|
||||
updatedAt: now,
|
||||
pid: lockMetadata.pid,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readBootstrapLaunchSnapshot(
|
||||
teamName: string
|
||||
): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||
const raw = await readRawBootstrapState(teamName);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const updatedAt = toIso(raw.updatedAt, new Date().toISOString());
|
||||
const rawMembers = Array.isArray(raw.members) ? raw.members : [];
|
||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
const expectedMembers: string[] = [];
|
||||
|
||||
for (const item of rawMembers) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const rawMember = item as RawBootstrapMemberState;
|
||||
const memberName = typeof rawMember.name === 'string' ? rawMember.name.trim() : '';
|
||||
if (!memberName || memberName === 'team-lead' || memberName === 'user') continue;
|
||||
expectedMembers.push(memberName);
|
||||
members[memberName] = normalizeBootstrapMemberState(memberName, rawMember, updatedAt);
|
||||
}
|
||||
|
||||
const terminal =
|
||||
raw.terminal && typeof raw.terminal === 'object'
|
||||
? (raw.terminal as Record<string, unknown>)
|
||||
: null;
|
||||
const terminalStatus = typeof terminal?.status === 'string' ? terminal.status : undefined;
|
||||
const phase = typeof raw.phase === 'string' ? raw.phase : undefined;
|
||||
const ownerState = classifyBootstrapOwnerState(raw);
|
||||
const launchPhase =
|
||||
terminalStatus === 'completed' ||
|
||||
terminalStatus === 'partial_success' ||
|
||||
terminalStatus === 'failed' ||
|
||||
terminalStatus === 'canceled' ||
|
||||
ownerState.ownerDead ||
|
||||
phase === 'completed' ||
|
||||
phase === 'failed' ||
|
||||
phase === 'canceled'
|
||||
? 'finished'
|
||||
: 'active';
|
||||
|
||||
if (ownerState.ownerDead) {
|
||||
const diagnostics = ownerState.failureReason ? [ownerState.failureReason] : undefined;
|
||||
for (const memberName of expectedMembers) {
|
||||
const entry = members[memberName];
|
||||
if (
|
||||
!entry ||
|
||||
entry.launchState === 'confirmed_alive' ||
|
||||
entry.launchState === 'failed_to_start'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
members[memberName] = {
|
||||
...entry,
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
hardFailureReason: ownerState.failureReason,
|
||||
diagnostics: diagnostics ?? entry.diagnostics,
|
||||
sources: {
|
||||
...entry.sources,
|
||||
hardFailureSignal: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return createPersistedLaunchSnapshot({
|
||||
teamName:
|
||||
typeof raw.teamName === 'string' && raw.teamName.trim().length > 0
|
||||
? raw.teamName.trim()
|
||||
: teamName,
|
||||
expectedMembers,
|
||||
launchPhase,
|
||||
members,
|
||||
updatedAt,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBootstrapRealTaskSubmissionState(
|
||||
teamName: string
|
||||
): Promise<'not_submitted' | 'submitted' | 'unknown' | null> {
|
||||
const raw = await readRawBootstrapState(teamName);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const state = raw.realTaskSubmissionState;
|
||||
return state === 'not_submitted' || state === 'submitted' || state === 'unknown' ? state : null;
|
||||
}
|
||||
|
||||
export async function readBootstrapRuntimeState(
|
||||
teamName: string
|
||||
): Promise<TeamRuntimeState | null> {
|
||||
const inspection = await inspectBootstrapState(teamName);
|
||||
const raw = inspection.raw;
|
||||
if (!raw) {
|
||||
return inspection.issue ? readDegradedBootstrapRuntimeState(teamName, inspection.issue) : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const journalWarnings = await readBootstrapJournalWarnings(teamName);
|
||||
const phase = typeof raw.phase === 'string' ? (raw.phase as BootstrapRuntimePhase) : null;
|
||||
if (!phase) {
|
||||
return null;
|
||||
}
|
||||
const ownerState = classifyBootstrapOwnerState(raw);
|
||||
if (ownerState.ownerDead) {
|
||||
const startedAt = toIso(raw.startedAt, new Date().toISOString());
|
||||
const updatedAt = toIso(raw.updatedAt, startedAt);
|
||||
return {
|
||||
teamName:
|
||||
typeof raw.teamName === 'string' && raw.teamName.trim().length > 0
|
||||
? raw.teamName.trim()
|
||||
: teamName,
|
||||
isAlive: false,
|
||||
runId: typeof raw.runId === 'string' ? raw.runId : null,
|
||||
progress: {
|
||||
runId: typeof raw.runId === 'string' ? raw.runId : teamName,
|
||||
teamName:
|
||||
typeof raw.teamName === 'string' && raw.teamName.trim().length > 0
|
||||
? raw.teamName.trim()
|
||||
: teamName,
|
||||
state: 'failed',
|
||||
message: ownerState.stale
|
||||
? 'Deterministic bootstrap became stuck after owner process exited'
|
||||
: 'Deterministic bootstrap owner exited before bootstrap completed',
|
||||
error: ownerState.failureReason,
|
||||
warnings: journalWarnings,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
...(typeof raw.ownerPid === 'number' ? { pid: raw.ownerPid } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
const activePhases: BootstrapRuntimePhase[] = [
|
||||
'validating_spec',
|
||||
'loading_existing_state',
|
||||
'acquiring_bootstrap_lock',
|
||||
'creating_team',
|
||||
'spawning_members',
|
||||
'auditing_truth',
|
||||
];
|
||||
if (!activePhases.includes(phase)) {
|
||||
return null;
|
||||
}
|
||||
const projection = getBootstrapProgressProjection(
|
||||
phase,
|
||||
Array.isArray(raw.members) ? raw.members.length : 0
|
||||
);
|
||||
if (!projection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt = toIso(raw.startedAt, new Date().toISOString());
|
||||
const updatedAt = toIso(raw.updatedAt, startedAt);
|
||||
const runId = typeof raw.runId === 'string' && raw.runId.trim().length > 0 ? raw.runId : null;
|
||||
const pid =
|
||||
typeof raw.ownerPid === 'number' && Number.isFinite(raw.ownerPid) && raw.ownerPid > 0
|
||||
? raw.ownerPid
|
||||
: undefined;
|
||||
|
||||
const progress: TeamProvisioningProgress = {
|
||||
runId: runId ?? `bootstrap:${teamName}`,
|
||||
teamName:
|
||||
typeof raw.teamName === 'string' && raw.teamName.trim().length > 0
|
||||
? raw.teamName.trim()
|
||||
: teamName,
|
||||
state: projection.state,
|
||||
message: projection.message,
|
||||
warnings: journalWarnings,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
...(pid ? { pid } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
teamName:
|
||||
typeof raw.teamName === 'string' && raw.teamName.trim().length > 0
|
||||
? raw.teamName.trim()
|
||||
: teamName,
|
||||
isAlive: false,
|
||||
runId,
|
||||
progress,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearBootstrapState(teamName: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.rm(getTeamBootstrapStatePath(teamName), { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function choosePreferredLaunchSnapshot<T extends { updatedAt?: string }>(
|
||||
bootstrapSnapshot: T | null,
|
||||
launchSnapshot: T | null
|
||||
): T | null {
|
||||
if (!bootstrapSnapshot) return launchSnapshot;
|
||||
if (!launchSnapshot) return bootstrapSnapshot;
|
||||
|
||||
const bootstrapMs = Date.parse(bootstrapSnapshot.updatedAt ?? '');
|
||||
const launchMs = Date.parse(launchSnapshot.updatedAt ?? '');
|
||||
if (Number.isFinite(bootstrapMs) && Number.isFinite(launchMs)) {
|
||||
return bootstrapMs >= launchMs ? bootstrapSnapshot : launchSnapshot;
|
||||
}
|
||||
return bootstrapSnapshot;
|
||||
}
|
||||
|
|
@ -9,7 +9,12 @@ import {
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
choosePreferredLaunchSnapshot,
|
||||
readBootstrapLaunchSnapshot,
|
||||
} from './TeamBootstrapStateReader';
|
||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
|
||||
|
|
@ -24,6 +29,71 @@ const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full conf
|
|||
const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
||||
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
||||
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||
|
||||
interface LaunchStateSummary {
|
||||
partialLaunchFailure?: true;
|
||||
expectedMemberCount?: number;
|
||||
confirmedMemberCount?: number;
|
||||
missingMembers?: string[];
|
||||
teamLaunchState?: TeamSummary['teamLaunchState'];
|
||||
launchUpdatedAt?: string;
|
||||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
}
|
||||
|
||||
async function readLaunchStateSummary(teamDir: string): Promise<LaunchStateSummary | null> {
|
||||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(path.basename(teamDir));
|
||||
const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE);
|
||||
const launchSnapshot = await (async () => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchStatePath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS);
|
||||
return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const snapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const missingMembers = snapshot.expectedMembers.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
? { partialLaunchFailure: true as const }
|
||||
: {}),
|
||||
...(snapshot.expectedMembers.length > 0
|
||||
? { expectedMemberCount: snapshot.expectedMembers.length }
|
||||
: {}),
|
||||
...(snapshot.summary.confirmedCount > 0
|
||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(
|
||||
items: readonly T[],
|
||||
|
|
@ -132,6 +202,7 @@ export class TeamConfigReader {
|
|||
|
||||
private async readTeamSummary(teamsDir: string, teamName: string): Promise<TeamSummary | null> {
|
||||
const configPath = path.join(teamsDir, teamName, 'config.json');
|
||||
const teamDir = path.join(teamsDir, teamName);
|
||||
|
||||
try {
|
||||
let config: TeamConfig | null = null;
|
||||
|
|
@ -204,6 +275,8 @@ export class TeamConfigReader {
|
|||
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
|
||||
const memberMap = new Map<string, TeamSummaryMember>();
|
||||
const removedKeys = new Set<string>();
|
||||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
|
||||
const mergeMember = (m: TeamMember): void => {
|
||||
const name = m.name?.trim();
|
||||
|
|
@ -235,6 +308,7 @@ export class TeamConfigReader {
|
|||
removedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
expectedTeammateNames.add(name);
|
||||
mergeMember(member);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -245,11 +319,28 @@ export class TeamConfigReader {
|
|||
if (config && Array.isArray(config.members)) {
|
||||
for (const member of config.members) {
|
||||
if (member && typeof member.name === 'string') {
|
||||
const name = member.name.trim();
|
||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||
confirmedArtifactNames.add(name);
|
||||
}
|
||||
mergeMember(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const inboxDir = path.join(teamDir, 'inboxes');
|
||||
const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true });
|
||||
for (const entry of inboxEntries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
||||
const inboxName = entry.name.slice(0, -'.json'.length).trim();
|
||||
if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue;
|
||||
confirmedArtifactNames.add(inboxName);
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
const allNames = Array.from(memberMap.values()).map((m) => m.name);
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
|
|
@ -262,6 +353,29 @@ export class TeamConfigReader {
|
|||
}
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const launchStateSummary =
|
||||
(await readLaunchStateSummary(teamDir)) ??
|
||||
(() => {
|
||||
if (
|
||||
!leadSessionId ||
|
||||
expectedTeammateNames.size === 0 ||
|
||||
confirmedArtifactNames.size === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const missingMembers = Array.from(expectedTeammateNames).filter(
|
||||
(name) => !confirmedArtifactNames.has(name)
|
||||
);
|
||||
if (missingMembers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
partialLaunchFailure: true as const,
|
||||
expectedMemberCount: expectedTeammateNames.size,
|
||||
confirmedMemberCount: confirmedArtifactNames.size,
|
||||
missingMembers,
|
||||
};
|
||||
})();
|
||||
const summary: TeamSummary = {
|
||||
teamName,
|
||||
displayName,
|
||||
|
|
@ -276,6 +390,7 @@ export class TeamConfigReader {
|
|||
...(projectPathHistory ? { projectPathHistory } : {}),
|
||||
...(sessionHistory ? { sessionHistory } : {}),
|
||||
...(deletedAt ? { deletedAt } : {}),
|
||||
...(launchStateSummary ?? {}),
|
||||
};
|
||||
return summary;
|
||||
} catch {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,10 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files
|
||||
|
|
@ -96,10 +97,10 @@ export class TeamInboxReader {
|
|||
// often lack messageId because Claude Code CLI doesn't generate one.
|
||||
// We produce a deterministic hash so the same message always gets the same ID
|
||||
// across reads — important for React keys, dedup, and message tracking.
|
||||
const messageId =
|
||||
typeof row.messageId === 'string' && row.messageId.trim().length > 0
|
||||
? row.messageId
|
||||
: `inbox-${createHash('sha256').update(`${row.from}\n${row.timestamp}\n${row.text}`).digest('hex').slice(0, 16)}`;
|
||||
const messageId = getEffectiveInboxMessageId(row);
|
||||
if (!messageId) {
|
||||
continue;
|
||||
}
|
||||
messages.push({
|
||||
from: row.from,
|
||||
to: typeof row.to === 'string' ? row.to : undefined,
|
||||
|
|
|
|||
427
src/main/services/team/TeamLaunchStateEvaluator.ts
Normal file
427
src/main/services/team/TeamLaunchStateEvaluator.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
PersistedTeamLaunchMemberSources,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
PersistedTeamLaunchSummary,
|
||||
TeamLaunchAggregateState,
|
||||
} from '@shared/types';
|
||||
|
||||
interface LegacyPartialLaunchStateFile {
|
||||
version?: unknown;
|
||||
state?: unknown;
|
||||
updatedAt?: unknown;
|
||||
leadSessionId?: unknown;
|
||||
expectedMembers?: unknown;
|
||||
confirmedMembers?: unknown;
|
||||
missingMembers?: unknown;
|
||||
}
|
||||
|
||||
type RuntimeMemberSpawnState = Pick<
|
||||
MemberSpawnStatusEntry,
|
||||
| 'launchState'
|
||||
| 'status'
|
||||
| 'error'
|
||||
| 'hardFailureReason'
|
||||
| 'livenessSource'
|
||||
| 'agentToolAccepted'
|
||||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailure'
|
||||
| 'firstSpawnAcceptedAt'
|
||||
| 'lastHeartbeatAt'
|
||||
| 'updatedAt'
|
||||
>;
|
||||
|
||||
function normalizeMemberName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' | 'sources'
|
||||
>
|
||||
): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
if (member.agentToolAccepted) diagnostics.push('spawn accepted');
|
||||
if (member.runtimeAlive) diagnostics.push('runtime alive');
|
||||
if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received');
|
||||
if (member.runtimeAlive && !member.bootstrapConfirmed)
|
||||
diagnostics.push('waiting for teammate check-in');
|
||||
if (member.hardFailureReason)
|
||||
diagnostics.push(`hard failure reason: ${member.hardFailureReason}`);
|
||||
if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate');
|
||||
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function deriveTeamLaunchAggregateState(
|
||||
summary: PersistedTeamLaunchSummary
|
||||
): TeamLaunchAggregateState {
|
||||
if (summary.failedCount > 0) {
|
||||
return 'partial_failure';
|
||||
}
|
||||
if (summary.pendingCount > 0) {
|
||||
return 'partial_pending';
|
||||
}
|
||||
return 'clean_success';
|
||||
}
|
||||
|
||||
export function summarizePersistedLaunchMembers(
|
||||
expectedMembers: readonly string[],
|
||||
members: Record<string, PersistedTeamLaunchMemberState>
|
||||
): PersistedTeamLaunchSummary {
|
||||
let confirmedCount = 0;
|
||||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
const normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean);
|
||||
|
||||
for (const memberName of normalizedExpected) {
|
||||
const entry = members[memberName];
|
||||
if (!entry) {
|
||||
pendingCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'confirmed_alive') {
|
||||
confirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
}
|
||||
pendingCount += 1;
|
||||
if (entry.runtimeAlive) {
|
||||
runtimeAlivePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount };
|
||||
}
|
||||
|
||||
function deriveMemberLaunchState(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
'hardFailure' | 'bootstrapConfirmed' | 'runtimeAlive' | 'agentToolAccepted'
|
||||
>
|
||||
): MemberLaunchState {
|
||||
if (member.hardFailure) {
|
||||
return 'failed_to_start';
|
||||
}
|
||||
if (member.bootstrapConfirmed) {
|
||||
return 'confirmed_alive';
|
||||
}
|
||||
if (member.runtimeAlive || member.agentToolAccepted) {
|
||||
return 'runtime_pending_bootstrap';
|
||||
}
|
||||
return 'starting';
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeSources(value: unknown): PersistedTeamLaunchMemberSources | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
const normalized: PersistedTeamLaunchMemberSources = {
|
||||
inboxHeartbeat: toBoolean(source.inboxHeartbeat),
|
||||
nativeHeartbeat: toBoolean(source.nativeHeartbeat),
|
||||
processAlive: toBoolean(source.processAlive),
|
||||
configRegistered: toBoolean(source.configRegistered),
|
||||
configDrift: toBoolean(source.configDrift),
|
||||
hardFailureSignal: toBoolean(source.hardFailureSignal),
|
||||
duplicateRespawnBlocked: toBoolean(source.duplicateRespawnBlocked),
|
||||
};
|
||||
return Object.values(normalized).some(Boolean) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizePersistedMemberState(
|
||||
memberName: string,
|
||||
value: unknown,
|
||||
updatedAtFallback: string
|
||||
): PersistedTeamLaunchMemberState | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const parsed = value as Record<string, unknown>;
|
||||
const normalizedName = normalizeMemberName(memberName);
|
||||
if (!normalizedName || normalizedName === 'user' || isLeadMember({ name: normalizedName })) {
|
||||
return null;
|
||||
}
|
||||
const next: PersistedTeamLaunchMemberState = {
|
||||
name: normalizedName,
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||
bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed),
|
||||
hardFailure: toBoolean(parsed.hardFailure),
|
||||
hardFailureReason:
|
||||
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||
? parsed.hardFailureReason.trim()
|
||||
: undefined,
|
||||
firstSpawnAcceptedAt:
|
||||
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
|
||||
lastHeartbeatAt:
|
||||
typeof parsed.lastHeartbeatAt === 'string' ? parsed.lastHeartbeatAt : undefined,
|
||||
lastRuntimeAliveAt:
|
||||
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
|
||||
lastEvaluatedAt:
|
||||
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
|
||||
sources: normalizeSources(parsed.sources),
|
||||
diagnostics: Array.isArray(parsed.diagnostics)
|
||||
? parsed.diagnostics.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
const launchState =
|
||||
parsed.launchState === 'starting' ||
|
||||
parsed.launchState === 'runtime_pending_bootstrap' ||
|
||||
parsed.launchState === 'confirmed_alive' ||
|
||||
parsed.launchState === 'failed_to_start'
|
||||
? parsed.launchState
|
||||
: deriveMemberLaunchState(next);
|
||||
next.launchState = launchState;
|
||||
next.diagnostics = next.diagnostics?.length ? next.diagnostics : buildDiagnostics(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function createPersistedLaunchSnapshot(params: {
|
||||
teamName: string;
|
||||
expectedMembers: readonly string[];
|
||||
leadSessionId?: string;
|
||||
launchPhase?: PersistedTeamLaunchPhase;
|
||||
members?: Record<string, PersistedTeamLaunchMemberState>;
|
||||
updatedAt?: string;
|
||||
}): PersistedTeamLaunchSnapshot {
|
||||
const updatedAt = params.updatedAt ?? new Date().toISOString();
|
||||
const expectedMembers = Array.from(
|
||||
new Set(
|
||||
params.expectedMembers
|
||||
.map(normalizeMemberName)
|
||||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
|
||||
)
|
||||
);
|
||||
const members = params.members ?? {};
|
||||
const summary = summarizePersistedLaunchMembers(expectedMembers, members);
|
||||
return {
|
||||
version: 2,
|
||||
teamName: params.teamName,
|
||||
updatedAt,
|
||||
...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}),
|
||||
launchPhase: params.launchPhase ?? 'active',
|
||||
expectedMembers,
|
||||
members,
|
||||
summary,
|
||||
teamLaunchState: deriveTeamLaunchAggregateState(summary),
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotFromRuntimeMemberStatuses(params: {
|
||||
teamName: string;
|
||||
expectedMembers: readonly string[];
|
||||
leadSessionId?: string;
|
||||
launchPhase?: PersistedTeamLaunchPhase;
|
||||
statuses: Record<string, RuntimeMemberSpawnState>;
|
||||
updatedAt?: string;
|
||||
}): PersistedTeamLaunchSnapshot {
|
||||
const updatedAt = params.updatedAt ?? new Date().toISOString();
|
||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
|
||||
for (const expected of params.expectedMembers) {
|
||||
const name = normalizeMemberName(expected);
|
||||
if (!name || name === 'user' || isLeadMember({ name })) continue;
|
||||
const runtime = params.statuses[name];
|
||||
const sources: PersistedTeamLaunchMemberSources = {};
|
||||
if (runtime?.livenessSource === 'heartbeat') {
|
||||
sources.nativeHeartbeat = true;
|
||||
sources.inboxHeartbeat = true;
|
||||
}
|
||||
if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
const entry: PersistedTeamLaunchMemberState = {
|
||||
name,
|
||||
launchState: runtime?.launchState ?? 'starting',
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: runtime?.runtimeAlive === true,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
|
||||
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
|
||||
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||
diagnostics: undefined,
|
||||
};
|
||||
entry.launchState = deriveMemberLaunchState(entry);
|
||||
entry.diagnostics = buildDiagnostics(entry);
|
||||
members[name] = entry;
|
||||
}
|
||||
|
||||
return createPersistedLaunchSnapshot({
|
||||
teamName: params.teamName,
|
||||
expectedMembers: params.expectedMembers,
|
||||
leadSessionId: params.leadSessionId,
|
||||
launchPhase: params.launchPhase,
|
||||
members,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function snapshotToMemberSpawnStatuses(
|
||||
snapshot: PersistedTeamLaunchSnapshot | null
|
||||
): Record<string, MemberSpawnStatusEntry> {
|
||||
if (!snapshot) return {};
|
||||
const statuses: Record<string, MemberSpawnStatusEntry> = {};
|
||||
for (const memberName of snapshot.expectedMembers) {
|
||||
const entry = snapshot.members[memberName];
|
||||
if (!entry) continue;
|
||||
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
||||
let livenessSource: MemberSpawnLivenessSource | undefined;
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
status = 'error';
|
||||
} else if (entry.launchState === 'confirmed_alive') {
|
||||
status = 'online';
|
||||
livenessSource = 'heartbeat';
|
||||
} else if (entry.launchState === 'runtime_pending_bootstrap') {
|
||||
status = entry.runtimeAlive ? 'online' : 'waiting';
|
||||
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
||||
} else {
|
||||
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
|
||||
}
|
||||
statuses[memberName] = {
|
||||
status,
|
||||
launchState: entry.launchState,
|
||||
error: entry.hardFailure ? entry.hardFailureReason : undefined,
|
||||
hardFailureReason: entry.hardFailureReason,
|
||||
livenessSource,
|
||||
agentToolAccepted: entry.agentToolAccepted,
|
||||
runtimeAlive: entry.runtimeAlive,
|
||||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
||||
hardFailure: entry.hardFailure,
|
||||
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: entry.lastHeartbeatAt,
|
||||
updatedAt: entry.lastEvaluatedAt,
|
||||
};
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
export function normalizePersistedLaunchSnapshot(
|
||||
teamName: string,
|
||||
parsed: unknown
|
||||
): PersistedTeamLaunchSnapshot | null {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maybeLegacy = parsed as LegacyPartialLaunchStateFile;
|
||||
if (maybeLegacy.state === 'partial_launch_failure') {
|
||||
const expectedMembers = Array.isArray(maybeLegacy.expectedMembers)
|
||||
? maybeLegacy.expectedMembers.filter(
|
||||
(name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0
|
||||
)
|
||||
: [];
|
||||
const confirmedMembers = Array.isArray(maybeLegacy.confirmedMembers)
|
||||
? maybeLegacy.confirmedMembers.filter(
|
||||
(name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0
|
||||
)
|
||||
: [];
|
||||
const missingMembers = Array.isArray(maybeLegacy.missingMembers)
|
||||
? maybeLegacy.missingMembers.filter(
|
||||
(name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0
|
||||
)
|
||||
: [];
|
||||
if (expectedMembers.length === 0 || missingMembers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const updatedAt =
|
||||
typeof maybeLegacy.updatedAt === 'string' ? maybeLegacy.updatedAt : new Date().toISOString();
|
||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
for (const name of expectedMembers) {
|
||||
const failed = missingMembers.includes(name);
|
||||
const confirmed = confirmedMembers.includes(name);
|
||||
const entry: PersistedTeamLaunchMemberState = {
|
||||
name,
|
||||
launchState: failed ? 'failed_to_start' : confirmed ? 'confirmed_alive' : 'starting',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: confirmed,
|
||||
bootstrapConfirmed: confirmed,
|
||||
hardFailure: failed,
|
||||
hardFailureReason: failed
|
||||
? 'Legacy partial launch marker reported teammate missing.'
|
||||
: undefined,
|
||||
lastEvaluatedAt: updatedAt,
|
||||
diagnostics: undefined,
|
||||
};
|
||||
entry.diagnostics = buildDiagnostics(entry);
|
||||
members[name] = entry;
|
||||
}
|
||||
return createPersistedLaunchSnapshot({
|
||||
teamName,
|
||||
expectedMembers,
|
||||
leadSessionId:
|
||||
typeof maybeLegacy.leadSessionId === 'string' && maybeLegacy.leadSessionId.trim().length > 0
|
||||
? maybeLegacy.leadSessionId.trim()
|
||||
: undefined,
|
||||
launchPhase: 'finished',
|
||||
members,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (record.version !== 2) {
|
||||
return null;
|
||||
}
|
||||
const expectedMembers = Array.isArray(record.expectedMembers)
|
||||
? record.expectedMembers.filter(
|
||||
(name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0
|
||||
)
|
||||
: [];
|
||||
const updatedAt =
|
||||
typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0
|
||||
? record.updatedAt
|
||||
: new Date().toISOString();
|
||||
const normalizedMembers: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
const rawMembers =
|
||||
record.members && typeof record.members === 'object'
|
||||
? (record.members as Record<string, unknown>)
|
||||
: {};
|
||||
for (const [memberName, value] of Object.entries(rawMembers)) {
|
||||
const normalized = normalizePersistedMemberState(memberName, value, updatedAt);
|
||||
if (!normalized) continue;
|
||||
normalizedMembers[normalized.name] = normalized;
|
||||
}
|
||||
return createPersistedLaunchSnapshot({
|
||||
teamName:
|
||||
typeof record.teamName === 'string' && record.teamName.trim().length > 0
|
||||
? record.teamName.trim()
|
||||
: teamName,
|
||||
expectedMembers,
|
||||
leadSessionId:
|
||||
typeof record.leadSessionId === 'string' && record.leadSessionId.trim().length > 0
|
||||
? record.leadSessionId.trim()
|
||||
: undefined,
|
||||
launchPhase:
|
||||
record.launchPhase === 'active' ||
|
||||
record.launchPhase === 'finished' ||
|
||||
record.launchPhase === 'reconciled'
|
||||
? record.launchPhase
|
||||
: 'finished',
|
||||
members: normalizedMembers,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
56
src/main/services/team/TeamLaunchStateStore.ts
Normal file
56
src/main/services/team/TeamLaunchStateStore.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
import type { PersistedTeamLaunchSnapshot } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamLaunchStateStore');
|
||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||
const MAX_LAUNCH_STATE_BYTES = 256 * 1024;
|
||||
|
||||
export function getTeamLaunchStatePath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_STATE_FILE);
|
||||
}
|
||||
|
||||
export class TeamLaunchStateStore {
|
||||
async read(teamName: string): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||
const targetPath = getTeamLaunchStatePath(teamName);
|
||||
try {
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const raw = await fs.promises.readFile(targetPath, 'utf8');
|
||||
return normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async write(teamName: string, snapshot: PersistedTeamLaunchSnapshot): Promise<void> {
|
||||
try {
|
||||
await atomicWriteAsync(
|
||||
getTeamLaunchStatePath(teamName),
|
||||
`${JSON.stringify(snapshot, null, 2)}\n`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to persist launch-state: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async clear(teamName: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.rm(getTeamLaunchStatePath(teamName), { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,13 @@ function getSourceServerEntry(): string {
|
|||
return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts');
|
||||
}
|
||||
|
||||
function getWorkspaceTsxBinCandidates(): string[] {
|
||||
return [
|
||||
path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'),
|
||||
path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'),
|
||||
];
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||
|
|
@ -211,17 +218,7 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
|
||||
}
|
||||
|
||||
// 2. Dev mode — prefer source for hot changes
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
checked.push(sourceEntry);
|
||||
if (await pathExists(sourceEntry)) {
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: ['--dir', getWorkspaceMcpServerDir(), 'exec', 'tsx', sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Dev mode — built dist
|
||||
// 2. Dev mode — prefer built dist for reliable direct execution
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
checked.push(builtEntry);
|
||||
if (await pathExists(builtEntry)) {
|
||||
|
|
@ -231,6 +228,21 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
};
|
||||
}
|
||||
|
||||
// 3. Dev mode fallback — run source directly through a local tsx binary
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
checked.push(sourceEntry);
|
||||
if (await pathExists(sourceEntry)) {
|
||||
for (const tsxBin of getWorkspaceTsxBinCandidates()) {
|
||||
checked.push(tsxBin);
|
||||
if (await pathExists(tsxBin)) {
|
||||
return {
|
||||
command: tsxBin,
|
||||
args: [sourceEntry],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamConfig,
|
||||
TeamMember,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
|||
'cross_team_list_targets',
|
||||
'cross_team_get_outbox',
|
||||
]);
|
||||
const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i;
|
||||
|
||||
function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
|
|
@ -51,6 +53,10 @@ function looksLikeCrossTeamToolRecipient(name: string): boolean {
|
|||
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
|
||||
}
|
||||
|
||||
function looksLikeGeneratedAgentId(name: string): boolean {
|
||||
return GENERATED_AGENT_ID_PATTERN.test(name.trim());
|
||||
}
|
||||
|
||||
export class TeamMemberResolver {
|
||||
resolveMembers(
|
||||
config: TeamConfig,
|
||||
|
|
@ -106,23 +112,49 @@ export class TeamMemberResolver {
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
addName(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const configMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string }
|
||||
{
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
color?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
>();
|
||||
if (Array.isArray(config.members)) {
|
||||
for (const m of config.members) {
|
||||
if (typeof m?.name === 'string' && m.name.trim() !== '') {
|
||||
const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' };
|
||||
const providerId =
|
||||
configMember.providerId === 'anthropic' ||
|
||||
configMember.providerId === 'codex' ||
|
||||
configMember.providerId === 'gemini'
|
||||
? configMember.providerId
|
||||
: configMember.provider === 'anthropic' ||
|
||||
configMember.provider === 'codex' ||
|
||||
configMember.provider === 'gemini'
|
||||
? configMember.provider
|
||||
: undefined;
|
||||
configMemberMap.set(m.name.trim(), {
|
||||
agentType: m.agentType,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
color: m.color,
|
||||
cwd: m.cwd,
|
||||
agentType: configMember.agentType,
|
||||
role: configMember.role,
|
||||
workflow: configMember.workflow,
|
||||
providerId,
|
||||
model: configMember.model,
|
||||
effort: configMember.effort,
|
||||
color: configMember.color,
|
||||
cwd: configMember.cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -130,7 +162,16 @@ export class TeamMemberResolver {
|
|||
|
||||
const metaMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number }
|
||||
{
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
color?: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
>();
|
||||
if (Array.isArray(metaMembers)) {
|
||||
for (const member of metaMembers) {
|
||||
|
|
@ -139,6 +180,9 @@ export class TeamMemberResolver {
|
|||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
providerId: member.providerId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
color: member.color,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
|
|
@ -193,6 +237,9 @@ export class TeamMemberResolver {
|
|||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
providerId: configMember?.providerId ?? metaMember?.providerId,
|
||||
model: configMember?.model ?? metaMember?.model,
|
||||
effort: configMember?.effort ?? metaMember?.effort,
|
||||
cwd: configMember?.cwd,
|
||||
removedAt: metaMember?.removedAt,
|
||||
});
|
||||
|
|
|
|||
374
src/main/services/team/TeamMemberRuntimeAdvisoryService.ts
Normal file
374
src/main/services/team/TeamMemberRuntimeAdvisoryService.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import * as fs from 'fs/promises';
|
||||
|
||||
import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
|
||||
const LOOKBACK_MS = 10 * 60 * 1000;
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
const TAIL_BYTES = 64 * 1024;
|
||||
const BATCH_WARN_MS = 200;
|
||||
const QUOTA_EXHAUSTED_TOKENS = [
|
||||
'exhausted your capacity',
|
||||
'capacity exceeded',
|
||||
'quota exceeded',
|
||||
'quota exhausted',
|
||||
];
|
||||
const RATE_LIMITED_TOKENS = ['rate limit', 'too many requests', '429'];
|
||||
const AUTH_ERROR_TOKENS = [
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'invalid api key',
|
||||
'authentication',
|
||||
'api key',
|
||||
];
|
||||
const NETWORK_ERROR_TOKENS = [
|
||||
'timeout',
|
||||
'timed out',
|
||||
'network',
|
||||
'connection',
|
||||
'econn',
|
||||
'enotfound',
|
||||
'fetch failed',
|
||||
];
|
||||
const PROVIDER_OVERLOADED_TOKENS = [
|
||||
'overloaded',
|
||||
'temporarily unavailable',
|
||||
'service unavailable',
|
||||
'503',
|
||||
];
|
||||
|
||||
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
||||
|
||||
interface CachedRuntimeAdvisory {
|
||||
value: MemberRuntimeAdvisory | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface CachedTeamBatchAdvisories {
|
||||
membersSignature: string;
|
||||
value: Map<string, MemberRuntimeAdvisory>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
|
||||
return tokens.some((token) => value.includes(token));
|
||||
}
|
||||
|
||||
function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory['reasonCode'] {
|
||||
const normalized = message?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return 'unknown';
|
||||
}
|
||||
if (includesAnyToken(normalized, QUOTA_EXHAUSTED_TOKENS)) {
|
||||
return 'quota_exhausted';
|
||||
}
|
||||
if (includesAnyToken(normalized, RATE_LIMITED_TOKENS)) {
|
||||
return 'rate_limited';
|
||||
}
|
||||
if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) {
|
||||
return 'auth_error';
|
||||
}
|
||||
if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) {
|
||||
return 'network_error';
|
||||
}
|
||||
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
|
||||
return 'provider_overloaded';
|
||||
}
|
||||
return 'backend_error';
|
||||
}
|
||||
|
||||
export class TeamMemberRuntimeAdvisoryService {
|
||||
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
||||
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
||||
private readonly inFlightBatchRequests = new Map<
|
||||
string,
|
||||
Promise<Map<string, MemberRuntimeAdvisory>>
|
||||
>();
|
||||
|
||||
constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {}
|
||||
|
||||
async getMemberAdvisories(
|
||||
teamName: string,
|
||||
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
|
||||
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
||||
const activeMembers = members.filter((member) => !member.removedAt);
|
||||
if (activeMembers.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const teamKey = this.normalizeToken(teamName);
|
||||
const membersSignature = this.buildMembersSignature(activeMembers);
|
||||
const now = Date.now();
|
||||
const cachedBatch = this.teamBatchCacheByTeam.get(teamKey);
|
||||
if (
|
||||
cachedBatch &&
|
||||
cachedBatch.membersSignature === membersSignature &&
|
||||
cachedBatch.expiresAt > now
|
||||
) {
|
||||
return this.materializeBatchAdvisories(activeMembers, cachedBatch.value);
|
||||
}
|
||||
|
||||
const inFlightKey = `${teamKey}::${membersSignature}`;
|
||||
const existingRequest = this.inFlightBatchRequests.get(inFlightKey);
|
||||
if (existingRequest) {
|
||||
return this.materializeBatchAdvisories(activeMembers, await existingRequest);
|
||||
}
|
||||
|
||||
const request = this.loadBatchAdvisories(teamName, teamKey, activeMembers, membersSignature);
|
||||
this.inFlightBatchRequests.set(inFlightKey, request);
|
||||
|
||||
try {
|
||||
return this.materializeBatchAdvisories(activeMembers, await request);
|
||||
} finally {
|
||||
if (this.inFlightBatchRequests.get(inFlightKey) === request) {
|
||||
this.inFlightBatchRequests.delete(inFlightKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getMemberAdvisory(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<MemberRuntimeAdvisory | null> {
|
||||
const cacheKey = this.getMemberCacheKey(teamName, memberName);
|
||||
const cached = this.memberCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value ? this.cloneAdvisory(cached.value) : null;
|
||||
}
|
||||
|
||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||
this.memberCache.set(cacheKey, {
|
||||
value: advisory,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
return advisory ? this.cloneAdvisory(advisory) : null;
|
||||
}
|
||||
|
||||
private async loadBatchAdvisories(
|
||||
teamName: string,
|
||||
teamKey: string,
|
||||
activeMembers: readonly Pick<ResolvedTeamMember, 'name'>[],
|
||||
membersSignature: string
|
||||
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
||||
const startedAt = performance.now();
|
||||
const now = Date.now();
|
||||
const result = new Map<string, MemberRuntimeAdvisory>();
|
||||
const membersToFetch: string[] = [];
|
||||
let memberCacheHits = 0;
|
||||
let memberCacheMisses = 0;
|
||||
|
||||
for (const member of activeMembers) {
|
||||
const normalizedMemberName = this.normalizeToken(member.name);
|
||||
const cacheKey = `${teamKey}::${normalizedMemberName}`;
|
||||
const cached = this.memberCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
memberCacheHits += 1;
|
||||
if (cached.value) {
|
||||
result.set(normalizedMemberName, this.cloneAdvisory(cached.value));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
memberCacheMisses += 1;
|
||||
membersToFetch.push(member.name);
|
||||
}
|
||||
|
||||
if (membersToFetch.length > 0) {
|
||||
const fetched = await Promise.all(
|
||||
membersToFetch.map(async (memberName) => {
|
||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||
return [memberName, advisory] as const;
|
||||
})
|
||||
);
|
||||
const fetchedAt = Date.now();
|
||||
for (const [memberName, advisory] of fetched) {
|
||||
const normalizedMemberName = this.normalizeToken(memberName);
|
||||
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
|
||||
value: advisory,
|
||||
expiresAt: fetchedAt + CACHE_TTL_MS,
|
||||
});
|
||||
if (advisory) {
|
||||
result.set(normalizedMemberName, this.cloneAdvisory(advisory));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.teamBatchCacheByTeam.set(teamKey, {
|
||||
membersSignature,
|
||||
value: this.cloneNormalizedAdvisories(result),
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
const totalMs = performance.now() - startedAt;
|
||||
if (totalMs >= BATCH_WARN_MS) {
|
||||
logger.warn(
|
||||
`[perf] getMemberAdvisories slow team=${teamName} activeMembers=${activeMembers.length} signatureMembers=${activeMembers.length} batchCache=miss memberCacheHits=${memberCacheHits} memberCacheMisses=${memberCacheMisses} fetchedMembers=${membersToFetch.length} total=${totalMs.toFixed(1)}ms`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private getMemberCacheKey(teamName: string, memberName: string): string {
|
||||
return `${this.normalizeToken(teamName)}::${this.normalizeToken(memberName)}`;
|
||||
}
|
||||
|
||||
private buildMembersSignature(members: readonly Pick<ResolvedTeamMember, 'name'>[]): string {
|
||||
return Array.from(new Set(members.map((member) => this.normalizeToken(member.name))))
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
private normalizeToken(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private cloneAdvisory(advisory: MemberRuntimeAdvisory): MemberRuntimeAdvisory {
|
||||
return { ...advisory };
|
||||
}
|
||||
|
||||
private cloneNormalizedAdvisories(
|
||||
advisories: ReadonlyMap<string, MemberRuntimeAdvisory>
|
||||
): Map<string, MemberRuntimeAdvisory> {
|
||||
return new Map(
|
||||
Array.from(advisories, ([memberName, advisory]) => [memberName, this.cloneAdvisory(advisory)])
|
||||
);
|
||||
}
|
||||
|
||||
private materializeBatchAdvisories(
|
||||
activeMembers: readonly Pick<ResolvedTeamMember, 'name'>[],
|
||||
advisories: ReadonlyMap<string, MemberRuntimeAdvisory>
|
||||
): Map<string, MemberRuntimeAdvisory> {
|
||||
const materialized = new Map<string, MemberRuntimeAdvisory>();
|
||||
for (const member of activeMembers) {
|
||||
const advisory = advisories.get(this.normalizeToken(member.name));
|
||||
if (advisory) {
|
||||
materialized.set(member.name, this.cloneAdvisory(advisory));
|
||||
}
|
||||
}
|
||||
return materialized;
|
||||
}
|
||||
|
||||
private async findRecentMemberAdvisory(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<MemberRuntimeAdvisory | null> {
|
||||
const summaries = await this.logsFinder.findMemberLogs(
|
||||
teamName,
|
||||
memberName,
|
||||
Date.now() - LOOKBACK_MS
|
||||
);
|
||||
for (const summary of summaries) {
|
||||
if (!summary.filePath) {
|
||||
continue;
|
||||
}
|
||||
const advisory = await this.readRecentApiRetryAdvisory(summary.filePath);
|
||||
if (advisory) {
|
||||
return advisory;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async readRecentApiRetryAdvisory(
|
||||
filePath: string
|
||||
): Promise<MemberRuntimeAdvisory | null> {
|
||||
let handle: fs.FileHandle | null = null;
|
||||
try {
|
||||
handle = await fs.open(filePath, 'r');
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
return null;
|
||||
}
|
||||
const start = Math.max(0, stat.size - TAIL_BYTES);
|
||||
const buffer = Buffer.alloc(stat.size - start);
|
||||
if (buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
await handle.read(buffer, 0, buffer.length, start);
|
||||
const tail = buffer.toString('utf8');
|
||||
const lines = tail.split('\n');
|
||||
if (start > 0) {
|
||||
lines.shift();
|
||||
}
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const advisory = this.extractApiRetryAdvisory(lines[index]?.trim() ?? '');
|
||||
if (advisory) {
|
||||
return advisory;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private extractApiRetryAdvisory(line: string): MemberRuntimeAdvisory | null {
|
||||
if (
|
||||
!line ||
|
||||
(!line.includes('"subtype":"api_error"') && !line.includes('"subtype": "api_error"'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line) as {
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
retryInMs?: number;
|
||||
timestamp?: string;
|
||||
error?: {
|
||||
message?: string;
|
||||
error?: {
|
||||
message?: string;
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
if (parsed.type !== 'system' || parsed.subtype !== 'api_error') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retryInMs =
|
||||
typeof parsed.retryInMs === 'number' &&
|
||||
Number.isFinite(parsed.retryInMs) &&
|
||||
parsed.retryInMs > 0
|
||||
? parsed.retryInMs
|
||||
: null;
|
||||
const observedAt =
|
||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||
if (!retryInMs || !Number.isFinite(observedAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retryUntil = observedAt + retryInMs;
|
||||
if (retryUntil <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
parsed.error?.error?.error?.message?.trim() ||
|
||||
parsed.error?.error?.message?.trim() ||
|
||||
parsed.error?.message?.trim() ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
kind: 'sdk_retrying',
|
||||
observedAt: new Date(observedAt).toISOString(),
|
||||
retryUntil: new Date(retryUntil).toISOString(),
|
||||
retryDelayMs: retryInMs,
|
||||
reasonCode: classifyRetryReason(message),
|
||||
...(message ? { message } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -24,6 +25,12 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
|||
name: trimmedName,
|
||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType:
|
||||
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
||||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface TeamMetaFile {
|
|||
color?: string;
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: string;
|
||||
skipPermissions?: boolean;
|
||||
|
|
@ -82,6 +83,12 @@ export class TeamMetaStore {
|
|||
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
|
||||
cwd: file.cwd.trim(),
|
||||
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
file.providerId === 'anthropic' ||
|
||||
file.providerId === 'codex' ||
|
||||
file.providerId === 'gemini'
|
||||
? file.providerId
|
||||
: undefined,
|
||||
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
|
||||
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
|
||||
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
|
||||
|
|
@ -101,6 +108,7 @@ export class TeamMetaStore {
|
|||
color: data.color?.trim() || undefined,
|
||||
cwd: data.cwd.trim(),
|
||||
prompt: data.prompt?.trim() || undefined,
|
||||
providerId: data.providerId,
|
||||
model: data.model?.trim() || undefined,
|
||||
effort: data.effort?.trim() || undefined,
|
||||
skipPermissions: data.skipPermissions,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
93
src/main/services/team/TeamReconcileDrainScheduler.ts
Normal file
93
src/main/services/team/TeamReconcileDrainScheduler.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||
|
||||
export interface TeamReconcileTrigger {
|
||||
source: 'inbox' | 'task';
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface TeamReconcileDrainState {
|
||||
running: boolean;
|
||||
pending: boolean;
|
||||
lastTrigger: TeamReconcileTrigger | null;
|
||||
}
|
||||
|
||||
export interface TeamReconcileDrainScheduler {
|
||||
schedule(teamName: string, trigger: TeamReconcileTrigger): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createTeamReconcileDrainScheduler(options: {
|
||||
run: (teamName: string, trigger: TeamReconcileTrigger) => Promise<void>;
|
||||
}): TeamReconcileDrainScheduler {
|
||||
const states = new Map<string, TeamReconcileDrainState>();
|
||||
let disposed = false;
|
||||
|
||||
const drainTeam = async (teamName: string): Promise<void> => {
|
||||
const state = states.get(teamName);
|
||||
if (!state || state.running || disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.running = true;
|
||||
let failed = false;
|
||||
|
||||
try {
|
||||
while (!disposed && state.pending) {
|
||||
state.pending = false;
|
||||
const trigger = state.lastTrigger;
|
||||
if (!trigger) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await options.run(teamName, trigger);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.running = false;
|
||||
if (disposed || !state.pending) {
|
||||
states.delete(teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
void drainTeam(teamName).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
schedule(teamName: string, trigger: TeamReconcileTrigger): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = states.get(teamName) ?? {
|
||||
running: false,
|
||||
pending: false,
|
||||
lastTrigger: null,
|
||||
};
|
||||
state.pending = true;
|
||||
state.lastTrigger = trigger;
|
||||
states.set(teamName, state);
|
||||
|
||||
if (state.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
void drainTeam(teamName).catch(() => undefined);
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
states.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -13,13 +13,16 @@ const ACTION_MODE_BLOCKS: Record<AgentActionMode, string[]> = {
|
|||
'TURN ACTION MODE: DO',
|
||||
'- This turn is full-execution mode.',
|
||||
'- You may discuss, read, edit files, change state, run commands/tools, and delegate if useful.',
|
||||
'- Agent tool policy for this mode: you MAY use the built-in Agent tool only as a normal Claude Code subagent helper, i.e. WITHOUT team_name.',
|
||||
'- If you use Agent in this mode, use it the same way normal Claude Code would use Agent: bounded helper work, parallel research, or implementation support when useful.',
|
||||
'- Even in DO mode, do NOT use Agent with team_name to create persistent teammates, and do NOT use Agent as a replacement for the team task board or normal teammate delegation.',
|
||||
'- No extra restrictions apply beyond your normal system/team rules.',
|
||||
],
|
||||
ask: [
|
||||
'TURN ACTION MODE: ASK',
|
||||
'- This turn is STRICTLY read-only conversation mode.',
|
||||
'- ALLOWED: read/analyze/explain, answer questions, discuss options, and request clarification if needed.',
|
||||
'- FORBIDDEN: editing files, changing code, changing task/board state, delegating work, running commands/scripts/tools with side effects, or causing any non-communication state change.',
|
||||
'- FORBIDDEN: editing files, changing code, changing task/board state, delegating work, launching Agent/subagents, running commands/scripts/tools with side effects, or causing any non-communication state change.',
|
||||
],
|
||||
delegate: [
|
||||
'TURN ACTION MODE: DELEGATE',
|
||||
|
|
@ -27,7 +30,8 @@ const ACTION_MODE_BLOCKS: Record<AgentActionMode, string[]> = {
|
|||
'- If you are the team lead, stay at orchestration level: decompose the work, create/assign tasks fast, delegate triage/research to the best teammate, and monitor progress.',
|
||||
'- In this mode, do NOT inspect code, do root-cause research, or spend time narrowing scope yourself before delegating unless the human explicitly asked you for analysis/planning instead of delegation.',
|
||||
'- If the request is underspecified, create a coarse investigation/triage task for the most relevant teammate immediately; that teammate should inspect the codebase, refine scope, and create follow-up tasks if needed.',
|
||||
'- FORBIDDEN: implementing the work yourself, editing files yourself, running state-changing/code-changing commands yourself, or taking direct execution ownership unless you are truly in SOLO MODE.',
|
||||
'- FORBIDDEN: implementing the work yourself, editing files yourself, running state-changing/code-changing commands yourself, launching Agent/subagents, or taking direct execution ownership unless you are truly in SOLO MODE.',
|
||||
'- In particular, do NOT use Agent as a shortcut for delegation in this mode. Use the team board, real teammates, and explicit task ownership instead.',
|
||||
'- If you are not the lead or no delegation target exists, do not execute the work yourself; explain the limitation briefly and request a different mode or a lead handoff.',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
162
src/main/services/team/cache/LeadSessionParseCache.ts
vendored
Normal file
162
src/main/services/team/cache/LeadSessionParseCache.ts
vendored
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
export interface LeadSessionParseCacheKey {
|
||||
jsonlPath: string;
|
||||
leadSessionId: string;
|
||||
leadName: string;
|
||||
maxTexts: number;
|
||||
schemaVersion: string;
|
||||
}
|
||||
|
||||
export interface LeadSessionFileSignature {
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs?: number;
|
||||
}
|
||||
|
||||
interface LeadSessionParseCacheEntry {
|
||||
signature: LeadSessionFileSignature;
|
||||
messages: InboxMessage[];
|
||||
cachedAtMs: number;
|
||||
}
|
||||
|
||||
interface LeadSessionParseInFlightEntry {
|
||||
signature: LeadSessionFileSignature;
|
||||
promise: Promise<InboxMessage[]>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ENTRIES = 64;
|
||||
|
||||
function keyToString(key: LeadSessionParseCacheKey): string {
|
||||
return JSON.stringify([
|
||||
key.schemaVersion,
|
||||
key.jsonlPath,
|
||||
key.leadSessionId,
|
||||
key.leadName,
|
||||
key.maxTexts,
|
||||
]);
|
||||
}
|
||||
|
||||
function inFlightKeyToString(
|
||||
key: LeadSessionParseCacheKey,
|
||||
signature: LeadSessionFileSignature
|
||||
): string {
|
||||
return `${keyToString(key)}::${signature.size}:${signature.mtimeMs}:${signature.ctimeMs ?? ''}`;
|
||||
}
|
||||
|
||||
export function areLeadSessionFileSignaturesEqual(
|
||||
left: LeadSessionFileSignature,
|
||||
right: LeadSessionFileSignature
|
||||
): boolean {
|
||||
return (
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
(left.ctimeMs ?? undefined) === (right.ctimeMs ?? undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function cloneMessage(message: InboxMessage): InboxMessage {
|
||||
return {
|
||||
...message,
|
||||
...(message.taskRefs ? { taskRefs: message.taskRefs.map((taskRef) => ({ ...taskRef })) } : {}),
|
||||
...(message.attachments
|
||||
? { attachments: message.attachments.map((attachment) => ({ ...attachment })) }
|
||||
: {}),
|
||||
...(message.toolCalls
|
||||
? { toolCalls: message.toolCalls.map((toolCall) => ({ ...toolCall })) }
|
||||
: {}),
|
||||
...(message.slashCommand ? { slashCommand: { ...message.slashCommand } } : {}),
|
||||
...(message.commandOutput ? { commandOutput: { ...message.commandOutput } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneMessages(messages: readonly InboxMessage[]): InboxMessage[] {
|
||||
return messages.map(cloneMessage);
|
||||
}
|
||||
|
||||
export class LeadSessionParseCache {
|
||||
private readonly entries = new Map<string, LeadSessionParseCacheEntry>();
|
||||
private readonly inFlightEntries = new Map<string, LeadSessionParseInFlightEntry>();
|
||||
|
||||
constructor(private readonly maxEntries: number = DEFAULT_MAX_ENTRIES) {}
|
||||
|
||||
getIfFresh(
|
||||
key: LeadSessionParseCacheKey,
|
||||
signature: LeadSessionFileSignature
|
||||
): InboxMessage[] | null {
|
||||
const entryKey = keyToString(key);
|
||||
const entry = this.entries.get(entryKey);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (!areLeadSessionFileSignaturesEqual(entry.signature, signature)) {
|
||||
this.entries.delete(entryKey);
|
||||
return null;
|
||||
}
|
||||
return cloneMessages(entry.messages);
|
||||
}
|
||||
|
||||
getInFlight(
|
||||
key: LeadSessionParseCacheKey,
|
||||
signature: LeadSessionFileSignature
|
||||
): Promise<InboxMessage[]> | null {
|
||||
const entry = this.inFlightEntries.get(inFlightKeyToString(key, signature));
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return entry.promise.then((messages) => cloneMessages(messages));
|
||||
}
|
||||
|
||||
setInFlight(
|
||||
key: LeadSessionParseCacheKey,
|
||||
signature: LeadSessionFileSignature,
|
||||
promise: Promise<InboxMessage[]>
|
||||
): void {
|
||||
this.inFlightEntries.set(inFlightKeyToString(key, signature), {
|
||||
signature,
|
||||
promise,
|
||||
});
|
||||
}
|
||||
|
||||
clearInFlight(key: LeadSessionParseCacheKey, signature: LeadSessionFileSignature): void {
|
||||
this.inFlightEntries.delete(inFlightKeyToString(key, signature));
|
||||
}
|
||||
|
||||
set(
|
||||
key: LeadSessionParseCacheKey,
|
||||
signature: LeadSessionFileSignature,
|
||||
messages: readonly InboxMessage[]
|
||||
): void {
|
||||
const entryKey = keyToString(key);
|
||||
this.entries.set(entryKey, {
|
||||
signature,
|
||||
messages: cloneMessages(messages),
|
||||
cachedAtMs: Date.now(),
|
||||
});
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const oldestKey = this.entries.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
this.entries.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
clearForPath(jsonlPath: string): void {
|
||||
for (const key of this.entries.keys()) {
|
||||
if (key.includes(`"${jsonlPath}"`)) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of this.inFlightEntries.keys()) {
|
||||
if (key.includes(`"${jsonlPath}"`)) {
|
||||
this.inFlightEntries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.entries.clear();
|
||||
this.inFlightEntries.clear();
|
||||
}
|
||||
}
|
||||
43
src/main/services/team/cliFlavor.ts
Normal file
43
src/main/services/team/cliFlavor.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
||||
|
||||
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
|
||||
|
||||
function parseFlavorOverride(raw: string | undefined): CliFlavor | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (trimmed === 'claude' || trimmed === 'agent_teams_orchestrator') {
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getConfiguredCliFlavor(): CliFlavor {
|
||||
const envOverride = parseFlavorOverride(process.env.CLAUDE_TEAM_CLI_FLAVOR);
|
||||
if (envOverride) {
|
||||
return envOverride;
|
||||
}
|
||||
|
||||
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
|
||||
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
|
||||
}
|
||||
|
||||
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
||||
switch (flavor) {
|
||||
case 'agent_teams_orchestrator':
|
||||
return {
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
};
|
||||
case 'claude':
|
||||
default:
|
||||
return {
|
||||
displayName: 'Claude CLI',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
classifyIdleNotificationText,
|
||||
type IdleNotificationPrimaryKind as MainProcessIdlePrimaryKind,
|
||||
} from '@shared/utils/idleNotificationSemantics';
|
||||
|
||||
export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable';
|
||||
|
||||
export type ClassifiedMainProcessIdle = {
|
||||
primaryKind: MainProcessIdlePrimaryKind;
|
||||
hasPeerSummary: boolean;
|
||||
peerSummary: string | null;
|
||||
handling: MainProcessIdleHandling;
|
||||
};
|
||||
|
||||
export function classifyIdleNotificationForMainProcess(
|
||||
text: string
|
||||
): ClassifiedMainProcessIdle | null {
|
||||
const classified = classifyIdleNotificationText(text);
|
||||
if (!classified) return null;
|
||||
|
||||
const handling: MainProcessIdleHandling =
|
||||
classified.primaryKind === 'heartbeat'
|
||||
? classified.hasPeerSummary
|
||||
? 'passive_activity'
|
||||
: 'silent_noise'
|
||||
: 'visible_actionable';
|
||||
|
||||
return {
|
||||
primaryKind: classified.primaryKind,
|
||||
hasPeerSummary: classified.hasPeerSummary,
|
||||
peerSummary: classified.peerSummary,
|
||||
handling,
|
||||
};
|
||||
}
|
||||
26
src/main/services/team/inboxMessageIdentity.ts
Normal file
26
src/main/services/team/inboxMessageIdentity.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
type InboxIdentityLike = {
|
||||
messageId?: unknown;
|
||||
from?: unknown;
|
||||
timestamp?: unknown;
|
||||
text?: unknown;
|
||||
};
|
||||
|
||||
export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string {
|
||||
return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`;
|
||||
}
|
||||
|
||||
export function getEffectiveInboxMessageId(row: InboxIdentityLike): string | null {
|
||||
if (typeof row.messageId === 'string' && row.messageId.trim().length > 0) {
|
||||
return row.messageId;
|
||||
}
|
||||
if (
|
||||
typeof row.from !== 'string' ||
|
||||
typeof row.timestamp !== 'string' ||
|
||||
typeof row.text !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return buildLegacyInboxMessageId(row.from, row.timestamp, row.text);
|
||||
}
|
||||
154
src/main/services/team/memberUpdateNotifications.ts
Normal file
154
src/main/services/team/memberUpdateNotifications.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
export type MemberDiffInput = {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
removedAt?: number | string | null;
|
||||
};
|
||||
|
||||
export type ReplaceMembersDiff = {
|
||||
added: Array<{
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
}>;
|
||||
removed: string[];
|
||||
updated: Array<{
|
||||
name: string;
|
||||
changes: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
function normalizeOptionalText(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function describeRoleChange(
|
||||
previousRole: string | undefined,
|
||||
nextRole: string | undefined
|
||||
): string | null {
|
||||
if (previousRole === nextRole) {
|
||||
return null;
|
||||
}
|
||||
if (previousRole && nextRole) {
|
||||
return `role changed from "${previousRole}" to "${nextRole}"`;
|
||||
}
|
||||
if (nextRole) {
|
||||
return `role set to "${nextRole}"`;
|
||||
}
|
||||
return 'role cleared';
|
||||
}
|
||||
|
||||
function describeWorkflowChange(
|
||||
previousWorkflow: string | undefined,
|
||||
nextWorkflow: string | undefined
|
||||
): string | null {
|
||||
if (previousWorkflow === nextWorkflow) {
|
||||
return null;
|
||||
}
|
||||
if (previousWorkflow && nextWorkflow) {
|
||||
return 'workflow instructions were updated';
|
||||
}
|
||||
if (nextWorkflow) {
|
||||
return 'workflow instructions were added';
|
||||
}
|
||||
return 'workflow instructions were cleared';
|
||||
}
|
||||
|
||||
export function buildReplaceMembersDiff(
|
||||
previousMembers: MemberDiffInput[],
|
||||
nextMembers: Array<{
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
}>
|
||||
): ReplaceMembersDiff {
|
||||
const previousByName = new Map(
|
||||
previousMembers
|
||||
.filter((member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead')
|
||||
.map((member) => [
|
||||
member.name.trim().toLowerCase(),
|
||||
{
|
||||
name: member.name.trim(),
|
||||
role: normalizeOptionalText(member.role),
|
||||
workflow: normalizeOptionalText(member.workflow),
|
||||
providerId: member.providerId,
|
||||
model: normalizeOptionalText(member.model),
|
||||
},
|
||||
])
|
||||
);
|
||||
const nextByName = new Map(
|
||||
nextMembers
|
||||
.filter((member) => member.name.trim().toLowerCase() !== 'team-lead')
|
||||
.map((member) => [
|
||||
member.name.trim().toLowerCase(),
|
||||
{
|
||||
name: member.name.trim(),
|
||||
role: normalizeOptionalText(member.role),
|
||||
workflow: normalizeOptionalText(member.workflow),
|
||||
providerId: member.providerId,
|
||||
model: normalizeOptionalText(member.model),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const added = Array.from(nextByName.entries())
|
||||
.filter(([name]) => !previousByName.has(name))
|
||||
.map(([, member]) => member);
|
||||
|
||||
const removed = Array.from(previousByName.entries())
|
||||
.filter(([name]) => !nextByName.has(name))
|
||||
.map(([, member]) => member.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const updated = Array.from(nextByName.entries())
|
||||
.flatMap(([name, nextMember]) => {
|
||||
const previousMember = previousByName.get(name);
|
||||
if (!previousMember) {
|
||||
return [];
|
||||
}
|
||||
const changes = [
|
||||
describeRoleChange(previousMember.role, nextMember.role),
|
||||
describeWorkflowChange(previousMember.workflow, nextMember.workflow),
|
||||
].filter((value): value is string => value !== null);
|
||||
if (changes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ name: nextMember.name, changes }];
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { added, removed, updated };
|
||||
}
|
||||
|
||||
export function buildReplaceMembersSummaryMessage(diff: ReplaceMembersDiff): string | null {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const name of diff.removed) {
|
||||
lines.push(
|
||||
`- Teammate "${name}" was removed from the team. Stop assigning them new work and reassign any active tasks if needed.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const update of diff.updated) {
|
||||
lines.push(
|
||||
`- Teammate "${update.name}" was updated: ${update.changes.join('; ')}. Please send them refreshed instructions so their live behavior matches the new config.`
|
||||
);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
'The user updated the live team roster.\n' +
|
||||
'Apply these changes to the running team now:\n' +
|
||||
lines.join('\n')
|
||||
);
|
||||
}
|
||||
112
src/main/services/team/runtimeTeammateMode.ts
Normal file
112
src/main/services/team/runtimeTeammateMode.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { execFile } from 'child_process';
|
||||
|
||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||
|
||||
const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000;
|
||||
|
||||
type DesktopTeammateModeDecision = {
|
||||
injectedTeammateMode: 'tmux' | null;
|
||||
forceProcessTeammates: boolean;
|
||||
};
|
||||
|
||||
let tmuxAvailabilityCache: { value: boolean; at: number } | null = null;
|
||||
let tmuxAvailablePromise: Promise<boolean> | null = null;
|
||||
|
||||
function execFileAsync(command: string, args: string[], timeout: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { timeout }, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getExplicitTeammateMode(
|
||||
rawExtraCliArgs: string | undefined
|
||||
): 'auto' | 'tmux' | 'in-process' | null {
|
||||
const tokens = parseCliArgs(rawExtraCliArgs);
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const token = tokens[i];
|
||||
if (token === '--teammate-mode') {
|
||||
const next = tokens[i + 1];
|
||||
if (next === 'auto' || next === 'tmux' || next === 'in-process') {
|
||||
return next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (token.startsWith('--teammate-mode=')) {
|
||||
const value = token.slice('--teammate-mode='.length);
|
||||
if (value === 'auto' || value === 'tmux' || value === 'in-process') {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function isTmuxAvailable(): Promise<boolean> {
|
||||
if (
|
||||
tmuxAvailabilityCache &&
|
||||
Date.now() - tmuxAvailabilityCache.at < TMUX_AVAILABILITY_CACHE_TTL_MS
|
||||
) {
|
||||
return tmuxAvailabilityCache.value;
|
||||
}
|
||||
|
||||
if (!tmuxAvailablePromise) {
|
||||
tmuxAvailablePromise = execFileAsync('tmux', ['-V'], 3_000)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
.then((value) => {
|
||||
tmuxAvailabilityCache = { value, at: Date.now() };
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
tmuxAvailablePromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return tmuxAvailablePromise;
|
||||
}
|
||||
|
||||
export async function resolveDesktopTeammateModeDecision(
|
||||
rawExtraCliArgs: string | undefined
|
||||
): Promise<DesktopTeammateModeDecision> {
|
||||
const explicitMode = getExplicitTeammateMode(rawExtraCliArgs);
|
||||
if (explicitMode === 'tmux') {
|
||||
return {
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (explicitMode === 'auto' || explicitMode === 'in-process') {
|
||||
return {
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await isTmuxAvailable())) {
|
||||
return {
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
injectedTeammateMode: 'tmux',
|
||||
forceProcessTeammates: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -18,15 +18,17 @@
|
|||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import {
|
||||
getProjectsBasePath,
|
||||
getTodosBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from './utils/pathDecoder';
|
||||
import { LocalFileSystemProvider, NotificationManager, ServiceContext } from './services';
|
||||
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
|
||||
|
||||
import type { HttpServices } from './http';
|
||||
import type { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import type { NotificationManager } from './services/infrastructure/NotificationManager';
|
||||
import type { ServiceContext } from './services/infrastructure/ServiceContext';
|
||||
import type { SshConnectionManager } from './services/infrastructure/SshConnectionManager';
|
||||
import type { UpdaterService } from './services/infrastructure/UpdaterService';
|
||||
|
||||
|
|
@ -99,6 +101,13 @@ async function start(): Promise<void> {
|
|||
logger.info(`Using CLAUDE_ROOT: ${CLAUDE_ROOT}`);
|
||||
}
|
||||
|
||||
// Import services after applying CLAUDE_ROOT so ConfigManager picks up the correct base path.
|
||||
const [{ HttpServer }, { NotificationManager }, { ServiceContext }] = await Promise.all([
|
||||
import('./services/infrastructure/HttpServer'),
|
||||
import('./services/infrastructure/NotificationManager'),
|
||||
import('./services/infrastructure/ServiceContext'),
|
||||
]);
|
||||
|
||||
const projectsDir = getProjectsBasePath();
|
||||
const todosDir = getTodosBasePath();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* Packaged macOS apps get a minimal PATH; login-shell cache fixes that once warm.
|
||||
*/
|
||||
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
||||
import { realpathSync } from 'fs';
|
||||
import { join as pathJoin, posix as pathPosix, win32 as pathWin32 } from 'path';
|
||||
|
|
@ -15,11 +16,12 @@ import { join as pathJoin, posix as pathPosix, win32 as pathWin32 } from 'path';
|
|||
export function buildMergedCliPath(binaryPath?: string | null): string {
|
||||
const home = getShellPreferredHome();
|
||||
const sep = process.platform === 'win32' ? pathWin32.delimiter : pathPosix.delimiter;
|
||||
const pathForBin = process.platform === 'win32' ? pathWin32 : pathPosix;
|
||||
const currentPath = process.env.PATH || '';
|
||||
const extraDirs: string[] = [];
|
||||
const vendorBinDir = pathForBin.join(getClaudeBasePath(), 'local', 'node_modules', '.bin');
|
||||
|
||||
if (binaryPath) {
|
||||
const pathForBin = process.platform === 'win32' ? pathWin32 : pathPosix;
|
||||
const binDir = pathForBin.dirname(binaryPath);
|
||||
extraDirs.push(binDir);
|
||||
try {
|
||||
|
|
@ -35,8 +37,13 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
|
|||
const cachedEnv = getCachedShellEnv();
|
||||
if (cachedEnv?.PATH) {
|
||||
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
|
||||
extraDirs.push(vendorBinDir);
|
||||
} else if (process.platform === 'win32') {
|
||||
extraDirs.push(pathJoin(home, 'AppData', 'Roaming', 'npm'), pathJoin(home, 'scoop', 'shims'));
|
||||
extraDirs.push(
|
||||
vendorBinDir,
|
||||
pathJoin(home, 'AppData', 'Roaming', 'npm'),
|
||||
pathJoin(home, 'scoop', 'shims')
|
||||
);
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
extraDirs.push(pathJoin(process.env.LOCALAPPDATA, 'Programs', 'claude'));
|
||||
}
|
||||
|
|
@ -45,6 +52,7 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
|
|||
}
|
||||
} else {
|
||||
extraDirs.push(
|
||||
vendorBinDir,
|
||||
pathPosix.join(home, '.local', 'bin'),
|
||||
pathPosix.join(home, '.npm-global', 'bin'),
|
||||
pathPosix.join(home, '.npm', 'bin'),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import * as path from 'node:path';
|
|||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||
|
||||
interface ListTeamsPayload {
|
||||
teamsDir: string;
|
||||
|
|
@ -86,6 +87,9 @@ interface TaskReadDiag {
|
|||
skipReasons: Record<string, number>;
|
||||
}
|
||||
|
||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsed JSON types (loose shapes from disk)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -316,6 +320,55 @@ function dropCliProvisionerMembers(
|
|||
}
|
||||
}
|
||||
|
||||
async function readLaunchState(
|
||||
teamsDir: string,
|
||||
teamName: string
|
||||
): Promise<{
|
||||
partialLaunchFailure?: true;
|
||||
expectedMemberCount?: number;
|
||||
confirmedMemberCount?: number;
|
||||
missingMembers?: string[];
|
||||
teamLaunchState?: string;
|
||||
launchUpdatedAt?: string;
|
||||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
} | null> {
|
||||
const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE);
|
||||
try {
|
||||
const stat = await fs.promises.stat(launchStatePath);
|
||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) return null;
|
||||
const raw = await fs.promises.readFile(launchStatePath, 'utf8');
|
||||
const snapshot = normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw));
|
||||
if (!snapshot) return null;
|
||||
const missingMembers = snapshot.expectedMembers.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
? { partialLaunchFailure: true as const }
|
||||
: {}),
|
||||
...(snapshot.expectedMembers.length > 0
|
||||
? { expectedMemberCount: snapshot.expectedMembers.length }
|
||||
: {}),
|
||||
...(snapshot.summary.confirmedCount > 0
|
||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a draft team summary from team.meta.json when config.json is missing.
|
||||
* Returns null if team.meta.json doesn't exist or is invalid.
|
||||
|
|
@ -482,6 +535,8 @@ async function listTeams(
|
|||
|
||||
const memberMap = new Map<string, { name: string; role?: string; color?: string }>();
|
||||
const removedKeys = new Set<string>();
|
||||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
|
||||
try {
|
||||
const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json');
|
||||
|
|
@ -500,6 +555,7 @@ async function listTeams(
|
|||
removedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
expectedTeammateNames.add(name);
|
||||
mergeMember(member, memberMap, removedKeys);
|
||||
}
|
||||
}
|
||||
|
|
@ -511,15 +567,55 @@ async function listTeams(
|
|||
if (config && Array.isArray(config.members)) {
|
||||
for (const member of config.members as unknown[]) {
|
||||
if (isRawMember(member)) {
|
||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||
confirmedArtifactNames.add(name);
|
||||
}
|
||||
mergeMember(member, memberMap, removedKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const inboxDir = path.join(payload.teamsDir, teamName, 'inboxes');
|
||||
const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true });
|
||||
for (const entry of inboxEntries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
||||
const inboxName = entry.name.slice(0, -'.json'.length).trim();
|
||||
if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue;
|
||||
confirmedArtifactNames.add(inboxName);
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
dropCliAutoSuffixedMembers(memberMap);
|
||||
dropCliProvisionerMembers(memberMap);
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const launchStateSummary =
|
||||
(await readLaunchState(payload.teamsDir, teamName)) ??
|
||||
(() => {
|
||||
if (
|
||||
!leadSessionId ||
|
||||
expectedTeammateNames.size === 0 ||
|
||||
confirmedArtifactNames.size === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const missingMembers = Array.from(expectedTeammateNames).filter(
|
||||
(name) => !confirmedArtifactNames.has(name)
|
||||
);
|
||||
if (missingMembers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
partialLaunchFailure: true as const,
|
||||
expectedMemberCount: expectedTeammateNames.size,
|
||||
confirmedMemberCount: confirmedArtifactNames.size,
|
||||
missingMembers,
|
||||
};
|
||||
})();
|
||||
const summary = {
|
||||
teamName,
|
||||
displayName,
|
||||
|
|
@ -534,6 +630,7 @@ async function listTeams(
|
|||
...(projectPathHistory ? { projectPathHistory } : {}),
|
||||
...(sessionHistory ? { sessionHistory } : {}),
|
||||
...(deletedAt ? { deletedAt } : {}),
|
||||
...(launchStateSummary ?? {}),
|
||||
};
|
||||
|
||||
const ms = nowMs() - t0;
|
||||
|
|
|
|||
|
|
@ -420,6 +420,9 @@ export const CROSS_TEAM_GET_OUTBOX = 'crossTeam:getOutbox';
|
|||
/** Get CLI installation status */
|
||||
export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus';
|
||||
|
||||
/** Get status for a single provider */
|
||||
export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus';
|
||||
|
||||
/** Start CLI install/update */
|
||||
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
|
||||
|
||||
|
|
@ -429,6 +432,9 @@ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress';
|
|||
/** Invalidate cached CLI status (forces fresh check on next getStatus) */
|
||||
export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus';
|
||||
|
||||
/** Get current tmux runtime availability for dashboard diagnostics */
|
||||
export const TMUX_GET_STATUS = 'tmux:getStatus';
|
||||
|
||||
// =============================================================================
|
||||
// Terminal API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import {
|
|||
API_KEYS_STORAGE_STATUS,
|
||||
APP_RELAUNCH,
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
CLI_INSTALLER_INSTALL,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
CLI_INSTALLER_PROGRESS,
|
||||
TMUX_GET_STATUS,
|
||||
CONTEXT_CHANGED,
|
||||
CONTEXT_GET_ACTIVE,
|
||||
CONTEXT_LIST,
|
||||
|
|
@ -291,6 +293,7 @@ import type {
|
|||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
UpdateKanbanPatch,
|
||||
UpdateSchedulePatch,
|
||||
|
|
@ -839,8 +842,17 @@ const electronAPI: ElectronAPI = {
|
|||
deleteDraft: async (teamName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_DELETE_DRAFT, teamName);
|
||||
},
|
||||
prepareProvisioning: async (cwd?: string) => {
|
||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
|
||||
prepareProvisioning: async (
|
||||
cwd?: string,
|
||||
providerId?: TeamLaunchRequest['providerId'],
|
||||
providerIds?: TeamLaunchRequest['providerId'][]
|
||||
) => {
|
||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
cwd,
|
||||
providerId,
|
||||
providerIds
|
||||
);
|
||||
},
|
||||
createTeam: async (request: TeamCreateRequest) => {
|
||||
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
|
||||
|
|
@ -1343,6 +1355,9 @@ const electronAPI: ElectronAPI = {
|
|||
getStatus: async (): Promise<CliInstallationStatus> => {
|
||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
||||
},
|
||||
getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => {
|
||||
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||
},
|
||||
install: async (): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
|
||||
},
|
||||
|
|
@ -1363,6 +1378,12 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
},
|
||||
|
||||
tmux: {
|
||||
getStatus: async (): Promise<TmuxStatus> => {
|
||||
return invokeIpcWithResult<TmuxStatus>(TMUX_GET_STATUS);
|
||||
},
|
||||
},
|
||||
|
||||
// ===== Terminal API =====
|
||||
terminal: {
|
||||
spawn: (options?: PtySpawnOptions) => invokeIpcWithResult<string>(TERMINAL_SPAWN, options),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ import type {
|
|||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TmuxAPI,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
UpdateKanbanPatch,
|
||||
UpdaterAPI,
|
||||
|
|
@ -702,7 +704,11 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
deleteDraft: async (_teamName: string): Promise<void> => {
|
||||
throw new Error('Draft team deletion is not available in browser mode');
|
||||
},
|
||||
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
|
||||
prepareProvisioning: async (
|
||||
_cwd?: string,
|
||||
_providerId?: TeamLaunchRequest['providerId'],
|
||||
_providerIds?: TeamLaunchRequest['providerId'][]
|
||||
): Promise<TeamProvisioningPrepareResult> => {
|
||||
throw new Error('Team provisioning is not available in browser mode');
|
||||
},
|
||||
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
|
||||
|
|
@ -1060,14 +1066,23 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
|
||||
cliInstaller: CliInstallerAPI = {
|
||||
getStatus: async () => ({
|
||||
flavor: 'claude',
|
||||
displayName: 'Claude CLI',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
launchError: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: false,
|
||||
authMethod: null,
|
||||
providers: [],
|
||||
}),
|
||||
getProviderStatus: async (): Promise<null> => null,
|
||||
install: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
||||
},
|
||||
|
|
@ -1077,6 +1092,18 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
},
|
||||
};
|
||||
|
||||
tmux: TmuxAPI = {
|
||||
getStatus: async (): Promise<TmuxStatus> => ({
|
||||
available: true,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
platform: 'unknown',
|
||||
nativeSupported: true,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal (not available in browser mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -865,7 +865,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
onClick={() => setContextPanelVisible(!isContextPanelVisible)}
|
||||
onMouseEnter={() => setIsContextButtonHovered(true)}
|
||||
onMouseLeave={() => setIsContextButtonHovered(false)}
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg transition-colors"
|
||||
style={{
|
||||
backgroundColor: isContextPanelVisible
|
||||
? 'var(--context-btn-active-bg)'
|
||||
|
|
@ -997,7 +997,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
scrollToBottom('smooth');
|
||||
setShowScrollButton(false);
|
||||
}}
|
||||
className="absolute bottom-5 z-20 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs shadow-lg backdrop-blur-md transition-all"
|
||||
className="absolute bottom-5 z-20 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs shadow-lg transition-colors"
|
||||
style={{
|
||||
right: '1rem',
|
||||
backgroundColor: 'var(--context-btn-bg)',
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerChatItemRef(item.group.id)}
|
||||
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
className={`rounded-lg transition-[background-color,box-shadow] ease-out ${hl.className} ${enterClass}`}
|
||||
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||
>
|
||||
<UserChatGroup userGroup={item.group} />
|
||||
|
|
@ -94,7 +94,7 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerChatItemRef(item.group.id)}
|
||||
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
className={`rounded-lg transition-[background-color,box-shadow] ease-out ${hl.className} ${enterClass}`}
|
||||
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||
>
|
||||
<SystemChatGroup systemGroup={item.group} />
|
||||
|
|
@ -116,7 +116,7 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerAIGroupRef(item.group.id)}
|
||||
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
className={`rounded-lg transition-[background-color,box-shadow] ease-out ${hl.className} ${enterClass}`}
|
||||
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
|
||||
>
|
||||
<AIChatGroup
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
TOOL_CALL_TEXT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { getToolContextTokens } from '@renderer/utils/toolRendering';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronRight, Layers, MailOpen } from 'lucide-react';
|
||||
|
||||
|
|
@ -43,6 +44,25 @@ interface DisplayItemListProps {
|
|||
registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void;
|
||||
/** Max characters for preview text in item headers (default: 150 for thinking/output, 80 for input) */
|
||||
previewMaxLength?: number;
|
||||
/** Optional timestamp format override for all items in this list. */
|
||||
timestampFormat?: string;
|
||||
/** Whether to include compact item metadata in a hover tooltip. */
|
||||
showItemMetaTooltip?: boolean;
|
||||
}
|
||||
|
||||
function buildItemMetaTooltip(
|
||||
timestamp: Date | undefined,
|
||||
tokenCount: number | undefined,
|
||||
tokenLabel = 'tokens'
|
||||
): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (timestamp) {
|
||||
parts.push(`Time: ${format(timestamp, 'HH:mm')}`);
|
||||
}
|
||||
if (tokenCount != null && tokenCount > 0) {
|
||||
parts.push(`Tokens: ~${formatTokensCompact(tokenCount)} ${tokenLabel}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' • ') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,6 +99,8 @@ export const DisplayItemList = ({
|
|||
notificationColorMap,
|
||||
registerToolRef,
|
||||
previewMaxLength,
|
||||
timestampFormat,
|
||||
showItemMetaTooltip = false,
|
||||
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
|
||||
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
|
||||
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
||||
|
|
@ -134,6 +156,12 @@ export const DisplayItemList = ({
|
|||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
timestamp={item.timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
showItemMetaTooltip
|
||||
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
|
||||
: undefined
|
||||
}
|
||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
|
|
@ -160,6 +188,12 @@ export const DisplayItemList = ({
|
|||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
timestamp={item.timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
showItemMetaTooltip
|
||||
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
|
||||
: undefined
|
||||
}
|
||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
|
|
@ -175,6 +209,16 @@ export const DisplayItemList = ({
|
|||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
timestamp={item.tool.startTime}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
showItemMetaTooltip
|
||||
? buildItemMetaTooltip(
|
||||
item.tool.startTime,
|
||||
getToolContextTokens(item.tool),
|
||||
'tokens'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
isHighlighted={highlightToolUseId === item.tool.id}
|
||||
highlightColor={highlightColor}
|
||||
|
|
@ -226,6 +270,16 @@ export const DisplayItemList = ({
|
|||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
timestamp={item.slash.timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
showItemMetaTooltip
|
||||
? buildItemMetaTooltip(
|
||||
item.slash.timestamp,
|
||||
item.slash.instructionsTokenCount,
|
||||
'tokens'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
|
@ -255,6 +309,12 @@ export const DisplayItemList = ({
|
|||
summary={truncateText(inputContent, previewMaxLength ?? 80)}
|
||||
tokenCount={inputTokenCount}
|
||||
timestamp={item.timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
showItemMetaTooltip
|
||||
? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens')
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
import { CopyButton } from '../common/CopyButton';
|
||||
import { OngoingBanner } from '../common/OngoingIndicator';
|
||||
|
||||
import { createMarkdownComponents, markdownComponents } from './markdownComponents';
|
||||
import { createMarkdownComponents, markdownComponentsWithCodeCopy } from './markdownComponents';
|
||||
import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils';
|
||||
|
||||
import type { AIGroupLastOutput } from '@renderer/types/groups';
|
||||
|
|
@ -63,7 +63,9 @@ export const LastOutputDisplay = ({
|
|||
// Create markdown components with optional search highlighting
|
||||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
||||
// useMemo would cache stale closures when parent re-renders without search deps changing
|
||||
const mdComponents = searchCtx ? createMarkdownComponents(searchCtx) : markdownComponents;
|
||||
const mdComponents = searchCtx
|
||||
? createMarkdownComponents(searchCtx, { copyCodeBlocks: true })
|
||||
: markdownComponentsWithCodeCopy;
|
||||
|
||||
// Show ongoing banner if this is the last AI group and session is ongoing
|
||||
// This uses the same source (sessions array) as the sidebar green dot for consistency
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@ import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { parseTaskNotifications } from '@shared/utils/contentSanitizer';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronDown, ChevronUp, User } from 'lucide-react';
|
||||
import { CheckCircle, ChevronDown, ChevronUp, Circle, FileText, User, XCircle } from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CopyButton } from '../common/CopyButton';
|
||||
|
||||
import { extractTextFromReactNode } from './markdownCopyUtils';
|
||||
import {
|
||||
createSearchContext,
|
||||
EMPTY_SEARCH_MATCHES,
|
||||
|
|
@ -284,18 +285,23 @@ function createUserMarkdownComponents(
|
|||
);
|
||||
},
|
||||
|
||||
pre: ({ children }) => (
|
||||
<pre
|
||||
className="my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
border: '1px solid var(--chat-user-tag-border)',
|
||||
color: userTextColor,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
pre: ({ children }) => {
|
||||
const codeText = extractTextFromReactNode(children).trim();
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={`my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
border: '1px solid var(--chat-user-tag-border)',
|
||||
color: userTextColor,
|
||||
}}
|
||||
>
|
||||
{codeText ? <CopyButton text={codeText} bgColor="var(--chat-user-bg)" /> : null}
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
|
|
@ -422,6 +428,29 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
const stripped = useMemo(() => stripAgentBlocks(textContent), [textContent]);
|
||||
const isLongContent = stripped.length > 500;
|
||||
|
||||
const taskNotifications = useMemo(() => {
|
||||
const rawContent =
|
||||
typeof userGroup.message.content === 'string'
|
||||
? userGroup.message.content
|
||||
: Array.isArray(userGroup.message.content)
|
||||
? userGroup.message.content
|
||||
.filter((block): block is { type: 'text'; text: string } => {
|
||||
return (
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block &&
|
||||
'text' in block &&
|
||||
(block as { type?: unknown }).type === 'text' &&
|
||||
typeof (block as { text?: unknown }).text === 'string'
|
||||
);
|
||||
})
|
||||
.map((block) => block.text)
|
||||
.join('')
|
||||
: '';
|
||||
|
||||
return parseTaskNotifications(rawContent);
|
||||
}, [userGroup.message.content]);
|
||||
|
||||
// Extract @path mentions from text
|
||||
const pathMentions = useMemo(() => {
|
||||
if (!textContent) return [];
|
||||
|
|
@ -573,6 +602,59 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{taskNotifications.length > 0 &&
|
||||
taskNotifications.map((notification) => {
|
||||
const isCompleted = notification.status === 'completed';
|
||||
const isFailed = notification.status === 'failed' || notification.status === 'error';
|
||||
const StatusIcon = isFailed ? XCircle : isCompleted ? CheckCircle : Circle;
|
||||
const statusColor = isFailed
|
||||
? 'var(--error-highlight-text, #ef4444)'
|
||||
: isCompleted
|
||||
? 'var(--badge-success-text, #22c55e)'
|
||||
: 'var(--color-text-muted)';
|
||||
const commandMatch = /"([^"]+)"/.exec(notification.summary);
|
||||
const commandName =
|
||||
commandMatch?.[1] ?? notification.summary.trim() ?? 'Background task';
|
||||
const exitCodeMatch = /\(exit code (\d+)\)/.exec(notification.summary);
|
||||
const outputFileName = notification.outputFile
|
||||
? (notification.outputFile.split(/[\\/]/).pop() ?? notification.outputFile)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.taskId || `${groupId}-${notification.summary}`}
|
||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
}}
|
||||
>
|
||||
<StatusIcon className="mt-0.5 size-3.5 shrink-0" style={{ color: statusColor }} />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div
|
||||
className="text-xs font-medium leading-snug"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{commandName || 'Background task'}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 text-[10px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<span className="capitalize">{notification.status || 'unknown'}</span>
|
||||
{exitCodeMatch?.[1] ? <span>exit {exitCodeMatch[1]}</span> : null}
|
||||
{outputFileName ? (
|
||||
<span className="flex min-w-0 items-center gap-0.5 truncate">
|
||||
<FileText className="size-2.5 shrink-0" />
|
||||
<span className="truncate">{outputFileName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Images indicator */}
|
||||
{hasImages && (
|
||||
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ interface BaseItemProps {
|
|||
durationMs?: number;
|
||||
/** Timestamp to display (compact HH:mm:ss) */
|
||||
timestamp?: Date;
|
||||
/** Optional date-fns format for the timestamp. Defaults to HH:mm:ss. */
|
||||
timestampFormat?: string;
|
||||
/** Optional tooltip text for the header row. */
|
||||
titleText?: string;
|
||||
/** Click handler for toggling */
|
||||
onClick: () => void;
|
||||
/** Whether the item is expanded */
|
||||
|
|
@ -56,7 +60,7 @@ interface BaseItemProps {
|
|||
export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
|
||||
return (
|
||||
<span
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: getStatusDotColor(status) }}
|
||||
/>
|
||||
);
|
||||
|
|
@ -84,6 +88,8 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
status,
|
||||
durationMs,
|
||||
timestamp,
|
||||
timestampFormat = 'HH:mm:ss',
|
||||
titleText,
|
||||
onClick,
|
||||
isExpanded,
|
||||
hasExpandableContent = true,
|
||||
|
|
@ -94,13 +100,14 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`rounded transition-all duration-300 ${highlightClasses}`}
|
||||
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
||||
style={highlightStyle}
|
||||
>
|
||||
{/* Clickable Header */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={titleText}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
|
@ -145,7 +152,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
{/* Token count badge */}
|
||||
{tokenCount != null && tokenCount > 0 && (
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-xs"
|
||||
className="base-item-tokens shrink-0 rounded px-1.5 py-0.5 text-xs"
|
||||
style={{
|
||||
color: TOOL_ITEM_MUTED,
|
||||
backgroundColor: 'var(--tool-item-badge-bg)',
|
||||
|
|
@ -161,7 +168,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
{/* Notification dot (replaces status dot when present) */}
|
||||
{notificationDotColor && (
|
||||
<span
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
className="base-item-notification-dot inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: getTriggerColorDef(notificationDotColor).hex }}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -179,7 +186,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
|
||||
style={{ color: TOOL_ITEM_MUTED }}
|
||||
>
|
||||
{format(timestamp, 'HH:mm:ss')}
|
||||
{format(timestamp, timestampFormat)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ interface LinkedToolItemProps {
|
|||
isExpanded: boolean;
|
||||
/** Timestamp for display */
|
||||
timestamp?: Date;
|
||||
timestampFormat?: string;
|
||||
/** Optional local search query override for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
/** Whether this item should be highlighted for error deep linking */
|
||||
|
|
@ -60,6 +61,7 @@ interface LinkedToolItemProps {
|
|||
notificationDotColor?: TriggerColor;
|
||||
/** Optional ref registration callback for external scroll control */
|
||||
registerRef?: (el: HTMLDivElement | null) => void;
|
||||
titleText?: string;
|
||||
}
|
||||
|
||||
export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
||||
|
|
@ -67,11 +69,13 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
onClick,
|
||||
isExpanded,
|
||||
timestamp,
|
||||
timestampFormat,
|
||||
searchQueryOverride,
|
||||
isHighlighted,
|
||||
highlightColor,
|
||||
notificationDotColor,
|
||||
registerRef,
|
||||
titleText,
|
||||
}) => {
|
||||
const status = getToolStatus(linkedTool);
|
||||
const { isLight } = useTheme();
|
||||
|
|
@ -181,6 +185,8 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
status={status}
|
||||
durationMs={linkedTool.durationMs}
|
||||
timestamp={timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={titleText}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
highlightClasses={highlightClasses}
|
||||
|
|
|
|||
|
|
@ -152,7 +152,6 @@ export const MetricsPill = ({
|
|||
style={{
|
||||
...tooltipStyle,
|
||||
border: `1px solid ${TAG_BORDER}`,
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ interface SlashItemProps {
|
|||
isExpanded: boolean;
|
||||
/** Timestamp for display */
|
||||
timestamp?: Date;
|
||||
timestampFormat?: string;
|
||||
/** Additional classes for highlighting (e.g., error deep linking) */
|
||||
highlightClasses?: string;
|
||||
/** Inline styles for highlighting (used by custom hex colors) */
|
||||
highlightStyle?: React.CSSProperties;
|
||||
/** Notification dot color for custom triggers */
|
||||
notificationDotColor?: TriggerColor;
|
||||
titleText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -37,9 +39,11 @@ export const SlashItem: React.FC<SlashItemProps> = ({
|
|||
onClick,
|
||||
isExpanded,
|
||||
timestamp,
|
||||
timestampFormat,
|
||||
highlightClasses,
|
||||
highlightStyle,
|
||||
notificationDotColor,
|
||||
titleText,
|
||||
}) => {
|
||||
const hasInstructions = !!slash.instructions;
|
||||
|
||||
|
|
@ -55,6 +59,8 @@ export const SlashItem: React.FC<SlashItemProps> = ({
|
|||
tokenLabel="tokens"
|
||||
status={hasInstructions ? 'ok' : undefined}
|
||||
timestamp={timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={titleText}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
hasExpandableContent={hasInstructions}
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
return (
|
||||
<div
|
||||
ref={outerCardRef}
|
||||
className={`overflow-hidden rounded-md transition-all duration-300 ${outerHighlight.className}`}
|
||||
className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${outerHighlight.className}`}
|
||||
style={{
|
||||
backgroundColor: CARD_BG,
|
||||
border: CARD_BORDER_STYLE,
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-md transition-all duration-300 ${highlightClasses}`}
|
||||
className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
||||
style={{
|
||||
backgroundColor: CARD_BG,
|
||||
border: CARD_BORDER_STYLE,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface TextItemProps {
|
|||
isExpanded: boolean;
|
||||
/** Timestamp for display */
|
||||
timestamp?: Date;
|
||||
timestampFormat?: string;
|
||||
/** Optional local search query for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
/** Optional stable item id for search highlighting */
|
||||
|
|
@ -27,6 +28,7 @@ interface TextItemProps {
|
|||
highlightStyle?: React.CSSProperties;
|
||||
/** Notification dot color for custom triggers */
|
||||
notificationDotColor?: TriggerColor;
|
||||
titleText?: string;
|
||||
}
|
||||
|
||||
export const TextItem: React.FC<TextItemProps> = ({
|
||||
|
|
@ -35,11 +37,13 @@ export const TextItem: React.FC<TextItemProps> = ({
|
|||
onClick,
|
||||
isExpanded,
|
||||
timestamp,
|
||||
timestampFormat,
|
||||
searchQueryOverride,
|
||||
markdownItemId,
|
||||
highlightClasses,
|
||||
highlightStyle,
|
||||
notificationDotColor,
|
||||
titleText,
|
||||
}) => {
|
||||
const fullContent = step.content.outputText ?? preview;
|
||||
const summary = searchQueryOverride
|
||||
|
|
@ -58,6 +62,8 @@ export const TextItem: React.FC<TextItemProps> = ({
|
|||
summary={summary}
|
||||
tokenCount={tokenCount}
|
||||
timestamp={timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={titleText}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
highlightClasses={highlightClasses}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface ThinkingItemProps {
|
|||
isExpanded: boolean;
|
||||
/** Timestamp for display */
|
||||
timestamp?: Date;
|
||||
timestampFormat?: string;
|
||||
/** Optional local search query for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
/** Optional stable item id for search highlighting */
|
||||
|
|
@ -27,6 +28,7 @@ interface ThinkingItemProps {
|
|||
highlightStyle?: React.CSSProperties;
|
||||
/** Notification dot color for custom triggers */
|
||||
notificationDotColor?: TriggerColor;
|
||||
titleText?: string;
|
||||
}
|
||||
|
||||
export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
||||
|
|
@ -35,11 +37,13 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
|||
onClick,
|
||||
isExpanded,
|
||||
timestamp,
|
||||
timestampFormat,
|
||||
searchQueryOverride,
|
||||
markdownItemId,
|
||||
highlightClasses,
|
||||
highlightStyle,
|
||||
notificationDotColor,
|
||||
titleText,
|
||||
}) => {
|
||||
const fullContent = step.content.thinkingText ?? preview;
|
||||
const summary = searchQueryOverride
|
||||
|
|
@ -58,6 +62,8 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
|||
summary={summary}
|
||||
tokenCount={tokenCount}
|
||||
timestamp={timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={titleText}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
highlightClasses={highlightClasses}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
DIFF_REMOVED_TEXT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary';
|
||||
|
||||
/**
|
||||
* Renders the input section based on tool type with theme-aware styling.
|
||||
|
|
@ -99,6 +100,71 @@ export function renderInput(toolName: string, input: Record<string, unknown>): R
|
|||
);
|
||||
}
|
||||
|
||||
// Special rendering for Agent tool - do not leak full bootstrap prompts in UI logs.
|
||||
if (toolName === 'Agent') {
|
||||
const details = getAgentToolDisplayDetails(input);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" style={{ color: COLOR_TEXT }}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||
action
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-all">{details.action}</div>
|
||||
</div>
|
||||
|
||||
{details.teammateName && (
|
||||
<div>
|
||||
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||
teammate
|
||||
</div>
|
||||
<div>{details.teammateName}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details.teamName && (
|
||||
<div>
|
||||
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||
team
|
||||
</div>
|
||||
<div>{details.teamName}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details.runtime && (
|
||||
<div>
|
||||
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||
runtime
|
||||
</div>
|
||||
<div>{details.runtime}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details.subagentType && (
|
||||
<div>
|
||||
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||
type
|
||||
</div>
|
||||
<div>{details.subagentType}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded px-3 py-2 text-[11px]"
|
||||
style={{
|
||||
backgroundColor: 'rgba(250, 204, 21, 0.08)',
|
||||
border: '1px solid rgba(250, 204, 21, 0.22)',
|
||||
color: COLOR_TEXT_MUTED,
|
||||
}}
|
||||
>
|
||||
Startup instructions are hidden in the UI.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: key-value format with readable string values
|
||||
return (
|
||||
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
||||
|
||||
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
||||
import { extractTextFromReactNode } from './markdownCopyUtils';
|
||||
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
||||
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
||||
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
interface MarkdownComponentOptions {
|
||||
copyCodeBlocks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create inline markdown components for rendering prose content.
|
||||
* When searchCtx is provided, search term highlighting is applied
|
||||
* to text nodes while preserving full markdown rendering.
|
||||
*/
|
||||
export function createMarkdownComponents(searchCtx: SearchContext | null): Components {
|
||||
export function createMarkdownComponents(
|
||||
searchCtx: SearchContext | null,
|
||||
options: MarkdownComponentOptions = {}
|
||||
): Components {
|
||||
const { copyCodeBlocks = false } = options;
|
||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||
|
||||
|
|
@ -148,18 +158,23 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
|
|||
},
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }) => (
|
||||
<pre
|
||||
className="my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
|
||||
style={{
|
||||
backgroundColor: 'var(--prose-pre-bg)',
|
||||
border: '1px solid var(--prose-pre-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
pre: ({ children }) => {
|
||||
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={`my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
|
||||
style={{
|
||||
backgroundColor: 'var(--prose-pre-bg)',
|
||||
border: '1px solid var(--prose-pre-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{codeText ? <CopyButton text={codeText} /> : null}
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
|
|
@ -235,3 +250,8 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
|
|||
|
||||
/** Default markdown components without search highlighting (used by CompactBoundary) */
|
||||
export const markdownComponents: Components = createMarkdownComponents(null);
|
||||
|
||||
/** Markdown components for message-style content with both whole-message and code-block copy */
|
||||
export const markdownComponentsWithCodeCopy: Components = createMarkdownComponents(null, {
|
||||
copyCodeBlocks: true,
|
||||
});
|
||||
|
|
|
|||
18
src/renderer/components/chat/markdownCopyUtils.ts
Normal file
18
src/renderer/components/chat/markdownCopyUtils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Extract plain text from rendered markdown children for per-block copy actions.
|
||||
*/
|
||||
export function extractTextFromReactNode(node: React.ReactNode): string {
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractTextFromReactNode).join('');
|
||||
}
|
||||
if (React.isValidElement(node)) {
|
||||
const props = node.props as { children?: React.ReactNode };
|
||||
return extractTextFromReactNode(props.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import { FileText, UsersRound } from 'lucide-react';
|
|||
import remarkGfm from 'remark-gfm';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { extractTextFromReactNode } from '../markdownCopyUtils';
|
||||
import {
|
||||
createSearchContext,
|
||||
EMPTY_SEARCH_MATCHES,
|
||||
|
|
@ -41,7 +42,6 @@ import {
|
|||
type SearchContext,
|
||||
} from '../searchHighlightUtils';
|
||||
import { highlightLine } from '../viewers/syntaxHighlighter';
|
||||
|
||||
import { FileLink, isRelativeUrl } from './FileLink';
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
|
||||
|
|
@ -320,7 +320,8 @@ function createViewerMarkdownComponents(
|
|||
searchCtx: SearchContext | null,
|
||||
isLight = false,
|
||||
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
||||
onTeamClick?: (teamName: string) => void
|
||||
onTeamClick?: (teamName: string) => void,
|
||||
copyCodeBlocks: boolean = false
|
||||
): Components {
|
||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||
|
|
@ -577,14 +578,17 @@ function createViewerMarkdownComponents(
|
|||
}
|
||||
}
|
||||
|
||||
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
||||
className={`my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
|
||||
style={{
|
||||
backgroundColor: PROSE_PRE_BG,
|
||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
{codeText ? <CopyButton text={codeText} /> : null}
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
|
|
@ -863,10 +867,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
||||
// useMemo would cache stale closures when parent re-renders without search deps changing
|
||||
const baseComponents = searchCtx
|
||||
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick)
|
||||
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable)
|
||||
: isLight
|
||||
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick)
|
||||
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick);
|
||||
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable)
|
||||
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable);
|
||||
|
||||
// When baseDir is set (editor preview), override img to load local files via IPC
|
||||
const components = baseDir
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
|
||||
const cliStatus = useStore(useShallow((s) => s.cliStatus));
|
||||
|
|
@ -39,7 +39,9 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => {
|
|||
>
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span className="text-xs">
|
||||
Claude Code is not installed. Install it from the Dashboard to enable all features.
|
||||
{cliStatus.binaryPath && cliStatus.launchError
|
||||
? 'Claude Code was found but failed to start. Open the Dashboard to repair or reinstall it.'
|
||||
: 'Claude Code is not installed. Install it from the Dashboard to enable all features.'}
|
||||
</span>
|
||||
<button
|
||||
onClick={openDashboard}
|
||||
|
|
|
|||
206
src/renderer/components/common/ProviderBrandLogo.tsx
Normal file
206
src/renderer/components/common/ProviderBrandLogo.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { useId } from 'react';
|
||||
|
||||
import type { CliProviderId } from '@shared/types';
|
||||
|
||||
type ProviderBrandLogoId = CliProviderId | 'opencode';
|
||||
|
||||
type BrandLogoProps = Readonly<{
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
interface ProviderBrandLogoProps {
|
||||
readonly providerId: ProviderBrandLogoId;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
const AnthropicBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<g fill="#D97757">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<rect
|
||||
key={index}
|
||||
x="10.75"
|
||||
y="1.8"
|
||||
width="2.5"
|
||||
height="7.7"
|
||||
rx="1.2"
|
||||
transform={`rotate(${index * 36} 12 12)`}
|
||||
/>
|
||||
))}
|
||||
<circle cx="12" cy="12" r="3.1" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const CodexBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
|
||||
const gradientId = useId();
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<rect x="1.25" y="1.25" width="21.5" height="21.5" rx="6.5" fill="#F8FAFC" />
|
||||
<path
|
||||
d="M17.6 10.2a4.95 4.95 0 0 0-8.58-2.43 3.7 3.7 0 0 0-4.25 5.73A3.46 3.46 0 0 0 6.34 20h10.12a3.65 3.65 0 0 0 1.14-7.14Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
<path
|
||||
d="M9.05 9.55 11.4 12l-2.35 2.45"
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.1 14.45h3.05"
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="12"
|
||||
y1="6.4"
|
||||
x2="12"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#A5B4FC" />
|
||||
<stop offset="0.55" stopColor="#6F8CFF" />
|
||||
<stop offset="1" stopColor="#3B46FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const GeminiBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
|
||||
const gradientId = useId();
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<path
|
||||
d="M12 2.25c.62 3.9 1.6 6.57 3.18 8.15 1.58 1.58 4.25 2.56 8.15 3.18-3.9.62-6.57 1.6-8.15 3.18-1.58 1.58-2.56 4.25-3.18 8.15-.62-3.9-1.6-6.57-3.18-8.15-1.58-1.58-4.25-2.56-8.15-3.18 3.9-.62 6.57-1.6 8.15-3.18C10.4 8.82 11.38 6.15 12 2.25Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="4"
|
||||
y1="4"
|
||||
x2="20"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#9F7AEA" />
|
||||
<stop offset="1" stopColor="#60A5FA" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenCodeBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
|
||||
const backgroundId = useId();
|
||||
const frameId = useId();
|
||||
const frameStrokeId = useId();
|
||||
const coreId = useId();
|
||||
const coreStrokeId = useId();
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={backgroundId}
|
||||
x1="4"
|
||||
y1="3"
|
||||
x2="20"
|
||||
y2="21"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#303030" />
|
||||
<stop offset="1" stopColor="#161616" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={frameId}
|
||||
x1="7"
|
||||
y1="4.5"
|
||||
x2="17"
|
||||
y2="19.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#f4f4f4" />
|
||||
<stop offset="0.35" stopColor="#d9d9d9" />
|
||||
<stop offset="0.68" stopColor="#a8a8a8" />
|
||||
<stop offset="1" stopColor="#ececec" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={frameStrokeId}
|
||||
x1="7"
|
||||
y1="4.5"
|
||||
x2="17"
|
||||
y2="19.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ffffff" stopOpacity="0.9" />
|
||||
<stop offset="1" stopColor="#5a5a5a" stopOpacity="0.9" />
|
||||
</linearGradient>
|
||||
<linearGradient id={coreId} x1="12" y1="7" x2="12" y2="17" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#121212" />
|
||||
<stop offset="0.42" stopColor="#3e3b33" />
|
||||
<stop offset="1" stopColor="#16140f" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={coreStrokeId}
|
||||
x1="9"
|
||||
y1="7"
|
||||
x2="15"
|
||||
y2="17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#f2f2f2" stopOpacity="0.95" />
|
||||
<stop offset="1" stopColor="#6e6e6e" stopOpacity="0.85" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="1.5" y="1.5" width="21" height="21" rx="5.2" fill={`url(#${backgroundId})`} />
|
||||
<path
|
||||
d="M7 4.25h10c.3 0 .55.25.55.55v14.4c0 .3-.25.55-.55.55H7c-.3 0-.55-.25-.55-.55V4.8c0-.3.25-.55.55-.55Z"
|
||||
fill={`url(#${frameId})`}
|
||||
stroke={`url(#${frameStrokeId})`}
|
||||
strokeWidth="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M8.95 7.25h6.1c.22 0 .4.18.4.4v8.7c0 .22-.18.4-.4.4h-6.1a.4.4 0 0 1-.4-.4v-8.7c0-.22.18-.4.4-.4Z"
|
||||
fill={`url(#${coreId})`}
|
||||
stroke={`url(#${coreStrokeId})`}
|
||||
strokeWidth="0.45"
|
||||
/>
|
||||
<path
|
||||
d="M9.25 7.6h5.5"
|
||||
stroke="#ffffff"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.45"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProviderBrandLogo = ({
|
||||
providerId,
|
||||
className,
|
||||
}: ProviderBrandLogoProps): React.JSX.Element => {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
return <AnthropicBrandLogo className={className} />;
|
||||
case 'codex':
|
||||
return <CodexBrandLogo className={className} />;
|
||||
case 'gemini':
|
||||
return <GeminiBrandLogo className={className} />;
|
||||
case 'opencode':
|
||||
return <OpenCodeBrandLogo className={className} />;
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue