fix(team): harden process bootstrap and codex auth

This commit is contained in:
777genius 2026-05-08 09:28:28 +03:00
parent 26baaf6924
commit 08ab7c6b6d
40 changed files with 2707 additions and 251 deletions

View file

@ -32,9 +32,9 @@ jobs:
- name: Generate static site - name: Generate static site
working-directory: landing working-directory: landing
env: env:
NUXT_APP_BASE_URL: /claude_agent_teams_ui/ NUXT_APP_BASE_URL: /agent-teams-ai/
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui NUXT_PUBLIC_SITE_URL: https://777genius.github.io/agent-teams-ai
NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui NUXT_PUBLIC_GITHUB_REPO: 777genius/agent-teams-ai
run: npm run generate:all run: npm run generate:all
- uses: actions/configure-pages@v5 - uses: actions/configure-pages@v5

View file

@ -497,13 +497,13 @@ jobs:
trap 'rm -rf "$TMP_DIR"' EXIT trap 'rm -rf "$TMP_DIR"' EXIT
declare -A FILES=( declare -A FILES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg" ["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-x64.dmg" ["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
["Claude-Agent-Teams-UI-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe" ["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage" ["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage"
["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb" ["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb"
["Claude-Agent-Teams-UI-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm" ["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm"
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman" ["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
) )
# Download versioned files and re-upload with stable names # Download versioned files and re-upload with stable names
@ -574,22 +574,22 @@ jobs:
# electron-updater on GitHub still consumes a single latest-mac.yml, so we # 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 # publish the Apple Silicon feed here and suppress Intel auto-update in-app
# until we switch to universal packaging or an arch-aware provider. # until we switch to universal packaging or an arch-aware provider.
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip" download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip"
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64.dmg" download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg"
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)" MAC_ZIP_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)" MAC_ZIP_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)" MAC_DMG_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64.dmg)"
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)" MAC_DMG_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64.dmg)"
cat > latest-mac.yml <<EOF cat > latest-mac.yml <<EOF
version: ${VERSION} version: ${VERSION}
files: files:
- url: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip - url: Agent.Teams.AI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA} sha512: ${MAC_ZIP_SHA}
size: ${MAC_ZIP_SIZE} size: ${MAC_ZIP_SIZE}
- url: Claude.Agent.Teams.UI-${VERSION}-arm64.dmg - url: Agent.Teams.AI-${VERSION}-arm64.dmg
sha512: ${MAC_DMG_SHA} sha512: ${MAC_DMG_SHA}
size: ${MAC_DMG_SIZE} size: ${MAC_DMG_SIZE}
path: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip path: Agent.Teams.AI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA} sha512: ${MAC_ZIP_SHA}
releaseDate: '${RELEASE_DATE}' releaseDate: '${RELEASE_DATE}'
EOF EOF

View file

@ -10,15 +10,15 @@
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a> <a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</p> </p>
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams</a></h1> <h1 align="center"><a href="https://777genius.github.io/agent-teams-ai/">Agent Teams</a></h1>
<p align="center"> <p align="center">
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong> <strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/tag/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp; <a href="https://github.com/777genius/agent-teams-ai/releases/latest"><img src="https://img.shields.io/github/v/tag/777genius/agent-teams-ai?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp; <a href="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml"><img src="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://discord.gg/qtqSZSyuEc"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a> <a href="https://discord.gg/qtqSZSyuEc"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
</p> </p>
@ -54,33 +54,33 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc
<table align="center"> <table align="center">
<tr> <tr>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" /> <img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
</a> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg">
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" /> <img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" /> <img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
</a> </a>
<br /> <br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub> <sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" /> <img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
</a> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb">
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" /> <img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
</a>&nbsp; </a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm">
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" /> <img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
</a>&nbsp; </a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.pacman"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI.pacman">
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" /> <img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
</a> </a>
</td> </td>
@ -268,8 +268,8 @@ Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.cl
**Prerequisites:** Node.js 20+, pnpm 10+ **Prerequisites:** Node.js 20+, pnpm 10+
```bash ```bash
git clone https://github.com/777genius/claude_agent_teams_ui.git git clone https://github.com/777genius/agent-teams-ai.git
cd claude_agent_teams_ui cd agent-teams-ai
pnpm install pnpm install
pnpm dev pnpm dev
``` ```

View file

@ -3,7 +3,7 @@
"configVersion": 1, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "claude-agent-teams-ui", "name": "agent-teams-ai",
"dependencies": { "dependencies": {
"@claude-teams/agent-graph": "workspace:*", "@claude-teams/agent-graph": "workspace:*",
"@codemirror/autocomplete": "^6.20.0", "@codemirror/autocomplete": "^6.20.0",

View file

@ -15,7 +15,7 @@ Initial release: Agent Teams with reliable CLI detection in packaged builds (she
After CI uploads artifacts, optional notes update: After CI uploads artifacts, optional notes update:
```bash ```bash
gh release edit v1.0.0 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF' gh release edit v1.0.0 --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF'
## Agent Teams v1.0.0 ## Agent Teams v1.0.0
First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging. First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging.
@ -37,33 +37,33 @@ First stable build: CLI/auth reliability in packaged apps, IPC hardening, and pl
<table> <table>
<tr> <tr>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0-arm64.dmg"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0-arm64.dmg">
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" /> <img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
</a> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.dmg"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.dmg">
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" /> <img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI.Setup.1.0.0.exe"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI.Setup.1.0.0.exe">
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" /> <img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
</a> </a>
<br /> <br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub> <sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.AppImage"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.AppImage">
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" /> <img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
</a> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui_1.0.0_amd64.deb"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui_1.0.0_amd64.deb">
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" /> <img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
</a>&nbsp; </a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.x86_64.rpm"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.x86_64.rpm">
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" /> <img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
</a>&nbsp; </a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.pacman"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.pacman">
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" /> <img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
</a> </a>
</td> </td>
@ -112,7 +112,7 @@ This triggers the `release.yml` GitHub Actions workflow which:
After the workflow completes, edit the release notes: After the workflow completes, edit the release notes:
```bash ```bash
gh release edit v<VERSION> --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF' gh release edit v<VERSION> --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF'
<paste release notes here> <paste release notes here>
EOF EOF
)" )"
@ -140,33 +140,33 @@ EOF
<table> <table>
<tr> <tr>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-arm64.dmg"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>-arm64.dmg">
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" /> <img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
</a> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-x64.dmg"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>-x64.dmg">
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" /> <img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI.Setup.<VERSION>.exe"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI.Setup.<VERSION>.exe">
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" /> <img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
</a> </a>
<br /> <br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub> <sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>.AppImage"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>.AppImage">
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" /> <img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
</a> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui_<VERSION>_amd64.deb"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai_<VERSION>_amd64.deb">
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" /> <img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
</a>&nbsp; </a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.x86_64.rpm"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai-<VERSION>.x86_64.rpm">
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" /> <img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
</a>&nbsp; </a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.pacman"> <a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai-<VERSION>.pacman">
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" /> <img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
</a> </a>
</td> </td>
@ -196,15 +196,15 @@ electron-builder generates these artifacts per platform:
| Platform | Versioned Name | Stable Name (for /latest/download) | | Platform | Versioned Name | Stable Name (for /latest/download) |
|------------------|--------------------------------------------------|--------------------------------------------| |------------------|--------------------------------------------------|--------------------------------------------|
| macOS arm64 DMG | `Claude.Agent.Teams.UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | | macOS arm64 DMG | `Agent.Teams.AI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | | macOS x64 DMG | `Agent.Teams.AI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | - | | macOS arm64 ZIP | `Agent.Teams.AI-<VER>-arm64-mac.zip` | - |
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-x64-mac.zip` | - | | macOS x64 ZIP | `Agent.Teams.AI-<VER>-x64-mac.zip` | - |
| Windows | `Claude.Agent.Teams.UI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` | | Windows | `Agent.Teams.AI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
| Linux AppImage | `Claude.Agent.Teams.UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` | | Linux AppImage | `Agent.Teams.AI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
| Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | | Linux deb | `agent-teams-ai_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
| Linux rpm | `claude-agent-teams-ui-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` | | Linux rpm | `agent-teams-ai-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
| Linux pacman | `claude-agent-teams-ui-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` | | Linux pacman | `agent-teams-ai-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
## Stable Download Links ## Stable Download Links
@ -214,7 +214,7 @@ It starts only after **release-mac** (two matrix jobs), **release-win**, and **r
This enables permanent links in README that always point to the latest release: This enables permanent links in README that always point to the latest release:
``` ```
https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
``` ```
GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version. GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version.
@ -251,10 +251,10 @@ git push origin v1.0.0
# Wait for CI to finish (~10 min), then update notes # Wait for CI to finish (~10 min), then update notes
# Delete a release (if needed) # Delete a release (if needed)
gh release delete v1.0.0 --repo 777genius/claude_agent_teams_ui --yes gh release delete v1.0.0 --repo 777genius/agent-teams-ai --yes
git tag -d v1.0.0 git tag -d v1.0.0
git push origin :refs/tags/v1.0.0 git push origin :refs/tags/v1.0.0
# Check workflow status # Check workflow status
gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3 gh run list --repo 777genius/agent-teams-ai --workflow release.yml --limit 3
``` ```

View file

@ -333,7 +333,7 @@ const runtimeFilePrefixCases = [
['bob.team', 'bob-team'], ['bob.team', 'bob-team'],
['bob_team', 'bob-team'], ['bob_team', 'bob-team'],
['con', '_con'], ['con', '_con'],
['con.txt', '_con-txt'], ['con.txt', 'con-txt'],
['aux', '_aux'], ['aux', '_aux'],
['COM1', '_com1'], ['COM1', '_com1'],
['LPT9', '_lpt9'], ['LPT9', '_lpt9'],
@ -425,6 +425,18 @@ These values are structured output and can be shown in diagnostics immediately.
`clean_success` finished launches can clear persisted launch-state. This is fine. Transport diagnostics matter for failed/pending launches, not clean success. `clean_success` finished launches can clear persisted launch-state. This is fine. Transport diagnostics matter for failed/pending launches, not clean success.
Clean-success clear rule:
- if the normalized snapshot is truly `clean_success` after proof/provider/transport overlays and `launchPhase !== 'active'`, preserve existing behavior and clear persisted launch-state;
- process transport enrichment must not keep a stale diagnostic alive after every expected member is confirmed/skipped according to existing summary semantics;
- if any process member is still `runtime_pending_bootstrap`, `failed_to_start`, or permission-pending, the snapshot is not clean success and must not be cleared;
- do not use runtime event absence to prevent clean-success clearing. Absence of a best-effort diagnostic file is not a reason to keep launch-state;
- `clearPersistedLaunchStateNow(...)` also clears bootstrap-state. Therefore clean-success clear must be fenced by current run identity immediately before clearing, not only before building the snapshot;
- a stale clean-success finalizer from an older run must not clear launch-state or bootstrap-state for a newer active/restarted run;
- if the current run identity cannot be proven at clear time, skip clear and keep the conservative persisted state;
- tests should cover clean-success launch with missing runtime event files and assert launch-state can still clear.
- tests should cover stale clean-success finalizer racing with a newer launch and assert neither launch-state nor bootstrap-state is cleared for the newer run.
### 27. Shared/public launch status types must not expose transport internals ### 27. Shared/public launch status types must not expose transport internals
`PersistedTeamLaunchMemberState`, `MemberSpawnStatusEntry`, and `TeamAgentRuntimeEntry` expose user-facing runtime/launch status fields like `runtimeDiagnostic`, `hardFailureReason`, `diagnostics`, `livenessKind`, and `runtimePid`. `PersistedTeamLaunchMemberState`, `MemberSpawnStatusEntry`, and `TeamAgentRuntimeEntry` expose user-facing runtime/launch status fields like `runtimeDiagnostic`, `hardFailureReason`, `diagnostics`, `livenessKind`, and `runtimePid`.
@ -636,7 +648,11 @@ type LaunchTransitionReason =
function mergeProcessBootstrapLaunchState( function mergeProcessBootstrapLaunchState(
previous: PersistedTeamLaunchMemberState, previous: PersistedTeamLaunchMemberState,
evidence: ProcessBootstrapTransportEvidence, evidence: ProcessBootstrapTransportEvidence,
context: { launchPhase: 'active' | 'terminal'; currentAttempt: boolean }, context: {
launchPhase: PersistedTeamLaunchPhase
projectionPhase: 'active' | 'final'
currentAttempt: boolean
},
): { next: PersistedTeamLaunchMemberState; changed: boolean; reason: LaunchTransitionReason } { ): { next: PersistedTeamLaunchMemberState; changed: boolean; reason: LaunchTransitionReason } {
// pure function, no filesystem, no process table, no renderer imports // pure function, no filesystem, no process table, no renderer imports
} }
@ -644,6 +660,36 @@ function mergeProcessBootstrapLaunchState(
Keep this transition helper pure and test it directly. Do not scatter transition checks across `TeamProvisioningService`, summary projection, and renderer helpers. Keep this transition helper pure and test it directly. Do not scatter transition checks across `TeamProvisioningService`, summary projection, and renderer helpers.
### Persisted launch phase compatibility
Current shared type is:
```ts
export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled'
```
Do not add new persisted values like `finalizing` or `terminal` for this phase. If merge logic needs an internal terminal/final concept, derive it as a local non-persisted value:
```ts
type ProcessTransportProjectionPhase = 'active' | 'final'
function deriveProcessTransportProjectionPhase(input: {
launchPhase: PersistedTeamLaunchPhase
finalTimeoutReached?: boolean
}): ProcessTransportProjectionPhase {
if (input.launchPhase !== 'active') return 'final'
return input.finalTimeoutReached === true ? 'final' : 'active'
}
```
Rules:
- persisted snapshots keep `launchPhase: 'active' | 'finished' | 'reconciled'` only;
- runner UI/progress step `finalizing` is not the same thing as persisted `launchPhase`;
- process transport merge can use internal `projectionPhase: 'final'` to decide timeout-to-failure behavior;
- do not cast new phase strings with `as PersistedTeamLaunchPhase`;
- tests must assert unknown phase strings are normalized by existing evaluator behavior, not introduced by this change.
## Transport failure taxonomy ## Transport failure taxonomy
Use typed failure categories internally. Do not decide terminal vs pending from arbitrary strings. Use typed failure categories internally. Do not decide terminal vs pending from arbitrary strings.
@ -1852,12 +1898,17 @@ Reader safety:
- do not reuse `readBoundRegularUtf8File(...)` directly for runtime JSONL, because oversized runtime logs should be tailed rather than rejected; - do not reuse `readBoundRegularUtf8File(...)` directly for runtime JSONL, because oversized runtime logs should be tailed rather than rejected;
- use the opened file handle's `stat().size` as the source of truth for the tail range. If the file grows while reading, that is acceptable; read only the selected stable range and skip a final partial line; - use the opened file handle's `stat().size` as the source of truth for the tail range. If the file grows while reading, that is acceptable; read only the selected stable range and skip a final partial line;
- tolerate corrupt/partial JSON lines by skipping only those lines; - tolerate corrupt/partial JSON lines by skipping only those lines;
- impose a per-line byte/char cap before `JSON.parse`, for example `16 KiB`. Oversized lines are treated as corrupt diagnostic noise and skipped;
- if a single oversized line consumes the whole tail slice, return an empty list and rely on timeout/fallback. Do not retry with a larger read just to recover diagnostics;
- parse at most a bounded number of lines/events from one file, for example last `256` candidate lines, after tail slicing;
- normalize optional fields defensively: `retryable` is used only when it is a boolean, `attempt` only when it is a positive finite number, and unknown event types remain readable but are ignored by process-transport classifiers; - normalize optional fields defensively: `retryable` is used only when it is a boolean, `attempt` only when it is a positive finite number, and unknown event types remain readable but are ignored by process-transport classifiers;
- when reading a tail slice, drop the first line because it can be a partial middle-of-file line; - when reading a tail slice, drop the first line because it can be a partial middle-of-file line;
- if the last line has no trailing newline, treat it as potentially partial and skip it unless it parses cleanly and has required event shape; - if the last line has no trailing newline, treat it as potentially partial and skip it unless it parses cleanly and has required event shape;
- never throw from malformed runtime event content during launch finalization; - never throw from malformed runtime event content during launch finalization;
- keep max bytes low enough for hot polling but high enough for bursty startup, default `256 KiB`. - keep max bytes low enough for hot polling but high enough for bursty startup, default `256 KiB`.
Reader output must preserve append order among accepted parsed events. Skipped corrupt/oversized lines do not create placeholder events and do not affect stage rank except through absence.
## Phase 4 - Add bootstrap submission outcome waiter ## Phase 4 - Add bootstrap submission outcome waiter
Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator` Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
@ -2608,7 +2659,8 @@ export interface ProcessBootstrapTransportMergeInput {
evidence: ProcessBootstrapTransportEvidence evidence: ProcessBootstrapTransportEvidence
diagnostic: ProcessBootstrapTransportDiagnostic diagnostic: ProcessBootstrapTransportDiagnostic
attemptMatch: 'strict-current' | 'legacy-current' | 'diagnostic-only' | 'no-match' attemptMatch: 'strict-current' | 'legacy-current' | 'diagnostic-only' | 'no-match'
launchPhase: 'active' | 'finalizing' | 'terminal' launchPhase: PersistedTeamLaunchPhase
projectionPhase: 'active' | 'final'
} }
``` ```
@ -2618,40 +2670,92 @@ Merge helper rules:
- `diagnostic-only` can append internal log/debug diagnostics but cannot change launch state; - `diagnostic-only` can append internal log/debug diagnostics but cannot change launch state;
- `legacy-current` can only produce pending diagnostics, not terminal failure, unless the final timeout path also confirms the current launch boundary; - `legacy-current` can only produce pending diagnostics, not terminal failure, unless the final timeout path also confirms the current launch boundary;
- `strict-current` is required for immediate terminal transport failure; - `strict-current` is required for immediate terminal transport failure;
- `launchPhase: 'active'` cannot convert retryable rejection into `failed_to_start`; - `projectionPhase: 'active'` cannot convert retryable rejection or timeout-only pending stages into `failed_to_start`;
- `launchPhase: 'terminal'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists. - `projectionPhase: 'final'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists.
Projection coordinator shape: Projection coordinator shape:
```ts ```ts
async function enrichAndPersistCurrentLaunchSnapshot(run: MutableTeamRun): Promise<void> { async function writeLaunchStateSnapshotNow(teamName, snapshot, options) {
const identitySnapshot = buildCurrentAttemptIdentities(run) const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null)
const evidenceSnapshot = await readProcessTransportEvidence(identitySnapshot) const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => [])
if (!isStillCurrentRun(run, identitySnapshot)) { const openCodeOverlaid = await applyOpenCodeSecondaryEvidenceOverlay({
return teamName,
snapshot,
previousSnapshot,
metaMembers,
})
const processOverlaid = await applyProcessTransportEvidenceOverlay({
teamName,
snapshot: openCodeOverlaid,
previousSnapshot,
metaMembers,
runIdentity: options?.runIdentity,
})
if (!isStillCurrentBeforeLaunchStateWrite(teamName, options?.runIdentity)) {
return { snapshot: previousSnapshot ?? processOverlaid, wrote: false, stale: true }
} }
const statuses = mergeProcessTransportEvidence(run.memberStatuses, evidenceSnapshot) const normalizedSnapshot =
const snapshot = buildLiveLaunchSnapshotForRun({ ...run, memberStatuses: statuses }) applyOpenCodeSecondaryBootstrapStallOverlay(processOverlaid) ?? processOverlaid
await this.teamLaunchStateStore.write(run.teamName, snapshot)
if (await canSkipLaunchStateWriteAndSummaryIsFresh(previousSnapshot, normalizedSnapshot, options)) {
return { snapshot: previousSnapshot, wrote: false }
}
if (normalizedSnapshot.teamLaunchState === 'clean_success' && normalizedSnapshot.launchPhase !== 'active') {
if (!isStillCurrentBeforeLaunchStateClear(teamName, options?.runIdentity)) {
return { snapshot: previousSnapshot ?? normalizedSnapshot, wrote: false, stale: true }
}
await clearPersistedLaunchStateNow(teamName)
return { snapshot: null, wrote: true, cleared: true }
}
await this.launchStateStore.write(teamName, normalizedSnapshot)
return { snapshot: normalizedSnapshot, wrote: true }
} }
``` ```
Rules: Rules:
- `identitySnapshot` is immutable for the projection pass; - the existing per-team `enqueueLaunchStateStoreOperation(...)` remains the serialization boundary. Do not create a parallel writer or a second read/compare/write queue;
- `previousSnapshot` is read once inside the queued operation and passed into overlays. Do not let each overlay call `launchStateStore.read(...)` independently;
- `metaMembers` is read once inside the queued operation and passed into overlays. Do not let OpenCode and process overlays race on separate member metadata reads;
- `runIdentity` / attempt identity is immutable for the projection pass;
- evidence read failures degrade to no enrichment, not launch crash; - evidence read failures degrade to no enrichment, not launch crash;
- evidence read budget exhaustion degrades remaining members to no enrichment and records internal debug/log detail only; - evidence read budget exhaustion degrades remaining members to no enrichment and records internal debug/log detail only;
- `isStillCurrentRun(...)` checks team name, run id, cancellation/killed state, and any restart generation if available; - `isStillCurrentRun(...)` checks team name, run id, cancellation/killed state, and any restart generation if available;
- `TeamLaunchStateStore.write(...)` remains the only persistence point for detailed and compact launch projections; - `TeamLaunchStateStore.write(...)` remains the only persistence point for detailed and compact launch projections;
- never mutate `run.memberStatuses` while iterating over event files. Build a merged copy and persist it atomically through the store. - never mutate `run.memberStatuses` while iterating over event files. Build a merged copy and persist it atomically through the store;
- integrate this inside the existing `persistLaunchStateSnapshot(...)` / `enqueueLaunchStateStoreOperation(...)` flow; - integrate this inside the existing `persistLaunchStateSnapshot(...)` / `enqueueLaunchStateStoreOperation(...)` flow;
- do not add a second launch-state writer for transport enrichment; - do not add a second launch-state writer for transport enrichment;
- if `TeamLaunchStateStore.write(...)` writes detail but summary write later fails, accept detailed state as truth and let existing stale-summary logic/list refresh recover. - if `TeamLaunchStateStore.write(...)` writes detail but summary write later fails, accept detailed state as truth and let existing stale-summary logic/list refresh recover.
- account for existing `writeLaunchStateSnapshotNow(...)` overlays: OpenCode secondary overlays and bootstrap-stall overlays already run before normalized snapshot write. Process transport enrichment should compose with them without changing OpenCode behavior. - account for existing `writeLaunchStateSnapshotNow(...)` overlays: OpenCode secondary overlays and bootstrap-stall overlays already run before normalized snapshot write. Process transport enrichment should compose with them without changing OpenCode behavior.
- preferred order inside `writeLaunchStateSnapshotNow(...)`: previous snapshot read -> existing OpenCode overlays -> process transport enrichment for non-OpenCode process members -> bootstrap-stall/normalization -> no-op/summary repair check -> store write. - preferred order inside `writeLaunchStateSnapshotNow(...)`: previous snapshot read -> existing OpenCode overlays -> process transport enrichment for non-OpenCode process members -> bootstrap-stall/normalization -> no-op/summary repair check -> store write.
- if this order conflicts with existing proof overlay in `persistLaunchStateSnapshotNow(...)`, preserve proof overlay as higher priority and document the exact order in code comments. - if this order conflicts with existing proof overlay in `persistLaunchStateSnapshotNow(...)`, preserve proof overlay as higher priority and document the exact order in code comments.
- do not evaluate clean-success clear before process transport enrichment and final current-run fence. A pre-enrichment clean-success check can erase evidence that would have kept a partial launch visible.
Queue and IO budget:
- bounded runtime event tail reads may happen inside the queued operation only if the total budget is small and deterministic. This keeps previous-snapshot comparison consistent;
- do not perform broad process table scans, project transcript scans, or network/provider checks inside the queued operation;
- if runtime event IO budget would be exceeded, stop reading more members and return no enrichment for the rest. Do not hold the queue for best-effort diagnostics;
- do not recursively call `persistLaunchStateSnapshot(...)` or `writeLaunchStateSnapshot(...)` from inside an overlay;
- overlay helpers return new snapshot objects. They do not write files, emit IPC, notify lead/user, or mutate live run maps.
No-op skip and summary repair:
- transport enrichment must be included before `areLaunchStateSnapshotsSemanticallyEqual(...)`;
- no-op skip is allowed only after checking whether `launch-summary.json` is present/fresh enough for the normalized detailed snapshot;
- if summary is missing/stale and detailed state is semantically unchanged, force `TeamLaunchStateStore.write(...)` to repair both files;
- the summary repair check must be bounded and must run inside the same serialized operation as the detailed-state comparison;
- do not direct-write `launch-summary.json`;
- do not update `launchStateWrittenRunIdByTeam` before the detailed + summary write path has succeeded or a verified no-op skip has occurred.
- clean-success clear is a write operation and must happen inside the same serialized queue with the same final run-identity fence as normal writes;
- because clear also removes bootstrap-state, it needs a stricter current-run check than ordinary diagnostic enrichment.
Normalizer interaction hazard: Normalizer interaction hazard:
@ -3072,6 +3176,8 @@ Cases:
- bounded reader reads tail and drops first partial line; - bounded reader reads tail and drops first partial line;
- bounded reader skips corrupt and final partial JSONL lines without throwing; - bounded reader skips corrupt and final partial JSONL lines without throwing;
- bounded reader skips oversized JSONL lines before parse and does not increase read budget to recover diagnostics;
- bounded reader caps candidate lines/events per file and preserves append order for accepted events;
- missing event file returns empty list; - missing event file returns empty list;
- append order beats misleading future/past timestamps for stage selection; - append order beats misleading future/past timestamps for stage selection;
- low-value later heartbeat does not hide earlier submit/failure stage in timeout diagnostic; - low-value later heartbeat does not hide earlier submit/failure stage in timeout diagnostic;
@ -3220,6 +3326,10 @@ Cases:
- no evidence still allows `never spawned`; - no evidence still allows `never spawned`;
- terminal launchPhase with `bootstrap_submitted` evidence does not trigger normalizer's `Teammate was never spawned during launch.`; - terminal launchPhase with `bootstrap_submitted` evidence does not trigger normalizer's `Teammate was never spawned during launch.`;
- terminal launchPhase with only true missing member still triggers `Teammate was never spawned during launch.`; - terminal launchPhase with only true missing member still triggers `Teammate was never spawned during launch.`;
- process transport merge uses internal `projectionPhase` and never writes persisted launchPhase values outside `active | finished | reconciled`;
- unknown persisted launchPhase strings continue to normalize through existing evaluator fallback and are not produced by new code;
- clean-success finished launch can still clear persisted launch-state even when runtime event files are missing/unreadable;
- partial/pending process member prevents clean-success clearing and keeps launch-state visible;
- `bootstrap_submitted` yields pending/unconfirmed, not confirmed; - `bootstrap_submitted` yields pending/unconfirmed, not confirmed;
- retryable rejection remains pending/warning during active launch; - retryable rejection remains pending/warning during active launch;
- parent failed event yields `failed_to_start` with exact reason; - parent failed event yields `failed_to_start` with exact reason;
@ -3360,6 +3470,7 @@ Use this checklist before committing implementation.
- No selected user-facing transport diagnostic is stored only in `diagnostics[]`. - No selected user-facing transport diagnostic is stored only in `diagnostics[]`.
- No pending transport state uses `runtimeDiagnosticSeverity: 'error'`. - No pending transport state uses `runtimeDiagnosticSeverity: 'error'`.
- No ordinary submitted/waiting transport state sets `bootstrapStalled`. - No ordinary submitted/waiting transport state sets `bootstrapStalled`.
- No new persisted `launchPhase` string is introduced. Internal final/terminal concepts stay internal.
- No stale finalizer/timeout path can persist after stop/cancel/restart identity changed. - No stale finalizer/timeout path can persist after stop/cancel/restart identity changed.
- No pending-only evidence clears provider/runtime hard failure. - No pending-only evidence clears provider/runtime hard failure.
- No generic transport timeout overwrites provider/auth/quota/model root cause. - No generic transport timeout overwrites provider/auth/quota/model root cause.

