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
working-directory: landing
env:
NUXT_APP_BASE_URL: /claude_agent_teams_ui/
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui
NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui
NUXT_APP_BASE_URL: /agent-teams-ai/
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/agent-teams-ai
NUXT_PUBLIC_GITHUB_REPO: 777genius/agent-teams-ai
run: npm run generate:all
- uses: actions/configure-pages@v5

View file

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

View file

@ -10,15 +10,15 @@
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</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">
<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 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/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/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/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>
</p>
@ -54,33 +54,33 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc
<table align="center">
<tr>
<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" />
</a>
<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" />
</a>
</td>
<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" />
</a>
<br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td>
<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" />
</a>
<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" />
</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" />
</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" />
</a>
</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+
```bash
git clone https://github.com/777genius/claude_agent_teams_ui.git
cd claude_agent_teams_ui
git clone https://github.com/777genius/agent-teams-ai.git
cd agent-teams-ai
pnpm install
pnpm dev
```

View file

@ -3,7 +3,7 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "claude-agent-teams-ui",
"name": "agent-teams-ai",
"dependencies": {
"@claude-teams/agent-graph": "workspace:*",
"@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:
```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
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>
<tr>
<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" />
</a>
<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" />
</a>
</td>
<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" />
</a>
<br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td>
<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" />
</a>
<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" />
</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" />
</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" />
</a>
</td>
@ -112,7 +112,7 @@ This triggers the `release.yml` GitHub Actions workflow which:
After the workflow completes, edit the release notes:
```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>
EOF
)"
@ -140,33 +140,33 @@ EOF
<table>
<tr>
<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" />
</a>
<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" />
</a>
</td>
<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" />
</a>
<br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td>
<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" />
</a>
<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" />
</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" />
</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" />
</a>
</td>
@ -196,15 +196,15 @@ electron-builder generates these artifacts per platform:
| 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 x64 DMG | `Claude.Agent.Teams.UI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | - |
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-x64-mac.zip` | - |
| Windows | `Claude.Agent.Teams.UI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
| Linux AppImage | `Claude.Agent.Teams.UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
| Linux deb | `claude-agent-teams-ui_<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 pacman | `claude-agent-teams-ui-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
| macOS arm64 DMG | `Agent.Teams.AI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
| macOS x64 DMG | `Agent.Teams.AI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
| macOS arm64 ZIP | `Agent.Teams.AI-<VER>-arm64-mac.zip` | - |
| macOS x64 ZIP | `Agent.Teams.AI-<VER>-x64-mac.zip` | - |
| Windows | `Agent.Teams.AI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
| Linux AppImage | `Agent.Teams.AI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
| Linux deb | `agent-teams-ai_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
| Linux rpm | `agent-teams-ai-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
| Linux pacman | `agent-teams-ai-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
## 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:
```
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.
@ -251,10 +251,10 @@ git push origin v1.0.0
# Wait for CI to finish (~10 min), then update notes
# 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 push origin :refs/tags/v1.0.0
# 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'],
['con', '_con'],
['con.txt', '_con-txt'],
['con.txt', 'con-txt'],
['aux', '_aux'],
['COM1', '_com1'],
['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 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
`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(
previous: PersistedTeamLaunchMemberState,
evidence: ProcessBootstrapTransportEvidence,
context: { launchPhase: 'active' | 'terminal'; currentAttempt: boolean },
context: {
launchPhase: PersistedTeamLaunchPhase
projectionPhase: 'active' | 'final'
currentAttempt: boolean
},
): { next: PersistedTeamLaunchMemberState; changed: boolean; reason: LaunchTransitionReason } {
// 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.
### 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
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;
- 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;
- 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;
- 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;
- 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`.
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
Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
@ -2608,7 +2659,8 @@ export interface ProcessBootstrapTransportMergeInput {
evidence: ProcessBootstrapTransportEvidence
diagnostic: ProcessBootstrapTransportDiagnostic
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;
- `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;
- `launchPhase: 'active'` cannot convert retryable rejection into `failed_to_start`;
- `launchPhase: 'terminal'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists.
- `projectionPhase: 'active'` cannot convert retryable rejection or timeout-only pending stages into `failed_to_start`;
- `projectionPhase: 'final'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists.
Projection coordinator shape:
```ts
async function enrichAndPersistCurrentLaunchSnapshot(run: MutableTeamRun): Promise<void> {
const identitySnapshot = buildCurrentAttemptIdentities(run)
const evidenceSnapshot = await readProcessTransportEvidence(identitySnapshot)
async function writeLaunchStateSnapshotNow(teamName, snapshot, options) {
const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null)
const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => [])
if (!isStillCurrentRun(run, identitySnapshot)) {
return
const openCodeOverlaid = await applyOpenCodeSecondaryEvidenceOverlay({
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 snapshot = buildLiveLaunchSnapshotForRun({ ...run, memberStatuses: statuses })
await this.teamLaunchStateStore.write(run.teamName, snapshot)
const normalizedSnapshot =
applyOpenCodeSecondaryBootstrapStallOverlay(processOverlaid) ?? processOverlaid
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:
- `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 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;
- `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;
- 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.
- 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.
- 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:
@ -3072,6 +3176,8 @@ Cases:
- bounded reader reads tail and drops first partial line;
- 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;
- append order beats misleading future/past timestamps for stage selection;
- 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`;
- 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.`;
- 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;
- retryable rejection remains pending/warning during active launch;
- 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 pending transport state uses `runtimeDiagnosticSeverity: 'error'`.
- 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 pending-only evidence clears provider/runtime hard failure.
- No generic transport timeout overwrites provider/auth/quota/model root cause.

View file

@ -1,7 +1,7 @@
export const useGithubRepo = () => {
const config = useRuntimeConfig();
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 releasesUrl = computed(

View file

@ -111,7 +111,7 @@ function writeCache(data: DownloadsApiResponse): void {
export const useReleaseDownloads = () => {
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 =
(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
declare const process: any;
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui";
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/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/agent-teams-ai";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;

View file

@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
import { defineConfig, type DefaultTheme } from "vitepress";
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_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 base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
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 =
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))

View file

@ -8,7 +8,7 @@ const props = withDefaults(
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",
copiedLabel: "Copied"
}

View file

@ -28,11 +28,11 @@ For source development, use:
## 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
git clone https://github.com/777genius/claude_agent_teams_ui.git
cd claude_agent_teams_ui
git clone https://github.com/777genius/agent-teams-ai.git
cd agent-teams-ai
pnpm install
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
git clone https://github.com/777genius/claude_agent_teams_ui.git
cd claude_agent_teams_ui
git clone https://github.com/777genius/agent-teams-ai.git
cd agent-teams-ai
pnpm install
pnpm dev
```

View file

@ -1,6 +1,6 @@
export default defineEventHandler((event) => {
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");

View file

@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
export default defineEventHandler((event) => {
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");

View file

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

View file

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

View file

@ -28,7 +28,10 @@ import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/Cod
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
import { CodexAccountEnvBuilder } from '../infrastructure/CodexAccountEnvBuilder';
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 { BrowserWindow } from 'electron';
@ -47,6 +50,7 @@ interface CodexLastKnownAccount {
interface CodexLastKnownRateLimits {
payload: CodexAppServerGetAccountRateLimitsResponse;
observedAt: number;
accountSignature: string | null;
}
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(
window: CodexAppServerRateLimitSnapshot['primary']
): CodexRateLimitWindowDto | null {
@ -471,6 +489,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
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 });
let appServerState: CodexAccountSnapshotDto['appServerState'] = 'healthy';
let appServerStatusMessage: string | null = null;
@ -497,7 +523,6 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
};
}
const canReuseLastKnownManagedAccount =
options?.forceRefreshToken !== true &&
localActiveChatgptAccountPresent &&
accountResult.account.account == null &&
accountResult.account.requiresOpenaiAuth === true &&
@ -520,6 +545,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
this.lastKnownRateLimits = {
payload: accountResult.rateLimits.payload,
observedAt: now,
accountSignature: getCodexAccountSignature(accountResult.account.account),
};
} else if (accountResult.rateLimits) {
rateLimitsReadFailure = accountResult.rateLimits.error;
@ -552,10 +578,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
let rateLimits: CodexRateLimitSnapshotDto | null = null;
const shouldLoadRateLimits =
options?.includeRateLimits === true || this.hasFreshRateLimits(now);
const currentAccountSignature = getCodexAccountSignature(accountPayload?.account ?? null);
const reusableLastKnownRateLimits =
this.lastKnownRateLimits?.accountSignature === currentAccountSignature
? this.lastKnownRateLimits
: null;
if (shouldLoadRateLimits) {
if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) {
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
if (this.hasFreshRateLimits(now) && reusableLastKnownRateLimits) {
rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits);
} else if (rateLimitsReadFailure) {
this.logger.warn('codex account rate limits refresh failed', {
error:
@ -563,6 +594,9 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
? rateLimitsReadFailure.message
: 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 path from 'path';
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex', 'accounts');
const LEGACY_AUTH_SYNC_MARKER_FILE = '.agent-teams-legacy-auth-sync.json';
interface CodexAccountsRegistry {
active_account_id?: string | null;
active_account_key?: string | null;
activeAccountId?: string | null;
activeAccountKey?: string | null;
}
interface CodexAuthFile {
auth_mode?: string | null;
authMode?: string | null;
tokens?: {
refresh_token?: string | null;
refreshToken?: string | null;
} | null;
}
export interface CodexLocalAccountState {
@ -19,6 +26,25 @@ export interface CodexLocalAccountState {
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 {
return Buffer.from(accountKey, 'utf8')
.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(
accountsDir = CODEX_ACCOUNTS_DIR
): Promise<CodexLocalAccountState> {
try {
const entries = await fs.readdir(accountsDir, { withFileTypes: true });
const hasArtifacts = entries.some(
let entries: Dirent[] = [];
try {
entries = await fs.readdir(accountsDir, { withFileTypes: true });
} catch {
entries = [];
}
const hasAccountsArtifacts = entries.some(
(entry) =>
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) {
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 {
hasArtifacts: true,
hasActiveChatgptAccount: authMode === 'chatgpt',
hasActiveChatgptAccount: (await resolveCodexActiveChatgptAuthFile(accountsDir)) !== null,
};
} catch {
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(
accountsDir = CODEX_ACCOUNTS_DIR
): Promise<boolean> {

View file

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

View file

@ -57,6 +57,7 @@ export class ApiKeyService {
private readonly filePath: string;
private cache: StoredApiKey[] | null = null;
private aesKey: Buffer | null = null;
private readonly reportedDecryptFailures = new Set<string>();
private readonly originalProcessEnv = new Map<string, string | undefined>();
constructor(claudeDir?: string) {
@ -288,7 +289,7 @@ export class ApiKeyService {
return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
}
} catch (err) {
logger.error(`Failed to decrypt API key "${stored.name}":`, err);
this.reportDecryptFailure(stored, err);
return '';
}
}
@ -313,6 +314,30 @@ export class ApiKeyService {
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 ───────────────────────────────────────
/**

View file

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

View file

@ -1,3 +1,4 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
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_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
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 {
const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase();
@ -119,6 +133,57 @@ function applyCodexForcedLoginMethodEnv(
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 {
private static instance: ProviderConnectionService | null = null;
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
@ -127,7 +192,8 @@ export class ProviderConnectionService {
constructor(
private apiKeyService = new ApiKeyService(),
private readonly configManager = ConfigManager.getInstance()
private readonly configManager = ConfigManager.getInstance(),
private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus
) {}
static getInstance(): ProviderConnectionService {
@ -331,7 +397,7 @@ export class ProviderConnectionService {
async getConfiguredConnectionIssue(
env: NodeJS.ProcessEnv,
providerId: CliProviderId,
_runtimeBackendOverride?: string | null
runtimeBackendOverride?: string | null
): Promise<string | null> {
if (providerId === 'anthropic') {
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
@ -358,6 +424,8 @@ export class ProviderConnectionService {
}
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
const runtimeEnv = { ...env };
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode,
managedAccount: snapshot.managedAccount,
@ -368,9 +436,43 @@ export class ProviderConnectionService {
});
if (readiness.launchAllowed) {
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 (snapshot.preferredAuthMode === 'chatgpt') {
return snapshot.requiresOpenaiAuth

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';
import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill';
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import {
@ -156,6 +157,15 @@ import {
parseBootstrapRuntimeProofDetail,
validateBootstrapRuntimeProofEnvelope,
} from './bootstrap/BootstrapProofValidation';
import {
buildProcessBootstrapPendingDiagnostic,
buildProcessBootstrapTimeoutDiagnostic,
deriveProcessTransportProjectionPhase,
sanitizeProcessRuntimeEventFilePrefix,
summarizeProcessBootstrapTransportEvents,
type ProcessBootstrapTransportEvent,
type ProcessBootstrapTransportSummary,
} from './ProcessBootstrapTransportEvidence';
import {
buildNativeAppManagedBootstrapSpecs,
type NativeAppManagedBootstrapSpec,
@ -372,11 +382,46 @@ interface LaunchStateWriteResult {
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
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 {
return String(value || 'default')
.replace(/[^a-zA-Z0-9]/g, '-')
.toLowerCase();
function getTeamRuntimeEventsDir(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'runtime');
}
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 =
@ -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 {
runId: string;
teamName: string;
@ -20660,7 +20711,14 @@ export class TeamProvisioningService {
};
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 =
current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'
? metadata.livenessKind === 'runtime_process' ||
@ -20673,7 +20731,9 @@ export class TeamProvisioningService {
...(metadata.model ? { runtimeModel: metadata.model } : {}),
...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...(metadata.runtimeDiagnosticSeverity
...(shouldPreserveProcessBootstrapTransportDiagnostic
? { runtimeDiagnosticSeverity: current.runtimeDiagnosticSeverity }
: metadata.runtimeDiagnosticSeverity
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
: {}),
livenessLastCheckedAt: nowIso(),
@ -21583,6 +21643,28 @@ export class TeamProvisioningService {
processTableAvailable: memberProcessTableAvailable,
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, {
...metadata,
alive: resolved.alive,
@ -21599,9 +21681,11 @@ export class TeamProvisioningService {
...(resolved.paneCurrentCommand ? { paneCurrentCommand: resolved.paneCurrentCommand } : {}),
...(resolved.runtimeSessionId ? { runtimeSessionId: resolved.runtimeSessionId } : {}),
...(resolved.runtimeLastSeenAt ? { runtimeLastSeenAt: resolved.runtimeLastSeenAt } : {}),
runtimeDiagnostic: resolved.runtimeDiagnostic,
runtimeDiagnosticSeverity: resolved.runtimeDiagnosticSeverity,
diagnostics: resolved.diagnostics,
runtimeDiagnostic,
runtimeDiagnosticSeverity,
diagnostics: hasProcessBootstrapTransportDiagnostic
? mergeRuntimeDiagnostics(resolved.diagnostics, [bootstrapTransportDiagnostic])
: resolved.diagnostics,
});
}
@ -21659,13 +21743,43 @@ export class TeamProvisioningService {
return rssBytesByPid;
}
private async clearPersistedLaunchState(teamName: string): Promise<void> {
private async clearPersistedLaunchState(
teamName: string,
options?: { expectedRunId?: string }
): Promise<void> {
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);
this.launchStateWrittenRunIdByTeam.delete(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(
run: ProvisioningRun,
snapshot: PersistedTeamLaunchSnapshot
@ -22995,7 +23233,7 @@ export class TeamProvisioningService {
const snapshot = this.buildLiveLaunchSnapshotForRun(run, launchPhase);
if (!snapshot) {
if (run.isLaunch) {
await this.clearPersistedLaunchStateNow(run.teamName);
await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId });
}
return null;
}
@ -23004,7 +23242,7 @@ export class TeamProvisioningService {
const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(snapshot, metaMembers);
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
await this.clearPersistedLaunchStateNow(run.teamName);
await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId });
this.invalidateRuntimeSnapshotCaches(run.teamName);
return null;
}
@ -23943,11 +24181,11 @@ export class TeamProvisioningService {
runtimeMember: PersistedRuntimeMemberLike | undefined
): string {
const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim();
if (configuredPath) {
if (configuredPath && isContainedTeamRuntimeEventsPath(teamName, configuredPath)) {
return configuredPath;
}
const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName);
return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`);
const filePrefix = sanitizeProcessRuntimeEventFilePrefix(runtimeMember?.name ?? memberName);
return path.join(getTeamRuntimeEventsDir(teamName), `${filePrefix}.runtime.jsonl`);
}
private async readRuntimeBootstrapProofEvents(
@ -23955,6 +24193,10 @@ export class TeamProvisioningService {
): Promise<Record<string, unknown>[]> {
let handle: fs.promises.FileHandle | null = null;
try {
const pathStat = await fs.promises.lstat(eventsPath);
if (!pathStat.isFile()) {
return [];
}
handle = await fs.promises.open(eventsPath, 'r');
const stat = await handle.stat();
if (!stat.isFile() || stat.size <= 0) {
@ -23970,10 +24212,16 @@ export class TeamProvisioningService {
if (start > 0) {
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>[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (Buffer.byteLength(line, 'utf8') > BOOTSTRAP_RUNTIME_EVENT_MAX_LINE_BYTES) {
continue;
}
try {
const parsed = JSON.parse(line) as unknown;
if (
@ -24077,6 +24325,216 @@ export class TeamProvisioningService {
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(
snapshot: PersistedTeamLaunchSnapshot | null
): Promise<PersistedTeamLaunchSnapshot | null> {
@ -24376,7 +24834,7 @@ export class TeamProvisioningService {
const now = nowIso();
for (const expected of persistedMemberNames) {
const bootstrapMember = bootstrapSnapshot?.members[expected];
const current = nextMembers[expected] ?? {
let current = nextMembers[expected] ?? {
name: expected,
launchState: 'starting',
agentToolAccepted: false,
@ -24514,9 +24972,19 @@ export class TeamProvisioningService {
}
}
const graceExpired =
current.agentToolAccepted === true &&
Number.isFinite(acceptedAtMs) &&
Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
Number.isFinite(acceptedAtMs) && Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
if (!isOpenCodeSecondaryLaneMember) {
current = this.applyProcessBootstrapTransportOverlay({
member: current,
summary: await this.readProcessBootstrapTransportSummary({
teamName,
memberName: expected,
member: current,
}),
launchPhase: persistedWithCommittedEvidence.launchPhase,
finalTimeoutReached: graceExpired,
});
}
if (
isOpenCodeSecondaryLaneMember &&
shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now())
@ -24540,6 +25008,7 @@ export class TeamProvisioningService {
]);
}
if (
current.agentToolAccepted === true &&
!current.bootstrapConfirmed &&
!current.runtimeAlive &&
!current.hardFailure &&
@ -27665,6 +28134,7 @@ export class TeamProvisioningService {
}
run.provisioningComplete = true;
this.scheduleDeterministicBootstrapCompletionRecovery(run);
this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
this.setLeadActivity(run, 'idle');
@ -27743,6 +28213,9 @@ export class TeamProvisioningService {
const hasPendingBootstrap =
!hasSpawnFailures &&
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
if (this.isProvisioningRunPromotedToAlive(run)) {
return;
}
const readyMessage = hasSpawnFailures
? `Launch completed with teammate errors — ${failedSpawnMembers
.map((member) => member.name)
@ -27923,6 +28396,9 @@ export class TeamProvisioningService {
const hasPendingBootstrap =
!hasSpawnFailures &&
this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot);
if (this.isProvisioningRunPromotedToAlive(run)) {
return;
}
const progress = updateProgress(
run,
'ready',
@ -29357,10 +29833,23 @@ export class TeamProvisioningService {
const envPatch: NodeJS.ProcessEnv = {};
let usesAnthropicApiKeyHelper = false;
for (const providerId of crossProviderIds) {
let env: ProvisioningEnvResolution;
try {
const env = await this.buildProvisioningEnv(providerId, undefined, {
env = await this.buildProvisioningEnv(providerId, undefined, {
teamRuntimeAuth: options?.teamRuntimeAuth,
});
} catch (error) {
console.error(
`[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`,
error
);
// 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);
@ -29375,13 +29864,6 @@ export class TeamProvisioningService {
if (flattenedArgs.length > 0) {
args.push(...flattenedArgs);
}
} catch (error) {
console.error(
`[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`,
error
);
// Best-effort: don't block launch if cross-provider env resolution fails
}
}
return { args, providerArgsByProvider, envPatch, usesAnthropicApiKeyHelper };
}

View file

@ -4,6 +4,8 @@ import * as path from 'path';
const LEGACY_USER_DATA_DIR_NAMES = [
'Claude Agent Teams UI',
'claude-agent-teams-ui',
'agent-teams-ai',
'Agent Teams UI',
'claude-devtools',
'claude-code-context',
] as const;
@ -20,6 +22,7 @@ export interface ElectronUserDataMigrationResult {
fallbackToLegacy: boolean;
reason:
| 'migrated'
| 'legacy-reused'
| 'current-populated'
| 'current-path-exists'
| 'legacy-missing'
@ -35,8 +38,42 @@ interface LoggerLike {
interface ElectronUserDataMigrationOptions {
logger?: LoggerLike;
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[] {
const parent = path.dirname(currentPath);
const normalizedCurrent = path.resolve(currentPath);
@ -55,6 +92,7 @@ export function migrateElectronUserDataDirectory(
try {
currentPath = app.getPath('userData');
scheduleStaleMigrationTempCleanup(currentPath, logger);
} catch (error) {
logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`);
return {
@ -66,7 +104,7 @@ export function migrateElectronUserDataDirectory(
};
}
if (directoryExists(currentPath) && directoryHasEntries(currentPath)) {
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
return {
currentPath,
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(
legacyPath,
currentPath,
@ -115,7 +176,7 @@ export function migrateElectronUserDataDirectory(
};
}
if (directoryExists(currentPath) && directoryHasEntries(currentPath)) {
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
return {
currentPath,
legacyPath: null,
@ -150,7 +211,10 @@ export function migrateElectronUserDataDirectory(
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
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,
errorOnExist: 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 {
try {
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 {
return error instanceof Error ? error.message : String(error);
}

View file

@ -100,7 +100,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
: releaseNotes;
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;
const openReleaseOnGitHub = (): void => {

View file

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

View file

@ -106,13 +106,6 @@ function normalizeLaunchFailureReason(value: string | undefined): string | 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 {
try {
const parsed = new URL(url);
@ -298,9 +291,6 @@ export const MemberCard = memo(function MemberCard({
const launchFailureReason = showFailedLaunchBadge
? normalizeLaunchFailureReason(rawLaunchFailureReason)
: null;
const displayedLaunchFailureReason = launchFailureReason
? truncateLaunchFailureReason(launchFailureReason)
: null;
const hasLiveLaunchControls =
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
const hasRestartMemberControl =
@ -512,14 +502,14 @@ export const MemberCard = memo(function MemberCard({
) : null}
</div>
) : null}
{displayedLaunchFailureReason ? (
{launchFailureReason ? (
<div
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}
>
<span className="line-clamp-2 break-words">
{renderLinkifiedText(displayedLaunchFailureReason, {
<span>
{renderLinkifiedText(launchFailureReason, {
linkClassName: 'underline underline-offset-2 hover:text-red-200',
stopPropagation: true,
getLinkLabel: getLaunchFailureLinkLabel,

View file

@ -1,6 +1,6 @@
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 URL_MAX_STACK_LENGTH = 1800;
const URL_MAX_COMPONENT_STACK_LENGTH = 1200;

View file

@ -85,6 +85,7 @@ vi.mock(
detectCodexLocalAccountState: detectLocalAccountStateMock,
detectCodexLocalAccountArtifacts: async () =>
(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 () => {
readAccountMock
.mockResolvedValueOnce({
@ -686,7 +780,7 @@ describe('createCodexAccountFeature', () => {
dateNowSpy.mockReturnValue(1_776_000_000_000);
const firstSnapshot = await feature.refreshSnapshot();
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(secondSnapshot.managedAccount).toMatchObject({

View file

@ -1,5 +1,5 @@
// @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 path from 'path';
@ -8,6 +8,8 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
detectCodexLocalAccountArtifacts,
detectCodexLocalAccountState,
ensureCodexLegacyAuthFromActiveAccount,
resolveCodexActiveChatgptAuthFile,
} from '../../../../../src/features/codex-account/main/infrastructure/detectCodexLocalAccountArtifacts';
const tempDirs: string[] = [];
@ -18,6 +20,13 @@ async function makeTempDir(): Promise<string> {
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 () => {
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 () => {
const accountsDir = await makeTempDir();
const { accountsDir } = await makeCodexHome();
const activeAccountKey = 'user-test::chatgpt-account';
await writeFile(
path.join(accountsDir, 'registry.json'),
@ -64,7 +73,171 @@ describe('detectCodexLocalAccountArtifacts', () => {
);
await writeFile(
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'
);
@ -75,7 +248,7 @@ describe('detectCodexLocalAccountArtifacts', () => {
});
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(
path.join(accountsDir, 'registry.json'),
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';
describe('ApiKeyService', () => {
@ -20,6 +22,11 @@ describe('ApiKeyService', () => {
let service: ApiKeyService;
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-'));
service = new ApiKeyService(tempDir);
});
@ -117,4 +124,35 @@ describe('ApiKeyService', () => {
await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]);
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', () => {
it('builds platform-specific asset URLs', () => {
expect(getExpectedReleaseAssetUrl('1.2.3', 'darwin', 'arm64')).toBe(
'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Claude.Agent.Teams.UI-1.2.3-arm64.dmg'
'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(
'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(
'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(
'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([
'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/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/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 = `
version: 1.2.3
files:
- url: "Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip"
- url: "Agent.Teams.AI-1.2.3-arm64-mac.zip"
sha512: abc
size: 123
- url: 'Claude.Agent.Teams.UI-1.2.3-arm64.dmg'
- url: 'Agent.Teams.AI-1.2.3-arm64.dmg'
sha512: def
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(
new Set([
'Claude.Agent.Teams.UI-1.2.3-arm64-mac.zip',
'Claude.Agent.Teams.UI-1.2.3-arm64.dmg',
'Agent.Teams.AI-1.2.3-arm64-mac.zip',
'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 = `
version: ${version}
files:
- url: Claude.Agent.Teams.UI-${version}-arm64-mac.zip
- url: Agent.Teams.AI-${version}-arm64-mac.zip
sha512: abc
size: 123
- url: Claude.Agent.Teams.UI-${version}-arm64.dmg
- url: Agent.Teams.AI-${version}-arm64.dmg
sha512: def
size: 456
path: Claude.Agent.Teams.UI-${version}-arm64-mac.zip
path: Agent.Teams.AI-${version}-arm64-mac.zip
`;
expect(getExpectedLatestMacArtifacts(version, 'arm64')).toEqual([
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`,
`Claude.Agent.Teams.UI-${version}-arm64.dmg`,
`Agent.Teams.AI-${version}-arm64-mac.zip`,
`Agent.Teams.AI-${version}-arm64.dmg`,
]);
expect(getExpectedLatestMacArtifacts(version, 'x64')).toEqual([
`Claude.Agent.Teams.UI-${version}-x64-mac.zip`,
`Claude.Agent.Teams.UI-${version}-x64.dmg`,
`Agent.Teams.AI-${version}-x64-mac.zip`,
`Agent.Teams.AI-${version}-x64.dmg`,
]);
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([
`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/claude_agent_teams_ui/releases/download/v${version}/latest-mac.yml`,
]);
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'arm64')).toBe(true);
expect(isLatestMacMetadataCompatible(arm64Metadata, version, 'x64')).toBe(false);

View file

@ -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 () => {
const { 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));
});
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 () => {
allowConsoleLogs();
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 () => {
allowConsoleLogs();
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 () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>

View file

@ -16,6 +16,7 @@ import {
import {
getLegacyElectronUserDataCandidates,
migrateElectronUserDataDirectory,
shouldCopyElectronUserDataEntry,
type ElectronUserDataMigrationApp,
} from '../../../src/main/utils/electronUserDataMigration';
@ -75,12 +76,63 @@ describe('electron userData migration', () => {
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
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-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 legacyPath = path.join(root, 'Claude 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":[]}'],
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
['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}'],
] 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) {
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({
currentPath,
@ -122,6 +203,9 @@ describe('electron userData migration', () => {
for (const [relativePath, content] of knownFiles) {
expect(readFile(currentPath, relativePath)).toBe(content);
}
for (const [relativePath] of transientFiles) {
expect(fs.existsSync(path.join(currentPath, relativePath))).toBe(false);
}
setAppDataBasePath(currentPath);
expect(getAppDataPath()).toBe(path.join(currentPath, 'data'));
@ -143,6 +227,39 @@ describe('electron userData migration', () => {
).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', () => {
const root = createTempRoot();
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');
const result = migrateElectronUserDataDirectory(app, {
strategy: 'copy',
copyDirectory: () => {
throw new Error('copy denied');
},
@ -199,6 +317,7 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app, {
strategy: 'copy',
copyDirectory: () => {
writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current');
throw new Error('destination appeared');
@ -226,6 +345,7 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app, {
strategy: 'copy',
copyDirectory: () => {
throw new Error('copy denied');
},
@ -270,16 +390,21 @@ describe('electron userData migration', () => {
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
const result = migrateElectronUserDataDirectory(new FakeElectronApp(currentPath));
const app = new FakeElectronApp(currentPath);
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath,
migrated: true,
migrated: 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', () => {
@ -291,16 +416,23 @@ describe('electron userData migration', () => {
fs.mkdirSync(emptyNewerLegacyPath, { recursive: true });
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({
currentPath,
legacyPath: populatedOlderLegacyPath,
migrated: true,
migrated: 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', () => {
@ -310,15 +442,22 @@ describe('electron userData migration', () => {
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({
currentPath,
legacyPath,
migrated: true,
migrated: 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 () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');