View file

@ -1,7 +1,7 @@
export const useGithubRepo = () => { export const useGithubRepo = () => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const githubRepo = computed( const githubRepo = computed(
() => (config.public.githubRepo as string) || '777genius/claude_agent_teams_ui', () => (config.public.githubRepo as string) || '777genius/agent-teams-ai',
); );
const repoUrl = computed(() => `https://github.com/${githubRepo.value}`); const repoUrl = computed(() => `https://github.com/${githubRepo.value}`);
const releasesUrl = computed( const releasesUrl = computed(

View file

@ -111,7 +111,7 @@ function writeCache(data: DownloadsApiResponse): void {
export const useReleaseDownloads = () => { export const useReleaseDownloads = () => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const githubRepo = (config.public.githubRepo as string) || "777genius/claude_agent_teams_ui"; const githubRepo = (config.public.githubRepo as string) || "777genius/agent-teams-ai";
const fallbackUrl = const fallbackUrl =
(config.public.githubReleasesUrl as string) || (config.public.githubReleasesUrl as string) ||

View file

@ -4,8 +4,8 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const process: any; declare const process: any;
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui"; const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui"; const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`; const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
const baseURL = process.env.NUXT_APP_BASE_URL || "/"; const baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`; const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;

View file

@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
import { defineConfig, type DefaultTheme } from "vitepress"; import { defineConfig, type DefaultTheme } from "vitepress";
import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms"; import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms";
const REPO = "777genius/claude_agent_teams_ui"; const REPO = "777genius/agent-teams-ai";
const SITE_TITLE = "Agent Teams Docs"; const SITE_TITLE = "Agent Teams Docs";
const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration."; const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration.";
@ -23,7 +23,7 @@ const withTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`;
const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/"); const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/");
const base = appBase === "/" ? "/docs/" : `${appBase}docs/`; const base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
const siteUrl = trimTrailingSlash( const siteUrl = trimTrailingSlash(
process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui" process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai"
); );
const publicBaseUrl = const publicBaseUrl =
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase)) appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))

View file

@ -8,7 +8,7 @@ const props = withDefaults(
copiedLabel?: string; copiedLabel?: string;
}>(), }>(),
{ {
command: "git clone https://github.com/777genius/claude_agent_teams_ui.git", command: "git clone https://github.com/777genius/agent-teams-ai.git",
label: "Click to copy", label: "Click to copy",
copiedLabel: "Copied" copiedLabel: "Copied"
} }

View file

@ -28,11 +28,11 @@ For source development, use:
## Run from source ## Run from source
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" /> <InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" />
```bash ```bash
git clone https://github.com/777genius/claude_agent_teams_ui.git git clone https://github.com/777genius/agent-teams-ai.git
cd claude_agent_teams_ui cd agent-teams-ai
pnpm install pnpm install
pnpm dev pnpm dev
``` ```

View file

@ -28,11 +28,11 @@ Agent Teams распространяется как desktop-приложение
## Запуск из исходников ## Запуск из исходников
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" /> <InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
```bash ```bash
git clone https://github.com/777genius/claude_agent_teams_ui.git git clone https://github.com/777genius/agent-teams-ai.git
cd claude_agent_teams_ui cd agent-teams-ai
pnpm install pnpm install
pnpm dev pnpm dev
``` ```

View file

@ -1,6 +1,6 @@
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui"; const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
setHeader(event, "content-type", "text/plain; charset=utf-8"); setHeader(event, "content-type", "text/plain; charset=utf-8");

View file

@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui"; const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
setHeader(event, "content-type", "application/xml; charset=utf-8"); setHeader(event, "content-type", "application/xml; charset=utf-8");

View file

@ -1,5 +1,5 @@
{ {
"name": "claude-agent-teams-ui", "name": "agent-teams-ai",
"type": "module", "type": "module",
"version": "1.3.0", "version": "1.3.0",
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows", "description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
@ -8,13 +8,13 @@
"name": "Илия (777genius)", "name": "Илия (777genius)",
"email": "quantjumppro@gmail.com" "email": "quantjumppro@gmail.com"
}, },
"homepage": "https://github.com/777genius/claude_agent_teams_ui", "homepage": "https://github.com/777genius/agent-teams-ai",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/777genius/claude_agent_teams_ui.git" "url": "https://github.com/777genius/agent-teams-ai.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/777genius/claude_agent_teams_ui/issues" "url": "https://github.com/777genius/agent-teams-ai/issues"
}, },
"main": "dist-electron/main/index.cjs", "main": "dist-electron/main/index.cjs",
"scripts": { "scripts": {
@ -270,7 +270,7 @@
"main": "dist-electron/main/index.cjs" "main": "dist-electron/main/index.cjs"
}, },
"mac": { "mac": {
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}", "artifactName": "Agent.Teams.AI-${version}-${arch}-mac.${ext}",
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"minimumSystemVersion": "12.0", "minimumSystemVersion": "12.0",
"target": [ "target": [
@ -286,7 +286,7 @@
}, },
"dmg": { "dmg": {
"sign": false, "sign": false,
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}" "artifactName": "Agent.Teams.AI-${version}-${arch}.${ext}"
}, },
"win": { "win": {
"target": [ "target": [
@ -305,13 +305,13 @@
"category": "Development" "category": "Development"
}, },
"appImage": { "appImage": {
"artifactName": "Claude.Agent.Teams.UI-${version}.${ext}" "artifactName": "Agent.Teams.AI-${version}.${ext}"
}, },
"deb": { "deb": {
"afterInstall": "resources/afterInstall.sh" "afterInstall": "resources/afterInstall.sh"
}, },
"nsis": { "nsis": {
"artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}", "artifactName": "Agent.Teams.AI.Setup.${version}.${ext}",
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true
@ -320,7 +320,7 @@
{ {
"provider": "github", "provider": "github",
"owner": "777genius", "owner": "777genius",
"repo": "claude_agent_teams_ui", "repo": "agent-teams-ai",
"releaseType": "release" "releaseType": "release"
} }
] ]

View file

@ -2,7 +2,7 @@
"version": "0.0.22", "version": "0.0.22",
"sourceRef": "v0.0.22", "sourceRef": "v0.0.22",
"sourceRepository": "777genius/agent_teams_orchestrator", "sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui", "releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v1.2.0", "releaseTag": "v1.2.0",
"assets": { "assets": {
"darwin-arm64": { "darwin-arm64": {

View file

@ -28,7 +28,10 @@ import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/Cod
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient'; import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder'; import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder';
import { CodexLoginSessionManager } from '../infrastructure/CodexLoginSessionManager'; import { CodexLoginSessionManager } from '../infrastructure/CodexLoginSessionManager';
import { detectCodexLocalAccountState } from '../infrastructure/detectCodexLocalAccountArtifacts'; import {
detectCodexLocalAccountState,
ensureCodexLegacyAuthFromActiveAccount,
} from '../infrastructure/detectCodexLocalAccountArtifacts';
import type { Logger } from '@shared/utils/logger'; import type { Logger } from '@shared/utils/logger';
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
@ -47,6 +50,7 @@ interface CodexLastKnownAccount {
interface CodexLastKnownRateLimits { interface CodexLastKnownRateLimits {
payload: CodexAppServerGetAccountRateLimitsResponse; payload: CodexAppServerGetAccountRateLimitsResponse;
observedAt: number; observedAt: number;
accountSignature: string | null;
} }
interface CodexRuntimeContext { interface CodexRuntimeContext {
@ -96,6 +100,20 @@ function asCodexManagedAccount(
}; };
} }
function getCodexAccountSignature(
account: CodexAppServerGetAccountResponse['account']
): string | null {
if (!account) {
return null;
}
if (account.type === 'apiKey') {
return 'api_key';
}
return `chatgpt:${account.email ?? 'unknown'}:${account.planType ?? 'unknown'}`;
}
function asRateLimitWindow( function asRateLimitWindow(
window: CodexAppServerRateLimitSnapshot['primary'] window: CodexAppServerRateLimitSnapshot['primary']
): CodexRateLimitWindowDto | null { ): CodexRateLimitWindowDto | null {
@ -471,6 +489,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
return snapshot; return snapshot;
} }
if (localActiveChatgptAccountPresent) {
await ensureCodexLegacyAuthFromActiveAccount().catch((error) => {
this.logger.warn('codex account legacy auth compatibility sync failed', {
error: error instanceof Error ? error.message : String(error),
});
});
}
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath }); const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy'; let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy';
let appServerStatusMessage: string | null = null; let appServerStatusMessage: string | null = null;
@ -497,7 +523,6 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
}; };
} }
const canReuseLastKnownManagedAccount = const canReuseLastKnownManagedAccount =
options?.forceRefreshToken !== true &&
localActiveChatgptAccountPresent && localActiveChatgptAccountPresent &&
accountResult.account.account == null && accountResult.account.account == null &&
accountResult.account.requiresOpenaiAuth === true && accountResult.account.requiresOpenaiAuth === true &&
@ -520,6 +545,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
this.lastKnownRateLimits = { this.lastKnownRateLimits = {
payload: accountResult.rateLimits.payload, payload: accountResult.rateLimits.payload,
observedAt: now, observedAt: now,
accountSignature: getCodexAccountSignature(accountResult.account.account),
}; };
} else if (accountResult.rateLimits) { } else if (accountResult.rateLimits) {
rateLimitsReadFailure = accountResult.rateLimits.error; rateLimitsReadFailure = accountResult.rateLimits.error;
@ -552,10 +578,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
let rateLimits: CodexRateLimitSnapshotDto | null = null; let rateLimits: CodexRateLimitSnapshotDto | null = null;
const shouldLoadRateLimits = const shouldLoadRateLimits =
options?.includeRateLimits === true || this.hasFreshRateLimits(now); options?.includeRateLimits === true || this.hasFreshRateLimits(now);
const currentAccountSignature = getCodexAccountSignature(accountPayload?.account ?? null);
const reusableLastKnownRateLimits =
this.lastKnownRateLimits?.accountSignature === currentAccountSignature
? this.lastKnownRateLimits
: null;
if (shouldLoadRateLimits) { if (shouldLoadRateLimits) {
if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) { if (this.hasFreshRateLimits(now) && reusableLastKnownRateLimits) {
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits); rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits);
} else if (rateLimitsReadFailure) { } else if (rateLimitsReadFailure) {
this.logger.warn('codex account rate limits refresh failed', { this.logger.warn('codex account rate limits refresh failed', {
error: error:
@ -563,6 +594,9 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
? rateLimitsReadFailure.message ? rateLimitsReadFailure.message
: String(rateLimitsReadFailure), : String(rateLimitsReadFailure),
}); });
if (reusableLastKnownRateLimits) {
rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits);
}
} }
} }

View file

@ -1,17 +1,24 @@
import { promises as fs } from 'fs'; import { promises as fs, type Dirent } from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts'); const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts');
const LEGACY_AUTH_SYNC_MARKER_FILE = '.agent-teams-legacy-auth-sync.json';
interface CodexAccountsRegistry { interface CodexAccountsRegistry {
active_account_id?: string | null;
active_account_key?: string | null; active_account_key?: string | null;
activeAccountId?: string | null;
activeAccountKey?: string | null; activeAccountKey?: string | null;
} }
interface CodexAuthFile { interface CodexAuthFile {
auth_mode?: string | null; auth_mode?: string | null;
authMode?: string | null; authMode?: string | null;
tokens?: {
refresh_token?: string | null;
refreshToken?: string | null;
} | null;
} }
export interface CodexLocalAccountState { export interface CodexLocalAccountState {
@ -19,6 +26,25 @@ export interface CodexLocalAccountState {
hasActiveChatgptAccount: boolean; hasActiveChatgptAccount: boolean;
} }
export interface CodexActiveChatgptAuthFile {
codexHome: string;
authFilePath: string;
source: 'accounts' | 'legacy';
activeAccountKey: string | null;
}
export interface CodexLegacyAuthCompatibilityResult {
codexHome: string;
authFilePath: string;
source: 'accounts' | 'legacy';
materializedLegacyAuth: boolean;
}
interface LegacyAuthSyncMarker {
activeAccountKey?: string | null;
sourceAuthFilePath?: string | null;
}
function encodeAccountKeyForAuthFilename(accountKey: string): string { function encodeAccountKeyForAuthFilename(accountKey: string): string {
return Buffer.from(accountKey, 'utf8') return Buffer.from(accountKey, 'utf8')
.toString('base64') .toString('base64')
@ -45,15 +71,66 @@ async function fileExists(filePath: string): Promise<boolean> {
} }
} }
function hasChatgptRefreshToken(authFile: CodexAuthFile | null): boolean {
if (!authFile) {
return false;
}
const authMode = authFile.auth_mode ?? authFile.authMode ?? null;
const refreshToken = authFile.tokens?.refresh_token ?? authFile.tokens?.refreshToken ?? null;
return (
authMode === 'chatgpt' && typeof refreshToken === 'string' && refreshToken.trim().length > 0
);
}
async function readCodexAuthFile(filePath: string): Promise<CodexAuthFile | null> {
return readJsonFile<CodexAuthFile>(filePath);
}
function getLegacyAuthFilePath(accountsDir: string): string {
return path.join(path.dirname(accountsDir), 'auth.json');
}
function getActiveAccountKey(registry: CodexAccountsRegistry | null): string | null {
return (
registry?.active_account_key?.trim() ||
registry?.activeAccountKey?.trim() ||
registry?.active_account_id?.trim() ||
registry?.activeAccountId?.trim() ||
null
);
}
function getActiveAccountAuthFileCandidates(
accountsDir: string,
activeAccountKey: string
): string[] {
const candidates = [
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
];
if (!activeAccountKey.includes('/') && !activeAccountKey.includes('\\')) {
candidates.push(path.join(accountsDir, `${activeAccountKey}.auth.json`));
}
return Array.from(new Set(candidates));
}
export async function detectCodexLocalAccountState( export async function detectCodexLocalAccountState(
accountsDir = CODEX_ACCOUNTS_DIR accountsDir = CODEX_ACCOUNTS_DIR
): Promise<CodexLocalAccountState> { ): Promise<CodexLocalAccountState> {
try { try {
const entries = await fs.readdir(accountsDir, { withFileTypes: true }); let entries: Dirent[] = [];
const hasArtifacts = entries.some( try {
entries = await fs.readdir(accountsDir, { withFileTypes: true });
} catch {
entries = [];
}
const hasAccountsArtifacts = entries.some(
(entry) => (entry) =>
entry.isFile() && (entry.name === 'registry.json' || entry.name.endsWith('.auth.json')) entry.isFile() && (entry.name === 'registry.json' || entry.name.endsWith('.auth.json'))
); );
const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir);
const hasLegacyAuthFile = await fileExists(legacyAuthFilePath);
const hasArtifacts = hasAccountsArtifacts || hasLegacyAuthFile;
if (!hasArtifacts) { if (!hasArtifacts) {
return { return {
@ -62,36 +139,9 @@ export async function detectCodexLocalAccountState(
}; };
} }
const registry = await readJsonFile<CodexAccountsRegistry>(
path.join(accountsDir, 'registry.json')
);
const activeAccountKey =
registry?.active_account_key?.trim() || registry?.activeAccountKey?.trim() || null;
if (!activeAccountKey) {
return {
hasArtifacts: true,
hasActiveChatgptAccount: false,
};
}
const authFilePath = path.join(
accountsDir,
`${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`
);
if (!(await fileExists(authFilePath))) {
return {
hasArtifacts: true,
hasActiveChatgptAccount: false,
};
}
const authFile = await readJsonFile<CodexAuthFile>(authFilePath);
const authMode = authFile?.auth_mode ?? authFile?.authMode ?? null;
return { return {
hasArtifacts: true, hasArtifacts: true,
hasActiveChatgptAccount: authMode === 'chatgpt', hasActiveChatgptAccount: (await resolveCodexActiveChatgptAuthFile(accountsDir)) !== null,
}; };
} catch { } catch {
return { return {
@ -101,6 +151,128 @@ export async function detectCodexLocalAccountState(
} }
} }
export async function resolveCodexActiveChatgptAuthFile(
accountsDir = CODEX_ACCOUNTS_DIR
): Promise<CodexActiveChatgptAuthFile | null> {
const codexHome = path.dirname(accountsDir);
const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir);
const registryPath = path.join(accountsDir, 'registry.json');
const hasRegistry = await fileExists(registryPath);
if (!hasRegistry) {
const legacyAuthFile = await readCodexAuthFile(legacyAuthFilePath);
return hasChatgptRefreshToken(legacyAuthFile)
? {
codexHome,
authFilePath: legacyAuthFilePath,
source: 'legacy',
activeAccountKey: null,
}
: null;
}
const registry = await readJsonFile<CodexAccountsRegistry>(registryPath);
const activeAccountKey = getActiveAccountKey(registry);
if (!activeAccountKey) {
return null;
}
for (const authFilePath of getActiveAccountAuthFileCandidates(accountsDir, activeAccountKey)) {
if (!(await fileExists(authFilePath))) {
continue;
}
if (hasChatgptRefreshToken(await readCodexAuthFile(authFilePath))) {
return {
codexHome,
authFilePath,
source: 'accounts',
activeAccountKey,
};
}
}
return null;
}
async function readLegacyAuthSyncMarker(markerPath: string): Promise<LegacyAuthSyncMarker | null> {
return readJsonFile<LegacyAuthSyncMarker>(markerPath);
}
async function getMtimeMs(filePath: string): Promise<number | null> {
try {
return (await fs.stat(filePath)).mtimeMs;
} catch {
return null;
}
}
async function writeFileAtomic(filePath: string, content: string, mode = 0o600): Promise<void> {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tempPath, content, { encoding: 'utf8', mode });
await fs.rename(tempPath, filePath);
await fs.chmod(filePath, mode).catch(() => undefined);
}
export async function ensureCodexLegacyAuthFromActiveAccount(
accountsDir = CODEX_ACCOUNTS_DIR
): Promise<CodexLegacyAuthCompatibilityResult | null> {
const activeAuth = await resolveCodexActiveChatgptAuthFile(accountsDir);
if (!activeAuth) {
return null;
}
if (activeAuth.source === 'legacy') {
return {
codexHome: activeAuth.codexHome,
authFilePath: activeAuth.authFilePath,
source: activeAuth.source,
materializedLegacyAuth: false,
};
}
const legacyAuthFilePath = getLegacyAuthFilePath(accountsDir);
const markerPath = path.join(activeAuth.codexHome, LEGACY_AUTH_SYNC_MARKER_FILE);
const [sourceRaw, sourceMtimeMs, legacyMtimeMs, legacyAuthFile, marker] = await Promise.all([
fs.readFile(activeAuth.authFilePath, 'utf8'),
getMtimeMs(activeAuth.authFilePath),
getMtimeMs(legacyAuthFilePath),
readCodexAuthFile(legacyAuthFilePath),
readLegacyAuthSyncMarker(markerPath),
]);
const legacyUsable = hasChatgptRefreshToken(legacyAuthFile);
const activeAccountChanged =
marker?.activeAccountKey !== activeAuth.activeAccountKey ||
marker?.sourceAuthFilePath !== activeAuth.authFilePath;
const activeAuthNewerThanLegacy =
sourceMtimeMs !== null && (legacyMtimeMs === null || sourceMtimeMs > legacyMtimeMs + 1);
const shouldMaterialize = !legacyUsable || activeAccountChanged || activeAuthNewerThanLegacy;
if (shouldMaterialize) {
await writeFileAtomic(legacyAuthFilePath, sourceRaw);
}
await writeFileAtomic(
markerPath,
`${JSON.stringify(
{
activeAccountKey: activeAuth.activeAccountKey,
sourceAuthFilePath: activeAuth.authFilePath,
},
null,
2
)}\n`,
0o600
).catch(() => undefined);
return {
codexHome: activeAuth.codexHome,
authFilePath: legacyAuthFilePath,
source: activeAuth.source,
materializedLegacyAuth: shouldMaterialize,
};
}
export async function detectCodexLocalAccountArtifacts( export async function detectCodexLocalAccountArtifacts(
accountsDir = CODEX_ACCOUNTS_DIR accountsDir = CODEX_ACCOUNTS_DIR
): Promise<boolean> { ): Promise<boolean> {

View file

@ -18,6 +18,7 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
// Keep userData stable before any integration can initialize Electron storage. // Keep userData stable before any integration can initialize Electron storage.
// Sentry must stay near the top to capture early errors after storage migration. // Sentry must stay near the top to capture early errors after storage migration.
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
import './sentry'; import './sentry';
import { import {
@ -166,7 +167,6 @@ import {
markRendererUnavailable, markRendererUnavailable,
safeSendToRenderer, safeSendToRenderer,
} from './utils/safeWebContentsSend'; } from './utils/safeWebContentsSend';
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
import { syncTelemetryFlag } from './sentry'; import { syncTelemetryFlag } from './sentry';
import { import {
ActiveTeamRegistry, ActiveTeamRegistry,
@ -221,6 +221,13 @@ if (
logger.info( logger.info(
`Migrated Electron userData from ${earlyElectronUserDataMigrationResult.legacyPath} to ${earlyElectronUserDataMigrationResult.currentPath}` `Migrated Electron userData from ${earlyElectronUserDataMigrationResult.legacyPath} to ${earlyElectronUserDataMigrationResult.currentPath}`
); );
} else if (
earlyElectronUserDataMigrationResult.reason === 'legacy-reused' &&
earlyElectronUserDataMigrationResult.legacyPath
) {
logger.info(
`Reusing legacy Electron userData at ${earlyElectronUserDataMigrationResult.legacyPath}`
);
} else if ( } else if (
earlyElectronUserDataMigrationResult.fallbackToLegacy && earlyElectronUserDataMigrationResult.fallbackToLegacy &&
earlyElectronUserDataMigrationResult.legacyPath earlyElectronUserDataMigrationResult.legacyPath

View file

@ -57,6 +57,7 @@ export class ApiKeyService {
private readonly filePath: string; private readonly filePath: string;
private cache: StoredApiKey[] | null = null; private cache: StoredApiKey[] | null = null;
private aesKey: Buffer | null = null; private aesKey: Buffer | null = null;
private readonly reportedDecryptFailures = new Set<string>();
private readonly originalProcessEnv = new Map<string, string | undefined>(); private readonly originalProcessEnv = new Map<string, string | undefined>();
constructor(claudeDir?: string) { constructor(claudeDir?: string) {
@ -288,7 +289,7 @@ export class ApiKeyService {
return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8'); return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
} }
} catch (err) { } catch (err) {
logger.error(`Failed to decrypt API key "${stored.name}":`, err); this.reportDecryptFailure(stored, err);
return ''; return '';
} }
} }
@ -313,6 +314,30 @@ export class ApiKeyService {
return matching.find((key) => key.scope === 'user') ?? null; return matching.find((key) => key.scope === 'user') ?? null;
} }
private reportDecryptFailure(stored: StoredApiKey, err: unknown): void {
const method = this.resolveMethod(stored);
const failureKey = [stored.id, stored.updatedAt ?? stored.createdAt, method].join(':');
if (this.reportedDecryptFailures.has(failureKey)) {
return;
}
this.reportedDecryptFailures.add(failureKey);
logger.debug(
[
'Stored API key could not be decrypted; ignoring it until it is saved again.',
`envVarName=${stored.envVarName}`,
`method=${method}`,
`reason=${this.getErrorMessage(err)}`,
].join(' ')
);
}
private getErrorMessage(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
return message.replace(/\s+/g, ' ').trim() || 'unknown';
}
// ── AES-256-GCM local encryption ─────────────────────────────────────── // ── AES-256-GCM local encryption ───────────────────────────────────────
/** /**

View file

@ -1,13 +1,13 @@
const REPO_OWNER = '777genius'; const REPO_OWNER = '777genius';
const REPO_NAME = 'claude_agent_teams_ui'; const REPO_NAME = 'agent-teams-ai';
const PLANNED_REPO_NAME = 'agent-teams-ai'; const LEGACY_REPO_NAME = 'claude_agent_teams_ui';
export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string { export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string {
return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`; return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`;
} }
export function buildReleaseAssetBases(version: string): readonly string[] { export function buildReleaseAssetBases(version: string): readonly string[] {
return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, PLANNED_REPO_NAME)]; return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, LEGACY_REPO_NAME)];
} }
export function getExpectedReleaseAssetUrl( export function getExpectedReleaseAssetUrl(
@ -20,12 +20,12 @@ export function getExpectedReleaseAssetUrl(
switch (platform) { switch (platform) {
case 'darwin': case 'darwin':
return arch === 'arm64' return arch === 'arm64'
? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg` ? `${base}/Agent.Teams.AI-${version}-arm64.dmg`
: `${base}/Claude.Agent.Teams.UI-${version}-x64.dmg`; : `${base}/Agent.Teams.AI-${version}-x64.dmg`;
case 'win32': case 'win32':
return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`; return `${base}/Agent.Teams.AI.Setup.${version}.exe`;
case 'linux': case 'linux':
return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`; return `${base}/Agent.Teams.AI-${version}.AppImage`;
default: default:
return null; return null;
} }
@ -58,11 +58,8 @@ export function getExpectedLatestMacArtifacts(
arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'> arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'>
): readonly string[] { ): readonly string[] {
return arch === 'arm64' return arch === 'arm64'
? [ ? [`Agent.Teams.AI-${version}-arm64-mac.zip`, `Agent.Teams.AI-${version}-arm64.dmg`]
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`, : [`Agent.Teams.AI-${version}-x64-mac.zip`, `Agent.Teams.AI-${version}-x64.dmg`];
`Claude.Agent.Teams.UI-${version}-arm64.dmg`,
]
: [`Claude.Agent.Teams.UI-${version}-x64-mac.zip`, `Claude.Agent.Teams.UI-${version}-x64.dmg`];
} }
function stripYamlScalar(rawValue: string): string { function stripYamlScalar(rawValue: string): string {

View file

@ -1,3 +1,4 @@
import { execFile } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
import { evaluateCodexLaunchReadiness } from '@features/codex-account'; import { evaluateCodexLaunchReadiness } from '@features/codex-account';
@ -70,6 +71,19 @@ const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
const CODEX_HOME_ENV_VAR = 'CODEX_HOME'; const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD'; const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
const CODEX_NATIVE_BACKEND_ID = 'codex-native'; const CODEX_NATIVE_BACKEND_ID = 'codex-native';
const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000;
type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown';
interface CodexCliLoginStatusCheckResult {
status: CodexCliLoginStatus;
detail: string | null;
}
type CodexCliLoginStatusChecker = (params: {
binaryPath: string | null;
env: NodeJS.ProcessEnv;
}) => Promise<CodexCliLoginStatusCheckResult>;
function isCodexExecBinary(binaryPath?: string | null): boolean { function isCodexExecBinary(binaryPath?: string | null): boolean {
const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase(); const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase();
@ -119,6 +133,57 @@ function applyCodexForcedLoginMethodEnv(
delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR]; delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR];
} }
function sanitizeCodexLoginStatusDetail(detail: string): string {
return detail
.replace(/sk-[A-Za-z0-9_-]+/g, '[redacted-api-key]')
.replace(
/"?(access_token|refresh_token|id_token)"?\s*[:=]\s*"?[^"\s,}]+/gi,
'$1=[redacted-token]'
)
.trim()
.slice(0, 500);
}
async function checkCodexCliLoginStatus({
binaryPath,
env,
}: {
binaryPath: string | null;
env: NodeJS.ProcessEnv;
}): Promise<CodexCliLoginStatusCheckResult> {
const executable = binaryPath?.trim() || 'codex';
const args = [...buildCodexForcedLoginLaunchArgs(executable, 'chatgpt'), 'login', 'status'];
return new Promise((resolve) => {
execFile(
executable,
args,
{
env,
timeout: CODEX_LOGIN_STATUS_TIMEOUT_MS,
windowsHide: true,
maxBuffer: 128 * 1024,
},
(error, stdout, stderr) => {
const detail = sanitizeCodexLoginStatusDetail(`${stdout ?? ''}\n${stderr ?? ''}`);
if (!error) {
resolve({ status: 'logged_in', detail: detail || null });
return;
}
if (/not logged in/i.test(detail)) {
resolve({ status: 'not_logged_in', detail: detail || null });
return;
}
const fallback =
error instanceof Error ? sanitizeCodexLoginStatusDetail(error.message) : null;
resolve({ status: 'unknown', detail: detail || fallback || null });
}
);
});
}
export class ProviderConnectionService { export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null; private static instance: ProviderConnectionService | null = null;
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null; private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
@ -127,7 +192,8 @@ export class ProviderConnectionService {
constructor( constructor(
private apiKeyService = new ApiKeyService(), private apiKeyService = new ApiKeyService(),
private readonly configManager = ConfigManager.getInstance() private readonly configManager = ConfigManager.getInstance(),
private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus
) {} ) {}
static getInstance(): ProviderConnectionService { static getInstance(): ProviderConnectionService {
@ -331,7 +397,7 @@ export class ProviderConnectionService {
async getConfiguredConnectionIssue( async getConfiguredConnectionIssue(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
providerId: CliProviderId, providerId: CliProviderId,
_runtimeBackendOverride?: string | null runtimeBackendOverride?: string | null
): Promise<string | null> { ): Promise<string | null> {
if (providerId === 'anthropic') { if (providerId === 'anthropic') {
if (this.getConfiguredAuthMode(providerId) !== 'api_key') { if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
@ -358,6 +424,8 @@ export class ProviderConnectionService {
} }
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
const runtimeEnv = { ...env };
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
const readiness = evaluateCodexLaunchReadiness({ const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode, preferredAuthMode: snapshot.preferredAuthMode,
managedAccount: snapshot.managedAccount, managedAccount: snapshot.managedAccount,
@ -368,7 +436,41 @@ export class ProviderConnectionService {
}); });
if (readiness.launchAllowed) { if (readiness.launchAllowed) {
return null; if (
readiness.effectiveAuthMode !== 'chatgpt' ||
this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride) !== CODEX_NATIVE_BACKEND_ID
) {
return null;
}
if (snapshot.appServerState === 'healthy' && snapshot.managedAccount?.type === 'chatgpt') {
return null;
}
delete runtimeEnv.OPENAI_API_KEY;
delete runtimeEnv[CODEX_NATIVE_API_KEY_ENV_VAR];
applyCodexForcedLoginMethodEnv(runtimeEnv, 'chatgpt');
const loginStatus = await this.codexCliLoginStatusChecker({
binaryPath: snapshot.runtimeContext?.binaryPath?.trim() || null,
env: runtimeEnv,
});
if (loginStatus.status === 'logged_in') {
return null;
}
const base =
loginStatus.status === 'not_logged_in'
? 'Codex ChatGPT account mode is selected, but the Codex CLI login status is not active for the launch runtime.'
: 'Codex ChatGPT account mode is selected, but the Codex CLI login status could not be verified for the launch runtime.';
const reconnectHint = snapshot.localActiveChatgptAccountPresent
? 'Reconnect ChatGPT to refresh the current Codex subscription session.'
: snapshot.localAccountArtifactsPresent
? 'Local Codex account data exists, but the launch runtime cannot use it. Reconnect ChatGPT.'
: 'Connect ChatGPT again or switch Codex auth mode to API key.';
return `${base} ${reconnectHint}${
loginStatus.detail ? ` Details: ${loginStatus.detail}` : ''
}`;
} }
if (readiness.state === 'missing_auth') { if (readiness.state === 'missing_auth') {

View file

@ -0,0 +1,217 @@
import type { PersistedTeamLaunchPhase } from '@shared/types';
export type ProcessBootstrapTransportEvent = Record<string, unknown>;
export type ProcessBootstrapTransportTerminalKind =
| 'non_retryable_submit_rejection'
| 'accepted_without_message_id'
| 'process_exited_before_confirmation'
| 'runtime_failed_before_confirmation';
export interface ProcessBootstrapTransportSummary {
lastStage?: string;
lastObservedAt?: string;
submitted: boolean;
hasProgress: boolean;
terminalFailure?: {
kind: ProcessBootstrapTransportTerminalKind;
reason: string;
observedAt?: string;
};
}
export type ProcessBootstrapTransportProjectionPhase = 'active' | 'final';
// These helpers intentionally summarize process transport only. They explain
// where bootstrap got stuck, but never prove teammate readiness by themselves.
const MAX_TRANSPORT_DETAIL_CHARS = 500;
const WINDOWS_RESERVED_BASENAMES = new Set([
'con',
'prn',
'aux',
'nul',
'com1',
'com2',
'com3',
'com4',
'com5',
'com6',
'com7',
'com8',
'com9',
'lpt1',
'lpt2',
'lpt3',
'lpt4',
'lpt5',
'lpt6',
'lpt7',
'lpt8',
'lpt9',
]);
const TRANSPORT_STAGE_LABELS: Record<string, string> = {
process_spawned: 'process spawned',
stdout_attached: 'stdout attached',
cli_started: 'CLI started',
runtime_ready: 'runtime ready',
inbox_poller_ready: 'inbox poller ready',
mailbox_bootstrap_written: 'bootstrap mailbox row written',
bootstrap_prompt_observed: 'bootstrap prompt observed',
bootstrap_submit_attempted: 'bootstrap submit attempted',
bootstrap_submit_deferred: 'bootstrap submit deferred',
bootstrap_submit_rejected: 'bootstrap submit rejected',
bootstrap_submit_accepted_without_uuid: 'bootstrap submit accepted without message id',
bootstrap_submitted: 'bootstrap submitted',
failed: 'runtime failed',
exited: 'runtime exited',
};
export function sanitizeProcessRuntimeEventFilePrefix(value: string): string {
const normalized = String(value)
.replace(/[^a-zA-Z0-9]/g, '-')
.toLowerCase();
const normalizedStem =
normalized
.trim()
.replace(/[. ]+$/g, '')
.split('.')[0] ?? normalized;
return normalizedStem && WINDOWS_RESERVED_BASENAMES.has(normalizedStem)
? `_${normalized}`
: normalized;
}
export function deriveProcessTransportProjectionPhase(input: {
launchPhase: PersistedTeamLaunchPhase;
finalTimeoutReached?: boolean;
}): ProcessBootstrapTransportProjectionPhase {
if (input.launchPhase !== 'active') {
return 'final';
}
return input.finalTimeoutReached === true ? 'final' : 'active';
}
export function sanitizeProcessBootstrapTransportDetail(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const sanitized = value
.replace(/\b(sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]{32,})\b/g, '[redacted]')
.replace(/\/[^\s"'`]+/g, '[path]')
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_TRANSPORT_DETAIL_CHARS);
return sanitized.length > 0 ? sanitized : undefined;
}
function eventType(event: ProcessBootstrapTransportEvent): string {
return typeof event.type === 'string' ? event.type : '';
}
function eventTimestamp(event: ProcessBootstrapTransportEvent): string | undefined {
return typeof event.timestamp === 'string' && Number.isFinite(Date.parse(event.timestamp))
? event.timestamp
: undefined;
}
function stageLabel(event: ProcessBootstrapTransportEvent): string | undefined {
const type = eventType(event);
const label = TRANSPORT_STAGE_LABELS[type];
if (!label) {
return undefined;
}
const detail = sanitizeProcessBootstrapTransportDetail(event.detail);
if (type === 'process_spawned' || type === 'stdout_attached' || type === 'cli_started') {
return label;
}
return detail ? `${label}: ${detail}` : label;
}
function terminalFailureForEvent(
event: ProcessBootstrapTransportEvent
): ProcessBootstrapTransportSummary['terminalFailure'] | undefined {
const type = eventType(event);
const label = stageLabel(event);
const observedAt = eventTimestamp(event);
if (type === 'failed') {
return {
kind: 'runtime_failed_before_confirmation',
reason: label ?? 'runtime failed before bootstrap confirmation',
observedAt,
};
}
if (type === 'exited') {
return {
kind: 'process_exited_before_confirmation',
reason: label ?? 'runtime exited before bootstrap confirmation',
observedAt,
};
}
if (type === 'bootstrap_submit_accepted_without_uuid') {
return {
kind: 'accepted_without_message_id',
reason: label ?? 'bootstrap submit accepted without message id',
observedAt,
};
}
if (type === 'bootstrap_submit_rejected' && event.retryable === false) {
return {
kind: 'non_retryable_submit_rejection',
reason: label ?? 'bootstrap submit rejected',
observedAt,
};
}
return undefined;
}
export function summarizeProcessBootstrapTransportEvents(
events: readonly ProcessBootstrapTransportEvent[]
): ProcessBootstrapTransportSummary | null {
if (events.length === 0) {
return null;
}
let lastStage: string | undefined;
let lastObservedAt: string | undefined;
let submitted = false;
let terminalFailure: ProcessBootstrapTransportSummary['terminalFailure'];
for (const event of events) {
const label = stageLabel(event);
if (!label) {
continue;
}
lastStage = label;
lastObservedAt = eventTimestamp(event) ?? lastObservedAt;
if (eventType(event) === 'bootstrap_submitted') {
submitted = true;
}
terminalFailure = terminalFailureForEvent(event) ?? terminalFailure;
}
if (!lastStage && !terminalFailure) {
return null;
}
return {
...(lastStage ? { lastStage } : {}),
...(lastObservedAt ? { lastObservedAt } : {}),
submitted,
hasProgress: Boolean(lastStage),
...(terminalFailure ? { terminalFailure } : {}),
};
}
export function buildProcessBootstrapPendingDiagnostic(
summary: ProcessBootstrapTransportSummary
): string {
return summary.lastStage
? `Bootstrap transport reached ${summary.lastStage}; waiting for bootstrap confirmation.`
: 'Bootstrap transport is waiting for bootstrap confirmation.';
}
export function buildProcessBootstrapTimeoutDiagnostic(
summary: ProcessBootstrapTransportSummary
): string {
return summary.lastStage
? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}`
: 'Teammate was registered but did not bootstrap-confirm before timeout.';
}

View file

@ -44,6 +44,7 @@ import {
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { isProcessAlive } from '@main/utils/processHealth'; import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill'; import { killProcessByPid } from '@main/utils/processKill';
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import { import {
@ -156,6 +157,15 @@ import {
parseBootstrapRuntimeProofDetail, parseBootstrapRuntimeProofDetail,
validateBootstrapRuntimeProofEnvelope, validateBootstrapRuntimeProofEnvelope,
} from './bootstrap/BootstrapProofValidation'; } from './bootstrap/BootstrapProofValidation';
import {
buildProcessBootstrapPendingDiagnostic,
buildProcessBootstrapTimeoutDiagnostic,
deriveProcessTransportProjectionPhase,
sanitizeProcessRuntimeEventFilePrefix,
summarizeProcessBootstrapTransportEvents,
type ProcessBootstrapTransportEvent,
type ProcessBootstrapTransportSummary,
} from './ProcessBootstrapTransportEvidence';
import { import {
buildNativeAppManagedBootstrapSpecs, buildNativeAppManagedBootstrapSpecs,
type NativeAppManagedBootstrapSpec, type NativeAppManagedBootstrapSpec,
@ -372,11 +382,46 @@ interface LaunchStateWriteResult {
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024; const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024;
const BOOTSTRAP_RUNTIME_EVENT_MAX_LINES = 256;
const BOOTSTRAP_RUNTIME_EVENT_MAX_LINE_BYTES = 16 * 1024;
function sanitizeRuntimeEventFilePrefix(value: string): string { function getTeamRuntimeEventsDir(teamName: string): string {
return String(value || 'default') return path.join(getTeamsBasePath(), teamName, 'runtime');
.replace(/[^a-zA-Z0-9]/g, '-') }
.toLowerCase();
function isProcessBootstrapTransportDiagnostic(value: unknown): value is string {
return (
typeof value === 'string' &&
(value.startsWith('Bootstrap transport ') ||
value.includes('Last transport stage:') ||
value.startsWith('bootstrap submit ') ||
value.startsWith('runtime failed') ||
value.startsWith('runtime exited'))
);
}
function realpathIfExists(inputPath: string): string | null {
try {
return fs.realpathSync.native(inputPath);
} catch {
return null;
}
}
function isContainedTeamRuntimeEventsPath(teamName: string, candidatePath: string): boolean {
const runtimeDir = getTeamRuntimeEventsDir(teamName);
const resolvedRuntimeDir = path.resolve(runtimeDir);
const resolvedCandidate = path.resolve(candidatePath);
if (!isPathWithinRoot(resolvedCandidate, resolvedRuntimeDir)) {
return false;
}
const realRuntimeDir = realpathIfExists(resolvedRuntimeDir);
const realCandidate = realpathIfExists(resolvedCandidate);
if (realCandidate) {
return isPathWithinRoot(realCandidate, realRuntimeDir ?? resolvedRuntimeDir);
}
return true;
} }
type BootstrapTranscriptOutcome = type BootstrapTranscriptOutcome =
@ -1543,6 +1588,12 @@ function looksLikeClaudeStdoutJsonFragment(text: string): boolean {
); );
} }
const DETERMINISTIC_BOOTSTRAP_COMPLETION_RECOVERY_MS = 12_000;
function isTerminalFailureProvisioningState(state: TeamProvisioningProgress['state']): boolean {
return state === 'failed' || state === 'cancelled' || state === 'disconnected';
}
interface ProvisioningRun { interface ProvisioningRun {
runId: string; runId: string;
teamName: string; teamName: string;
@ -20660,7 +20711,14 @@ export class TeamProvisioningService {
}; };
continue; continue;
} }
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); const shouldPreserveProcessBootstrapTransportDiagnostic =
current.bootstrapConfirmed !== true &&
(current.launchState === 'runtime_pending_bootstrap' ||
current.launchState === 'failed_to_start') &&
isProcessBootstrapTransportDiagnostic(current.runtimeDiagnostic);
const runtimeDiagnostic = shouldPreserveProcessBootstrapTransportDiagnostic
? current.runtimeDiagnostic
: buildRuntimeDiagnosticForSpawn(metadata);
const metadataLivenessKind = const metadataLivenessKind =
current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive' current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'
? metadata.livenessKind === 'runtime_process' || ? metadata.livenessKind === 'runtime_process' ||
@ -20673,9 +20731,11 @@ export class TeamProvisioningService {
...(metadata.model ? { runtimeModel: metadata.model } : {}), ...(metadata.model ? { runtimeModel: metadata.model } : {}),
...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}), ...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...(metadata.runtimeDiagnosticSeverity ...(shouldPreserveProcessBootstrapTransportDiagnostic
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } ? { runtimeDiagnosticSeverity: current.runtimeDiagnosticSeverity }
: {}), : metadata.runtimeDiagnosticSeverity
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
: {}),
livenessLastCheckedAt: nowIso(), livenessLastCheckedAt: nowIso(),
}; };
const failureReason = current.hardFailureReason ?? current.error; const failureReason = current.hardFailureReason ?? current.error;
@ -21583,6 +21643,28 @@ export class TeamProvisioningService {
processTableAvailable: memberProcessTableAvailable, processTableAvailable: memberProcessTableAvailable,
nowIso: nowIso(), nowIso: nowIso(),
}); });
const bootstrapTransportDiagnostic =
status?.runtimeDiagnostic ?? launchMember?.runtimeDiagnostic;
const bootstrapTransportDiagnosticSeverity =
status?.runtimeDiagnosticSeverity ?? launchMember?.runtimeDiagnosticSeverity;
const bootstrapTransportLaunchState = status?.launchState ?? launchMember?.launchState;
const bootstrapTransportConfirmed =
status?.bootstrapConfirmed === true || launchMember?.bootstrapConfirmed === true;
const hasProcessBootstrapTransportDiagnostic =
(metadata.backendType === 'process' || metadata.tmuxPaneId?.startsWith('process:')) &&
!bootstrapTransportConfirmed &&
(bootstrapTransportLaunchState === 'runtime_pending_bootstrap' ||
bootstrapTransportLaunchState === 'failed_to_start') &&
isProcessBootstrapTransportDiagnostic(bootstrapTransportDiagnostic);
// Prefer bootstrap transport diagnostics over generic pid/liveness text
// while launch is unconfirmed, otherwise the UI hides the exact stage
// where process bootstrap got stuck.
const runtimeDiagnostic = hasProcessBootstrapTransportDiagnostic
? bootstrapTransportDiagnostic
: resolved.runtimeDiagnostic;
const runtimeDiagnosticSeverity = hasProcessBootstrapTransportDiagnostic
? (bootstrapTransportDiagnosticSeverity ?? resolved.runtimeDiagnosticSeverity)
: resolved.runtimeDiagnosticSeverity;
metadataByMember.set(memberName, { metadataByMember.set(memberName, {
...metadata, ...metadata,
alive: resolved.alive, alive: resolved.alive,
@ -21599,9 +21681,11 @@ export class TeamProvisioningService {
...(resolved.paneCurrentCommand ? { paneCurrentCommand: resolved.paneCurrentCommand } : {}), ...(resolved.paneCurrentCommand ? { paneCurrentCommand: resolved.paneCurrentCommand } : {}),
...(resolved.runtimeSessionId ? { runtimeSessionId: resolved.runtimeSessionId } : {}), ...(resolved.runtimeSessionId ? { runtimeSessionId: resolved.runtimeSessionId } : {}),
...(resolved.runtimeLastSeenAt ? { runtimeLastSeenAt: resolved.runtimeLastSeenAt } : {}), ...(resolved.runtimeLastSeenAt ? { runtimeLastSeenAt: resolved.runtimeLastSeenAt } : {}),
runtimeDiagnostic: resolved.runtimeDiagnostic, runtimeDiagnostic,
runtimeDiagnosticSeverity: resolved.runtimeDiagnosticSeverity, runtimeDiagnosticSeverity,
diagnostics: resolved.diagnostics, diagnostics: hasProcessBootstrapTransportDiagnostic
? mergeRuntimeDiagnostics(resolved.diagnostics, [bootstrapTransportDiagnostic])
: resolved.diagnostics,
}); });
} }
@ -21659,13 +21743,43 @@ export class TeamProvisioningService {
return rssBytesByPid; return rssBytesByPid;
} }
private async clearPersistedLaunchState(teamName: string): Promise<void> { private async clearPersistedLaunchState(
teamName: string,
options?: { expectedRunId?: string }
): Promise<void> {
await this.enqueueLaunchStateStoreOperation(teamName, () => await this.enqueueLaunchStateStoreOperation(teamName, () =>
this.clearPersistedLaunchStateNow(teamName) this.clearPersistedLaunchStateNow(teamName, options)
); );
} }
private async clearPersistedLaunchStateNow(teamName: string): Promise<void> { private canClearPersistedLaunchStateForRun(
teamName: string,
expectedRunId: string | undefined
): boolean {
if (!expectedRunId) {
return true;
}
const trackedRunId = this.getTrackedRunId(teamName);
if (trackedRunId && trackedRunId !== expectedRunId) {
return false;
}
const lastWrittenRunId = this.launchStateWrittenRunIdByTeam.get(teamName);
if (lastWrittenRunId && lastWrittenRunId !== expectedRunId) {
return false;
}
return true;
}
private async clearPersistedLaunchStateNow(
teamName: string,
options?: { expectedRunId?: string }
): Promise<void> {
if (!this.canClearPersistedLaunchStateForRun(teamName, options?.expectedRunId)) {
logger.debug(
`[${teamName}] Skipping stale launch-state clear for run ${options?.expectedRunId}`
);
return;
}
await this.launchStateStore.clear(teamName); await this.launchStateStore.clear(teamName);
this.launchStateWrittenRunIdByTeam.delete(teamName); this.launchStateWrittenRunIdByTeam.delete(teamName);
await clearBootstrapState(teamName); await clearBootstrapState(teamName);
@ -22512,6 +22626,130 @@ export class TeamProvisioningService {
} }
} }
private scheduleDeterministicBootstrapCompletionRecovery(run: ProvisioningRun): void {
if (!run.deterministicBootstrap) {
return;
}
const handle = setTimeout(() => {
void this.recoverDeterministicBootstrapCompletion(run).catch((error: unknown) => {
logger.warn(
`[${run.teamName}] Failed to recover completed deterministic bootstrap state: ${getErrorMessage(
error
)}`
);
});
}, DETERMINISTIC_BOOTSTRAP_COMPLETION_RECOVERY_MS);
handle.unref?.();
}
private async recoverDeterministicBootstrapCompletion(run: ProvisioningRun): Promise<void> {
if (
!run.provisioningComplete ||
run.cancelRequested ||
run.processKilled ||
isTerminalFailureProvisioningState(run.progress.state) ||
this.isProvisioningRunPromotedToAlive(run) ||
this.provisioningRunByTeam.get(run.teamName) !== run.runId
) {
return;
}
if ((run.mixedSecondaryLanes ?? []).length > 0) {
return;
}
const snapshot = await readBootstrapLaunchSnapshot(run.teamName).catch(() => null);
if (
!snapshot ||
(snapshot.launchPhase !== 'finished' && snapshot.launchPhase !== 'reconciled')
) {
return;
}
const runStartedAtMs = Date.parse(run.startedAt);
const snapshotUpdatedAtMs = Date.parse(snapshot.updatedAt);
if (
Number.isFinite(runStartedAtMs) &&
Number.isFinite(snapshotUpdatedAtMs) &&
snapshotUpdatedAtMs < runStartedAtMs
) {
return;
}
const memberNames = this.getPersistedLaunchMemberNames(snapshot);
if (memberNames.length === 0) {
return;
}
this.syncRunMemberSpawnStatusesFromSnapshot(run, snapshot);
await this.writeLaunchStateSnapshot(run.teamName, snapshot).catch((error: unknown) => {
logger.warn(
`[${run.teamName}] Failed to persist recovered deterministic bootstrap snapshot: ${getErrorMessage(
error
)}`
);
});
const failedSpawnMembers = memberNames
.filter((memberName) => snapshot.members[memberName]?.launchState === 'failed_to_start')
.map((memberName) => ({
name: memberName,
error: snapshot.members[memberName]?.hardFailureReason,
updatedAt: snapshot.members[memberName]?.lastEvaluatedAt ?? nowIso(),
}));
const launchSummary = snapshot.summary ?? this.getMemberLaunchSummary(run);
const hasSpawnFailures = failedSpawnMembers.length > 0;
const hasPendingBootstrap =
!hasSpawnFailures && this.hasPendingLaunchMembers(run, launchSummary, snapshot);
const messagePrefix = run.isLaunch ? 'Launch completed' : 'Team provisioned';
const readyMessage = hasSpawnFailures
? `${messagePrefix} with teammate errors - ${failedSpawnMembers
.map((member) => member.name)
.join(', ')} failed to start`
: hasPendingBootstrap
? this.buildAggregatePendingLaunchMessage(messagePrefix, run, launchSummary, snapshot)
: run.isLaunch
? 'Team launched - process alive and ready'
: 'Team provisioned - process alive and ready';
const progress = updateProgress(run, 'ready', readyMessage, {
cliLogsTail: extractCliLogsFromRun(run),
messageSeverity: hasSpawnFailures || hasPendingBootstrap ? 'warning' : undefined,
});
run.onProgress(progress);
this.provisioningRunByTeam.delete(run.teamName);
this.aliveRunByTeam.set(run.teamName, run.runId);
logger.warn(
`[${run.teamName}] Recovered ready state from completed deterministic bootstrap snapshot after post-bootstrap finalization delay.`
);
this.teamChangeEmitter?.({
type: 'lead-message',
teamName: run.teamName,
runId: run.runId,
detail: 'lead-session-sync',
});
if (!hasSpawnFailures && !hasPendingBootstrap) {
void this.fireTeamLaunchedNotification(run);
} else if (hasSpawnFailures) {
void this.fireTeamLaunchIncompleteNotification(
run,
failedSpawnMembers,
launchSummary,
snapshot
);
}
}
private isProvisioningRunPromotedToAlive(run: ProvisioningRun): boolean {
return (
this.aliveRunByTeam.get(run.teamName) === run.runId &&
this.provisioningRunByTeam.get(run.teamName) !== run.runId
);
}
private syncRunMemberSpawnStatusesFromSnapshot( private syncRunMemberSpawnStatusesFromSnapshot(
run: ProvisioningRun, run: ProvisioningRun,
snapshot: PersistedTeamLaunchSnapshot snapshot: PersistedTeamLaunchSnapshot
@ -22995,7 +23233,7 @@ export class TeamProvisioningService {
const snapshot = this.buildLiveLaunchSnapshotForRun(run, launchPhase); const snapshot = this.buildLiveLaunchSnapshotForRun(run, launchPhase);
if (!snapshot) { if (!snapshot) {
if (run.isLaunch) { if (run.isLaunch) {
await this.clearPersistedLaunchStateNow(run.teamName); await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId });
} }
return null; return null;
} }
@ -23004,7 +23242,7 @@ export class TeamProvisioningService {
const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(snapshot, metaMembers); const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(snapshot, metaMembers);
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
await this.clearPersistedLaunchStateNow(run.teamName); await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId });
this.invalidateRuntimeSnapshotCaches(run.teamName); this.invalidateRuntimeSnapshotCaches(run.teamName);
return null; return null;
} }
@ -23943,11 +24181,11 @@ export class TeamProvisioningService {
runtimeMember: PersistedRuntimeMemberLike | undefined runtimeMember: PersistedRuntimeMemberLike | undefined
): string { ): string {
const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim(); const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim();
if (configuredPath) { if (configuredPath && isContainedTeamRuntimeEventsPath(teamName, configuredPath)) {
return configuredPath; return configuredPath;
} }
const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName); const filePrefix = sanitizeProcessRuntimeEventFilePrefix(runtimeMember?.name ?? memberName);
return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`); return path.join(getTeamRuntimeEventsDir(teamName), `${filePrefix}.runtime.jsonl`);
} }
private async readRuntimeBootstrapProofEvents( private async readRuntimeBootstrapProofEvents(
@ -23955,6 +24193,10 @@ export class TeamProvisioningService {
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
let handle: fs.promises.FileHandle | null = null; let handle: fs.promises.FileHandle | null = null;
try { try {
const pathStat = await fs.promises.lstat(eventsPath);
if (!pathStat.isFile()) {
return [];
}
handle = await fs.promises.open(eventsPath, 'r'); handle = await fs.promises.open(eventsPath, 'r');
const stat = await handle.stat(); const stat = await handle.stat();
if (!stat.isFile() || stat.size <= 0) { if (!stat.isFile() || stat.size <= 0) {
@ -23970,10 +24212,16 @@ export class TeamProvisioningService {
if (start > 0) { if (start > 0) {
lines.shift(); lines.shift();
} }
if (lines.length > BOOTSTRAP_RUNTIME_EVENT_MAX_LINES) {
lines.splice(0, lines.length - BOOTSTRAP_RUNTIME_EVENT_MAX_LINES);
}
const events: Record<string, unknown>[] = []; const events: Record<string, unknown>[] = [];
for (const rawLine of lines) { for (const rawLine of lines) {
const line = rawLine.trim(); const line = rawLine.trim();
if (!line) continue; if (!line) continue;
if (Buffer.byteLength(line, 'utf8') > BOOTSTRAP_RUNTIME_EVENT_MAX_LINE_BYTES) {
continue;
}
try { try {
const parsed = JSON.parse(line) as unknown; const parsed = JSON.parse(line) as unknown;
if ( if (
@ -24077,6 +24325,216 @@ export class TeamProvisioningService {
return latest; return latest;
} }
private isRuntimeBootstrapTransportEventCurrent(input: {
event: Record<string, unknown>;
teamName: string;
memberName: string;
runtimeMember?: PersistedRuntimeMemberLike;
expectedPid?: number;
expectedBootstrapRunId?: string;
boundaryMs: number;
}): boolean {
const { event, teamName, memberName, runtimeMember, expectedPid, expectedBootstrapRunId } =
input;
const eventTeamName = typeof event.teamName === 'string' ? event.teamName.trim() : '';
if (eventTeamName && eventTeamName !== teamName) {
return false;
}
const eventAgentId = typeof event.agentId === 'string' ? event.agentId.trim() : '';
const expectedAgentId = runtimeMember?.agentId?.trim() ?? '';
if (eventAgentId && expectedAgentId && eventAgentId !== expectedAgentId) {
return false;
}
const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : '';
const runtimeName = runtimeMember?.name?.trim() ?? '';
if (
eventAgentName &&
!matchesMemberNameOrBase(eventAgentName, memberName) &&
!(runtimeName && matchesTeamMemberIdentity(eventAgentName, runtimeName))
) {
return false;
}
const eventBootstrapRunId =
typeof event.bootstrapRunId === 'string' ? event.bootstrapRunId.trim() : '';
if (
expectedBootstrapRunId &&
eventBootstrapRunId &&
eventBootstrapRunId !== expectedBootstrapRunId
) {
return false;
}
const eventPid = typeof event.pid === 'number' && Number.isFinite(event.pid) ? event.pid : NaN;
if (typeof expectedPid === 'number' && expectedPid > 0 && eventPid !== expectedPid) {
return false;
}
if (Number.isFinite(input.boundaryMs)) {
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
const timestampMs = Date.parse(timestamp);
if (!Number.isFinite(timestampMs) || timestampMs < input.boundaryMs) {
return false;
}
}
return true;
}
private async readProcessBootstrapTransportSummary(input: {
teamName: string;
memberName: string;
member: PersistedTeamLaunchMemberState;
}): Promise<ProcessBootstrapTransportSummary | null> {
const { teamName, memberName, member } = input;
const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName);
const memberRecord = member as unknown as Record<string, unknown>;
const runtimeBackendType =
runtimeMember?.backendType?.trim() ||
(typeof memberRecord.backendType === 'string' ? memberRecord.backendType.trim() : '');
const processPaneId =
runtimeMember?.tmuxPaneId?.trim() ||
(typeof memberRecord.tmuxPaneId === 'string' ? memberRecord.tmuxPaneId.trim() : '');
if (runtimeBackendType !== 'process' && !processPaneId?.startsWith('process:')) {
return null;
}
const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter;
const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN;
const expectedPid =
typeof member.runtimePid === 'number' && member.runtimePid > 0
? member.runtimePid
: typeof runtimeMember?.runtimePid === 'number' && runtimeMember.runtimePid > 0
? runtimeMember.runtimePid
: undefined;
const expectedBootstrapRunId =
runtimeMember?.bootstrapRunId?.trim() ||
(typeof member.runtimeRunId === 'string' ? member.runtimeRunId.trim() : '') ||
(typeof memberRecord.bootstrapRunId === 'string' ? memberRecord.bootstrapRunId.trim() : '');
if (!expectedBootstrapRunId && !Number.isFinite(boundaryMs) && !expectedPid) {
return null;
}
const eventsPath = this.getBootstrapRuntimeEventsPath(teamName, memberName, runtimeMember);
// Runtime event paths are persisted by process teammates. Keep both path
// containment and payload identity checks so stale or foreign JSONL cannot
// affect another team/member launch.
if (!isContainedTeamRuntimeEventsPath(teamName, eventsPath)) {
return null;
}
const events = await this.readRuntimeBootstrapProofEvents(eventsPath);
const currentEvents = events.filter((event) =>
this.isRuntimeBootstrapTransportEventCurrent({
event,
teamName,
memberName,
runtimeMember,
expectedPid,
expectedBootstrapRunId,
boundaryMs,
})
);
return summarizeProcessBootstrapTransportEvents(
currentEvents as ProcessBootstrapTransportEvent[]
);
}
private applyProcessBootstrapTransportOverlay(input: {
member: PersistedTeamLaunchMemberState;
summary: ProcessBootstrapTransportSummary | null;
launchPhase: PersistedTeamLaunchPhase;
finalTimeoutReached?: boolean;
}): PersistedTeamLaunchMemberState {
const { member, summary } = input;
if (
!summary ||
member.bootstrapConfirmed ||
member.launchState === 'confirmed_alive' ||
member.launchState === 'skipped_for_launch' ||
member.launchState === 'runtime_pending_permission' ||
member.skippedForLaunch === true
) {
return member;
}
const existingFailure = member.hardFailureReason ?? member.runtimeDiagnostic;
if (
member.launchState === 'failed_to_start' &&
member.hardFailure === true &&
!isAutoClearableLaunchFailureReason(existingFailure)
) {
return member;
}
const projectionPhase = deriveProcessTransportProjectionPhase({
launchPhase: input.launchPhase,
finalTimeoutReached: input.finalTimeoutReached,
});
const base: PersistedTeamLaunchMemberState = {
...member,
agentToolAccepted: true,
lastEvaluatedAt: nowIso(),
};
if (summary.terminalFailure) {
// Terminal transport events are failures for bootstrap only. They are
// surfaced as hard failure text, but still do not create readiness proof.
const reason = summary.terminalFailure.reason;
return {
...base,
launchState: 'failed_to_start',
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: reason,
runtimeDiagnostic: reason,
runtimeDiagnosticSeverity: 'error',
diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [reason, summary.lastStage]),
sources: {
...(base.sources ?? {}),
hardFailureSignal: true,
},
};
}
if (!summary.hasProgress) {
return member;
}
if (projectionPhase === 'final') {
const reason = buildProcessBootstrapTimeoutDiagnostic(summary);
return {
...base,
launchState: 'failed_to_start',
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: reason,
runtimeDiagnostic: reason,
runtimeDiagnosticSeverity: 'error',
diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [reason, summary.lastStage]),
sources: {
...(base.sources ?? {}),
hardFailureSignal: true,
},
};
}
const runtimeDiagnostic = buildProcessBootstrapPendingDiagnostic(summary);
// Active launch progress remains pending. A submitted bootstrap prompt is
// not enough for confirmed_alive; durable bootstrap proof is handled by
// the runtime/transcript evidence path above.
return {
...base,
launchState: 'runtime_pending_bootstrap',
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
runtimeDiagnostic,
runtimeDiagnosticSeverity: summary.submitted ? 'info' : 'warning',
diagnostics: mergeRuntimeDiagnostics(base.diagnostics, [
runtimeDiagnostic,
summary.lastStage,
]),
sources: {
...(base.sources ?? {}),
hardFailureSignal: undefined,
},
};
}
private async applyBootstrapTranscriptEvidenceOverlay( private async applyBootstrapTranscriptEvidenceOverlay(
snapshot: PersistedTeamLaunchSnapshot | null snapshot: PersistedTeamLaunchSnapshot | null
): Promise<PersistedTeamLaunchSnapshot | null> { ): Promise<PersistedTeamLaunchSnapshot | null> {
@ -24376,7 +24834,7 @@ export class TeamProvisioningService {
const now = nowIso(); const now = nowIso();
for (const expected of persistedMemberNames) { for (const expected of persistedMemberNames) {
const bootstrapMember = bootstrapSnapshot?.members[expected]; const bootstrapMember = bootstrapSnapshot?.members[expected];
const current = nextMembers[expected] ?? { let current = nextMembers[expected] ?? {
name: expected, name: expected,
launchState: 'starting', launchState: 'starting',
agentToolAccepted: false, agentToolAccepted: false,
@ -24514,9 +24972,19 @@ export class TeamProvisioningService {
} }
} }
const graceExpired = const graceExpired =
current.agentToolAccepted === true && Number.isFinite(acceptedAtMs) && Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
Number.isFinite(acceptedAtMs) && if (!isOpenCodeSecondaryLaneMember) {
Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS; current = this.applyProcessBootstrapTransportOverlay({
member: current,
summary: await this.readProcessBootstrapTransportSummary({
teamName,
memberName: expected,
member: current,
}),
launchPhase: persistedWithCommittedEvidence.launchPhase,
finalTimeoutReached: graceExpired,
});
}
if ( if (
isOpenCodeSecondaryLaneMember && isOpenCodeSecondaryLaneMember &&
shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now()) shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now())
@ -24540,6 +25008,7 @@ export class TeamProvisioningService {
]); ]);
} }
if ( if (
current.agentToolAccepted === true &&
!current.bootstrapConfirmed && !current.bootstrapConfirmed &&
!current.runtimeAlive && !current.runtimeAlive &&
!current.hardFailure && !current.hardFailure &&
@ -27665,6 +28134,7 @@ export class TeamProvisioningService {
} }
run.provisioningComplete = true; run.provisioningComplete = true;
this.scheduleDeterministicBootstrapCompletionRecovery(run);
this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
this.setLeadActivity(run, 'idle'); this.setLeadActivity(run, 'idle');
@ -27743,6 +28213,9 @@ export class TeamProvisioningService {
const hasPendingBootstrap = const hasPendingBootstrap =
!hasSpawnFailures && !hasSpawnFailures &&
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
if (this.isProvisioningRunPromotedToAlive(run)) {
return;
}
const readyMessage = hasSpawnFailures const readyMessage = hasSpawnFailures
? `Launch completed with teammate errors — ${failedSpawnMembers ? `Launch completed with teammate errors — ${failedSpawnMembers
.map((member) => member.name) .map((member) => member.name)
@ -27923,6 +28396,9 @@ export class TeamProvisioningService {
const hasPendingBootstrap = const hasPendingBootstrap =
!hasSpawnFailures && !hasSpawnFailures &&
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
if (this.isProvisioningRunPromotedToAlive(run)) {
return;
}
const progress = updateProgress( const progress = updateProgress(
run, run,
'ready', 'ready',
@ -29357,30 +29833,36 @@ export class TeamProvisioningService {
const envPatch: NodeJS.ProcessEnv = {}; const envPatch: NodeJS.ProcessEnv = {};
let usesAnthropicApiKeyHelper = false; let usesAnthropicApiKeyHelper = false;
for (const providerId of crossProviderIds) { for (const providerId of crossProviderIds) {
let env: ProvisioningEnvResolution;
try { try {
const env = await this.buildProvisioningEnv(providerId, undefined, { env = await this.buildProvisioningEnv(providerId, undefined, {
teamRuntimeAuth: options?.teamRuntimeAuth, teamRuntimeAuth: options?.teamRuntimeAuth,
}); });
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
const providerArgs = env.providerArgs ?? [];
providerArgsByProvider.set(providerId, providerArgs);
if (env.anthropicApiKeyHelper) {
usesAnthropicApiKeyHelper = true;
Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch);
}
const flattenedArgs =
providerId === 'anthropic' && env.anthropicApiKeyHelper
? filterOutSettingsPathArgs(providerArgs, env.anthropicApiKeyHelper.settingsPath)
: providerArgs;
if (flattenedArgs.length > 0) {
args.push(...flattenedArgs);
}
} catch (error) { } catch (error) {
console.error( console.error(
`[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`, `[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`,
error error
); );
// Best-effort: don't block launch if cross-provider env resolution fails // Best-effort: don't block launch if cross-provider env resolution fails
// before the provider can report a concrete auth/readiness issue.
continue;
}
if (env.warning) {
throw new Error(`${getTeamProviderLabel(providerId)}: ${env.warning}`);
}
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
const providerArgs = env.providerArgs ?? [];
providerArgsByProvider.set(providerId, providerArgs);
if (env.anthropicApiKeyHelper) {
usesAnthropicApiKeyHelper = true;
Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch);
}
const flattenedArgs =
providerId === 'anthropic' && env.anthropicApiKeyHelper
? filterOutSettingsPathArgs(providerArgs, env.anthropicApiKeyHelper.settingsPath)
: providerArgs;
if (flattenedArgs.length > 0) {
args.push(...flattenedArgs);
} }
} }
return { args, providerArgsByProvider, envPatch, usesAnthropicApiKeyHelper }; return { args, providerArgsByProvider, envPatch, usesAnthropicApiKeyHelper };

View file

@ -4,6 +4,8 @@ import * as path from 'path';
const LEGACY_USER_DATA_DIR_NAMES = [ const LEGACY_USER_DATA_DIR_NAMES = [
'Claude Agent Teams UI', 'Claude Agent Teams UI',
'claude-agent-teams-ui', 'claude-agent-teams-ui',
'agent-teams-ai',
'Agent Teams UI',
'claude-devtools', 'claude-devtools',
'claude-code-context', 'claude-code-context',
] as const; ] as const;
@ -20,6 +22,7 @@ export interface ElectronUserDataMigrationResult {
fallbackToLegacy: boolean; fallbackToLegacy: boolean;
reason: reason:
| 'migrated' | 'migrated'
| 'legacy-reused'
| 'current-populated' | 'current-populated'
| 'current-path-exists' | 'current-path-exists'
| 'legacy-missing' | 'legacy-missing'
@ -35,8 +38,42 @@ interface LoggerLike {
interface ElectronUserDataMigrationOptions { interface ElectronUserDataMigrationOptions {
logger?: LoggerLike; logger?: LoggerLike;
copyDirectory?: (sourcePath: string, targetPath: string) => void; copyDirectory?: (sourcePath: string, targetPath: string) => void;
strategy?: 'reuse-legacy' | 'copy';
} }
const TRANSIENT_CHROMIUM_DIRECTORY_NAMES = new Set([
'Cache',
'Code Cache',
'Crashpad',
'Crash Reports',
'DawnGraphiteCache',
'DawnWebGPUCache',
'GPUCache',
'GrShaderCache',
'ShaderCache',
'Session Storage',
'Shared Dictionary',
'Service Worker',
'VideoDecodeStats',
'blob_storage',
]);
const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([
'DIPS',
'DIPS-journal',
'DIPS-wal',
'LOCK',
'Network Persistent State',
'SingletonCookie',
'SingletonLock',
'SingletonSocket',
'TransportSecurity',
'Trust Tokens',
'Trust Tokens-journal',
]);
const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000;
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] { export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
const parent = path.dirname(currentPath); const parent = path.dirname(currentPath);
const normalizedCurrent = path.resolve(currentPath); const normalizedCurrent = path.resolve(currentPath);
@ -55,6 +92,7 @@ export function migrateElectronUserDataDirectory(
try { try {
currentPath = app.getPath('userData'); currentPath = app.getPath('userData');
scheduleStaleMigrationTempCleanup(currentPath, logger);
} catch (error) { } catch (error) {
logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`); logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`);
return { return {
@ -66,7 +104,7 @@ export function migrateElectronUserDataDirectory(
}; };
} }
if (directoryExists(currentPath) && directoryHasEntries(currentPath)) { if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
return { return {
currentPath, currentPath,
legacyPath: null, legacyPath: null,
@ -98,6 +136,29 @@ export function migrateElectronUserDataDirectory(
}; };
} }
if ((options.strategy ?? 'reuse-legacy') === 'reuse-legacy') {
try {
setLegacyElectronPaths(app, legacyPath, logger);
logger?.info(`Reusing legacy Electron userData at ${legacyPath}`);
return {
currentPath,
legacyPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
};
} catch (error) {
logger?.warn(`Electron userData legacy reuse failed: ${stringifyError(error)}`);
return {
currentPath,
legacyPath,
migrated: false,
fallbackToLegacy: false,
reason: 'error',
};
}
}
const migrated = copyLegacyUserDataDirectory( const migrated = copyLegacyUserDataDirectory(
legacyPath, legacyPath,
currentPath, currentPath,
@ -115,7 +176,7 @@ export function migrateElectronUserDataDirectory(
}; };
} }
if (directoryExists(currentPath) && directoryHasEntries(currentPath)) { if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
return { return {
currentPath, currentPath,
legacyPath: null, legacyPath: null,
@ -150,7 +211,10 @@ export function migrateElectronUserDataDirectory(
function selectLegacyElectronUserDataPath(currentPath: string): string | null { function selectLegacyElectronUserDataPath(currentPath: string): string | null {
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists); const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
return ( return (
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ?? candidates[0] ?? null candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ??
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ??
candidates[0] ??
null
); );
} }
@ -212,9 +276,73 @@ function copyDirectorySync(sourcePath: string, targetPath: string): void {
recursive: true, recursive: true,
errorOnExist: false, errorOnExist: false,
force: false, force: false,
filter: (sourceEntryPath) => shouldCopyElectronUserDataEntry(sourcePath, sourceEntryPath),
}); });
} }
function scheduleStaleMigrationTempCleanup(currentPath: string, logger?: LoggerLike): void {
const parent = path.dirname(currentPath);
const prefix = `${path.basename(currentPath)}.migrating-`;
const timeout = setTimeout(() => {
fs.readdir(parent, { withFileTypes: true }, (readError, entries) => {
if (readError) {
return;
}
const now = Date.now();
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) {
continue;
}
const stalePath = path.join(parent, entry.name);
fs.stat(stalePath, (statError, stats) => {
if (statError || now - stats.mtimeMs < STALE_MIGRATION_TEMP_MAX_AGE_MS) {
return;
}
fs.rm(stalePath, { recursive: true, force: true }, (removeError) => {
if (removeError) {
logger?.warn(
`Failed to remove stale Electron userData migration temp path: ${stringifyError(
removeError
)}`
);
return;
}
logger?.info(`Removed stale Electron userData migration temp path: ${stalePath}`);
});
});
}
});
}, 30_000);
timeout.unref?.();
}
export function shouldCopyElectronUserDataEntry(
sourceRootPath: string,
sourceEntryPath: string
): boolean {
const relativePath = path.relative(sourceRootPath, sourceEntryPath);
if (!relativePath || relativePath === '.') {
return true;
}
const segments = relativePath.split(path.sep).filter(Boolean);
if (segments.some((segment) => TRANSIENT_CHROMIUM_DIRECTORY_NAMES.has(segment))) {
return false;
}
const basename = segments[segments.length - 1];
if (TRANSIENT_CHROMIUM_FILE_NAMES.has(basename)) {
return false;
}
return true;
}
function pathExists(targetPath: string): boolean { function pathExists(targetPath: string): boolean {
try { try {
fs.accessSync(targetPath); fs.accessSync(targetPath);
@ -240,6 +368,35 @@ function directoryHasEntries(targetPath: string): boolean {
} }
} }
function directoryHasDurableUserDataEntries(targetPath: string): boolean {
try {
return directoryHasDurableUserDataEntriesWithin(targetPath, targetPath);
} catch {
return false;
}
}
function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath: string): boolean {
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name);
if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) {
continue;
}
if (!entry.isDirectory()) {
return true;
}
if (directoryHasDurableUserDataEntriesWithin(rootPath, entryPath)) {
return true;
}
}
return false;
}
function stringifyError(error: unknown): string { function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }

View file

@ -100,7 +100,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
: releaseNotes; : releaseNotes;
const releaseUrl = availableVersion const releaseUrl = availableVersion
? `https://github.com/777genius/claude_agent_teams_ui/releases/tag/v${availableVersion}` ? `https://github.com/777genius/agent-teams-ai/releases/tag/v${availableVersion}`
: null; : null;
const openReleaseOnGitHub = (): void => { const openReleaseOnGitHub = (): void => {

View file

@ -117,13 +117,13 @@ export const TabBarActions = (): React.JSX.Element => {
onClick={async () => { onClick={async () => {
if (isElectronMode()) { if (isElectronMode()) {
await window.electronAPI.openExternal( await window.electronAPI.openExternal(
'https://github.com/777genius/claude_agent_teams_ui' 'https://github.com/777genius/agent-teams-ai'
); );
return; return;
} }
window.open( window.open(
'https://github.com/777genius/claude_agent_teams_ui', 'https://github.com/777genius/agent-teams-ai',
'_blank', '_blank',
'noopener,noreferrer' 'noopener,noreferrer'
); );

View file

@ -106,13 +106,6 @@ function normalizeLaunchFailureReason(value: string | undefined): string | null
return normalized && normalized.length > 0 ? normalized : null; return normalized && normalized.length > 0 ? normalized : null;
} }
function truncateLaunchFailureReason(value: string, maxLength = 220): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
}
function getLaunchFailureLinkLabel(url: string): string { function getLaunchFailureLinkLabel(url: string): string {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
@ -298,9 +291,6 @@ export const MemberCard = memo(function MemberCard({
const launchFailureReason = showFailedLaunchBadge const launchFailureReason = showFailedLaunchBadge
? normalizeLaunchFailureReason(rawLaunchFailureReason) ? normalizeLaunchFailureReason(rawLaunchFailureReason)
: null; : null;
const displayedLaunchFailureReason = launchFailureReason
? truncateLaunchFailureReason(launchFailureReason)
: null;
const hasLiveLaunchControls = const hasLiveLaunchControls =
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
const hasRestartMemberControl = const hasRestartMemberControl =
@ -512,14 +502,14 @@ export const MemberCard = memo(function MemberCard({
) : null} ) : null}
</div> </div>
) : null} ) : null}
{displayedLaunchFailureReason ? ( {launchFailureReason ? (
<div <div
data-testid="member-launch-failure-reason" data-testid="member-launch-failure-reason"
className="mt-1 min-w-0 text-[10px] font-medium leading-snug text-red-300/90" className="mt-1 min-w-0 whitespace-pre-wrap break-words text-[10px] font-medium leading-snug text-red-300/90"
title={rawLaunchFailureReason} title={rawLaunchFailureReason}
> >
<span className="line-clamp-2 break-words"> <span>
{renderLinkifiedText(displayedLaunchFailureReason, { {renderLinkifiedText(launchFailureReason, {
linkClassName: 'underline underline-offset-2 hover:text-red-200', linkClassName: 'underline underline-offset-2 hover:text-red-200',
stopPropagation: true, stopPropagation: true,
getLinkLabel: getLaunchFailureLinkLabel, getLinkLabel: getLaunchFailureLinkLabel,

View file

@ -1,6 +1,6 @@
import packageJson from '../../../package.json'; import packageJson from '../../../package.json';
const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/claude_agent_teams_ui/issues/new'; const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/agent-teams-ai/issues/new';
const MAX_TITLE_LENGTH = 120; const MAX_TITLE_LENGTH = 120;
const URL_MAX_STACK_LENGTH = 1800; const URL_MAX_STACK_LENGTH = 1800;
const URL_MAX_COMPONENT_STACK_LENGTH = 1200; const URL_MAX_COMPONENT_STACK_LENGTH = 1200;

View file

@ -85,6 +85,7 @@ vi.mock(
detectCodexLocalAccountState: detectLocalAccountStateMock, detectCodexLocalAccountState: detectLocalAccountStateMock,
detectCodexLocalAccountArtifacts: async () => detectCodexLocalAccountArtifacts: async () =>
(await detectLocalAccountStateMock()).hasArtifacts, (await detectLocalAccountStateMock()).hasArtifacts,
ensureCodexLegacyAuthFromActiveAccount: vi.fn().mockResolvedValue(null),
}) })
); );
@ -610,6 +611,99 @@ describe('createCodexAccountFeature', () => {
} }
}); });
it('keeps last known rate limits visible during a transient optional rate limit refresh failure', async () => {
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
readRateLimitsMock
.mockResolvedValueOnce(createRateLimitsResponse())
.mockRejectedValueOnce(new Error('codex account authentication required to read rate limits'));
const logger = createLoggerPort();
const feature = createCodexAccountFeature({
logger,
configManager: createConfigManager('chatgpt'),
});
const dateNowSpy = vi.spyOn(Date, 'now');
try {
dateNowSpy.mockReturnValue(1_776_000_000_000);
const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
dateNowSpy.mockReturnValue(1_776_000_060_000);
const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
expect(secondSnapshot.appServerState).toBe('healthy');
expect(secondSnapshot.managedAccount?.email).toBe('user@example.com');
expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
expect(logger.warn).toHaveBeenCalledWith('codex account rate limits refresh failed', {
error: 'codex account authentication required to read rate limits',
});
} finally {
dateNowSpy.mockRestore();
await feature.dispose();
}
});
it('does not reuse stale rate limits after the active ChatGPT account changes', async () => {
readAccountMock
.mockResolvedValueOnce({
account: createAccountResponse({
account: {
type: 'chatgpt',
email: 'first@example.com',
planType: 'pro',
},
}),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
})
.mockResolvedValueOnce({
account: createAccountResponse({
account: {
type: 'chatgpt',
email: 'second@example.com',
planType: 'pro',
},
}),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
readRateLimitsMock
.mockResolvedValueOnce(createRateLimitsResponse())
.mockRejectedValueOnce(new Error('rate limit service unavailable'));
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
const dateNowSpy = vi.spyOn(Date, 'now');
try {
dateNowSpy.mockReturnValue(1_776_000_000_000);
const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
dateNowSpy.mockReturnValue(1_776_000_060_000);
const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
expect(firstSnapshot.managedAccount?.email).toBe('first@example.com');
expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
expect(secondSnapshot.managedAccount?.email).toBe('second@example.com');
expect(secondSnapshot.rateLimits).toBeNull();
} finally {
dateNowSpy.mockRestore();
await feature.dispose();
}
});
it('keeps the last known managed account during a transient degraded read', async () => { it('keeps the last known managed account during a transient degraded read', async () => {
readAccountMock readAccountMock
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@ -686,7 +780,7 @@ describe('createCodexAccountFeature', () => {
dateNowSpy.mockReturnValue(1_776_000_000_000); dateNowSpy.mockReturnValue(1_776_000_000_000);
const firstSnapshot = await feature.refreshSnapshot(); const firstSnapshot = await feature.refreshSnapshot();
dateNowSpy.mockReturnValue(1_776_000_006_000); dateNowSpy.mockReturnValue(1_776_000_006_000);
const secondSnapshot = await feature.refreshSnapshot(); const secondSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true });
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
expect(secondSnapshot.managedAccount).toMatchObject({ expect(secondSnapshot.managedAccount).toMatchObject({

View file

@ -1,5 +1,5 @@
// @vitest-environment node // @vitest-environment node
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'fs/promises';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
@ -8,6 +8,8 @@ import { afterEach, describe, expect, it } from 'vitest';
import { import {
detectCodexLocalAccountArtifacts, detectCodexLocalAccountArtifacts,
detectCodexLocalAccountState, detectCodexLocalAccountState,
ensureCodexLegacyAuthFromActiveAccount,
resolveCodexActiveChatgptAuthFile,
} from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts'; } from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts';
const tempDirs: string[] = []; const tempDirs: string[] = [];
@ -18,6 +20,13 @@ async function makeTempDir(): Promise<string> {
return dir; return dir;
} }
async function makeCodexHome(): Promise<{ codexHome: string; accountsDir: string }> {
const codexHome = await makeTempDir();
const accountsDir = path.join(codexHome, 'accounts');
await mkdir(accountsDir, { recursive: true });
return { codexHome, accountsDir };
}
afterEach(async () => { afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
}); });
@ -55,7 +64,7 @@ describe('detectCodexLocalAccountArtifacts', () => {
}); });
it('detects a locally selected ChatGPT account from the registry and active auth file', async () => { it('detects a locally selected ChatGPT account from the registry and active auth file', async () => {
const accountsDir = await makeTempDir(); const { accountsDir } = await makeCodexHome();
const activeAccountKey = 'user-test::chatgpt-account'; const activeAccountKey = 'user-test::chatgpt-account';
await writeFile( await writeFile(
path.join(accountsDir, 'registry.json'), path.join(accountsDir, 'registry.json'),
@ -64,7 +73,171 @@ describe('detectCodexLocalAccountArtifacts', () => {
); );
await writeFile( await writeFile(
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`), path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
JSON.stringify({ auth_mode: 'chatgpt' }), JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'refresh-token' } }),
'utf8'
);
await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({
hasArtifacts: true,
hasActiveChatgptAccount: true,
});
});
it('resolves the active accounts-format auth file before legacy auth when a registry exists', async () => {
const { codexHome, accountsDir } = await makeCodexHome();
const activeAccountKey = 'user-active::chatgpt-account';
await writeFile(
path.join(codexHome, 'auth.json'),
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'legacy-refresh-token' } }),
'utf8'
);
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: activeAccountKey }),
'utf8'
);
const activeAuthPath = path.join(
accountsDir,
`${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`
);
await writeFile(
activeAuthPath,
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'active-refresh-token' } }),
'utf8'
);
await expect(resolveCodexActiveChatgptAuthFile(accountsDir)).resolves.toMatchObject({
authFilePath: activeAuthPath,
source: 'accounts',
activeAccountKey,
});
});
it('materializes active accounts-format auth into legacy auth.json for Codex CLI compatibility', async () => {
const { codexHome, accountsDir } = await makeCodexHome();
const activeAccountKey = 'user-active::chatgpt-account';
const authPayload = {
auth_mode: 'chatgpt',
tokens: { refresh_token: 'active-refresh-token', access_token: 'active-access-token' },
};
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: activeAccountKey }),
'utf8'
);
await writeFile(
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
JSON.stringify(authPayload),
'utf8'
);
const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
expect(result).toMatchObject({
codexHome,
authFilePath: path.join(codexHome, 'auth.json'),
source: 'accounts',
materializedLegacyAuth: true,
});
await expect(readFile(path.join(codexHome, 'auth.json'), 'utf8')).resolves.toBe(
JSON.stringify(authPayload)
);
});
it('does not overwrite a newer synced legacy auth file for the same active account', async () => {
const { codexHome, accountsDir } = await makeCodexHome();
const activeAccountKey = 'user-active::chatgpt-account';
const activeAuthPath = path.join(
accountsDir,
`${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`
);
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: activeAccountKey }),
'utf8'
);
await writeFile(
activeAuthPath,
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'first-refresh-token' } }),
'utf8'
);
await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
const refreshedLegacyPayload = JSON.stringify({
auth_mode: 'chatgpt',
tokens: { refresh_token: 'runtime-refreshed-token' },
});
const legacyAuthPath = path.join(codexHome, 'auth.json');
await writeFile(legacyAuthPath, refreshedLegacyPayload, 'utf8');
const future = new Date(Date.now() + 60_000);
await utimes(legacyAuthPath, future, future);
const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
expect(result?.materializedLegacyAuth).toBe(false);
await expect(readFile(legacyAuthPath, 'utf8')).resolves.toBe(refreshedLegacyPayload);
});
it('refreshes legacy auth when the selected accounts-format account changes', async () => {
const { codexHome, accountsDir } = await makeCodexHome();
const firstAccountKey = 'user-first::chatgpt-account';
const secondAccountKey = 'user-second::chatgpt-account';
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: firstAccountKey }),
'utf8'
);
await writeFile(
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(firstAccountKey)}.auth.json`),
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'first-refresh-token' } }),
'utf8'
);
await writeFile(
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(secondAccountKey)}.auth.json`),
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'second-refresh-token' } }),
'utf8'
);
await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: secondAccountKey }),
'utf8'
);
const result = await ensureCodexLegacyAuthFromActiveAccount(accountsDir);
expect(result?.materializedLegacyAuth).toBe(true);
await expect(readFile(path.join(codexHome, 'auth.json'), 'utf8')).resolves.toContain(
'second-refresh-token'
);
});
it('requires a ChatGPT refresh token for the selected account', async () => {
const { accountsDir } = await makeCodexHome();
const activeAccountKey = 'user-test::chatgpt-account';
await writeFile(
path.join(accountsDir, 'registry.json'),
JSON.stringify({ activeAccountId: activeAccountKey }),
'utf8'
);
await writeFile(
path.join(accountsDir, `${encodeAccountKeyForAuthFilename(activeAccountKey)}.auth.json`),
JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'access-token' } }),
'utf8'
);
await expect(detectCodexLocalAccountState(accountsDir)).resolves.toEqual({
hasArtifacts: true,
hasActiveChatgptAccount: false,
});
});
it('falls back to legacy auth.json when the accounts registry is absent', async () => {
const { codexHome, accountsDir } = await makeCodexHome();
await writeFile(
path.join(codexHome, 'auth.json'),
JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: 'legacy-refresh-token' } }),
'utf8' 'utf8'
); );
@ -75,7 +248,7 @@ describe('detectCodexLocalAccountArtifacts', () => {
}); });
it('keeps artifact detection true but selected-account detection false when the active auth file is missing', async () => { it('keeps artifact detection true but selected-account detection false when the active auth file is missing', async () => {
const accountsDir = await makeTempDir(); const { accountsDir } = await makeCodexHome();
await writeFile( await writeFile(
path.join(accountsDir, 'registry.json'), path.join(accountsDir, 'registry.json'),
JSON.stringify({ active_account_key: 'user-test::missing-auth' }), JSON.stringify({ active_account_key: 'user-test::missing-auth' }),

View file

@ -13,6 +13,8 @@ vi.mock('electron', () => ({
}, },
})); }));
import { safeStorage } from 'electron';
import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService'; import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService';
describe('ApiKeyService', () => { describe('ApiKeyService', () => {
@ -20,6 +22,11 @@ describe('ApiKeyService', () => {
let service: ApiKeyService; let service: ApiKeyService;
beforeEach(async () => { beforeEach(async () => {
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('basic_text');
vi.mocked(safeStorage.encryptString).mockReset();
vi.mocked(safeStorage.decryptString).mockReset();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-')); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-'));
service = new ApiKeyService(tempDir); service = new ApiKeyService(tempDir);
}); });
@ -117,4 +124,35 @@ describe('ApiKeyService', () => {
await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]); await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]);
await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull(); await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull();
}); });
it('does not print decrypt failures to the normal console', async () => {
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('gnome_libsecret');
vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-value'));
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
throw new Error('Error while decrypting the ciphertext provided to safeStorage.decryptString.');
});
await service.save({
name: 'Anthropic API Key',
envVarName: 'ANTHROPIC_API_KEY',
value: 'secret',
scope: 'user',
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
await expect(service.lookupPreferred('ANTHROPIC_API_KEY')).resolves.toEqual({
envVarName: 'ANTHROPIC_API_KEY',
value: '',
});
expect(consoleError).not.toHaveBeenCalled();
expect(consoleWarn).not.toHaveBeenCalled();
} finally {
consoleError.mockRestore();
consoleWarn.mockRestore();
}
});
}); });

View file

@ -13,23 +13,23 @@ import {
describe('updaterReleaseMetadata', () => { describe('updaterReleaseMetadata', () => {
it('builds platform-specific asset URLs', () => { it('builds platform-specific asset URLs', () => {
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'arm64')).toBe( expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'arm64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg' 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg'
); );
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'x64')).toBe( expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'x64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-x64.dmg' 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-x64.dmg'
); );
expect(getExpectedReleaseAssetUrl('1.2.3', 'win32', 'x64')).toBe( expect(getExpectedReleaseAssetUrl('1.2.3', 'win32', 'x64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI.Setup.1.2.3.exe' 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI.Setup.1.2.3.exe'
); );
expect(getExpectedReleaseAssetUrl('1.2.3', 'linux', 'x64')).toBe( expect(getExpectedReleaseAssetUrl('1.2.3', 'linux', 'x64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3.AppImage' 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3.AppImage'
); );
}); });
it('builds current and planned repo asset URLs while the GitHub repo rename is pending', () => { it('builds primary and legacy repo asset URLs after the GitHub repo rename', () => {
expect(getExpectedReleaseAssetUrls('1.2.3', 'darwin', 'arm64')).toEqual([ expect(getExpectedReleaseAssetUrls('1.2.3', 'darwin', 'arm64')).toEqual([
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg', 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg',
'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg', 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg',
]); ]);
}); });
@ -37,19 +37,19 @@ describe('updaterReleaseMetadata', () => {
const metadata = ` const metadata = `
version: 1.2.3 version: 1.2.3
files: files:
- url: "Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip" - url: "Agent.Teams.AI-1.2.3-arm64-mac.zip"
sha512: abc sha512: abc
size: 123 size: 123
- url: 'Claude.Agent.Teams.UI-1.2.3-arm64.dmg' - url: 'Agent.Teams.AI-1.2.3-arm64.dmg'
sha512: def sha512: def
size: 456 size: 456
path: Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip path: Agent.Teams.AI-1.2.3-arm64-mac.zip
`; `;
expect(parseReleaseMetadataAssetNames(metadata)).toEqual( expect(parseReleaseMetadataAssetNames(metadata)).toEqual(
new Set([ new Set([
'Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip', 'Agent.Teams.AI-1.2.3-arm64-mac.zip',
'Claude.Agent.Teams.UI-1.2.3-arm64.dmg', 'Agent.Teams.AI-1.2.3-arm64.dmg',
]) ])
); );
}); });
@ -59,29 +59,29 @@ path: Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip
const arm64Metadata = ` const arm64Metadata = `
version: ${version} version: ${version}
files: files:
- url: Claude.Agent.Teams.UI-${version}-arm64-mac.zip - url: Agent.Teams.AI-${version}-arm64-mac.zip
sha512: abc sha512: abc
size: 123 size: 123
- url: Claude.Agent.Teams.UI-${version}-arm64.dmg - url: Agent.Teams.AI-${version}-arm64.dmg
sha512: def sha512: def
size: 456 size: 456
path: Claude.Agent.Teams.UI-${version}-arm64-mac.zip path: Agent.Teams.AI-${version}-arm64-mac.zip
`; `;
expect(getExpectedLatestMacArtifacts(version, 'arm64')).toEqual([ expect(getExpectedLatestMacArtifacts(version, 'arm64')).toEqual([
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`, `Agent.Teams.AI-${version}-arm64-mac.zip`,
`Claude.Agent.Teams.UI-${version}-arm64.dmg`, `Agent.Teams.AI-${version}-arm64.dmg`,
]); ]);
expect(getExpectedLatestMacArtifacts(version, 'x64')).toEqual([ expect(getExpectedLatestMacArtifacts(version, 'x64')).toEqual([
`Claude.Agent.Teams.UI-${version}-x64-mac.zip`, `Agent.Teams.AI-${version}-x64-mac.zip`,
`Claude.Agent.Teams.UI-${version}-x64.dmg`, `Agent.Teams.AI-${version}-x64.dmg`,
]); ]);
expect(getLatestMacMetadataUrl(version)).toBe( expect(getLatestMacMetadataUrl(version)).toBe(
`https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml` `https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml`
); );
expect(getLatestMacMetadataUrls(version)).toEqual([ expect(getLatestMacMetadataUrls(version)).toEqual([
`https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`,
`https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml`, `https://github.com/777genius/agent-teams-ai/releases/download/v${version}/latest-mac.yml`,
`https://github.com/777genius/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`,
]); ]);
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'arm64')).toBe(true); expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'arm64')).toBe(true);
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'x64')).toBe(false); expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'x64')).toBe(false);

View file

@ -708,6 +708,149 @@ describe('ProviderConnectionService', () => {
); );
}); });
it('does not block launch when the Codex app-server freshly verifies ChatGPT auth', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const loginStatusChecker = vi.fn().mockResolvedValue({
status: 'not_logged_in',
detail: 'Not logged in',
});
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never,
loginStatusChecker
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
runtimeContext: {
binaryPath: '/opt/codex/bin/codex',
codexHome: '/Users/tester/.codex-custom',
},
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
await expect(
service.getConfiguredConnectionIssue(
{
OPENAI_API_KEY: 'ambient-openai-key',
CODEX_API_KEY: 'ambient-codex-key',
},
'codex'
)
).resolves.toBeNull();
expect(loginStatusChecker).not.toHaveBeenCalled();
});
it('blocks launch when managed ChatGPT is selected but degraded exact runtime login is logged out', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const loginStatusChecker = vi.fn().mockResolvedValue({
status: 'not_logged_in',
detail: 'Not logged in',
});
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never,
loginStatusChecker
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'warning_degraded_but_launchable',
appServerState: 'degraded',
appServerStatusMessage: 'Using cached ChatGPT account after transient app-server failure.',
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
runtimeContext: {
binaryPath: '/opt/codex/bin/codex',
codexHome: '/Users/tester/.codex-custom',
},
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const issue = await service.getConfiguredConnectionIssue(
{
OPENAI_API_KEY: 'ambient-openai-key',
CODEX_API_KEY: 'ambient-codex-key',
},
'codex'
);
expect(issue).toContain('Codex CLI login status is not active');
expect(issue).toContain('Reconnect ChatGPT');
expect(loginStatusChecker).toHaveBeenCalledWith({
binaryPath: '/opt/codex/bin/codex',
env: expect.objectContaining({
CODEX_CLI_PATH: '/opt/codex/bin/codex',
CODEX_HOME: '/Users/tester/.codex-custom',
CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt',
}),
});
expect(loginStatusChecker.mock.calls[0]?.[0].env.OPENAI_API_KEY).toBeUndefined();
expect(loginStatusChecker.mock.calls[0]?.[0].env.CODEX_API_KEY).toBeUndefined();
});
it('reports a pinned Codex API-key mode as missing only the API key credential', async () => { it('reports a pinned Codex API-key mode as missing only the API key credential', async () => {
const { ProviderConnectionService } = const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService'); await import('@main/services/runtime/ProviderConnectionService');

View file

@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import {
buildProcessBootstrapPendingDiagnostic,
buildProcessBootstrapTimeoutDiagnostic,
deriveProcessTransportProjectionPhase,
sanitizeProcessRuntimeEventFilePrefix,
summarizeProcessBootstrapTransportEvents,
} from '@main/services/team/ProcessBootstrapTransportEvidence';
describe('ProcessBootstrapTransportEvidence', () => {
it('keeps retryable submit rejection non-terminal when a later submit succeeds', () => {
const summary = summarizeProcessBootstrapTransportEvents([
{
type: 'runtime_ready',
timestamp: '2026-05-07T10:00:00.000Z',
detail: 'ready',
},
{
type: 'bootstrap_submit_rejected',
timestamp: '2026-05-07T10:00:01.000Z',
detail: 'temporary backoff',
retryable: true,
},
{
type: 'bootstrap_submitted',
timestamp: '2026-05-07T10:00:02.000Z',
detail: 'messageId=abc',
},
]);
expect(summary).toMatchObject({
submitted: true,
hasProgress: true,
});
expect(summary?.terminalFailure).toBeUndefined();
expect(summary?.lastStage).toContain('bootstrap submitted');
});
it('treats non-retryable submit rejection as terminal', () => {
const summary = summarizeProcessBootstrapTransportEvents([
{
type: 'bootstrap_submit_rejected',
timestamp: '2026-05-07T10:00:01.000Z',
detail: 'fatal submit rejection',
retryable: false,
},
]);
expect(summary?.terminalFailure).toMatchObject({
kind: 'non_retryable_submit_rejection',
reason: 'bootstrap submit rejected: fatal submit rejection',
});
});
it('treats accepted submit without a message id as terminal', () => {
const summary = summarizeProcessBootstrapTransportEvents([
{
type: 'bootstrap_submit_accepted_without_uuid',
timestamp: '2026-05-07T10:00:01.000Z',
detail: 'accepted but missing message id',
},
]);
expect(summary?.terminalFailure).toMatchObject({
kind: 'accepted_without_message_id',
reason: 'bootstrap submit accepted without message id: accepted but missing message id',
});
});
it('redacts secrets and paths from transport diagnostics', () => {
const summary = summarizeProcessBootstrapTransportEvents([
{
type: 'bootstrap_submit_rejected',
timestamp: '2026-05-07T10:00:01.000Z',
detail:
'failed in /Users/belief/dev/project with token sk-ant-api03-abcdefghijklmnopqrstuvwxyz',
retryable: false,
},
]);
expect(summary?.terminalFailure?.reason).toContain('[path]');
expect(summary?.terminalFailure?.reason).toContain('[redacted]');
expect(summary?.terminalFailure?.reason).not.toContain('/Users/belief');
expect(summary?.terminalFailure?.reason).not.toContain('sk-ant-api03');
});
it('does not surface raw command or cwd details for parent-owned process stages', () => {
const summary = summarizeProcessBootstrapTransportEvents([
{
type: 'process_spawned',
timestamp: '2026-05-07T10:00:01.000Z',
detail: 'spawned /Users/belief/project with command secret',
},
]);
expect(summary?.lastStage).toBe('process spawned');
});
it('builds stable pending and timeout diagnostics from the last transport stage', () => {
const summary = summarizeProcessBootstrapTransportEvents([
{
type: 'bootstrap_prompt_observed',
timestamp: '2026-05-07T10:00:01.000Z',
detail: 'prompt seen',
},
]);
expect(summary).not.toBeNull();
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
'Bootstrap transport reached bootstrap prompt observed: prompt seen; waiting for bootstrap confirmation.'
);
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
'Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap prompt observed: prompt seen'
);
});
it('keeps active phase pending and turns final timeout into final projection', () => {
expect(deriveProcessTransportProjectionPhase({ launchPhase: 'active' })).toBe('active');
expect(
deriveProcessTransportProjectionPhase({
launchPhase: 'active',
finalTimeoutReached: true,
})
).toBe('final');
expect(deriveProcessTransportProjectionPhase({ launchPhase: 'finished' })).toBe('final');
});
it('matches orchestrator runtime-event filename sanitization for important names', () => {
expect(sanitizeProcessRuntimeEventFilePrefix('jack')).toBe('jack');
expect(sanitizeProcessRuntimeEventFilePrefix('con.txt')).toBe('con-txt');
expect(sanitizeProcessRuntimeEventFilePrefix('CON')).toBe('_con');
expect(sanitizeProcessRuntimeEventFilePrefix('alice/bob')).toBe('alice-bob');
});
});

View file

@ -12594,6 +12594,122 @@ describe('TeamProvisioningService', () => {
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
}); });
it('recovers ready progress when deterministic create finalization stalls after completed bootstrap-state', async () => {
allowConsoleLogs();
vi.useFakeTimers();
const teamName = 'create-completed-bootstrap-finalization-stall';
const child = createRunningChild();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(child as any);
const mcpConfigBuilder = {
writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'),
removeConfigFile: vi.fn(async () => {}),
};
const membersMetaStore = {
writeMembers: vi.fn(async () => {}),
getMembers: vi.fn(async () => []),
};
const teamMetaStore = {
writeMeta: vi.fn(async () => {}),
deleteMeta: vi.fn(async () => {}),
getMeta: vi.fn(async () => null),
};
const svc = new TeamProvisioningService(
undefined,
undefined,
membersMetaStore as any,
undefined,
mcpConfigBuilder as any,
teamMetaStore as any
);
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { CODEX_API_KEY: 'test' },
authSource: 'codex_runtime',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).startStallWatchdog = vi.fn();
(svc as any).stopStallWatchdog = vi.fn();
(svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({
providerId: 'codex',
providerBackendId: 'codex-native',
selectedModel: 'gpt-5.5',
selectedModelKind: 'explicit',
resolvedLaunchModel: 'gpt-5.5',
catalogId: 'gpt-5.5',
catalogSource: 'test',
catalogFetchedAt: '2026-05-07T00:00:00.000Z',
selectedEffort: 'medium',
resolvedEffort: 'medium',
selectedFastMode: null,
resolvedFastMode: null,
fastResolutionReason: null,
}));
const waitForValidConfig = vi.fn(() => new Promise(() => {}));
(svc as any).waitForValidConfig = waitForValidConfig;
const progressStates: string[] = [];
const { runId } = await svc.createTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.5',
members: [{ name: 'alice' }, { name: 'tom' }],
},
(progress) => {
progressStates.push(progress.state);
}
);
const run = (svc as any).runs.get(runId);
expect(run).toBeTruthy();
run.deterministicBootstrap = true;
const scheduleRecovery = vi.spyOn(
svc as any,
'scheduleDeterministicBootstrapCompletionRecovery'
);
writeBootstrapState(
teamName,
[
{ name: 'alice', status: 'bootstrap_confirmed' },
{ name: 'tom', status: 'bootstrap_confirmed' },
],
new Date(Date.now() + 1_000).toISOString()
);
child.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
type: 'system',
subtype: 'team_bootstrap',
event: 'completed',
run_id: runId,
team_name: teamName,
seq: 1,
failed_members: [],
})}\n`,
'utf8'
)
);
await Promise.resolve();
await Promise.resolve();
expect(waitForValidConfig).toHaveBeenCalledTimes(1);
expect(scheduleRecovery).toHaveBeenCalledWith(run);
expect(progressStates.at(-1)).not.toBe('ready');
await (svc as any).recoverDeterministicBootstrapCompletion(run);
expect(progressStates.at(-1)).toBe('ready');
expect((svc as any).provisioningRunByTeam.has(teamName)).toBe(false);
expect((svc as any).aliveRunByTeam.get(teamName)).toBe(runId);
});
it('does not verify provisioning again after flushing a final newline-less error result', async () => { it('does not verify provisioning again after flushing a final newline-less error result', async () => {
allowConsoleLogs(); allowConsoleLogs();
const teamName = 'launch-close-flushes-final-error-team'; const teamName = 'launch-close-flushes-final-error-team';
@ -13437,6 +13553,266 @@ describe('TeamProvisioningService', () => {
}); });
}); });
it('keeps active process bootstrap transport progress pending without turning retryable rejection into failure', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-process-bootstrap-transport-pending';
const leadSessionId = 'lead-session';
const projectPath = '/Users/test/proj';
const acceptedAt = new Date(Date.now() - 15_000).toISOString();
const bootstrapRunId = 'run-process-transport-pending';
const runtimePid = 1234;
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
members: Array<Record<string, unknown>>;
};
config.members = config.members.map((member) =>
member.name === 'jack'
? {
...member,
agentId: `jack@${teamName}`,
backendType: 'process',
tmuxPaneId: `process:${runtimePid}`,
runtimePid,
bootstrapExpectedAfter: acceptedAt,
bootstrapRunId,
bootstrapRuntimeEventsPath: runtimeEventsPath,
}
: member
);
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
writeLaunchState(
teamName,
leadSessionId,
{
jack: {
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
runtimePid,
runtimeRunId: bootstrapRunId,
tmuxPaneId: `process:${runtimePid}`,
backendType: 'process',
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
firstSpawnAcceptedAt: acceptedAt,
},
},
{ launchPhase: 'active' }
);
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
fs.writeFileSync(
runtimeEventsPath,
[
{
version: 1,
type: 'runtime_ready',
timestamp: acceptedAt,
pid: runtimePid,
teamName,
agentName: 'jack',
agentId: `jack@${teamName}`,
bootstrapRunId,
detail: 'ready',
},
{
version: 1,
type: 'bootstrap_submit_rejected',
timestamp: new Date(Date.now() - 10_000).toISOString(),
pid: runtimePid,
teamName,
agentName: 'jack',
agentId: `jack@${teamName}`,
bootstrapRunId,
retryable: true,
detail: 'cooldown before retry',
},
]
.map((event) => JSON.stringify(event))
.join('\n') + '\n',
'utf8'
);
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.jack).toMatchObject({
launchState: 'runtime_pending_bootstrap',
bootstrapConfirmed: false,
hardFailure: false,
runtimeDiagnosticSeverity: 'warning',
});
expect(result.statuses.jack?.runtimeDiagnostic).toContain(
'Bootstrap transport reached bootstrap submit rejected'
);
});
it('uses the last process transport stage when active launch grace expires', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-process-bootstrap-transport-timeout';
const leadSessionId = 'lead-session';
const projectPath = '/Users/test/proj';
const acceptedAt = new Date(Date.now() - 15 * 60_000).toISOString();
const bootstrapRunId = 'run-process-transport-timeout';
const runtimePid = 1235;
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
members: Array<Record<string, unknown>>;
};
config.members = config.members.map((member) =>
member.name === 'jack'
? {
...member,
agentId: `jack@${teamName}`,
backendType: 'process',
tmuxPaneId: `process:${runtimePid}`,
runtimePid,
bootstrapExpectedAfter: acceptedAt,
bootstrapRunId,
bootstrapRuntimeEventsPath: runtimeEventsPath,
}
: member
);
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
writeLaunchState(
teamName,
leadSessionId,
{
jack: {
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
runtimePid,
runtimeRunId: bootstrapRunId,
tmuxPaneId: `process:${runtimePid}`,
backendType: 'process',
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
firstSpawnAcceptedAt: acceptedAt,
},
},
{ launchPhase: 'active' }
);
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
fs.writeFileSync(
runtimeEventsPath,
`${JSON.stringify({
version: 1,
type: 'bootstrap_prompt_observed',
timestamp: new Date(Date.now() - 14 * 60_000).toISOString(),
pid: runtimePid,
teamName,
agentName: 'jack',
agentId: `jack@${teamName}`,
bootstrapRunId,
detail: 'prompt seen',
})}\n`,
'utf8'
);
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.jack).toMatchObject({
launchState: 'failed_to_start',
bootstrapConfirmed: false,
hardFailure: true,
runtimeDiagnosticSeverity: 'error',
});
expect(result.statuses.jack?.hardFailureReason).toContain(
'Last transport stage: bootstrap prompt observed: prompt seen'
);
});
it('uses non-retryable process transport rejection as terminal launch failure', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-process-bootstrap-transport-terminal';
const leadSessionId = 'lead-session';
const projectPath = '/Users/test/proj';
const acceptedAt = new Date(Date.now() - 15_000).toISOString();
const bootstrapRunId = 'run-process-transport-terminal';
const runtimePid = 1236;
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
members: Array<Record<string, unknown>>;
};
config.members = config.members.map((member) =>
member.name === 'jack'
? {
...member,
agentId: `jack@${teamName}`,
backendType: 'process',
tmuxPaneId: `process:${runtimePid}`,
runtimePid,
bootstrapExpectedAfter: acceptedAt,
bootstrapRunId,
bootstrapRuntimeEventsPath: runtimeEventsPath,
}
: member
);
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
writeLaunchState(
teamName,
leadSessionId,
{
jack: {
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
runtimePid,
runtimeRunId: bootstrapRunId,
tmuxPaneId: `process:${runtimePid}`,
backendType: 'process',
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
firstSpawnAcceptedAt: acceptedAt,
},
},
{ launchPhase: 'active' }
);
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
fs.writeFileSync(
runtimeEventsPath,
`${JSON.stringify({
version: 1,
type: 'bootstrap_submit_rejected',
timestamp: new Date(Date.now() - 10_000).toISOString(),
pid: runtimePid,
teamName,
agentName: 'jack',
agentId: `jack@${teamName}`,
bootstrapRunId,
retryable: false,
detail: 'fatal submit rejection',
})}\n`,
'utf8'
);
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.jack).toMatchObject({
launchState: 'failed_to_start',
bootstrapConfirmed: false,
hardFailure: true,
runtimeDiagnosticSeverity: 'error',
});
expect(result.statuses.jack?.hardFailureReason).toBe(
'bootstrap submit rejected: fatal submit rejection'
);
});
it('does not classify the bootstrap instruction prompt as a member launch failure', async () => { it('does not classify the bootstrap instruction prompt as a member launch failure', async () => {
allowConsoleLogs(); allowConsoleLogs();
const teamName = 'zz-unit-bootstrap-prompt-not-failure'; const teamName = 'zz-unit-bootstrap-prompt-not-failure';

View file

@ -2101,6 +2101,28 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
}); });
}); });
it('blocks launch args when a secondary Codex provider reports a concrete auth issue', async () => {
const svc = new TeamProvisioningService();
buildProviderAwareCliEnvMock.mockImplementation(
({ providerId, env }: { providerId?: string; env: NodeJS.ProcessEnv }) =>
Promise.resolve({
env,
authSource: providerId === 'codex' ? 'configured_api_key_missing' : 'none',
geminiRuntimeAuth: null,
connectionIssues:
providerId === 'codex' ? { codex: 'Codex CLI login status is not active' } : {},
warning: providerId === 'codex' ? 'Codex CLI login status is not active' : undefined,
})
);
await expect(
(svc as any).buildCrossProviderMemberArgs('anthropic', [
{ name: 'alice', providerId: 'anthropic' },
{ name: 'jack', providerId: 'codex' },
])
).rejects.toThrow('Codex: Codex CLI login status is not active');
});
it('adds Codex turn-settled env when a secondary member infers Codex from model', async () => { it('adds Codex turn-settled env when a secondary member infers Codex from model', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>

View file

@ -16,6 +16,7 @@ import {
import { import {
getLegacyElectronUserDataCandidates, getLegacyElectronUserDataCandidates,
migrateElectronUserDataDirectory, migrateElectronUserDataDirectory,
shouldCopyElectronUserDataEntry,
type ElectronUserDataMigrationApp, type ElectronUserDataMigrationApp,
} from '../../../src/main/utils/electronUserDataMigration'; } from '../../../src/main/utils/electronUserDataMigration';
@ -75,12 +76,63 @@ describe('electron userData migration', () => {
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([ expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
path.join(parentPath, 'Claude Agent Teams UI'), path.join(parentPath, 'Claude Agent Teams UI'),
path.join(parentPath, 'claude-agent-teams-ui'), path.join(parentPath, 'claude-agent-teams-ui'),
path.join(parentPath, 'agent-teams-ai'),
path.join(parentPath, 'claude-devtools'), path.join(parentPath, 'claude-devtools'),
path.join(parentPath, 'claude-code-context'), path.join(parentPath, 'claude-code-context'),
]); ]);
}); });
it('copies the complete legacy userData tree, including all current app-owned stores', async () => { it('reuses populated legacy userData by default instead of copying it during startup', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'agent-teams-ai');
const app = new FakeElectronApp(currentPath);
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
});
expect(app.setPathCalls).toEqual([
{ name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath },
]);
expect(fs.existsSync(currentPath)).toBe(false);
});
it('does not treat a cache-only new userData directory as populated', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'agent-teams-ai');
const app = new FakeElectronApp(currentPath);
writeFile(currentPath, 'Cache/Cache_Data/blob', 'cache');
writeFile(currentPath, 'Code Cache/js/cache', 'code cache');
writeFile(currentPath, 'Partitions/dev/Cache/Cache_Data/blob', 'partition cache');
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
});
expect(app.setPathCalls).toEqual([
{ name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath },
]);
});
it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => {
const root = createTempRoot(); const root = createTempRoot();
const legacyPath = path.join(root, 'Claude Agent Teams UI'); const legacyPath = path.join(root, 'Claude Agent Teams UI');
const currentPath = path.join(root, 'Agent Teams UI'); const currentPath = path.join(root, 'Agent Teams UI');
@ -102,14 +154,43 @@ describe('electron userData migration', () => {
['opencode-bridge/command-leases.json', '{"leases":[]}'], ['opencode-bridge/command-leases.json', '{"leases":[]}'],
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'], ['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'], ['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'],
['IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', 'renderer indexeddb bytes'],
['Partitions/dev/Local Storage/leveldb/000003.log', 'dev partition localStorage bytes'],
[
'Partitions/dev/IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log',
'dev partition indexeddb bytes',
],
['future-feature/state.json', '{"kept":true}'], ['future-feature/state.json', '{"kept":true}'],
] as const; ] as const;
const transientFiles = [
['Cache/Cache_Data/blob', 'http cache'],
['Code Cache/js/cache', 'code cache'],
['GPUCache/data_0', 'gpu cache'],
['DawnGraphiteCache/data_0', 'graphite cache'],
['DawnWebGPUCache/data_0', 'webgpu cache'],
['Crashpad/settings.dat', 'crashpad state'],
['Session Storage/000003.log', 'session storage'],
['Local Storage/leveldb/LOCK', 'stale leveldb lock'],
['IndexedDB/http_localhost_5173.indexeddb.leveldb/LOCK', 'stale indexeddb lock'],
['Network Persistent State', 'network state'],
['DIPS', 'tracking protection state'],
['Trust Tokens', 'trust tokens'],
['Partitions/dev/Cache/Cache_Data/blob', 'partition http cache'],
['Partitions/dev/Code Cache/js/cache', 'partition code cache'],
['Partitions/dev/GPUCache/data_0', 'partition gpu cache'],
['Partitions/dev/Session Storage/000003.log', 'partition session storage'],
] as const;
for (const [relativePath, content] of knownFiles) { for (const [relativePath, content] of knownFiles) {
writeFile(legacyPath, relativePath, content); writeFile(legacyPath, relativePath, content);
} }
for (const [relativePath, content] of transientFiles) {
writeFile(legacyPath, relativePath, content);
}
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath), {
strategy: 'copy',
});
expect(result).toMatchObject({ expect(result).toMatchObject({
currentPath, currentPath,
@ -122,6 +203,9 @@ describe('electron userData migration', () => {
for (const [relativePath, content] of knownFiles) { for (const [relativePath, content] of knownFiles) {
expect(readFile(currentPath, relativePath)).toBe(content); expect(readFile(currentPath, relativePath)).toBe(content);
} }
for (const [relativePath] of transientFiles) {
expect(fs.existsSync(path.join(currentPath, relativePath))).toBe(false);
}
setAppDataBasePath(currentPath); setAppDataBasePath(currentPath);
expect(getAppDataPath()).toBe(path.join(currentPath, 'data')); expect(getAppDataPath()).toBe(path.join(currentPath, 'data'));
@ -143,6 +227,39 @@ describe('electron userData migration', () => {
).resolves.toBe(Buffer.from('task attachment').toString('base64')); ).resolves.toBe(Buffer.from('task attachment').toString('base64'));
}); });
it('keeps unknown durable state but skips transient Chromium cache entries', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'Claude Agent Teams UI');
expect(
shouldCopyElectronUserDataEntry(legacyPath, path.join(legacyPath, 'data/state.json'))
).toBe(true);
expect(
shouldCopyElectronUserDataEntry(
legacyPath,
path.join(legacyPath, 'future-feature/state.json')
)
).toBe(true);
expect(
shouldCopyElectronUserDataEntry(
legacyPath,
path.join(legacyPath, 'Partitions/dev/Local Storage/leveldb/000003.log')
)
).toBe(true);
expect(
shouldCopyElectronUserDataEntry(
legacyPath,
path.join(legacyPath, 'Partitions/dev/Cache/Cache_Data/blob')
)
).toBe(false);
expect(
shouldCopyElectronUserDataEntry(
legacyPath,
path.join(legacyPath, 'Local Storage/leveldb/LOCK')
)
).toBe(false);
});
it('does not merge legacy data into an already populated new userData directory', () => { it('does not merge legacy data into an already populated new userData directory', () => {
const root = createTempRoot(); const root = createTempRoot();
const legacyPath = path.join(root, 'Claude Agent Teams UI'); const legacyPath = path.join(root, 'Claude Agent Teams UI');
@ -172,6 +289,7 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app, { const result = migrateElectronUserDataDirectory(app, {
strategy: 'copy',
copyDirectory: () => { copyDirectory: () => {
throw new Error('copy denied'); throw new Error('copy denied');
}, },
@ -199,6 +317,7 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app, { const result = migrateElectronUserDataDirectory(app, {
strategy: 'copy',
copyDirectory: () => { copyDirectory: () => {
writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current'); writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current');
throw new Error('destination appeared'); throw new Error('destination appeared');
@ -226,6 +345,7 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy'); writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app, { const result = migrateElectronUserDataDirectory(app, {
strategy: 'copy',
copyDirectory: () => { copyDirectory: () => {
throw new Error('copy denied'); throw new Error('copy denied');
}, },
@ -270,16 +390,21 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}'); writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); const app = new FakeElectronApp(currentPath);
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({ expect(result).toMatchObject({
currentPath, currentPath,
legacyPath, legacyPath,
migrated: true, migrated: false,
fallbackToLegacy: false, fallbackToLegacy: false,
reason: 'migrated', reason: 'legacy-reused',
}); });
expect(readFile(currentPath, 'mcp-configs/legacy.json')).toBe('{}'); expect(app.setPathCalls).toEqual([
{ name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath },
]);
expect(fs.existsSync(path.join(currentPath, 'mcp-configs/legacy.json'))).toBe(false);
}); });
it('prefers populated older legacy data over an empty newer legacy directory', () => { it('prefers populated older legacy data over an empty newer legacy directory', () => {
@ -291,16 +416,23 @@ describe('electron userData migration', () => {
fs.mkdirSync(emptyNewerLegacyPath, { recursive: true }); fs.mkdirSync(emptyNewerLegacyPath, { recursive: true });
writeFile(populatedOlderLegacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release'); writeFile(populatedOlderLegacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release');
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); const app = new FakeElectronApp(currentPath);
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({ expect(result).toMatchObject({
currentPath, currentPath,
legacyPath: populatedOlderLegacyPath, legacyPath: populatedOlderLegacyPath,
migrated: true, migrated: false,
fallbackToLegacy: false, fallbackToLegacy: false,
reason: 'migrated', reason: 'legacy-reused',
}); });
expect(readFile(currentPath, 'data/attachments/team-a/pre-release.txt')).toBe('pre-release'); expect(app.setPathCalls).toEqual([
{ name: 'userData', value: populatedOlderLegacyPath },
{ name: 'sessionData', value: populatedOlderLegacyPath },
]);
expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/pre-release.txt'))).toBe(
false
);
}); });
it('uses the pre-1.0 claude-devtools legacy directory when newer legacy data is absent', () => { it('uses the pre-1.0 claude-devtools legacy directory when newer legacy data is absent', () => {
@ -310,15 +442,22 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release'); writeFile(legacyPath, 'data/attachments/team-a/pre-release.txt', 'pre-release');
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath)); const app = new FakeElectronApp(currentPath);
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({ expect(result).toMatchObject({
currentPath, currentPath,
legacyPath, legacyPath,
migrated: true, migrated: false,
fallbackToLegacy: false, fallbackToLegacy: false,
reason: 'migrated', reason: 'legacy-reused',
}); });
expect(readFile(currentPath, 'data/attachments/team-a/pre-release.txt')).toBe('pre-release'); expect(app.setPathCalls).toEqual([
{ name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath },
]);
expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/pre-release.txt'))).toBe(
false
);
}); });
}); });

View file

@ -837,6 +837,48 @@ describe('MemberCard starting-state visuals', () => {
}); });
}); });
it('does not truncate long failed launch reasons on the member row', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const reason = `APIError - ${'Codex runtime context includes missing login session. '.repeat(
8
)}final diagnostic marker`;
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'error',
spawnLaunchState: 'failed_to_start',
spawnRuntimeAlive: false,
spawnError: reason,
spawnEntry: {
...failedSpawnEntry,
hardFailureReason: reason,
runtimeDiagnostic: reason,
},
onRestartMember: vi.fn(),
})
);
await Promise.resolve();
});
const failureReason = host.querySelector('[data-testid="member-launch-failure-reason"]');
expect(failureReason?.textContent).toContain('final diagnostic marker');
expect(failureReason?.querySelector('.line-clamp-2')).toBeNull();
expect(failureReason?.textContent).not.toContain('...');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => { it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');